diff --git a/packages/opencode/src/cli/cmd/run/tool.ts b/packages/opencode/src/cli/cmd/run/tool.ts index 1467120c60..d46447768b 100644 --- a/packages/opencode/src/cli/cmd/run/tool.ts +++ b/packages/opencode/src/cli/cmd/run/tool.ts @@ -1,15 +1,15 @@ -// Per-tool display rules for direct interactive mode. +// Per-tool display rules shared across `opencode run` output paths. // // Each known tool (bash, edit, write, task, etc.) has a ToolRule that controls -// four rendering contexts: +// five display hooks: // -// view → controls which phases produce scrollback output (output for -// progress, final for completion, snap for rich snapshots) +// view → visibility policy for progress/final scrollback entries and +// whether completed finals can render as structured 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 +// snap → structured snapshot (code block, diff, task card) for rich +// scrollback entries // // Tools not in TOOL_RULES get fallback formatting. The registry is typed // against the actual tool parameter/metadata types so each formatter gets @@ -499,7 +499,7 @@ function patchTitle(file: PatchFile): string { return `# Moved ${toolPath(from)} -> ${rel || toolPath(file.movePath)}` } - return `← Patched ${rel || toolPath(from)}` + return `# Patched ${rel || toolPath(from)}` } function snapWrite(p: ToolProps): ToolSnapshot | undefined { @@ -528,7 +528,7 @@ function snapEdit(p: ToolProps): ToolSnapshot | undefined { kind: "diff", items: [ { - title: `← Edit ${toolPath(file)}`, + title: `# Edited ${toolPath(file)}`, diff, file, }, @@ -569,29 +569,15 @@ function snapPatch(p: ToolProps): ToolSnapshot | undefine function snapTask(p: ToolProps): 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}`) - } + const rows = [desc || title].filter((item): item is string => Boolean(item)) return { kind: "task", title: `# ${kind} Task`, rows, - tail: done(`${kind} task`, span(p.frame.state)), + tail: "", } } @@ -705,23 +691,16 @@ function scrollReadStart(p: ToolProps): string { return `→ Read ${file}${tail}`.trim() } -function scrollWriteStart(p: ToolProps): string { - return `← Write ${toolPath(p.input.filePath)}`.trim() +function scrollWriteStart(_: ToolProps): string { + return "" } -function scrollEditStart(p: ToolProps): string { - const flag = info({ replaceAll: p.input.replaceAll }) - const tail = flag ? ` ${flag}` : "" - return `← Edit ${toolPath(p.input.filePath)}${tail}`.trim() +function scrollEditStart(_: ToolProps): string { + return "" } -function scrollPatchStart(p: ToolProps): string { - const files = list(p.frame.meta.files) - if (files.length === 0) { - return "% Patch" - } - - return `% Patch ${files.length} file${files.length === 1 ? "" : "s"}` +function scrollPatchStart(_: ToolProps): string { + return "" } function patchLine(file: PatchFile): string { @@ -745,6 +724,10 @@ function patchLine(file: PatchFile): string { } function scrollPatchFinal(p: ToolProps): string { + if (p.frame.status === "error") { + return fail(p.frame) + } + const files = list(p.frame.meta.files) const head = done("patch", span(p.frame.state)) if (files.length === 0) { @@ -782,26 +765,17 @@ function taskResult(output: string) { } function scrollTaskFinal(p: ToolProps): string { + if (p.frame.status === "error") { + return fail(p.frame) + } + 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 row = p.input.description || text(p.frame.state.title) + if (!row) { + return `# ${kind} Task` } - 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") + return `# ${kind} Task\n${row}` } function scrollTodoStart(_: ToolProps): string { diff --git a/packages/opencode/test/cli/run/entry.body.test.ts b/packages/opencode/test/cli/run/entry.body.test.ts index 1aaf0cf48b..0d11e57de5 100644 --- a/packages/opencode/test/cli/run/entry.body.test.ts +++ b/packages/opencode/test/cli/run/entry.body.test.ts @@ -114,6 +114,112 @@ describe("run entry body", () => { )).toBe(true) }) + test("keeps completed edit tool finals structured", () => { + const body = entryBody( + commit({ + kind: "tool", + text: "", + phase: "final", + source: "tool", + tool: "edit", + toolState: "completed", + part: { + id: "tool-2", + sessionID: "session-1", + messageID: "msg-2", + type: "tool", + callID: "call-2", + tool: "edit", + state: { + status: "completed", + input: { + filePath: "src/a.ts", + }, + metadata: { + diff: "@@ -1 +1 @@\n-old\n+new\n", + }, + time: { + start: 1, + end: 2, + }, + }, + } as never, + }), + ) + + expect(body.type).toBe("structured") + if (body.type !== "structured") { + throw new Error("expected structured body") + } + + expect(body.snapshot).toEqual({ + kind: "diff", + items: [ + { + title: "# Edited src/a.ts", + diff: "@@ -1 +1 @@\n-old\n+new\n", + file: "src/a.ts", + }, + ], + }) + }) + + test("keeps completed apply_patch tool finals structured", () => { + const body = entryBody( + commit({ + kind: "tool", + text: "", + phase: "final", + source: "tool", + tool: "apply_patch", + toolState: "completed", + part: { + id: "tool-3", + sessionID: "session-1", + messageID: "msg-3", + type: "tool", + callID: "call-3", + tool: "apply_patch", + state: { + status: "completed", + input: {}, + metadata: { + files: [ + { + type: "update", + filePath: "src/a.ts", + relativePath: "src/a.ts", + patch: "@@ -1 +1 @@\n-old\n+new\n", + }, + ], + }, + time: { + start: 1, + end: 2, + }, + }, + } as never, + }), + ) + + expect(body.type).toBe("structured") + if (body.type !== "structured") { + throw new Error("expected structured body") + } + + expect(body.snapshot).toEqual({ + kind: "diff", + items: [ + { + title: "# Patched src/a.ts", + diff: "@@ -1 +1 @@\n-old\n+new\n", + file: "src/a.ts", + deletions: 0, + }, + ], + }) + }) + test("keeps running task tool state out of scrollback", () => { expect( entryBody( @@ -242,8 +348,8 @@ describe("run entry body", () => { expect(body.snapshot).toEqual({ kind: "task", title: "# Explore Task", - rows: ["◉ Inspect reducer", "↳ session child-1"], - tail: "└ Explore task completed · 1ms", + rows: ["Inspect reducer"], + tail: "", }) }) diff --git a/packages/opencode/test/cli/run/scrollback.surface.test.ts b/packages/opencode/test/cli/run/scrollback.surface.test.ts index 0c5b4f1f78..c21adba703 100644 --- a/packages/opencode/test/cli/run/scrollback.surface.test.ts +++ b/packages/opencode/test/cli/run/scrollback.surface.test.ts @@ -272,7 +272,7 @@ test("preserves blank rows between streamed markdown block commits", async () => } }) -test("inserts a spacer between inline tool starts and block tool finals", async () => { +test("renders write finals without a redundant start row", async () => { const out = await createTestRenderer({ width: 80, screenMode: "split-footer", @@ -321,8 +321,7 @@ test("inserts a spacer between inline tool starts and block tool finals", async const start = claimCommits(out.renderer) try { - expect(start).toHaveLength(1) - expect(renderCommit(start[0]!).trim()).toBe("← Write src/a.ts") + expect(start).toHaveLength(0) } finally { destroyCommits(start) } @@ -360,14 +359,208 @@ test("inserts a spacer between inline tool starts and block tool finals", async const final = claimCommits(out.renderer) try { - expect(final).toHaveLength(2) - expect(renderCommit(final[0]!).trim()).toBe("") - expect(renderCommit(final[1]!)).toContain("# Wrote src/a.ts") + expect(final).toHaveLength(1) + expect(renderCommit(final[0]!)).toContain("# Wrote src/a.ts") } finally { destroyCommits(final) } }) +test("renders edit finals without a redundant start row", async () => { + const out = await createTestRenderer({ + width: 80, + screenMode: "split-footer", + footerHeight: 6, + externalOutputMode: "capture-stdout", + consoleMode: "disabled", + }) + active.push(out.renderer) + + const treeSitterClient = new MockTreeSitterClient({ autoResolveTimeout: 0 }) + treeSitterClient.setMockResult({ highlights: [] }) + + const scrollback = new RunScrollbackStream(out.renderer, RUN_THEME_FALLBACK, { + treeSitterClient, + wrote: false, + }) + + await scrollback.append({ + kind: "tool", + text: "", + phase: "start", + source: "tool", + partID: "tool-edit", + messageID: "msg-edit", + tool: "edit", + toolState: "running", + part: { + id: "tool-edit", + sessionID: "session-1", + messageID: "msg-edit", + type: "tool", + callID: "call-edit", + tool: "edit", + state: { + status: "running", + input: { + filePath: "src/a.ts", + oldString: "old", + newString: "new", + }, + time: { + start: 1, + }, + }, + } as never, + }) + + expect(claimCommits(out.renderer)).toHaveLength(0) + + await scrollback.append({ + kind: "tool", + text: "", + phase: "final", + source: "tool", + partID: "tool-edit", + messageID: "msg-edit", + tool: "edit", + toolState: "completed", + part: { + id: "tool-edit", + sessionID: "session-1", + messageID: "msg-edit", + type: "tool", + callID: "call-edit", + tool: "edit", + state: { + status: "completed", + input: { + filePath: "src/a.ts", + oldString: "old", + newString: "new", + }, + metadata: { + diff: "@@ -1 +1 @@\n-old\n+new\n", + }, + time: { + start: 1, + end: 2, + }, + }, + } as never, + }) + + const commits = claimCommits(out.renderer) + try { + expect(commits).toHaveLength(1) + expect(renderCommit(commits[0]!)).toContain("# Edited src/a.ts") + } finally { + destroyCommits(commits) + } +}) + +test("renders apply_patch finals without a redundant start row", async () => { + const out = await createTestRenderer({ + width: 80, + screenMode: "split-footer", + footerHeight: 6, + externalOutputMode: "capture-stdout", + consoleMode: "disabled", + }) + active.push(out.renderer) + + const treeSitterClient = new MockTreeSitterClient({ autoResolveTimeout: 0 }) + treeSitterClient.setMockResult({ highlights: [] }) + + const scrollback = new RunScrollbackStream(out.renderer, RUN_THEME_FALLBACK, { + treeSitterClient, + wrote: false, + }) + + await scrollback.append({ + kind: "tool", + text: "", + phase: "start", + source: "tool", + partID: "tool-patch", + messageID: "msg-patch", + tool: "apply_patch", + toolState: "running", + part: { + id: "tool-patch", + sessionID: "session-1", + messageID: "msg-patch", + type: "tool", + callID: "call-patch", + tool: "apply_patch", + state: { + status: "running", + input: {}, + metadata: { + files: [ + { + type: "update", + filePath: "src/a.ts", + relativePath: "src/a.ts", + patch: "@@ -1 +1 @@\n-old\n+new\n", + }, + ], + }, + time: { + start: 1, + }, + }, + } as never, + }) + + expect(claimCommits(out.renderer)).toHaveLength(0) + + await scrollback.append({ + kind: "tool", + text: "", + phase: "final", + source: "tool", + partID: "tool-patch", + messageID: "msg-patch", + tool: "apply_patch", + toolState: "completed", + part: { + id: "tool-patch", + sessionID: "session-1", + messageID: "msg-patch", + type: "tool", + callID: "call-patch", + tool: "apply_patch", + state: { + status: "completed", + input: {}, + metadata: { + files: [ + { + type: "update", + filePath: "src/a.ts", + relativePath: "src/a.ts", + patch: "@@ -1 +1 @@\n-old\n+new\n", + }, + ], + }, + time: { + start: 1, + end: 2, + }, + }, + } as never, + }) + + const commits = claimCommits(out.renderer) + try { + expect(commits).toHaveLength(1) + expect(renderCommit(commits[0]!)).toContain("# Patched src/a.ts") + } finally { + destroyCommits(commits) + } +}) + test("inserts a spacer between block assistant entries and following inline tools", async () => { const out = await createTestRenderer({ width: 80, @@ -751,7 +944,7 @@ test("bodyless starts keep the previous rendered item as separator context", asy try { expect(final).toHaveLength(2) expect(renderCommit(final[0]!).trim()).toBe("") - expect(renderCommit(final[1]!)).toContain("Explore task completed") + expect(renderCommit(final[1]!)).toContain("failed") } finally { destroyCommits(final) }