diff --git a/packages/opencode/src/cli/cmd/run/scrollback.writer.tsx b/packages/opencode/src/cli/cmd/run/scrollback.writer.tsx index 17a72278b6..bc14c37d8b 100644 --- a/packages/opencode/src/cli/cmd/run/scrollback.writer.tsx +++ b/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 ( - {snapshot()?.title} + {snap.title} @@ -174,11 +183,11 @@ export function RunEntryContent(props: { ) } - if (snapshot()?.kind === "diff") { + if (snap.kind === "diff") { const view = toolDiffView(width, props.opts?.diffStyle) return ( - {(snapshot()?.items ?? []).map((item) => ( + {snap.items.map((item) => ( {item.title} @@ -216,21 +225,21 @@ export function RunEntryContent(props: { ) } - if (snapshot()?.kind === "task") { + if (snap.kind === "task") { return ( - {snapshot()?.title} + {snap.title} - {(snapshot()?.rows ?? []).map((row) => ( + {snap.rows.map((row) => ( {row} ))} - {snapshot()?.tail ? ( + {snap.tail ? ( - {snapshot()?.tail} + {snap.tail} ) : null} @@ -238,21 +247,21 @@ export function RunEntryContent(props: { ) } - if (snapshot()?.kind === "todo") { + if (snap.kind === "todo") { return ( # Todos - - {(snapshot()?.items ?? []).map((item) => ( - + + {snap.items.map((item) => ( + {todoText(item)} ))} - {snapshot()?.tail ? ( + {snap.tail ? ( - {snapshot()?.tail} + {snap.tail} ) : null} @@ -260,13 +269,17 @@ export function RunEntryContent(props: { ) } + if (snap.kind !== "question") { + return null + } + return ( # Questions - {(snapshot()?.items ?? []).map((item) => ( + {snap.items.map((item) => ( {item.question} @@ -276,9 +289,9 @@ export function RunEntryContent(props: { ))} - {snapshot()?.tail ? ( + {snap.tail ? ( - {snapshot()?.tail} + {snap.tail} ) : null} diff --git a/packages/opencode/src/cli/cmd/run/tool.ts b/packages/opencode/src/cli/cmd/run/tool.ts index a41f4a855f..d8258f4785 100644 --- a/packages/opencode/src/cli/cmd/run/tool.ts +++ b/packages/opencode/src/cli/cmd/run/tool.ts @@ -391,7 +391,8 @@ function runTodo(p: ToolProps): 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): 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): string { return rows.join("\n") } -function scrollTodoStart(p: ToolProps): 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): string { + return "" } function scrollTodoFinal(p: ToolProps): string { diff --git a/packages/opencode/test/cli/run/scrollback.surface.test.ts b/packages/opencode/test/cli/run/scrollback.surface.test.ts index e86371a0cd..094af15ac3 100644 --- a/packages/opencode/test/cli/run/scrollback.surface.test.ts +++ b/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,