mirror of
https://fastgit.cc/https://github.com/anomalyco/opencode
synced 2026-04-21 05:10:58 +08:00
cleanup tool formatting
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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: "",
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user