fix(cli): tighten run scrollback separators

This commit is contained in:
Simon Klee
2026-04-20 11:56:32 +02:00
parent 1a57f7e62b
commit fef2f8119c
7 changed files with 580 additions and 113 deletions

View File

@@ -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) => (
<box flexDirection="column" gap={0}>
{index() > 0 && !sameEntryGroup(commits()[index() - 1], commit) ? <box height={1} flexShrink={0} /> : null}
<box flexDirection="column" gap={0} flexShrink={0}>
{index() > 0 && separatorRows(commits()[index() - 1], commit) > 0 ? <box height={1} flexShrink={0} /> : null}
<RunEntryContent commit={commit} theme={theme()} opts={opts()} width={props.width()} />
</box>
))

View File

@@ -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<void> {
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<boolean> {
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<void> {
private async finishActive(trailingNewline: boolean): Promise<StreamCommit | undefined> {
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<void> {
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<void> {
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))
},
)
}

View File

@@ -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 (
<text width="100%" wrapMode="word" fg={style.fg} attributes={style.attrs}>
{body().content}
{text()?.content}
</text>
)
}
@@ -77,11 +138,11 @@ export function RunEntryContent(props: {
<code
width="100%"
wrapMode="word"
filetype={body().filetype}
filetype={code()?.filetype}
drawUnstyledText={false}
streaming={props.commit.phase === "progress"}
syntaxStyle={entrySyntax(props.commit, theme)}
content={body().content}
content={code()?.content}
fg={entryColor(props.commit, theme)}
/>
)
@@ -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 (
<box width="100%" flexDirection="column" gap={1}>
<text width="100%" wrapMode="word" fg={theme.block.muted}>
{body().snapshot.title}
{snapshot()?.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(body().snapshot.file)}
streaming={false}
syntaxStyle={entrySyntax(props.commit, theme)}
content={body().snapshot.content}
fg={theme.block.text}
/>
<code
width="100%"
wrapMode="char"
filetype={toolFiletype(snapshot()?.file)}
streaming={false}
syntaxStyle={entrySyntax(props.commit, theme)}
content={snapshot()?.content}
fg={theme.block.text}
/>
</line_number>
</box>
</box>
)
}
if (body().snapshot.kind === "diff") {
if (snapshot()?.kind === "diff") {
const view = toolDiffView(width, props.opts?.diffStyle)
return (
<box width="100%" flexDirection="column" gap={1}>
{body().snapshot.items.map((item) => (
{(snapshot()?.items ?? []).map((item) => (
<box width="100%" flexDirection="column" gap={1}>
<text width="100%" wrapMode="word" fg={theme.block.muted}>
{item.title}
</text>
{item.diff.trim() ? (
<box width="100%" paddingLeft={1}>
<diff
diff={item.diff}
view={view}
filetype={toolFiletype(item.file)}
syntaxStyle={entrySyntax(props.commit, theme)}
showLineNumbers={true}
width="100%"
wrapMode="word"
<diff
diff={item.diff}
view={view}
filetype={toolFiletype(item.file)}
syntaxStyle={entrySyntax(props.commit, theme)}
showLineNumbers={true}
width="100%"
wrapMode="word"
fg={theme.block.text}
addedBg={theme.block.diffAddedBg}
removedBg={theme.block.diffRemovedBg}
@@ -155,21 +216,21 @@ export function RunEntryContent(props: {
)
}
if (body().snapshot.kind === "task") {
if (snapshot()?.kind === "task") {
return (
<box width="100%" flexDirection="column" gap={1}>
<text width="100%" wrapMode="word" fg={theme.block.muted}>
{body().snapshot.title}
{snapshot()?.title}
</text>
<box width="100%" flexDirection="column" gap={0} paddingLeft={1}>
{body().snapshot.rows.map((row) => (
{(snapshot()?.rows ?? []).map((row) => (
<text width="100%" wrapMode="word" fg={theme.block.text}>
{row}
</text>
))}
{body().snapshot.tail ? (
{snapshot()?.tail ? (
<text width="100%" wrapMode="word" fg={theme.block.muted}>
{body().snapshot.tail}
{snapshot()?.tail}
</text>
) : null}
</box>
@@ -177,21 +238,21 @@ export function RunEntryContent(props: {
)
}
if (body().snapshot.kind === "todo") {
if (snapshot()?.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}>
{body().snapshot.items.map((item) => (
{(snapshot()?.items ?? []).map((item) => (
<text width="100%" wrapMode="word" fg={theme.block.text}>
{todoText(item)}
</text>
))}
{body().snapshot.tail ? (
{snapshot()?.tail ? (
<text width="100%" wrapMode="word" fg={theme.block.muted}>
{body().snapshot.tail}
{snapshot()?.tail}
</text>
) : null}
</box>
@@ -200,27 +261,27 @@ export function RunEntryContent(props: {
}
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}>
{body().snapshot.items.map((item) => (
<box width="100%" flexDirection="column" gap={0}>
<text width="100%" wrapMode="word" fg={theme.block.muted}>
{item.question}
<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) => (
<box width="100%" flexDirection="column" gap={0}>
<text width="100%" wrapMode="word" fg={theme.block.muted}>
{item.question}
</text>
<text width="100%" wrapMode="word" fg={theme.block.text}>
{item.answer}
</text>
</box>
))}
{body().snapshot.tail ? (
<text width="100%" wrapMode="word" fg={theme.block.muted}>
{body().snapshot.tail}
</text>
) : null}
</box>
</box>
))}
{snapshot()?.tail ? (
<text width="100%" wrapMode="word" fg={theme.block.muted}>
{snapshot()?.tail}
</text>
) : null}
</box>
</box>
)
}
@@ -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" }}
/>

View File

@@ -132,6 +132,8 @@ export type ToolSnapshot =
| ToolTodoSnapshot
| ToolQuestionSnapshot
export type EntryLayout = "inline" | "block"
export type RunEntryBody =
| { type: "none" }
| { type: "text"; content: string }

View File

@@ -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
}

View File

@@ -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)
})

View File

@@ -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,