From fef2f8119ca579591adee68171ac9f74b33115ce Mon Sep 17 00:00:00 2001 From: Simon Klee Date: Mon, 20 Apr 2026 11:56:32 +0200 Subject: [PATCH] fix(cli): tighten run scrollback separators --- .../src/cli/cmd/run/footer.subagent.tsx | 8 +- .../src/cli/cmd/run/scrollback.surface.ts | 119 ++++--- .../src/cli/cmd/run/scrollback.writer.tsx | 171 ++++++--- packages/opencode/src/cli/cmd/run/types.ts | 2 + packages/opencode/test/cli/run/footer.test.ts | 15 +- .../test/cli/run/footer.view.test.tsx | 45 ++- .../test/cli/run/scrollback.surface.test.ts | 333 ++++++++++++++++++ 7 files changed, 580 insertions(+), 113 deletions(-) diff --git a/packages/opencode/src/cli/cmd/run/footer.subagent.tsx b/packages/opencode/src/cli/cmd/run/footer.subagent.tsx index a608f79d96..d09b3a059d 100644 --- a/packages/opencode/src/cli/cmd/run/footer.subagent.tsx +++ b/packages/opencode/src/cli/cmd/run/footer.subagent.tsx @@ -4,7 +4,7 @@ import { useKeyboard } from "@opentui/solid" import "opentui-spinner/solid" import { createMemo, mapArray } from "solid-js" import { SPINNER_FRAMES } from "../tui/component/spinner" -import { RunEntryContent, sameEntryGroup } from "./scrollback.writer" +import { RunEntryContent, separatorRows } from "./scrollback.writer" import type { FooterSubagentDetail, FooterSubagentTab, RunDiffStyle } from "./types" import type { RunFooterTheme, RunTheme } from "./theme" @@ -13,7 +13,7 @@ export const SUBAGENT_INSPECTOR_ROWS = 8 function statusColor(theme: RunFooterTheme, status: FooterSubagentTab["status"]) { if (status === "completed") { - return theme.success + return theme.highlight } if (status === "error") { @@ -121,8 +121,8 @@ export function RunFooterSubagentBody(props: { }, })) const rows = mapArray(commits, (commit, index) => ( - - {index() > 0 && !sameEntryGroup(commits()[index() - 1], commit) ? : null} + + {index() > 0 && separatorRows(commits()[index() - 1], commit) > 0 ? : null} )) diff --git a/packages/opencode/src/cli/cmd/run/scrollback.surface.ts b/packages/opencode/src/cli/cmd/run/scrollback.surface.ts index b885126949..87caf5ef3a 100644 --- a/packages/opencode/src/cli/cmd/run/scrollback.surface.ts +++ b/packages/opencode/src/cli/cmd/run/scrollback.surface.ts @@ -1,7 +1,7 @@ // Retained streaming append logic for direct-mode scrollback. // // Static entries are rendered through `scrollback.writer.tsx`. This file only -// keeps the minimum retained-surface machinery needed for streaming assistant, +// keeps the retained-surface machinery needed for streaming assistant, // reasoning, and tool progress entries that need stable markdown/code layout // while content is still arriving. import { @@ -16,7 +16,7 @@ import { import { entryBody, entryCanStream, entryDone, entryFlags } from "./entry.body" import { withRunSpan } from "./otel" import { entryColor, entryLook, entrySyntax } from "./scrollback.shared" -import { entryWriter, sameEntryGroup, spacerWriter } from "./scrollback.writer" +import { entryWriter, sameEntryGroup, separatorRows, spacerWriter } from "./scrollback.writer" import { type RunTheme } from "./theme" import type { RunDiffStyle, RunEntryBody, StreamCommit } from "./types" @@ -30,6 +30,8 @@ type ActiveEntry = { content: string committedRows: number committedBlocks: number + pendingSpacerRows: number + rendered: boolean } let nextId = 0 @@ -40,6 +42,7 @@ function commitMarkdownBlocks(input: { startBlock: number endBlockExclusive: number trailingNewline: boolean + beforeCommit?: () => void }) { if (input.endBlockExclusive <= input.startBlock) { return false @@ -53,30 +56,19 @@ function commitMarkdownBlocks(input: { const next = input.renderable._blockStates[input.endBlockExclusive] const start = first.renderable.y - const end = next ? next.renderable.y : last.renderable.y + last.renderable.height + (last.marginBottom ?? 0) + const end = next ? next.renderable.y : last.renderable.y + last.renderable.height + input.beforeCommit?.() input.surface.commitRows(start, end, { trailingNewline: input.trailingNewline, }) return true } -function wantsSpacer(prev: StreamCommit | undefined, next: StreamCommit): boolean { - if (!prev) { - return false - } - - if (sameEntryGroup(prev, next)) { - return false - } - - return !(prev.kind === "tool" && prev.phase === "start") -} - export class RunScrollbackStream { private tail: StreamCommit | undefined + private rendered: StreamCommit | undefined private active: ActiveEntry | undefined - private wrote: boolean private diffStyle: RunDiffStyle | undefined private sessionID?: () => string | undefined private treeSitterClient: TreeSitterClient | undefined @@ -91,7 +83,6 @@ export class RunScrollbackStream { treeSitterClient?: TreeSitterClient } = {}, ) { - this.wrote = options.wrote ?? true this.diffStyle = options.diffStyle this.sessionID = options.sessionID this.treeSitterClient = options.treeSitterClient ?? getTreeSitterClient() @@ -148,13 +139,36 @@ export class RunScrollbackStream { content: "", committedRows: 0, committedBlocks: 0, + pendingSpacerRows: separatorRows(this.rendered, commit, body), + rendered: false, } } - private async flushActive(done: boolean, trailingNewline: boolean): Promise { + private markRendered(commit: StreamCommit | undefined): void { + if (!commit) { + return + } + + this.rendered = commit + } + + private writeSpacer(rows: number): void { + if (rows === 0) { + return + } + + this.renderer.writeToScrollback(spacerWriter()) + } + + private flushPendingSpacer(active: ActiveEntry): void { + this.writeSpacer(active.pendingSpacerRows) + active.pendingSpacerRows = 0 + } + + private async flushActive(done: boolean, trailingNewline: boolean): Promise { const active = this.active if (!active) { - return + return false } if (active.body.type === "text") { @@ -162,13 +176,17 @@ export class RunScrollbackStream { renderable.content = active.content active.surface.render() const targetRows = done ? active.surface.height : Math.max(active.committedRows, active.surface.height - 1) - if (targetRows > active.committedRows) { - active.surface.commitRows(active.committedRows, targetRows, { - trailingNewline: done && targetRows === active.surface.height ? trailingNewline : false, - }) - active.committedRows = targetRows + if (targetRows <= active.committedRows) { + return false } - return + + this.flushPendingSpacer(active) + active.surface.commitRows(active.committedRows, targetRows, { + trailingNewline: done && targetRows === active.surface.height ? trailingNewline : false, + }) + active.committedRows = targetRows + active.rendered = true + return true } if (active.body.type === "code") { @@ -177,13 +195,17 @@ export class RunScrollbackStream { renderable.streaming = !done await active.surface.settle() const targetRows = done ? active.surface.height : Math.max(active.committedRows, active.surface.height - 1) - if (targetRows > active.committedRows) { - active.surface.commitRows(active.committedRows, targetRows, { - trailingNewline: done && targetRows === active.surface.height ? trailingNewline : false, - }) - active.committedRows = targetRows + if (targetRows <= active.committedRows) { + return false } - return + + this.flushPendingSpacer(active) + active.surface.commitRows(active.committedRows, targetRows, { + trailingNewline: done && targetRows === active.surface.height ? trailingNewline : false, + }) + active.committedRows = targetRows + active.rendered = true + return true } const renderable = active.renderable as MarkdownRenderable @@ -192,7 +214,7 @@ export class RunScrollbackStream { await active.surface.settle() const targetBlockCount = done ? renderable._blockStates.length : renderable._stableBlockCount if (targetBlockCount <= active.committedBlocks) { - return + return false } if ( @@ -202,13 +224,18 @@ export class RunScrollbackStream { startBlock: active.committedBlocks, endBlockExclusive: targetBlockCount, trailingNewline: done && targetBlockCount === renderable._blockStates.length ? trailingNewline : false, + beforeCommit: () => this.flushPendingSpacer(active), }) ) { active.committedBlocks = targetBlockCount + active.rendered = true + return true } + + return false } - private async finishActive(trailingNewline: boolean): Promise { + private async finishActive(trailingNewline: boolean): Promise { if (!this.active) { return } @@ -226,11 +253,13 @@ export class RunScrollbackStream { active.surface.destroy() } } + + return active.rendered ? active.commit : undefined } private async writeStreaming(commit: StreamCommit, body: ActiveBody): Promise { if (!this.active || !sameEntryGroup(this.active.commit, commit) || this.active.body.type !== body.type) { - await this.finishActive(false) + this.markRendered(await this.finishActive(false)) this.active = this.createEntry(commit, body) } @@ -238,28 +267,27 @@ export class RunScrollbackStream { this.active.commit = commit this.active.content += body.content await this.flushActive(false, false) + if (this.active.rendered) { + this.markRendered(this.active.commit) + } } public async append(commit: StreamCommit): Promise { const same = sameEntryGroup(this.tail, commit) if (!same) { - await this.finishActive(false) + this.markRendered(await this.finishActive(false)) } const body = entryBody(commit) if (body.type === "none") { if (entryDone(commit)) { - await this.finishActive(false) + this.markRendered(await this.finishActive(false)) } this.tail = commit return } - if (this.wrote && wantsSpacer(this.tail, commit)) { - this.renderer.writeToScrollback(spacerWriter()) - } - if ( body.type !== "structured" && (entryCanStream(commit, body) || @@ -267,17 +295,18 @@ export class RunScrollbackStream { ) { await this.writeStreaming(commit, body) if (entryDone(commit)) { - await this.finishActive(false) + this.markRendered(await this.finishActive(false)) } - this.wrote = true this.tail = commit return } if (same) { - await this.finishActive(false) + this.markRendered(await this.finishActive(false)) } + this.writeSpacer(separatorRows(this.rendered, commit, body)) + this.renderer.writeToScrollback( entryWriter({ commit, @@ -287,7 +316,7 @@ export class RunScrollbackStream { }, }), ) - this.wrote = true + this.markRendered(commit) this.tail = commit } @@ -312,7 +341,7 @@ export class RunScrollbackStream { "session.id": this.sessionID?.() || undefined, }, async () => { - await this.finishActive(trailingNewline) + this.markRendered(await this.finishActive(trailingNewline)) }, ) } diff --git a/packages/opencode/src/cli/cmd/run/scrollback.writer.tsx b/packages/opencode/src/cli/cmd/run/scrollback.writer.tsx index 70d14b9955..17a72278b6 100644 --- a/packages/opencode/src/cli/cmd/run/scrollback.writer.tsx +++ b/packages/opencode/src/cli/cmd/run/scrollback.writer.tsx @@ -7,7 +7,7 @@ import { entryBody, entryFlags } from "./entry.body" import { entryColor, entryLook, entrySyntax } from "./scrollback.shared" import { toolDiffView, toolFiletype, toolStructuredFinal } from "./tool" import { RUN_THEME_FALLBACK, type RunTheme } from "./theme" -import type { ScrollbackOptions, StreamCommit } from "./types" +import type { EntryLayout, RunEntryBody, ScrollbackOptions, StreamCommit } from "./types" function todoText(item: { status: string; content: string }): string { if (item.status === "completed") { @@ -44,11 +44,39 @@ export function sameEntryGroup(left: StreamCommit | undefined, right: StreamComm const current = entryGroupKey(left) const next = entryGroupKey(right) - if (current && next && current === next) { - return true + return Boolean(current && next && current === next) +} + +export function entryLayout(commit: StreamCommit, body: RunEntryBody = entryBody(commit)): EntryLayout { + if (commit.kind === "tool") { + if (body.type === "structured" || body.type === "markdown") { + return "block" + } + + return "inline" } - return left.kind === "tool" && left.phase === "start" && right.kind === "tool" && right.phase === "start" + if (commit.kind === "reasoning") { + return "block" + } + + return "block" +} + +export function separatorRows( + prev: StreamCommit | undefined, + next: StreamCommit, + body: RunEntryBody = entryBody(next), +): number { + if (!prev || sameEntryGroup(prev, next)) { + return 0 + } + + if (entryLayout(prev) === "inline" && entryLayout(next, body) === "inline") { + return 0 + } + + return 1 } export function RunEntryContent(props: { @@ -59,6 +87,39 @@ export function RunEntryContent(props: { }) { const theme = props.theme ?? RUN_THEME_FALLBACK const body = createMemo(() => entryBody(props.commit)) + const text = () => { + const value = body() + if (value.type !== "text") { + return + } + + return value + } + const code = () => { + const value = body() + if (value.type !== "code") { + return + } + + return value + } + const snapshot = () => { + const value = body() + if (value.type !== "structured") { + return + } + + return value.snapshot + } + const markdown = () => { + const value = body() + if (value.type !== "markdown") { + return + } + + return value + } + if (body().type === "none") { return null } @@ -67,7 +128,7 @@ export function RunEntryContent(props: { const style = entryLook(props.commit, theme.entry) return ( - {body().content} + {text()?.content} ) } @@ -77,11 +138,11 @@ export function RunEntryContent(props: { ) @@ -90,48 +151,48 @@ export function RunEntryContent(props: { if (body().type === "structured") { const width = Math.max(1, Math.trunc(props.width ?? 80)) - if (body().snapshot.kind === "code") { + if (snapshot()?.kind === "code") { return ( - {body().snapshot.title} + {snapshot()?.title} - + ) } - if (body().snapshot.kind === "diff") { + if (snapshot()?.kind === "diff") { const view = toolDiffView(width, props.opts?.diffStyle) return ( - {body().snapshot.items.map((item) => ( + {(snapshot()?.items ?? []).map((item) => ( {item.title} {item.diff.trim() ? ( - - {body().snapshot.title} + {snapshot()?.title} - {body().snapshot.rows.map((row) => ( + {(snapshot()?.rows ?? []).map((row) => ( {row} ))} - {body().snapshot.tail ? ( + {snapshot()?.tail ? ( - {body().snapshot.tail} + {snapshot()?.tail} ) : null} @@ -177,21 +238,21 @@ export function RunEntryContent(props: { ) } - if (body().snapshot.kind === "todo") { + if (snapshot()?.kind === "todo") { return ( # Todos - {body().snapshot.items.map((item) => ( + {(snapshot()?.items ?? []).map((item) => ( {todoText(item)} ))} - {body().snapshot.tail ? ( + {snapshot()?.tail ? ( - {body().snapshot.tail} + {snapshot()?.tail} ) : null} @@ -200,27 +261,27 @@ export function RunEntryContent(props: { } return ( - - - # Questions - - - {body().snapshot.items.map((item) => ( - - - {item.question} + + + # Questions + + + {(snapshot()?.items ?? []).map((item) => ( + + + {item.question} {item.answer} - - ))} - {body().snapshot.tail ? ( - - {body().snapshot.tail} - - ) : null} - + + ))} + {snapshot()?.tail ? ( + + {snapshot()?.tail} + + ) : null} + ) } @@ -230,7 +291,7 @@ export function RunEntryContent(props: { width="100%" syntaxStyle={entrySyntax(props.commit, theme)} streaming={props.commit.phase === "progress"} - content={body().content} + content={markdown()?.content} fg={entryColor(props.commit, theme)} tableOptions={{ widthMode: "content" }} /> diff --git a/packages/opencode/src/cli/cmd/run/types.ts b/packages/opencode/src/cli/cmd/run/types.ts index b2f5f50fd2..108012e362 100644 --- a/packages/opencode/src/cli/cmd/run/types.ts +++ b/packages/opencode/src/cli/cmd/run/types.ts @@ -132,6 +132,8 @@ export type ToolSnapshot = | ToolTodoSnapshot | ToolQuestionSnapshot +export type EntryLayout = "inline" | "block" + export type RunEntryBody = | { type: "none" } | { type: "text"; content: string } diff --git a/packages/opencode/test/cli/run/footer.test.ts b/packages/opencode/test/cli/run/footer.test.ts index a3d214dd8b..bb852f14ad 100644 --- a/packages/opencode/test/cli/run/footer.test.ts +++ b/packages/opencode/test/cli/run/footer.test.ts @@ -38,9 +38,9 @@ function createFooter(renderer: TestRenderer) { inputNewline: "shift+enter", }, diffStyle: "auto", - onPermissionReply: () => {}, - onQuestionReply: () => {}, - onQuestionReject: () => {}, + onPermissionReply: () => { }, + onQuestionReply: () => { }, + onQuestionReject: () => { }, treeSitterClient, }) } @@ -209,16 +209,15 @@ test("run footer keeps tool start rows tight with following reasoning", async () messageID: "msg-reasoning", partID: "part-reasoning", phase: "progress", - text: "Thinking: Found it.", + text: "Thinking: Found it.", }) await footer.idle() - const rows = payloads - .map((item) => item.replace(/ +/g, " ").trim()) - .filter(Boolean) + const rows = payloads.map((item) => item.replace(/ +/g, " ").trim()) - expect(rows).toEqual(['✱ Glob "**/run.ts"', "_Thinking:_ Found it."]) + expect(payloads).toHaveLength(3) + expect(rows).toEqual(['✱ Glob "**/run.ts"', "", "_Thinking:_ Found it."]) } finally { lib.commitSplitFooterSnapshot = originalCommitSplitFooterSnapshot } diff --git a/packages/opencode/test/cli/run/footer.view.test.tsx b/packages/opencode/test/cli/run/footer.view.test.tsx index 6998089d27..55e70a0081 100644 --- a/packages/opencode/test/cli/run/footer.view.test.tsx +++ b/packages/opencode/test/cli/run/footer.view.test.tsx @@ -2,7 +2,7 @@ import { expect, test } from "bun:test" import { testRender } from "@opentui/solid" import { createSignal } from "solid-js" -import { RunEntryContent } from "@/cli/cmd/run/scrollback.writer" +import { RunEntryContent, separatorRows } from "@/cli/cmd/run/scrollback.writer" import { RunFooterView } from "@/cli/cmd/run/footer.view" import { RUN_THEME_FALLBACK } from "@/cli/cmd/run/theme" import type { StreamCommit } from "@/cli/cmd/run/types" @@ -51,3 +51,46 @@ test("run entry content updates when live commit text changes", async () => { app.renderer.destroy() } }) + +test("subagent rows use shared separator rules", async () => { + const commits: StreamCommit[] = [ + { + kind: "tool", + source: "tool", + messageID: "msg-tool", + partID: "part-tool", + tool: "glob", + phase: "start", + text: "running glob", + toolState: "running", + part: { + id: "part-tool", + type: "tool", + tool: "glob", + callID: "call-tool", + messageID: "msg-tool", + sessionID: "session-1", + state: { + status: "running", + input: { + pattern: "**/run.ts", + }, + time: { + start: 1, + }, + }, + } as never, + }, + { + kind: "reasoning", + source: "reasoning", + messageID: "msg-reasoning", + partID: "part-reasoning", + phase: "progress", + text: "Thinking: Found it.", + }, + ] + + expect(separatorRows(undefined, commits[0]!)).toBe(0) + expect(separatorRows(commits[0], commits[1]!)).toBe(1) +}) diff --git a/packages/opencode/test/cli/run/scrollback.surface.test.ts b/packages/opencode/test/cli/run/scrollback.surface.test.ts index 95a48fbb85..e86371a0cd 100644 --- a/packages/opencode/test/cli/run/scrollback.surface.test.ts +++ b/packages/opencode/test/cli/run/scrollback.surface.test.ts @@ -25,6 +25,10 @@ function claimCommits(renderer: TestRenderer): ClaimedCommit[] { return (renderer as any).externalOutputQueue.claim() as ClaimedCommit[] } +function renderCommit(commit: ClaimedCommit): string { + return decoder.decode(commit.snapshot.getRealCharBytes(true)).replace(/ +\n/g, "\n") +} + function destroyCommits(commits: ClaimedCommit[]) { for (const commit of commits) { commit.snapshot.destroy() @@ -268,6 +272,335 @@ test("preserves blank rows between streamed markdown block commits", async () => } }) +test("inserts a spacer between inline tool starts and block tool finals", 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-1", + messageID: "msg-1", + tool: "write", + toolState: "running", + part: { + id: "tool-1", + sessionID: "session-1", + messageID: "msg-1", + type: "tool", + callID: "call-1", + tool: "write", + state: { + status: "running", + input: { + filePath: "src/a.ts", + content: "const x = 1\n", + }, + time: { + start: 1, + }, + }, + } as never, + }) + + const start = claimCommits(out.renderer) + try { + expect(start).toHaveLength(1) + expect(renderCommit(start[0]!).trim()).toBe("← Write src/a.ts") + } finally { + destroyCommits(start) + } + + await scrollback.append({ + kind: "tool", + text: "", + phase: "final", + source: "tool", + partID: "tool-1", + messageID: "msg-1", + tool: "write", + toolState: "completed", + part: { + id: "tool-1", + sessionID: "session-1", + messageID: "msg-1", + type: "tool", + callID: "call-1", + tool: "write", + state: { + status: "completed", + input: { + filePath: "src/a.ts", + content: "const x = 1\n", + }, + metadata: {}, + time: { + start: 1, + end: 2, + }, + }, + } as never, + }) + + 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") + } finally { + destroyCommits(final) + } +}) + +test("inserts a spacer between block assistant entries and following inline tools", 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: "assistant", + text: "hello", + phase: "progress", + source: "assistant", + messageID: "msg-1", + partID: "part-1", + }) + await scrollback.complete() + + const first = claimCommits(out.renderer) + try { + expect(first).toHaveLength(1) + expect(renderCommit(first[0]!).trim()).toBe("hello") + } finally { + destroyCommits(first) + } + + await scrollback.append({ + kind: "tool", + source: "tool", + messageID: "msg-tool", + partID: "part-tool", + tool: "glob", + phase: "start", + text: "running glob", + toolState: "running", + part: { + id: "part-tool", + type: "tool", + tool: "glob", + callID: "call-tool", + messageID: "msg-tool", + sessionID: "session-1", + state: { + status: "running", + input: { + pattern: "**/run.ts", + }, + time: { + start: 1, + }, + }, + } as never, + }) + + const next = claimCommits(out.renderer) + try { + expect(next).toHaveLength(2) + expect(renderCommit(next[0]!).trim()).toBe("") + expect(renderCommit(next[1]!).replace(/ +/g, " ").trim()).toBe('✱ Glob "**/run.ts"') + } finally { + destroyCommits(next) + } +}) + +test("bodyless starts keep the previous rendered item as separator context", 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: "assistant", + text: "hello", + phase: "progress", + source: "assistant", + messageID: "msg-1", + partID: "part-1", + }) + await scrollback.complete() + destroyCommits(claimCommits(out.renderer)) + + await scrollback.append({ + kind: "tool", + text: "", + phase: "start", + source: "tool", + partID: "task-1", + messageID: "msg-2", + tool: "task", + toolState: "running", + part: { + id: "task-1", + sessionID: "session-1", + messageID: "msg-2", + type: "tool", + callID: "call-2", + tool: "task", + state: { + status: "running", + input: { + description: "Explore run.ts", + subagent_type: "explore", + }, + time: { + start: 1, + }, + }, + } as never, + }) + + expect(claimCommits(out.renderer)).toHaveLength(0) + + await scrollback.append({ + kind: "tool", + text: "", + phase: "final", + source: "tool", + partID: "task-1", + messageID: "msg-2", + tool: "task", + toolState: "error", + part: { + id: "task-1", + sessionID: "session-1", + messageID: "msg-2", + type: "tool", + callID: "call-2", + tool: "task", + state: { + status: "error", + input: { + description: "Explore run.ts", + subagent_type: "explore", + }, + error: "boom", + time: { + start: 1, + end: 2, + }, + }, + } as never, + }) + + const final = claimCommits(out.renderer) + try { + expect(final).toHaveLength(2) + expect(renderCommit(final[0]!).trim()).toBe("") + expect(renderCommit(final[1]!)).toContain("Explore task completed") + } finally { + destroyCommits(final) + } +}) + +test("streamed assistant blocks defer their spacer until first render", 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: "user", + text: "use subagent to explore run.ts", + phase: "start", + source: "system", + }) + destroyCommits(claimCommits(out.renderer)) + + for (const chunk of ["Exploring", " run.ts", " via", " a codebase-aware", " subagent next."]) { + await scrollback.append({ + kind: "assistant", + text: chunk, + phase: "progress", + source: "assistant", + messageID: "msg-1", + partID: "part-1", + }) + } + + const progress = claimCommits(out.renderer) + try { + expect(progress).toHaveLength(0) + } finally { + destroyCommits(progress) + } + + await scrollback.complete() + + const final = claimCommits(out.renderer) + try { + expect(final).toHaveLength(2) + expect(renderCommit(final[0]!).trim()).toBe("") + expect(renderCommit(final[1]!).replace(/\n/g, " ")).toContain( + "Exploring run.ts via a codebase-aware subagent next.", + ) + } finally { + destroyCommits(final) + } +}) + test("coalesces same-line tool progress into one snapshot", async () => { const out = await createTestRenderer({ width: 80,