cleanup tool formatting

This commit is contained in:
Simon Klee
2026-04-20 13:33:56 +02:00
parent 29069b08f7
commit 3b7cccd870
3 changed files with 336 additions and 63 deletions

View File

@@ -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<typeof WriteTool>): ToolSnapshot | undefined {
@@ -528,7 +528,7 @@ function snapEdit(p: ToolProps<typeof EditTool>): ToolSnapshot | undefined {
kind: "diff",
items: [
{
title: ` Edit ${toolPath(file)}`,
title: `# Edited ${toolPath(file)}`,
diff,
file,
},
@@ -569,29 +569,15 @@ function snapPatch(p: ToolProps<typeof ApplyPatchTool>): ToolSnapshot | undefine
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}`)
}
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<typeof ReadTool>): string {
return `→ Read ${file}${tail}`.trim()
}
function scrollWriteStart(p: ToolProps<typeof WriteTool>): string {
return `← Write ${toolPath(p.input.filePath)}`.trim()
function scrollWriteStart(_: ToolProps<typeof WriteTool>): string {
return ""
}
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 scrollEditStart(_: ToolProps<typeof EditTool>): string {
return ""
}
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 scrollPatchStart(_: ToolProps<typeof ApplyPatchTool>): string {
return ""
}
function patchLine(file: PatchFile): string {
@@ -745,6 +724,10 @@ function patchLine(file: PatchFile): string {
}
function scrollPatchFinal(p: ToolProps<typeof ApplyPatchTool>): string {
if (p.frame.status === "error") {
return fail(p.frame)
}
const files = list<PatchFile>(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<typeof TaskTool>): 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<typeof TodoWriteTool>): string {

View File

@@ -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: "",
})
})

View File

@@ -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)
}