Simon Klee 7 часов назад
Родитель
Сommit
472cd737be

+ 36 - 23
packages/opencode/src/cli/cmd/run/scrollback.writer.tsx

@@ -11,20 +11,24 @@ import type { EntryLayout, RunEntryBody, ScrollbackOptions, StreamCommit } from
 
 function todoText(item: { status: string; content: string }): string {
   if (item.status === "completed") {
-    return `[x] ${item.content}`
+    return `[] ${item.content}`
   }
 
   if (item.status === "cancelled") {
-    return `[ ] ${item.content} (cancelled)`
+    return `~[ ] ${item.content}~`
   }
 
   if (item.status === "in_progress") {
-    return `[ ] ${item.content} (in progress)`
+    return `[•] ${item.content}`
   }
 
   return `[ ] ${item.content}`
 }
 
+function todoColor(theme: RunTheme, status: string) {
+  return status === "in_progress" ? theme.footer.warning : theme.block.muted
+}
+
 export function entryGroupKey(commit: StreamCommit): string | undefined {
   if (!commit.partID) {
     return
@@ -149,23 +153,28 @@ export function RunEntryContent(props: {
   }
 
   if (body().type === "structured") {
+    const snap = snapshot()
+    if (!snap) {
+      return null
+    }
+
     const width = Math.max(1, Math.trunc(props.width ?? 80))
 
-    if (snapshot()?.kind === "code") {
+    if (snap.kind === "code") {
       return (
         <box width="100%" flexDirection="column" gap={1}>
           <text width="100%" wrapMode="word" fg={theme.block.muted}>
-            {snapshot()?.title}
+            {snap.title}
           </text>
           <box width="100%" paddingLeft={1}>
             <line_number width="100%" fg={theme.block.muted} minWidth={3} paddingRight={1}>
               <code
                 width="100%"
                 wrapMode="char"
-                filetype={toolFiletype(snapshot()?.file)}
+                filetype={toolFiletype(snap.file)}
                 streaming={false}
                 syntaxStyle={entrySyntax(props.commit, theme)}
-                content={snapshot()?.content}
+                content={snap.content}
                 fg={theme.block.text}
               />
             </line_number>
@@ -174,11 +183,11 @@ export function RunEntryContent(props: {
       )
     }
 
-    if (snapshot()?.kind === "diff") {
+    if (snap.kind === "diff") {
       const view = toolDiffView(width, props.opts?.diffStyle)
       return (
         <box width="100%" flexDirection="column" gap={1}>
-          {(snapshot()?.items ?? []).map((item) => (
+          {snap.items.map((item) => (
             <box width="100%" flexDirection="column" gap={1}>
               <text width="100%" wrapMode="word" fg={theme.block.muted}>
                 {item.title}
@@ -216,21 +225,21 @@ export function RunEntryContent(props: {
       )
     }
 
-    if (snapshot()?.kind === "task") {
+    if (snap.kind === "task") {
       return (
         <box width="100%" flexDirection="column" gap={1}>
           <text width="100%" wrapMode="word" fg={theme.block.muted}>
-            {snapshot()?.title}
+            {snap.title}
           </text>
           <box width="100%" flexDirection="column" gap={0} paddingLeft={1}>
-            {(snapshot()?.rows ?? []).map((row) => (
+            {snap.rows.map((row) => (
               <text width="100%" wrapMode="word" fg={theme.block.text}>
                 {row}
               </text>
             ))}
-            {snapshot()?.tail ? (
+            {snap.tail ? (
               <text width="100%" wrapMode="word" fg={theme.block.muted}>
-                {snapshot()?.tail}
+                {snap.tail}
               </text>
             ) : null}
           </box>
@@ -238,21 +247,21 @@ export function RunEntryContent(props: {
       )
     }
 
-    if (snapshot()?.kind === "todo") {
+    if (snap.kind === "todo") {
       return (
         <box width="100%" flexDirection="column" gap={1}>
           <text width="100%" wrapMode="word" fg={theme.block.muted}>
             # Todos
           </text>
-          <box width="100%" flexDirection="column" gap={0} paddingLeft={1}>
-            {(snapshot()?.items ?? []).map((item) => (
-              <text width="100%" wrapMode="word" fg={theme.block.text}>
+          <box width="100%" flexDirection="column" gap={0}>
+            {snap.items.map((item) => (
+              <text width="100%" wrapMode="word" fg={todoColor(theme, item.status)}>
                 {todoText(item)}
               </text>
             ))}
-            {snapshot()?.tail ? (
+            {snap.tail ? (
               <text width="100%" wrapMode="word" fg={theme.block.muted}>
-                {snapshot()?.tail}
+                {snap.tail}
               </text>
             ) : null}
           </box>
@@ -260,13 +269,17 @@ export function RunEntryContent(props: {
       )
     }
 
+    if (snap.kind !== "question") {
+      return null
+    }
+
     return (
       <box width="100%" flexDirection="column" gap={1}>
         <text width="100%" wrapMode="word" fg={theme.block.muted}>
           # Questions
         </text>
         <box width="100%" flexDirection="column" gap={1} paddingLeft={1}>
-          {(snapshot()?.items ?? []).map((item) => (
+          {snap.items.map((item) => (
             <box width="100%" flexDirection="column" gap={0}>
               <text width="100%" wrapMode="word" fg={theme.block.muted}>
                 {item.question}
@@ -276,9 +289,9 @@ export function RunEntryContent(props: {
               </text>
             </box>
           ))}
-          {snapshot()?.tail ? (
+          {snap.tail ? (
             <text width="100%" wrapMode="word" fg={theme.block.muted}>
-              {snapshot()?.tail}
+              {snap.tail}
             </text>
           ) : null}
         </box>

+ 5 - 22
packages/opencode/src/cli/cmd/run/tool.ts

@@ -391,7 +391,8 @@ function runTodo(p: ToolProps<typeof TodoWriteTool>): ToolInline {
           return []
         }
 
-        return [`${item.status === "completed" ? "[x]" : "[ ]"} ${body}`]
+        const mark = item.status === "completed" ? "[✓]" : item.status === "in_progress" ? "[•]" : "[ ]"
+        return [`${mark} ${body}`]
       })
       .join("\n"),
   }
@@ -608,24 +609,11 @@ function snapTodo(p: ToolProps<typeof TodoWriteTool>): ToolSnapshot {
       },
     ]
   })
-  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(" · ")}`,
+    tail: "",
   }
 }
 
@@ -816,13 +804,8 @@ function scrollTaskFinal(p: ToolProps<typeof TaskTool>): string {
   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 scrollTodoStart(_: ToolProps<typeof TodoWriteTool>): string {
+  return ""
 }
 
 function scrollTodoFinal(p: ToolProps<typeof TodoWriteTool>): string {

+ 104 - 0
packages/opencode/test/cli/run/scrollback.surface.test.ts

@@ -442,6 +442,110 @@ test("inserts a spacer between block assistant entries and following inline tool
   }
 })
 
+test("renders todos without redundant start or footer lines", async () => {
+  const out = await createTestRenderer({
+    width: 80,
+    screenMode: "split-footer",
+    footerHeight: 6,
+    externalOutputMode: "capture-stdout",
+    consoleMode: "disabled",
+  })
+  active.push(out.renderer)
+
+  const scrollback = new RunScrollbackStream(out.renderer, RUN_THEME_FALLBACK, {
+    wrote: false,
+  })
+
+  await scrollback.append({
+    kind: "tool",
+    text: "",
+    phase: "start",
+    source: "tool",
+    partID: "todo-1",
+    messageID: "msg-1",
+    tool: "todowrite",
+    toolState: "running",
+    part: {
+      id: "todo-1",
+      sessionID: "session-1",
+      messageID: "msg-1",
+      type: "tool",
+      callID: "call-1",
+      tool: "todowrite",
+      state: {
+        status: "running",
+        input: {
+          todos: [
+            { status: "completed", content: "List files under `run/`" },
+            { status: "in_progress", content: "Count functions in each `run/` file" },
+            { status: "pending", content: "Mark each tracking item complete" },
+          ],
+        },
+        time: {
+          start: 1,
+        },
+      },
+    } as never,
+  })
+
+  expect(claimCommits(out.renderer)).toHaveLength(0)
+
+  await scrollback.append({
+    kind: "tool",
+    text: "",
+    phase: "final",
+    source: "tool",
+    partID: "todo-1",
+    messageID: "msg-1",
+    tool: "todowrite",
+    toolState: "completed",
+    part: {
+      id: "todo-1",
+      sessionID: "session-1",
+      messageID: "msg-1",
+      type: "tool",
+      callID: "call-1",
+      tool: "todowrite",
+      state: {
+        status: "completed",
+        input: {
+          todos: [
+            { status: "completed", content: "List files under `run/`" },
+            { status: "in_progress", content: "Count functions in each `run/` file" },
+            { status: "pending", content: "Mark each tracking item complete" },
+          ],
+        },
+        metadata: {},
+        time: {
+          start: 1,
+          end: 4,
+        },
+      },
+    } as never,
+  })
+
+  const commits = claimCommits(out.renderer)
+  try {
+    expect(commits).toHaveLength(1)
+    const raw = decoder.decode(commits[0]!.snapshot.getRealCharBytes(true))
+    const rows = Array.from({ length: commits[0]!.snapshot.height }, (_, index) =>
+      raw.slice(index * 80, (index + 1) * 80).trimEnd(),
+    )
+    const rendered = rows.join("\n")
+    expect(rendered).toContain("# Todos")
+    expect(rendered).toContain("[✓] List files under `run/`")
+    expect(rendered).toContain("[•] Count functions in each `run/` file")
+    expect(rendered).toContain("[ ] Mark each tracking item complete")
+    expect(rendered).not.toContain("Updating")
+    expect(rendered).not.toContain("todos completed")
+    expect(rows).toContain("[✓] List files under `run/`")
+    expect(rows).toContain("[•] Count functions in each `run/` file")
+    expect(rows).toContain("[ ] Mark each tracking item complete")
+  } finally {
+    destroyCommits(commits)
+  }
+})
+
 test("bodyless starts keep the previous rendered item as separator context", async () => {
   const out = await createTestRenderer({
     width: 80,