Simon Klee 10 часов назад
Родитель
Сommit
3b7cccd870

+ 27 - 53
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<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 {
-  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"}`)
+  if (p.frame.status === "error") {
+    return fail(p.frame)
   }
 
-  const sid = text(p.frame.meta.sessionId) || text(p.frame.meta.sessionID)
-  if (sid) {
-    rows.push(`↳ session ${sid}`)
+  const kind = Locale.titlecase(p.input.subagent_type || "general")
+  const row = p.input.description || text(p.frame.state.title)
+  if (!row) {
+    return `# ${kind} Task`
   }
 
-  return rows.join("\n")
+  return `# ${kind} Task\n${row}`
 }
 
 function scrollTodoStart(_: ToolProps<typeof TodoWriteTool>): string {

+ 108 - 2
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: "",
     })
   })
 

+ 200 - 7
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)
   }