|
|
@@ -0,0 +1,1465 @@
|
|
|
+// Per-tool display rules for direct interactive mode.
|
|
|
+//
|
|
|
+// Each known tool (bash, edit, write, task, etc.) has a ToolRule that controls
|
|
|
+// four rendering contexts:
|
|
|
+//
|
|
|
+// view → controls which phases produce scrollback output (output for
|
|
|
+// progress, final for completion, snap for rich snapshots)
|
|
|
+// run → inline summary for the non-interactive `run` command output
|
|
|
+// scroll → text formatting for start/progress/final scrollback entries
|
|
|
+// permission → display info for the permission UI (icon, title, diff)
|
|
|
+// snap → structured snapshot (code block, diff, task card) for the
|
|
|
+// rich scrollback writer
|
|
|
+//
|
|
|
+// Tools not in TOOL_RULES get fallback formatting. The registry is typed
|
|
|
+// against the actual tool parameter/metadata types so each formatter gets
|
|
|
+// proper type inference.
|
|
|
+import os from "os"
|
|
|
+import path from "path"
|
|
|
+import stripAnsi from "strip-ansi"
|
|
|
+import type { ToolPart } from "@opencode-ai/sdk/v2"
|
|
|
+import type { Tool } from "../../../tool/tool"
|
|
|
+import type { ApplyPatchTool } from "../../../tool/apply_patch"
|
|
|
+import type { BatchTool } from "../../../tool/batch"
|
|
|
+import type { BashTool } from "../../../tool/bash"
|
|
|
+import type { CodeSearchTool } from "../../../tool/codesearch"
|
|
|
+import type { EditTool } from "../../../tool/edit"
|
|
|
+import type { GlobTool } from "../../../tool/glob"
|
|
|
+import type { GrepTool } from "../../../tool/grep"
|
|
|
+import type { InvalidTool } from "../../../tool/invalid"
|
|
|
+import type { ListTool } from "../../../tool/ls"
|
|
|
+import type { LspTool } from "../../../tool/lsp"
|
|
|
+import type { PlanExitTool } from "../../../tool/plan"
|
|
|
+import type { QuestionTool } from "../../../tool/question"
|
|
|
+import type { ReadTool } from "../../../tool/read"
|
|
|
+import type { SkillTool } from "../../../tool/skill"
|
|
|
+import type { TaskTool } from "../../../tool/task"
|
|
|
+import type { TodoWriteTool } from "../../../tool/todo"
|
|
|
+import type { WebFetchTool } from "../../../tool/webfetch"
|
|
|
+import type { WebSearchTool } from "../../../tool/websearch"
|
|
|
+import type { WriteTool } from "../../../tool/write"
|
|
|
+import { LANGUAGE_EXTENSIONS } from "../../../lsp/language"
|
|
|
+import { Locale } from "../../../util/locale"
|
|
|
+import type { RunDiffStyle, StreamCommit } from "./types"
|
|
|
+
|
|
|
+export type ToolView = {
|
|
|
+ output: boolean
|
|
|
+ final: boolean
|
|
|
+ snap?: "code" | "diff" | "structured"
|
|
|
+}
|
|
|
+
|
|
|
+export type ToolPhase = "start" | "progress" | "final"
|
|
|
+
|
|
|
+export type ToolDict = Record<string, unknown>
|
|
|
+
|
|
|
+export type ToolFrame = {
|
|
|
+ raw: string
|
|
|
+ name: string
|
|
|
+ input: ToolDict
|
|
|
+ meta: ToolDict
|
|
|
+ state: ToolDict
|
|
|
+ status: string
|
|
|
+ error: string
|
|
|
+}
|
|
|
+
|
|
|
+export type ToolInline = {
|
|
|
+ icon: string
|
|
|
+ title: string
|
|
|
+ description?: string
|
|
|
+ mode?: "inline" | "block"
|
|
|
+ body?: string
|
|
|
+}
|
|
|
+
|
|
|
+export type ToolPermissionInfo = {
|
|
|
+ icon: string
|
|
|
+ title: string
|
|
|
+ lines: string[]
|
|
|
+ diff?: string
|
|
|
+ file?: string
|
|
|
+}
|
|
|
+
|
|
|
+export type ToolCodeSnapshot = {
|
|
|
+ kind: "code"
|
|
|
+ title: string
|
|
|
+ content: string
|
|
|
+ file?: string
|
|
|
+}
|
|
|
+
|
|
|
+export type ToolDiffSnapshot = {
|
|
|
+ kind: "diff"
|
|
|
+ items: Array<{
|
|
|
+ title: string
|
|
|
+ diff: string
|
|
|
+ file?: string
|
|
|
+ deletions?: number
|
|
|
+ }>
|
|
|
+}
|
|
|
+
|
|
|
+export type ToolTaskSnapshot = {
|
|
|
+ kind: "task"
|
|
|
+ title: string
|
|
|
+ rows: string[]
|
|
|
+ tail: string
|
|
|
+}
|
|
|
+
|
|
|
+export type ToolTodoSnapshot = {
|
|
|
+ kind: "todo"
|
|
|
+ items: Array<{
|
|
|
+ status: string
|
|
|
+ content: string
|
|
|
+ }>
|
|
|
+ tail: string
|
|
|
+}
|
|
|
+
|
|
|
+export type ToolQuestionSnapshot = {
|
|
|
+ kind: "question"
|
|
|
+ items: Array<{
|
|
|
+ question: string
|
|
|
+ answer: string
|
|
|
+ }>
|
|
|
+ tail: string
|
|
|
+}
|
|
|
+
|
|
|
+export type ToolSnapshot =
|
|
|
+ | ToolCodeSnapshot
|
|
|
+ | ToolDiffSnapshot
|
|
|
+ | ToolTaskSnapshot
|
|
|
+ | ToolTodoSnapshot
|
|
|
+ | ToolQuestionSnapshot
|
|
|
+
|
|
|
+export type ToolProps<T extends Tool.Info> = {
|
|
|
+ input: Partial<Tool.InferParameters<T>>
|
|
|
+ metadata: Partial<Tool.InferMetadata<T>>
|
|
|
+ frame: ToolFrame
|
|
|
+}
|
|
|
+
|
|
|
+type ToolPermissionProps<T extends Tool.Info> = {
|
|
|
+ input: Partial<Tool.InferParameters<T>>
|
|
|
+ metadata: Partial<Tool.InferMetadata<T>>
|
|
|
+ patterns: string[]
|
|
|
+}
|
|
|
+
|
|
|
+type ToolPermissionCtx = {
|
|
|
+ input: ToolDict
|
|
|
+ meta: ToolDict
|
|
|
+ patterns: string[]
|
|
|
+}
|
|
|
+
|
|
|
+type ToolDefs = {
|
|
|
+ invalid: typeof InvalidTool
|
|
|
+ bash: typeof BashTool
|
|
|
+ write: typeof WriteTool
|
|
|
+ edit: typeof EditTool
|
|
|
+ apply_patch: typeof ApplyPatchTool
|
|
|
+ batch: typeof BatchTool
|
|
|
+ task: typeof TaskTool
|
|
|
+ todowrite: typeof TodoWriteTool
|
|
|
+ question: typeof QuestionTool
|
|
|
+ read: typeof ReadTool
|
|
|
+ glob: typeof GlobTool
|
|
|
+ grep: typeof GrepTool
|
|
|
+ list: typeof ListTool
|
|
|
+ lsp: typeof LspTool
|
|
|
+ webfetch: typeof WebFetchTool
|
|
|
+ codesearch: typeof CodeSearchTool
|
|
|
+ websearch: typeof WebSearchTool
|
|
|
+ skill: typeof SkillTool
|
|
|
+ plan_exit: typeof PlanExitTool
|
|
|
+}
|
|
|
+
|
|
|
+type ToolName = keyof ToolDefs
|
|
|
+
|
|
|
+type ToolRule<T extends Tool.Info> = {
|
|
|
+ view: ToolView
|
|
|
+ run: (props: ToolProps<T>) => ToolInline
|
|
|
+ scroll?: Partial<Record<ToolPhase, (props: ToolProps<T>) => string>>
|
|
|
+ permission?: (props: ToolPermissionProps<T>) => ToolPermissionInfo
|
|
|
+ snap?: (props: ToolProps<T>) => ToolSnapshot | undefined
|
|
|
+}
|
|
|
+
|
|
|
+type ToolRegistry = {
|
|
|
+ [K in ToolName]: ToolRule<ToolDefs[K]>
|
|
|
+}
|
|
|
+
|
|
|
+type AnyToolRule = ToolRule<Tool.Info>
|
|
|
+
|
|
|
+function dict(v: unknown): ToolDict {
|
|
|
+ if (!v || typeof v !== "object" || Array.isArray(v)) {
|
|
|
+ return {}
|
|
|
+ }
|
|
|
+
|
|
|
+ return v as ToolDict
|
|
|
+}
|
|
|
+
|
|
|
+function props<T extends Tool.Info = Tool.Info>(frame: ToolFrame): ToolProps<T> {
|
|
|
+ return {
|
|
|
+ input: frame.input as Partial<Tool.InferParameters<T>>,
|
|
|
+ metadata: frame.meta as Partial<Tool.InferMetadata<T>>,
|
|
|
+ frame,
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function permission<T extends Tool.Info = Tool.Info>(ctx: ToolPermissionCtx): ToolPermissionProps<T> {
|
|
|
+ return {
|
|
|
+ input: ctx.input as Partial<Tool.InferParameters<T>>,
|
|
|
+ metadata: ctx.meta as Partial<Tool.InferMetadata<T>>,
|
|
|
+ patterns: ctx.patterns,
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function text(v: unknown): string {
|
|
|
+ return typeof v === "string" ? v : ""
|
|
|
+}
|
|
|
+
|
|
|
+function num(v: unknown): number | undefined {
|
|
|
+ if (typeof v !== "number" || !Number.isFinite(v)) {
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ return v
|
|
|
+}
|
|
|
+
|
|
|
+function list<T>(v: unknown): T[] {
|
|
|
+ return Array.isArray(v) ? (v as T[]) : []
|
|
|
+}
|
|
|
+
|
|
|
+function done(name: string, time: string): string {
|
|
|
+ if (!time) {
|
|
|
+ return `└ ${name} completed`
|
|
|
+ }
|
|
|
+
|
|
|
+ return `└ ${name} completed · ${time}`
|
|
|
+}
|
|
|
+
|
|
|
+function info(data: ToolDict, skip: string[] = []): string {
|
|
|
+ const list = Object.entries(data).filter(([key, val]) => {
|
|
|
+ if (skip.includes(key)) {
|
|
|
+ return false
|
|
|
+ }
|
|
|
+
|
|
|
+ return typeof val === "string" || typeof val === "number" || typeof val === "boolean"
|
|
|
+ })
|
|
|
+
|
|
|
+ if (list.length === 0) {
|
|
|
+ return ""
|
|
|
+ }
|
|
|
+
|
|
|
+ return `[${list.map(([key, val]) => `${key}=${val}`).join(", ")}]`
|
|
|
+}
|
|
|
+
|
|
|
+function span(state: ToolDict): string {
|
|
|
+ const time = dict(state.time)
|
|
|
+ const start = num(time.start)
|
|
|
+ const end = num(time.end)
|
|
|
+ if (start === undefined || end === undefined || end <= start) {
|
|
|
+ return ""
|
|
|
+ }
|
|
|
+
|
|
|
+ return Locale.duration(end - start)
|
|
|
+}
|
|
|
+
|
|
|
+function fail(ctx: ToolFrame): string {
|
|
|
+ if (ctx.error) {
|
|
|
+ return `✖ ${ctx.name} failed: ${ctx.error}`
|
|
|
+ }
|
|
|
+
|
|
|
+ const state = text(ctx.state.error).trim()
|
|
|
+ if (state) {
|
|
|
+ return `✖ ${ctx.name} failed: ${state}`
|
|
|
+ }
|
|
|
+
|
|
|
+ const raw = ctx.raw.trim()
|
|
|
+ if (raw) {
|
|
|
+ return `✖ ${ctx.name} failed: ${raw}`
|
|
|
+ }
|
|
|
+
|
|
|
+ return `✖ ${ctx.name} failed`
|
|
|
+}
|
|
|
+
|
|
|
+function fallbackStart(ctx: ToolFrame): string {
|
|
|
+ const extra = info(ctx.input)
|
|
|
+ if (!extra) {
|
|
|
+ return `⚙ ${ctx.name}`
|
|
|
+ }
|
|
|
+
|
|
|
+ return `⚙ ${ctx.name} ${extra}`
|
|
|
+}
|
|
|
+
|
|
|
+function fallbackFinal(ctx: ToolFrame): string {
|
|
|
+ if (ctx.status === "error") {
|
|
|
+ return fail(ctx)
|
|
|
+ }
|
|
|
+
|
|
|
+ if (ctx.status && ctx.status !== "completed") {
|
|
|
+ return ctx.raw.trim()
|
|
|
+ }
|
|
|
+
|
|
|
+ return done(ctx.name, span(ctx.state))
|
|
|
+}
|
|
|
+
|
|
|
+export function toolPath(input?: string, opts: { home?: boolean } = {}): string {
|
|
|
+ if (!input) {
|
|
|
+ return ""
|
|
|
+ }
|
|
|
+
|
|
|
+ const cwd = process.cwd()
|
|
|
+ const home = os.homedir()
|
|
|
+ const abs = path.isAbsolute(input) ? input : path.resolve(cwd, input)
|
|
|
+ const rel = path.relative(cwd, abs)
|
|
|
+
|
|
|
+ if (!rel) {
|
|
|
+ return "."
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!rel.startsWith("..")) {
|
|
|
+ return rel
|
|
|
+ }
|
|
|
+
|
|
|
+ if (opts.home && home && (abs === home || abs.startsWith(home + path.sep))) {
|
|
|
+ return abs.replace(home, "~")
|
|
|
+ }
|
|
|
+
|
|
|
+ return abs
|
|
|
+}
|
|
|
+
|
|
|
+function fallbackInline(ctx: ToolFrame): ToolInline {
|
|
|
+ const title = text(ctx.state.title) || (Object.keys(ctx.input).length > 0 ? JSON.stringify(ctx.input) : "Unknown")
|
|
|
+
|
|
|
+ return {
|
|
|
+ icon: "⚙",
|
|
|
+ title: `${ctx.name} ${title}`,
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function count(n: number, label: string): string {
|
|
|
+ return `${n} ${label}${n === 1 ? "" : "es"}`
|
|
|
+}
|
|
|
+
|
|
|
+function runGlob(p: ToolProps<typeof GlobTool>): ToolInline {
|
|
|
+ const root = p.input.path ?? ""
|
|
|
+ const title = `Glob "${p.input.pattern ?? ""}"`
|
|
|
+ const suffix = root ? `in ${toolPath(root)}` : ""
|
|
|
+ const matches = p.metadata.count
|
|
|
+ const description = matches === undefined ? suffix : `${suffix}${suffix ? " · " : ""}${count(matches, "match")}`
|
|
|
+ return {
|
|
|
+ icon: "✱",
|
|
|
+ title,
|
|
|
+ ...(description && { description }),
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function runGrep(p: ToolProps<typeof GrepTool>): ToolInline {
|
|
|
+ const root = p.input.path ?? ""
|
|
|
+ const title = `Grep "${p.input.pattern ?? ""}"`
|
|
|
+ const suffix = root ? `in ${toolPath(root)}` : ""
|
|
|
+ const matches = p.metadata.matches
|
|
|
+ const description = matches === undefined ? suffix : `${suffix}${suffix ? " · " : ""}${count(matches, "match")}`
|
|
|
+ return {
|
|
|
+ icon: "✱",
|
|
|
+ title,
|
|
|
+ ...(description && { description }),
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function runList(p: ToolProps<typeof ListTool>): ToolInline {
|
|
|
+ const dir = p.input.path ?? ""
|
|
|
+ return {
|
|
|
+ icon: "→",
|
|
|
+ title: dir ? `List ${toolPath(dir)}` : "List",
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function runRead(p: ToolProps<typeof ReadTool>): ToolInline {
|
|
|
+ const file = toolPath(p.input.filePath)
|
|
|
+ const description = info(p.frame.input, ["filePath"]) || undefined
|
|
|
+ return {
|
|
|
+ icon: "→",
|
|
|
+ title: `Read ${file}`,
|
|
|
+ ...(description && { description }),
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function runWrite(p: ToolProps<typeof WriteTool>): ToolInline {
|
|
|
+ return {
|
|
|
+ icon: "←",
|
|
|
+ title: `Write ${toolPath(p.input.filePath)}`,
|
|
|
+ mode: "block",
|
|
|
+ body: p.frame.status === "completed" ? text(p.frame.state.output) : undefined,
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function runWebfetch(p: ToolProps<typeof WebFetchTool>): ToolInline {
|
|
|
+ const url = p.input.url ?? ""
|
|
|
+ return {
|
|
|
+ icon: "%",
|
|
|
+ title: url ? `WebFetch ${url}` : "WebFetch",
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function runEdit(p: ToolProps<typeof EditTool>): ToolInline {
|
|
|
+ return {
|
|
|
+ icon: "←",
|
|
|
+ title: `Edit ${toolPath(p.input.filePath)}`,
|
|
|
+ mode: "block",
|
|
|
+ body: p.metadata.diff,
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function runCodeSearch(p: ToolProps<typeof CodeSearchTool>): ToolInline {
|
|
|
+ return {
|
|
|
+ icon: "◇",
|
|
|
+ title: `Exa Code Search "${p.input.query ?? ""}"`,
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function runWebSearch(p: ToolProps<typeof WebSearchTool>): ToolInline {
|
|
|
+ return {
|
|
|
+ icon: "◈",
|
|
|
+ title: `Exa Web Search "${p.input.query ?? ""}"`,
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function runTask(p: ToolProps<typeof TaskTool>): ToolInline {
|
|
|
+ const kind = Locale.titlecase(p.input.subagent_type || "unknown")
|
|
|
+ const desc = p.input.description
|
|
|
+ const icon = p.frame.status === "error" ? "✗" : p.frame.status === "running" ? "•" : "✓"
|
|
|
+ return {
|
|
|
+ icon,
|
|
|
+ title: desc || `${kind} Task`,
|
|
|
+ description: desc ? `${kind} Agent` : undefined,
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function runTodo(p: ToolProps<typeof TodoWriteTool>): ToolInline {
|
|
|
+ return {
|
|
|
+ icon: "#",
|
|
|
+ title: "Todos",
|
|
|
+ mode: "block",
|
|
|
+ body: list<{ status?: string; content?: string }>(p.frame.input.todos)
|
|
|
+ .flatMap((item) => {
|
|
|
+ const body = typeof item?.content === "string" ? item.content : ""
|
|
|
+ if (!body) {
|
|
|
+ return []
|
|
|
+ }
|
|
|
+
|
|
|
+ return [`${item.status === "completed" ? "[x]" : "[ ]"} ${body}`]
|
|
|
+ })
|
|
|
+ .join("\n"),
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function runSkill(p: ToolProps<typeof SkillTool>): ToolInline {
|
|
|
+ return {
|
|
|
+ icon: "→",
|
|
|
+ title: `Skill "${p.input.name ?? ""}"`,
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function runPatch(p: ToolProps<typeof ApplyPatchTool>): ToolInline {
|
|
|
+ const files = p.metadata.files?.length ?? 0
|
|
|
+ if (files === 0) {
|
|
|
+ return {
|
|
|
+ icon: "%",
|
|
|
+ title: "Patch",
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return {
|
|
|
+ icon: "%",
|
|
|
+ title: `Patch ${files} file${files === 1 ? "" : "s"}`,
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function runQuestion(p: ToolProps<typeof QuestionTool>): ToolInline {
|
|
|
+ const total = list(p.frame.input.questions).length
|
|
|
+ return {
|
|
|
+ icon: "→",
|
|
|
+ title: `Asked ${total} question${total === 1 ? "" : "s"}`,
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function runInvalid(p: ToolProps<typeof InvalidTool>): ToolInline {
|
|
|
+ return {
|
|
|
+ icon: "✗",
|
|
|
+ title: text(p.frame.state.title) || "Invalid Tool",
|
|
|
+ mode: "block",
|
|
|
+ body: p.frame.status === "completed" ? text(p.frame.state.output) : undefined,
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function runBatch(p: ToolProps<typeof BatchTool>): ToolInline {
|
|
|
+ const calls = list(p.input.tool_calls).length
|
|
|
+ return {
|
|
|
+ icon: "#",
|
|
|
+ title: text(p.frame.state.title) || (calls > 0 ? `Batch ${calls} tool${calls === 1 ? "" : "s"}` : "Batch"),
|
|
|
+ mode: "block",
|
|
|
+ body: p.frame.status === "completed" ? text(p.frame.state.output) : undefined,
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function lspTitle(
|
|
|
+ input: {
|
|
|
+ operation?: string
|
|
|
+ filePath?: string
|
|
|
+ line?: number
|
|
|
+ character?: number
|
|
|
+ },
|
|
|
+ opts: { home?: boolean } = {},
|
|
|
+): string {
|
|
|
+ const op = input.operation || "request"
|
|
|
+ const file = input.filePath ? toolPath(input.filePath, opts) : ""
|
|
|
+ const line = typeof input.line === "number" ? input.line : undefined
|
|
|
+ const char = typeof input.character === "number" ? input.character : undefined
|
|
|
+ const pos = line !== undefined && char !== undefined ? `:${line}:${char}` : ""
|
|
|
+ if (!file) {
|
|
|
+ return `LSP ${op}`
|
|
|
+ }
|
|
|
+
|
|
|
+ return `LSP ${op} ${file}${pos}`
|
|
|
+}
|
|
|
+
|
|
|
+function runLsp(p: ToolProps<typeof LspTool>): ToolInline {
|
|
|
+ return {
|
|
|
+ icon: "→",
|
|
|
+ title: text(p.frame.state.title) || lspTitle(p.input),
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function runPlanExit(p: ToolProps<typeof PlanExitTool>): ToolInline {
|
|
|
+ return {
|
|
|
+ icon: "→",
|
|
|
+ title: text(p.frame.state.title) || "Switching to build agent",
|
|
|
+ mode: "block",
|
|
|
+ body: p.frame.status === "completed" ? text(p.frame.state.output) : undefined,
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+type PatchFile = Tool.InferMetadata<typeof ApplyPatchTool>["files"][number]
|
|
|
+
|
|
|
+function patchTitle(file: PatchFile): string {
|
|
|
+ const rel = file.relativePath
|
|
|
+ const from = file.filePath
|
|
|
+ if (file.type === "add") {
|
|
|
+ return `# Created ${rel || toolPath(from)}`
|
|
|
+ }
|
|
|
+ if (file.type === "delete") {
|
|
|
+ return `# Deleted ${rel || toolPath(from)}`
|
|
|
+ }
|
|
|
+ if (file.type === "move") {
|
|
|
+ return `# Moved ${toolPath(from)} -> ${rel || toolPath(file.movePath)}`
|
|
|
+ }
|
|
|
+
|
|
|
+ return `← Patched ${rel || toolPath(from)}`
|
|
|
+}
|
|
|
+
|
|
|
+function snapWrite(p: ToolProps<typeof WriteTool>): ToolSnapshot | undefined {
|
|
|
+ const file = p.input.filePath || ""
|
|
|
+ const content = p.input.content || ""
|
|
|
+ if (!file && !content) {
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ return {
|
|
|
+ kind: "code",
|
|
|
+ title: `# Wrote ${toolPath(file)}`,
|
|
|
+ content,
|
|
|
+ file,
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function snapEdit(p: ToolProps<typeof EditTool>): ToolSnapshot | undefined {
|
|
|
+ const file = p.input.filePath || ""
|
|
|
+ const diff = p.metadata.diff || ""
|
|
|
+ if (!file || !diff.trim()) {
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ return {
|
|
|
+ kind: "diff",
|
|
|
+ items: [
|
|
|
+ {
|
|
|
+ title: `← Edit ${toolPath(file)}`,
|
|
|
+ diff,
|
|
|
+ file,
|
|
|
+ },
|
|
|
+ ],
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function snapPatch(p: ToolProps<typeof ApplyPatchTool>): ToolSnapshot | undefined {
|
|
|
+ const files = list<PatchFile>(p.frame.meta.files)
|
|
|
+ if (files.length === 0) {
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ return {
|
|
|
+ kind: "diff",
|
|
|
+ items: files
|
|
|
+ .map((file) => {
|
|
|
+ if (!file || typeof file !== "object") {
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ const diff = typeof file.diff === "string" ? file.diff : ""
|
|
|
+ if (!diff.trim()) {
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ const name = file.movePath || file.filePath || file.relativePath
|
|
|
+ return {
|
|
|
+ title: patchTitle(file),
|
|
|
+ diff,
|
|
|
+ file: name,
|
|
|
+ deletions: typeof file.deletions === "number" ? file.deletions : 0,
|
|
|
+ }
|
|
|
+ })
|
|
|
+ .filter((item): item is NonNullable<typeof item> => Boolean(item)),
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function snapTask(p: ToolProps<typeof TaskTool>): ToolSnapshot {
|
|
|
+ const kind = Locale.titlecase(p.input.subagent_type || "general")
|
|
|
+ const rows: string[] = []
|
|
|
+ const desc = p.input.description
|
|
|
+ if (desc) {
|
|
|
+ rows.push(`◉ ${desc}`)
|
|
|
+ }
|
|
|
+ const title = text(p.frame.state.title)
|
|
|
+ if (title) {
|
|
|
+ rows.push(`↳ ${title}`)
|
|
|
+ }
|
|
|
+ const calls = num(p.frame.meta.toolcalls) ?? num(p.frame.meta.toolCalls) ?? num(p.frame.meta.calls)
|
|
|
+ if (calls !== undefined) {
|
|
|
+ rows.push(`↳ ${Locale.number(calls)} toolcall${calls === 1 ? "" : "s"}`)
|
|
|
+ }
|
|
|
+ const sid = text(p.frame.meta.sessionId) || text(p.frame.meta.sessionID)
|
|
|
+ if (sid) {
|
|
|
+ rows.push(`↳ session ${sid}`)
|
|
|
+ }
|
|
|
+
|
|
|
+ return {
|
|
|
+ kind: "task",
|
|
|
+ title: `# ${kind} Task`,
|
|
|
+ rows,
|
|
|
+ tail: done(`${kind} task`, span(p.frame.state)),
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function snapTodo(p: ToolProps<typeof TodoWriteTool>): ToolSnapshot {
|
|
|
+ const items = list<{ status?: string; content?: string }>(p.frame.input.todos).flatMap((item) => {
|
|
|
+ const content = typeof item?.content === "string" ? item.content : ""
|
|
|
+ if (!content) {
|
|
|
+ return []
|
|
|
+ }
|
|
|
+
|
|
|
+ return [
|
|
|
+ {
|
|
|
+ status: typeof item.status === "string" ? item.status : "",
|
|
|
+ content,
|
|
|
+ },
|
|
|
+ ]
|
|
|
+ })
|
|
|
+ const doneN = items.filter((item) => item.status === "completed").length
|
|
|
+ const runN = items.filter((item) => item.status === "in_progress").length
|
|
|
+ const left = items.length - doneN - runN
|
|
|
+ const tail = [`${items.length} total`]
|
|
|
+ if (doneN > 0) {
|
|
|
+ tail.push(`${doneN} done`)
|
|
|
+ }
|
|
|
+ if (runN > 0) {
|
|
|
+ tail.push(`${runN} active`)
|
|
|
+ }
|
|
|
+ if (left > 0) {
|
|
|
+ tail.push(`${left} pending`)
|
|
|
+ }
|
|
|
+
|
|
|
+ return {
|
|
|
+ kind: "todo",
|
|
|
+ items,
|
|
|
+ tail: `${done("todos", span(p.frame.state))} · ${tail.join(" · ")}`,
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function snapQuestion(p: ToolProps<typeof QuestionTool>): ToolSnapshot {
|
|
|
+ const answers = list<unknown[]>(p.frame.meta.answers)
|
|
|
+ const items = list<{ question?: string }>(p.frame.input.questions).map((item, i) => {
|
|
|
+ const answer = list<string>(answers[i]).filter((entry) => typeof entry === "string")
|
|
|
+ return {
|
|
|
+ question: item.question || `Question ${i + 1}`,
|
|
|
+ answer: answer.length > 0 ? answer.join(", ") : "(no answer)",
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ return {
|
|
|
+ kind: "question",
|
|
|
+ items,
|
|
|
+ tail: done("questions", span(p.frame.state)),
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function scrollBashStart(p: ToolProps<typeof BashTool>): string {
|
|
|
+ const cmd = p.input.command ?? ""
|
|
|
+ const desc = p.input.description || "Shell"
|
|
|
+ const wd = p.input.workdir ?? ""
|
|
|
+ const dir = wd && wd !== "." ? toolPath(wd) : ""
|
|
|
+ const title = dir && !desc.includes(dir) ? `${desc} in ${dir}` : desc
|
|
|
+
|
|
|
+ if (!cmd) {
|
|
|
+ return `# ${title}`
|
|
|
+ }
|
|
|
+
|
|
|
+ return `# ${title}\n$ ${cmd}`
|
|
|
+}
|
|
|
+
|
|
|
+function scrollBashProgress(p: ToolProps<typeof BashTool>): string {
|
|
|
+ const out = stripAnsi(p.frame.raw)
|
|
|
+ const cmd = (p.input.command ?? "").trim()
|
|
|
+ if (!cmd) {
|
|
|
+ return out
|
|
|
+ }
|
|
|
+
|
|
|
+ const wdRaw = (p.input.workdir ?? "").trim()
|
|
|
+ const wd = wdRaw ? toolPath(wdRaw) : ""
|
|
|
+ const lines = out.split("\n")
|
|
|
+ const first = (lines[0] || "").trim()
|
|
|
+ const second = (lines[1] || "").trim()
|
|
|
+
|
|
|
+ if (wd && (first === wd || first === wdRaw) && second === cmd) {
|
|
|
+ const body = lines.slice(2).join("\n")
|
|
|
+ if (body.length > 0) {
|
|
|
+ return body
|
|
|
+ }
|
|
|
+ return out
|
|
|
+ }
|
|
|
+
|
|
|
+ if (first === cmd || first === `$ ${cmd}`) {
|
|
|
+ const body = lines.slice(1).join("\n")
|
|
|
+ if (body.length > 0) {
|
|
|
+ return body
|
|
|
+ }
|
|
|
+ return out
|
|
|
+ }
|
|
|
+
|
|
|
+ if (wd && (first === `${wd} ${cmd}` || first === `${wdRaw} ${cmd}`)) {
|
|
|
+ const body = lines.slice(1).join("\n")
|
|
|
+ if (body.length > 0) {
|
|
|
+ return body
|
|
|
+ }
|
|
|
+ return out
|
|
|
+ }
|
|
|
+
|
|
|
+ return out
|
|
|
+}
|
|
|
+
|
|
|
+function scrollBashFinal(p: ToolProps<typeof BashTool>): string {
|
|
|
+ const code = p.metadata.exit ?? num(p.frame.meta.exitCode) ?? num(p.frame.meta.exit_code)
|
|
|
+ const time = span(p.frame.state)
|
|
|
+ if (code === undefined) {
|
|
|
+ return done("bash", time)
|
|
|
+ }
|
|
|
+
|
|
|
+ return `└ bash completed (exit ${code})${time ? ` · ${time}` : ""}`
|
|
|
+}
|
|
|
+
|
|
|
+function scrollReadStart(p: ToolProps<typeof ReadTool>): string {
|
|
|
+ const file = toolPath(p.input.filePath)
|
|
|
+ const extra = info(p.frame.input, ["filePath"])
|
|
|
+ const tail = extra ? ` ${extra}` : ""
|
|
|
+ return `→ Read ${file}${tail}`.trim()
|
|
|
+}
|
|
|
+
|
|
|
+function scrollWriteStart(p: ToolProps<typeof WriteTool>): string {
|
|
|
+ return `← Write ${toolPath(p.input.filePath)}`.trim()
|
|
|
+}
|
|
|
+
|
|
|
+function scrollEditStart(p: ToolProps<typeof EditTool>): string {
|
|
|
+ const flag = info({ replaceAll: p.input.replaceAll })
|
|
|
+ const tail = flag ? ` ${flag}` : ""
|
|
|
+ return `← Edit ${toolPath(p.input.filePath)}${tail}`.trim()
|
|
|
+}
|
|
|
+
|
|
|
+function scrollPatchStart(p: ToolProps<typeof ApplyPatchTool>): string {
|
|
|
+ const files = list<PatchFile>(p.frame.meta.files)
|
|
|
+ if (files.length === 0) {
|
|
|
+ return "% Patch"
|
|
|
+ }
|
|
|
+
|
|
|
+ return `% Patch ${files.length} file${files.length === 1 ? "" : "s"}`
|
|
|
+}
|
|
|
+
|
|
|
+function patchLine(file: PatchFile): string {
|
|
|
+ const type = file.type
|
|
|
+ const rel = file.relativePath
|
|
|
+ const from = file.filePath
|
|
|
+
|
|
|
+ if (type === "add") {
|
|
|
+ return `+ Created ${rel || toolPath(from)}`
|
|
|
+ }
|
|
|
+
|
|
|
+ if (type === "delete") {
|
|
|
+ return `- Deleted ${rel || toolPath(from)}`
|
|
|
+ }
|
|
|
+
|
|
|
+ if (type === "move") {
|
|
|
+ return `→ Moved ${toolPath(from)} → ${rel || toolPath(file.movePath)}`
|
|
|
+ }
|
|
|
+
|
|
|
+ return `~ Patched ${rel || toolPath(from)}`
|
|
|
+}
|
|
|
+
|
|
|
+function scrollPatchFinal(p: ToolProps<typeof ApplyPatchTool>): string {
|
|
|
+ const files = list<PatchFile>(p.frame.meta.files)
|
|
|
+ const head = done("patch", span(p.frame.state))
|
|
|
+ if (files.length === 0) {
|
|
|
+ return head
|
|
|
+ }
|
|
|
+
|
|
|
+ const rows = [head, ...files.slice(0, 6).map(patchLine)]
|
|
|
+ if (files.length > 6) {
|
|
|
+ rows.push(`... and ${files.length - 6} more`)
|
|
|
+ }
|
|
|
+
|
|
|
+ return rows.join("\n")
|
|
|
+}
|
|
|
+
|
|
|
+function scrollTaskStart(p: ToolProps<typeof TaskTool>): string {
|
|
|
+ const kind = Locale.titlecase(p.input.subagent_type || "general")
|
|
|
+ const desc = p.input.description
|
|
|
+ if (!desc) {
|
|
|
+ return `│ ${kind} Task`
|
|
|
+ }
|
|
|
+
|
|
|
+ return `│ ${kind} Task — ${desc}`
|
|
|
+}
|
|
|
+
|
|
|
+function scrollTaskFinal(p: ToolProps<typeof TaskTool>): string {
|
|
|
+ const kind = Locale.titlecase(p.input.subagent_type || "general")
|
|
|
+ const head = done(`${kind} task`, span(p.frame.state))
|
|
|
+ const rows: string[] = [head]
|
|
|
+
|
|
|
+ const title = text(p.frame.state.title)
|
|
|
+ if (title) {
|
|
|
+ rows.push(`↳ ${title}`)
|
|
|
+ }
|
|
|
+
|
|
|
+ const calls = num(p.frame.meta.toolcalls) ?? num(p.frame.meta.toolCalls) ?? num(p.frame.meta.calls)
|
|
|
+ if (calls !== undefined) {
|
|
|
+ rows.push(`↳ ${Locale.number(calls)} toolcall${calls === 1 ? "" : "s"}`)
|
|
|
+ }
|
|
|
+
|
|
|
+ const sid = text(p.frame.meta.sessionId) || text(p.frame.meta.sessionID)
|
|
|
+ if (sid) {
|
|
|
+ rows.push(`↳ session ${sid}`)
|
|
|
+ }
|
|
|
+
|
|
|
+ return rows.join("\n")
|
|
|
+}
|
|
|
+
|
|
|
+function scrollTodoStart(p: ToolProps<typeof TodoWriteTool>): string {
|
|
|
+ const todos = p.input.todos ?? []
|
|
|
+ if (todos.length === 0) {
|
|
|
+ return "⚙ Updating todos..."
|
|
|
+ }
|
|
|
+
|
|
|
+ return `⚙ Updating ${todos.length} todo${todos.length === 1 ? "" : "s"}`
|
|
|
+}
|
|
|
+
|
|
|
+function scrollTodoFinal(p: ToolProps<typeof TodoWriteTool>): string {
|
|
|
+ const list = p.input.todos ?? []
|
|
|
+ if (list.length === 0) {
|
|
|
+ return done("todos", span(p.frame.state))
|
|
|
+ }
|
|
|
+
|
|
|
+ const doneN = list.filter((item) => item.status === "completed").length
|
|
|
+ const runN = list.filter((item) => item.status === "in_progress").length
|
|
|
+ const left = list.length - doneN - runN
|
|
|
+ const tail = [`${list.length} total`]
|
|
|
+ if (doneN > 0) {
|
|
|
+ tail.push(`${doneN} done`)
|
|
|
+ }
|
|
|
+ if (runN > 0) {
|
|
|
+ tail.push(`${runN} active`)
|
|
|
+ }
|
|
|
+ if (left > 0) {
|
|
|
+ tail.push(`${left} pending`)
|
|
|
+ }
|
|
|
+
|
|
|
+ return `${done("todos", span(p.frame.state))} · ${tail.join(" · ")}`
|
|
|
+}
|
|
|
+
|
|
|
+function scrollQuestionStart(p: ToolProps<typeof QuestionTool>): string {
|
|
|
+ const total = list(p.frame.input.questions).length
|
|
|
+ return `→ Asked ${total} question${total === 1 ? "" : "s"}`
|
|
|
+}
|
|
|
+
|
|
|
+function scrollQuestionFinal(p: ToolProps<typeof QuestionTool>): string {
|
|
|
+ const q = p.input.questions ?? []
|
|
|
+ const a = p.metadata.answers ?? []
|
|
|
+ if (q.length === 0) {
|
|
|
+ return done("questions", span(p.frame.state))
|
|
|
+ }
|
|
|
+
|
|
|
+ const rows = [done("questions", span(p.frame.state))]
|
|
|
+ for (const [i, item] of q.slice(0, 4).entries()) {
|
|
|
+ const prompt = item.question
|
|
|
+ const reply = a[i] ?? []
|
|
|
+ rows.push(`? ${prompt || `Question ${i + 1}`}`)
|
|
|
+ rows.push(` ${reply.length > 0 ? reply.join(", ") : "(no answer)"}`)
|
|
|
+ }
|
|
|
+
|
|
|
+ if (q.length > 4) {
|
|
|
+ rows.push(`... and ${q.length - 4} more`)
|
|
|
+ }
|
|
|
+
|
|
|
+ return rows.join("\n")
|
|
|
+}
|
|
|
+
|
|
|
+function scrollLspStart(p: ToolProps<typeof LspTool>): string {
|
|
|
+ return `→ ${lspTitle(p.input)}`
|
|
|
+}
|
|
|
+
|
|
|
+function scrollSkillStart(p: ToolProps<typeof SkillTool>): string {
|
|
|
+ return `→ Skill "${p.input.name ?? ""}"`
|
|
|
+}
|
|
|
+
|
|
|
+function scrollGlobStart(p: ToolProps<typeof GlobTool>): string {
|
|
|
+ const pattern = p.input.pattern ?? ""
|
|
|
+ const head = pattern ? `✱ Glob "${pattern}"` : "✱ Glob"
|
|
|
+ const dir = p.input.path ?? ""
|
|
|
+ if (!dir) {
|
|
|
+ return head
|
|
|
+ }
|
|
|
+
|
|
|
+ return `${head} in ${toolPath(dir)}`
|
|
|
+}
|
|
|
+
|
|
|
+function scrollGrepStart(p: ToolProps<typeof GrepTool>): string {
|
|
|
+ const pattern = p.input.pattern ?? ""
|
|
|
+ const head = pattern ? `✱ Grep "${pattern}"` : "✱ Grep"
|
|
|
+ const dir = p.input.path ?? ""
|
|
|
+ if (!dir) {
|
|
|
+ return head
|
|
|
+ }
|
|
|
+
|
|
|
+ return `${head} in ${toolPath(dir)}`
|
|
|
+}
|
|
|
+
|
|
|
+function scrollListStart(p: ToolProps<typeof ListTool>): string {
|
|
|
+ const dir = p.input.path ?? ""
|
|
|
+ if (!dir) {
|
|
|
+ return "→ List"
|
|
|
+ }
|
|
|
+
|
|
|
+ return `→ List ${toolPath(dir)}`
|
|
|
+}
|
|
|
+
|
|
|
+function scrollWebfetchStart(p: ToolProps<typeof WebFetchTool>): string {
|
|
|
+ const url = p.input.url ?? ""
|
|
|
+ if (!url) {
|
|
|
+ return "% WebFetch"
|
|
|
+ }
|
|
|
+
|
|
|
+ return `% WebFetch ${url}`
|
|
|
+}
|
|
|
+
|
|
|
+function scrollCodeSearchStart(p: ToolProps<typeof CodeSearchTool>): string {
|
|
|
+ const query = p.input.query ?? ""
|
|
|
+ if (!query) {
|
|
|
+ return "◇ Exa Code Search"
|
|
|
+ }
|
|
|
+
|
|
|
+ return `◇ Exa Code Search "${query}"`
|
|
|
+}
|
|
|
+
|
|
|
+function scrollWebSearchStart(p: ToolProps<typeof WebSearchTool>): string {
|
|
|
+ const query = p.input.query ?? ""
|
|
|
+ if (!query) {
|
|
|
+ return "◈ Exa Web Search"
|
|
|
+ }
|
|
|
+
|
|
|
+ return `◈ Exa Web Search "${query}"`
|
|
|
+}
|
|
|
+
|
|
|
+function permEdit(p: ToolPermissionProps<typeof EditTool>): ToolPermissionInfo {
|
|
|
+ const input = p.input as { filePath?: string; filepath?: string; diff?: string }
|
|
|
+ const file = input.filePath || input.filepath || p.patterns[0] || ""
|
|
|
+ return {
|
|
|
+ icon: "→",
|
|
|
+ title: `Edit ${toolPath(file, { home: true })}`,
|
|
|
+ lines: [],
|
|
|
+ diff: p.metadata.diff ?? input.diff,
|
|
|
+ file,
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function permRead(p: ToolPermissionProps<typeof ReadTool>): ToolPermissionInfo {
|
|
|
+ const file = p.input.filePath || p.patterns[0] || ""
|
|
|
+ return {
|
|
|
+ icon: "→",
|
|
|
+ title: `Read ${toolPath(file, { home: true })}`,
|
|
|
+ lines: file ? [`Path: ${toolPath(file, { home: true })}`] : [],
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function permGlob(p: ToolPermissionProps<typeof GlobTool>): ToolPermissionInfo {
|
|
|
+ const pattern = p.input.pattern || p.patterns[0] || ""
|
|
|
+ return {
|
|
|
+ icon: "✱",
|
|
|
+ title: `Glob "${pattern}"`,
|
|
|
+ lines: pattern ? [`Pattern: ${pattern}`] : [],
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function permGrep(p: ToolPermissionProps<typeof GrepTool>): ToolPermissionInfo {
|
|
|
+ const pattern = p.input.pattern || p.patterns[0] || ""
|
|
|
+ return {
|
|
|
+ icon: "✱",
|
|
|
+ title: `Grep "${pattern}"`,
|
|
|
+ lines: pattern ? [`Pattern: ${pattern}`] : [],
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function permList(p: ToolPermissionProps<typeof ListTool>): ToolPermissionInfo {
|
|
|
+ const dir = p.input.path || p.patterns[0] || ""
|
|
|
+ return {
|
|
|
+ icon: "→",
|
|
|
+ title: `List ${toolPath(dir, { home: true })}`,
|
|
|
+ lines: dir ? [`Path: ${toolPath(dir, { home: true })}`] : [],
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function permBash(p: ToolPermissionProps<typeof BashTool>): ToolPermissionInfo {
|
|
|
+ const title = p.input.description || "Shell command"
|
|
|
+ const cmd = p.input.command || ""
|
|
|
+ return {
|
|
|
+ icon: "#",
|
|
|
+ title,
|
|
|
+ lines: cmd ? [`$ ${cmd}`] : p.patterns.map((item) => `- ${item}`),
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function permTask(p: ToolPermissionProps<typeof TaskTool>): ToolPermissionInfo {
|
|
|
+ const type = p.input.subagent_type || "general"
|
|
|
+ const desc = p.input.description
|
|
|
+ return {
|
|
|
+ icon: "#",
|
|
|
+ title: `${Locale.titlecase(type)} Task`,
|
|
|
+ lines: desc ? [`◉ ${desc}`] : [],
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function permWebfetch(p: ToolPermissionProps<typeof WebFetchTool>): ToolPermissionInfo {
|
|
|
+ const url = p.input.url || ""
|
|
|
+ return {
|
|
|
+ icon: "%",
|
|
|
+ title: `WebFetch ${url}`,
|
|
|
+ lines: url ? [`URL: ${url}`] : [],
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function permWebSearch(p: ToolPermissionProps<typeof WebSearchTool>): ToolPermissionInfo {
|
|
|
+ const query = p.input.query || ""
|
|
|
+ return {
|
|
|
+ icon: "◈",
|
|
|
+ title: `Exa Web Search "${query}"`,
|
|
|
+ lines: query ? [`Query: ${query}`] : [],
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function permCodeSearch(p: ToolPermissionProps<typeof CodeSearchTool>): ToolPermissionInfo {
|
|
|
+ const query = p.input.query || ""
|
|
|
+ return {
|
|
|
+ icon: "◇",
|
|
|
+ title: `Exa Code Search "${query}"`,
|
|
|
+ lines: query ? [`Query: ${query}`] : [],
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function permLsp(p: ToolPermissionProps<typeof LspTool>): ToolPermissionInfo {
|
|
|
+ const file = p.input.filePath || ""
|
|
|
+ const line = typeof p.input.line === "number" ? p.input.line : undefined
|
|
|
+ const char = typeof p.input.character === "number" ? p.input.character : undefined
|
|
|
+ const pos = line !== undefined && char !== undefined ? `${line}:${char}` : undefined
|
|
|
+ return {
|
|
|
+ icon: "→",
|
|
|
+ title: lspTitle(p.input, { home: true }),
|
|
|
+ lines: [
|
|
|
+ ...(p.input.operation ? [`Operation: ${p.input.operation}`] : []),
|
|
|
+ ...(file ? [`Path: ${toolPath(file, { home: true })}`] : []),
|
|
|
+ ...(pos ? [`Position: ${pos}`] : []),
|
|
|
+ ],
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+const TOOL_RULES = {
|
|
|
+ invalid: {
|
|
|
+ view: {
|
|
|
+ output: true,
|
|
|
+ final: false,
|
|
|
+ },
|
|
|
+ run: runInvalid,
|
|
|
+ scroll: {
|
|
|
+ start: () => "",
|
|
|
+ },
|
|
|
+ },
|
|
|
+ bash: {
|
|
|
+ view: {
|
|
|
+ output: true,
|
|
|
+ final: false,
|
|
|
+ },
|
|
|
+ run: runBash,
|
|
|
+ scroll: {
|
|
|
+ start: scrollBashStart,
|
|
|
+ progress: scrollBashProgress,
|
|
|
+ final: scrollBashFinal,
|
|
|
+ },
|
|
|
+ permission: permBash,
|
|
|
+ },
|
|
|
+ write: {
|
|
|
+ view: {
|
|
|
+ output: false,
|
|
|
+ final: true,
|
|
|
+ snap: "code",
|
|
|
+ },
|
|
|
+ run: runWrite,
|
|
|
+ snap: snapWrite,
|
|
|
+ scroll: {
|
|
|
+ start: scrollWriteStart,
|
|
|
+ },
|
|
|
+ },
|
|
|
+ edit: {
|
|
|
+ view: {
|
|
|
+ output: false,
|
|
|
+ final: true,
|
|
|
+ snap: "diff",
|
|
|
+ },
|
|
|
+ run: runEdit,
|
|
|
+ snap: snapEdit,
|
|
|
+ scroll: {
|
|
|
+ start: scrollEditStart,
|
|
|
+ },
|
|
|
+ permission: permEdit,
|
|
|
+ },
|
|
|
+ apply_patch: {
|
|
|
+ view: {
|
|
|
+ output: false,
|
|
|
+ final: true,
|
|
|
+ snap: "diff",
|
|
|
+ },
|
|
|
+ run: runPatch,
|
|
|
+ snap: snapPatch,
|
|
|
+ scroll: {
|
|
|
+ start: scrollPatchStart,
|
|
|
+ final: scrollPatchFinal,
|
|
|
+ },
|
|
|
+ },
|
|
|
+ batch: {
|
|
|
+ view: {
|
|
|
+ output: true,
|
|
|
+ final: false,
|
|
|
+ },
|
|
|
+ run: runBatch,
|
|
|
+ scroll: {
|
|
|
+ start: () => "",
|
|
|
+ },
|
|
|
+ },
|
|
|
+ task: {
|
|
|
+ view: {
|
|
|
+ output: false,
|
|
|
+ final: true,
|
|
|
+ snap: "structured",
|
|
|
+ },
|
|
|
+ run: runTask,
|
|
|
+ snap: snapTask,
|
|
|
+ scroll: {
|
|
|
+ start: scrollTaskStart,
|
|
|
+ final: scrollTaskFinal,
|
|
|
+ },
|
|
|
+ permission: permTask,
|
|
|
+ },
|
|
|
+ todowrite: {
|
|
|
+ view: {
|
|
|
+ output: false,
|
|
|
+ final: true,
|
|
|
+ snap: "structured",
|
|
|
+ },
|
|
|
+ run: runTodo,
|
|
|
+ snap: snapTodo,
|
|
|
+ scroll: {
|
|
|
+ start: scrollTodoStart,
|
|
|
+ final: scrollTodoFinal,
|
|
|
+ },
|
|
|
+ },
|
|
|
+ question: {
|
|
|
+ view: {
|
|
|
+ output: false,
|
|
|
+ final: true,
|
|
|
+ snap: "structured",
|
|
|
+ },
|
|
|
+ run: runQuestion,
|
|
|
+ snap: snapQuestion,
|
|
|
+ scroll: {
|
|
|
+ start: scrollQuestionStart,
|
|
|
+ final: scrollQuestionFinal,
|
|
|
+ },
|
|
|
+ },
|
|
|
+ read: {
|
|
|
+ view: {
|
|
|
+ output: false,
|
|
|
+ final: false,
|
|
|
+ },
|
|
|
+ run: runRead,
|
|
|
+ scroll: {
|
|
|
+ start: scrollReadStart,
|
|
|
+ },
|
|
|
+ permission: permRead,
|
|
|
+ },
|
|
|
+ glob: {
|
|
|
+ view: {
|
|
|
+ output: false,
|
|
|
+ final: false,
|
|
|
+ },
|
|
|
+ run: runGlob,
|
|
|
+ scroll: {
|
|
|
+ start: scrollGlobStart,
|
|
|
+ },
|
|
|
+ permission: permGlob,
|
|
|
+ },
|
|
|
+ grep: {
|
|
|
+ view: {
|
|
|
+ output: false,
|
|
|
+ final: false,
|
|
|
+ },
|
|
|
+ run: runGrep,
|
|
|
+ scroll: {
|
|
|
+ start: scrollGrepStart,
|
|
|
+ },
|
|
|
+ permission: permGrep,
|
|
|
+ },
|
|
|
+ list: {
|
|
|
+ view: {
|
|
|
+ output: false,
|
|
|
+ final: false,
|
|
|
+ },
|
|
|
+ run: runList,
|
|
|
+ scroll: {
|
|
|
+ start: scrollListStart,
|
|
|
+ },
|
|
|
+ permission: permList,
|
|
|
+ },
|
|
|
+ lsp: {
|
|
|
+ view: {
|
|
|
+ output: false,
|
|
|
+ final: false,
|
|
|
+ },
|
|
|
+ run: runLsp,
|
|
|
+ scroll: {
|
|
|
+ start: scrollLspStart,
|
|
|
+ },
|
|
|
+ permission: permLsp,
|
|
|
+ },
|
|
|
+ webfetch: {
|
|
|
+ view: {
|
|
|
+ output: false,
|
|
|
+ final: false,
|
|
|
+ },
|
|
|
+ run: runWebfetch,
|
|
|
+ scroll: {
|
|
|
+ start: scrollWebfetchStart,
|
|
|
+ },
|
|
|
+ permission: permWebfetch,
|
|
|
+ },
|
|
|
+ codesearch: {
|
|
|
+ view: {
|
|
|
+ output: false,
|
|
|
+ final: false,
|
|
|
+ },
|
|
|
+ run: runCodeSearch,
|
|
|
+ scroll: {
|
|
|
+ start: scrollCodeSearchStart,
|
|
|
+ },
|
|
|
+ permission: permCodeSearch,
|
|
|
+ },
|
|
|
+ websearch: {
|
|
|
+ view: {
|
|
|
+ output: false,
|
|
|
+ final: false,
|
|
|
+ },
|
|
|
+ run: runWebSearch,
|
|
|
+ scroll: {
|
|
|
+ start: scrollWebSearchStart,
|
|
|
+ },
|
|
|
+ permission: permWebSearch,
|
|
|
+ },
|
|
|
+ skill: {
|
|
|
+ view: {
|
|
|
+ output: false,
|
|
|
+ final: false,
|
|
|
+ },
|
|
|
+ run: runSkill,
|
|
|
+ scroll: {
|
|
|
+ start: scrollSkillStart,
|
|
|
+ },
|
|
|
+ },
|
|
|
+ plan_exit: {
|
|
|
+ view: {
|
|
|
+ output: true,
|
|
|
+ final: false,
|
|
|
+ },
|
|
|
+ run: runPlanExit,
|
|
|
+ scroll: {
|
|
|
+ start: () => "",
|
|
|
+ },
|
|
|
+ },
|
|
|
+} as const satisfies ToolRegistry
|
|
|
+
|
|
|
+function key(name: string): name is ToolName {
|
|
|
+ return Object.prototype.hasOwnProperty.call(TOOL_RULES, name)
|
|
|
+}
|
|
|
+
|
|
|
+function rule(name?: string): AnyToolRule | undefined {
|
|
|
+ if (!name || !key(name)) {
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ return TOOL_RULES[name] as AnyToolRule
|
|
|
+}
|
|
|
+
|
|
|
+function frame(part: ToolPart): ToolFrame {
|
|
|
+ const state = dict(part.state)
|
|
|
+ return {
|
|
|
+ raw: "",
|
|
|
+ name: part.tool,
|
|
|
+ input: dict(state.input),
|
|
|
+ meta: dict(state.metadata),
|
|
|
+ state,
|
|
|
+ status: text(state.status),
|
|
|
+ error: text(state.error),
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+export function toolFrame(commit: StreamCommit, raw: string): ToolFrame {
|
|
|
+ const state = dict(commit.part?.state)
|
|
|
+ return {
|
|
|
+ raw,
|
|
|
+ name: commit.tool || commit.part?.tool || "tool",
|
|
|
+ input: dict(state.input),
|
|
|
+ meta: dict(state.metadata),
|
|
|
+ state,
|
|
|
+ status: commit.toolState ?? text(state.status),
|
|
|
+ error: (commit.toolError ?? "").trim(),
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function runBash(p: ToolProps<typeof BashTool>): ToolInline {
|
|
|
+ return {
|
|
|
+ icon: "$",
|
|
|
+ title: p.input.command || "",
|
|
|
+ mode: "block",
|
|
|
+ body: p.frame.status === "completed" ? text(p.frame.state.output).trim() : undefined,
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+export function toolView(name?: string): ToolView {
|
|
|
+ return (
|
|
|
+ rule(name)?.view ?? {
|
|
|
+ output: true,
|
|
|
+ final: true,
|
|
|
+ }
|
|
|
+ )
|
|
|
+}
|
|
|
+
|
|
|
+export function toolInlineInfo(part: ToolPart): ToolInline {
|
|
|
+ const ctx = frame(part)
|
|
|
+ const draw = rule(ctx.name)?.run
|
|
|
+ try {
|
|
|
+ if (draw) {
|
|
|
+ return draw(props(ctx))
|
|
|
+ }
|
|
|
+ } catch {
|
|
|
+ return fallbackInline(ctx)
|
|
|
+ }
|
|
|
+
|
|
|
+ return fallbackInline(ctx)
|
|
|
+}
|
|
|
+
|
|
|
+export function toolScroll(phase: ToolPhase, ctx: ToolFrame): string {
|
|
|
+ const draw = rule(ctx.name)?.scroll?.[phase]
|
|
|
+ try {
|
|
|
+ if (draw) {
|
|
|
+ return draw(props(ctx))
|
|
|
+ }
|
|
|
+ } catch {
|
|
|
+ if (phase === "start") {
|
|
|
+ return fallbackStart(ctx)
|
|
|
+ }
|
|
|
+ if (phase === "progress") {
|
|
|
+ return ctx.raw
|
|
|
+ }
|
|
|
+ return fallbackFinal(ctx)
|
|
|
+ }
|
|
|
+
|
|
|
+ if (phase === "start") {
|
|
|
+ return fallbackStart(ctx)
|
|
|
+ }
|
|
|
+
|
|
|
+ if (phase === "progress") {
|
|
|
+ return ctx.raw
|
|
|
+ }
|
|
|
+
|
|
|
+ return fallbackFinal(ctx)
|
|
|
+}
|
|
|
+
|
|
|
+export function toolPermissionInfo(
|
|
|
+ name: string,
|
|
|
+ input: ToolDict,
|
|
|
+ meta: ToolDict,
|
|
|
+ patterns: string[],
|
|
|
+): ToolPermissionInfo | undefined {
|
|
|
+ const draw = rule(name)?.permission
|
|
|
+ if (!draw) {
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ try {
|
|
|
+ return draw(permission({ input, meta, patterns }))
|
|
|
+ } catch {
|
|
|
+ return
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+export function toolSnapshot(commit: StreamCommit, raw: string): ToolSnapshot | undefined {
|
|
|
+ const ctx = toolFrame(commit, raw)
|
|
|
+ const draw = rule(ctx.name)?.snap
|
|
|
+ if (!draw) {
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ try {
|
|
|
+ return draw(props(ctx))
|
|
|
+ } catch {
|
|
|
+ return
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+export function toolFiletype(input?: string): string | undefined {
|
|
|
+ if (!input) {
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ const ext = path.extname(input)
|
|
|
+ const lang = LANGUAGE_EXTENSIONS[ext]
|
|
|
+ if (["typescriptreact", "javascriptreact", "javascript"].includes(lang)) {
|
|
|
+ return "typescript"
|
|
|
+ }
|
|
|
+
|
|
|
+ return lang
|
|
|
+}
|
|
|
+
|
|
|
+export function toolDiffView(width: number, style: RunDiffStyle | undefined): "unified" | "split" {
|
|
|
+ if (style === "stacked") {
|
|
|
+ return "unified"
|
|
|
+ }
|
|
|
+
|
|
|
+ return width > 120 ? "split" : "unified"
|
|
|
+}
|