From 6208f497502c6fe923b576fb037e3344b952588c Mon Sep 17 00:00:00 2001 From: Simon Klee Date: Fri, 17 Apr 2026 23:08:47 +0200 Subject: [PATCH] markdown rendering pass 1 --- packages/opencode/src/cli/cmd/run/demo.ts | 16 +- .../opencode/src/cli/cmd/run/entry.body.ts | 183 +++++ .../src/cli/cmd/run/footer.question.tsx | 7 +- .../src/cli/cmd/run/footer.subagent.tsx | 12 +- packages/opencode/src/cli/cmd/run/footer.ts | 82 +- .../src/cli/cmd/run/runtime.lifecycle.ts | 3 + packages/opencode/src/cli/cmd/run/runtime.ts | 24 +- .../src/cli/cmd/run/scrollback.format.ts | 92 --- .../src/cli/cmd/run/scrollback.surface.ts | 382 +++++++++ .../opencode/src/cli/cmd/run/scrollback.tsx | 26 - .../src/cli/cmd/run/scrollback.writer.tsx | 760 +++++------------- packages/opencode/src/cli/cmd/run/theme.ts | 6 +- packages/opencode/src/cli/cmd/run/tool.ts | 151 ++-- packages/opencode/src/cli/cmd/run/types.ts | 56 ++ .../src/cli/cmd/tui/context/theme.tsx | 2 +- .../opencode/test/cli/run/entry.body.test.ts | 153 ++++ packages/opencode/test/cli/run/footer.test.ts | 146 +++- .../test/cli/run/scrollback.surface.test.ts | 225 ++++++ .../test/cli/run/subagent-data.test.ts | 39 +- 19 files changed, 1570 insertions(+), 795 deletions(-) create mode 100644 packages/opencode/src/cli/cmd/run/entry.body.ts delete mode 100644 packages/opencode/src/cli/cmd/run/scrollback.format.ts create mode 100644 packages/opencode/src/cli/cmd/run/scrollback.surface.ts delete mode 100644 packages/opencode/src/cli/cmd/run/scrollback.tsx create mode 100644 packages/opencode/test/cli/run/entry.body.test.ts create mode 100644 packages/opencode/test/cli/run/scrollback.surface.test.ts diff --git a/packages/opencode/src/cli/cmd/run/demo.ts b/packages/opencode/src/cli/cmd/run/demo.ts index 46f5d1f010..6305c413f6 100644 --- a/packages/opencode/src/cli/cmd/run/demo.ts +++ b/packages/opencode/src/cli/cmd/run/demo.ts @@ -27,7 +27,21 @@ import type { StreamCommit, } from "./types" -const KINDS = ["markdown", "table", "text", "reasoning", "bash", "write", "edit", "patch", "task", "todo", "question", "error", "mix"] +const KINDS = [ + "markdown", + "table", + "text", + "reasoning", + "bash", + "write", + "edit", + "patch", + "task", + "todo", + "question", + "error", + "mix", +] const PERMISSIONS = ["edit", "bash", "read", "task", "external", "doom"] as const const QUESTIONS = ["multi", "single", "checklist", "custom"] as const diff --git a/packages/opencode/src/cli/cmd/run/entry.body.ts b/packages/opencode/src/cli/cmd/run/entry.body.ts new file mode 100644 index 0000000000..1bc4dc6215 --- /dev/null +++ b/packages/opencode/src/cli/cmd/run/entry.body.ts @@ -0,0 +1,183 @@ +import { toolEntryBody } from "./tool" +import type { RunEntryBody, StreamCommit } from "./types" + +export type EntryFlags = { + startOnNewLine: boolean + trailingNewline: boolean +} + +export const RUN_ENTRY_NONE: RunEntryBody = { + type: "none", +} + +export function cleanRunText(text: string): string { + return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n") +} + +function textBody(content: string): RunEntryBody { + if (!content) { + return RUN_ENTRY_NONE + } + + return { + type: "text", + content, + } +} + +function codeBody(content: string, filetype?: string): RunEntryBody { + if (!content) { + return RUN_ENTRY_NONE + } + + return { + type: "code", + content, + filetype, + } +} + +function markdownBody(content: string): RunEntryBody { + if (!content) { + return RUN_ENTRY_NONE + } + + return { + type: "markdown", + content, + } +} + +function userBody(raw: string): RunEntryBody { + if (!raw.trim()) { + return RUN_ENTRY_NONE + } + + const lead = raw.match(/^\n+/)?.[0] ?? "" + const body = lead ? raw.slice(lead.length) : raw + return textBody(`${lead}› ${body}`) +} + +function reasoningBody(raw: string): RunEntryBody { + const clean = raw.replace(/\[REDACTED\]/g, "") + if (!clean) { + return RUN_ENTRY_NONE + } + + const lead = clean.match(/^\n+/)?.[0] ?? "" + const body = lead ? clean.slice(lead.length) : clean + const mark = "Thinking: " + if (body.startsWith(mark)) { + return codeBody(`${lead}_Thinking:_ ${body.slice(mark.length)}`, "markdown") + } + + return codeBody(clean, "markdown") +} + +function systemBody(raw: string, phase: StreamCommit["phase"]): RunEntryBody { + return textBody(phase === "progress" ? raw : raw.trim()) +} + +export function entryFlags(commit: StreamCommit): EntryFlags { + if (commit.kind === "user") { + return { + startOnNewLine: true, + trailingNewline: false, + } + } + + if (commit.kind === "tool") { + if (commit.phase === "progress") { + return { + startOnNewLine: false, + trailingNewline: false, + } + } + + return { + startOnNewLine: true, + trailingNewline: true, + } + } + + if (commit.kind === "assistant" || commit.kind === "reasoning") { + if (commit.phase === "progress") { + return { + startOnNewLine: false, + trailingNewline: false, + } + } + + return { + startOnNewLine: true, + trailingNewline: true, + } + } + + return { + startOnNewLine: true, + trailingNewline: true, + } +} + +export function entryDone(commit: StreamCommit): boolean { + if (commit.kind === "assistant" || commit.kind === "reasoning") { + return commit.phase === "final" + } + + if (commit.kind === "tool") { + return commit.phase === "final" || (commit.phase === "progress" && commit.toolState === "completed") + } + + return true +} + +export function entryCanStream(commit: StreamCommit, body: RunEntryBody): boolean { + if (commit.phase !== "progress") { + return false + } + + if (body.type === "none") { + return false + } + + return commit.kind === "assistant" || commit.kind === "reasoning" || commit.kind === "tool" +} + +export function entryBody(commit: StreamCommit): RunEntryBody { + const raw = cleanRunText(commit.text) + + if (commit.kind === "user") { + return userBody(raw) + } + + if (commit.kind === "tool") { + return toolEntryBody(commit, raw) ?? RUN_ENTRY_NONE + } + + if (commit.kind === "assistant") { + if (commit.phase === "start") { + return RUN_ENTRY_NONE + } + + if (commit.phase === "final") { + return commit.interrupted ? textBody("assistant interrupted") : RUN_ENTRY_NONE + } + + return markdownBody(raw) + } + + if (commit.kind === "reasoning") { + if (commit.phase === "start") { + return RUN_ENTRY_NONE + } + + if (commit.phase === "final") { + return commit.interrupted ? textBody("reasoning interrupted") : RUN_ENTRY_NONE + } + + return reasoningBody(raw) + } + + return systemBody(raw, commit.phase) +} diff --git a/packages/opencode/src/cli/cmd/run/footer.question.tsx b/packages/opencode/src/cli/cmd/run/footer.question.tsx index 6f18181153..6173ccba9e 100644 --- a/packages/opencode/src/cli/cmd/run/footer.question.tsx +++ b/packages/opencode/src/cli/cmd/run/footer.question.tsx @@ -292,12 +292,7 @@ export function RunQuestionBody(props: { backgroundColor={props.theme.surface} > - + {(item, index) => { const active = () => state().tab === index() diff --git a/packages/opencode/src/cli/cmd/run/footer.subagent.tsx b/packages/opencode/src/cli/cmd/run/footer.subagent.tsx index ef8b6562c1..93344b66ee 100644 --- a/packages/opencode/src/cli/cmd/run/footer.subagent.tsx +++ b/packages/opencode/src/cli/cmd/run/footer.subagent.tsx @@ -35,13 +35,11 @@ function statusIcon(status: FooterSubagentTab["status"]) { return "◔" } -function tabText(input: { - tab: FooterSubagentTab - slot: string - count: number - width: number -}) { - const perTab = Math.max(1, Math.floor((input.width - 4 - Math.max(0, input.count - 1) * 3) / Math.max(1, input.count))) +function tabText(input: { tab: FooterSubagentTab; slot: string; count: number; width: number }) { + const perTab = Math.max( + 1, + Math.floor((input.width - 4 - Math.max(0, input.count - 1) * 3) / Math.max(1, input.count)), + ) if (input.count >= 8 || perTab < 12) { return `[${input.slot}]` } diff --git a/packages/opencode/src/cli/cmd/run/footer.ts b/packages/opencode/src/cli/cmd/run/footer.ts index e71be4b4e0..6594cb6351 100644 --- a/packages/opencode/src/cli/cmd/run/footer.ts +++ b/packages/opencode/src/cli/cmd/run/footer.ts @@ -4,10 +4,10 @@ // and the footer is the only region that can repaint. RunFooter owns both // sides of that boundary: // -// Scrollback: append() queues StreamCommit entries and flush() writes them -// to the renderer via writeToScrollback(). Commits coalesce in a microtask -// queue -- consecutive progress chunks for the same part merge into one -// write to avoid excessive scrollback snapshots. +// Scrollback: append() queues StreamCommit entries and flush() drains them +// through retained scrollback surfaces. Commits coalesce in a microtask +// queue so direct-mode transcript updates still preserve ordering without +// rebuilding the session model. // // Footer: event() updates the SolidJS signal-backed FooterState, which // drives the reactive footer view (prompt, status, permission, question). @@ -23,16 +23,14 @@ // // Interrupt and exit use a two-press pattern: first press shows a hint, // second press within 5 seconds actually fires the action. -import { CliRenderEvents, type CliRenderer } from "@opentui/core" +import { CliRenderEvents, type CliRenderer, type TreeSitterClient } from "@opentui/core" import { render } from "@opentui/solid" import { createComponent, createSignal, type Accessor, type Setter } from "solid-js" import { SUBAGENT_INSPECTOR_ROWS, SUBAGENT_TAB_ROWS } from "./footer.subagent" import { PROMPT_MAX_ROWS, TEXTAREA_MIN_ROWS } from "./footer.prompt" import { printableBinding } from "./prompt.shared" import { RunFooterView } from "./footer.view" -import { normalizeEntry } from "./scrollback.format" -import { entryWriter } from "./scrollback" -import { sameEntryGroup, spacerWriter } from "./scrollback.writer" +import { RunScrollbackStream } from "./scrollback.surface" import type { RunTheme } from "./theme" import type { RunAgent, @@ -77,6 +75,7 @@ type RunFooterOptions = { onInterrupt?: () => void onExit?: () => void onSubagentSelect?: (sessionID: string | undefined) => void + treeSitterClient?: TreeSitterClient } const PERMISSION_ROWS = 12 @@ -96,13 +95,10 @@ export class RunFooter implements FooterApi { private destroyed = false private prompts = new Set<(input: RunPrompt) => void>() private closes = new Set<() => void>() - // Most recent visible scrollback commit. - private tail: StreamCommit | undefined - // The entry splash is already in scrollback before footer output starts. - private wrote = true // Microtask-coalesced commit queue. Flushed on next microtask or on close/destroy. private queue: StreamCommit[] = [] private pending = false + private flushing: Promise = Promise.resolve() // Fixed portion of footer height above the textarea. private base: number private rows = TEXTAREA_MIN_ROWS @@ -121,6 +117,7 @@ export class RunFooter implements FooterApi { private interruptTimeout: NodeJS.Timeout | undefined private exitTimeout: NodeJS.Timeout | undefined private interruptHint: string + private scrollback: RunScrollbackStream constructor( private renderer: CliRenderer, @@ -153,6 +150,10 @@ export class RunFooter implements FooterApi { this.setSubagent = setSubagent this.base = Math.max(1, renderer.footerHeight - TEXTAREA_MIN_ROWS) this.interruptHint = printableBinding(options.keybinds.interrupt, options.keybinds.leader) || "esc" + this.scrollback = new RunScrollbackStream(renderer, options.theme, { + diffStyle: options.diffStyle, + treeSitterClient: options.treeSitterClient, + }) this.renderer.on(CliRenderEvents.DESTROY, this.handleDestroy) @@ -325,6 +326,7 @@ export class RunFooter implements FooterApi { if (prev.phase === "running" && state.phase === "idle") { this.flush() + this.flushing = this.flushing.then(() => this.scrollback.complete()).catch(() => {}) } } @@ -338,18 +340,14 @@ export class RunFooter implements FooterApi { } // Queues a scrollback commit. Consecutive progress chunks for the same - // part coalesce by appending text, reducing the number of renderer writes. - // Actual flush happens on the next microtask, so a burst of events from - // one reducer pass becomes a single scrollback write. + // part coalesce by appending text, reducing the number of retained-surface + // updates. Actual flush happens on the next microtask, so a burst of events + // from one reducer pass becomes a single ordered drain. public append(commit: StreamCommit): void { if (this.destroyed || this.renderer.isDestroyed) { return } - if (!normalizeEntry(commit)) { - return - } - const last = this.queue.at(-1) if ( last && @@ -381,7 +379,22 @@ export class RunFooter implements FooterApi { return Promise.resolve() } - return this.renderer.idle().catch(() => {}) + this.flush() + if (this.state().phase === "idle") { + this.flushing = this.flushing.then(() => this.scrollback.complete()).catch(() => {}) + } + + return this.flushing.then(async () => { + if (this.destroyed || this.renderer.isDestroyed) { + return + } + + if (this.queue.length > 0) { + return this.idle() + } + + await this.renderer.idle().catch(() => {}) + }) } public close(): void { @@ -410,8 +423,7 @@ export class RunFooter implements FooterApi { this.renderer.off(CliRenderEvents.DESTROY, this.handleDestroy) this.prompts.clear() this.closes.clear() - this.tail = undefined - this.wrote = false + this.scrollback.destroy() } private notifyClose(): void { @@ -638,27 +650,25 @@ export class RunFooter implements FooterApi { this.renderer.off(CliRenderEvents.DESTROY, this.handleDestroy) this.prompts.clear() this.closes.clear() - this.tail = undefined - this.wrote = false + this.scrollback.destroy() } - // Drains the commit queue to scrollback. Visible commits start a new block - // whenever their block key changes, and new blocks get a single spacer. + // Drains the commit queue to scrollback. The surface manager owns grouping, + // spacing, and progressive markdown/code settling so direct mode can append + // immutable transcript rows without rewriting history. private flush(): void { if (this.destroyed || this.renderer.isDestroyed || this.queue.length === 0) { this.queue.length = 0 return } - for (const item of this.queue.splice(0)) { - const same = sameEntryGroup(this.tail, item) - if (this.wrote && !same) { - this.renderer.writeToScrollback(spacerWriter()) - } - - this.renderer.writeToScrollback(entryWriter(item, this.options.theme, { diffStyle: this.options.diffStyle })) - this.wrote = true - this.tail = item - } + const batch = this.queue.splice(0) + this.flushing = this.flushing + .then(async () => { + for (const item of batch) { + await this.scrollback.append(item) + } + }) + .catch(() => {}) } } diff --git a/packages/opencode/src/cli/cmd/run/runtime.lifecycle.ts b/packages/opencode/src/cli/cmd/run/runtime.lifecycle.ts index ed6f204e73..6fc6bf28ce 100644 --- a/packages/opencode/src/cli/cmd/run/runtime.lifecycle.ts +++ b/packages/opencode/src/cli/cmd/run/runtime.lifecycle.ts @@ -230,6 +230,8 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise {}) + const show = renderer.isDestroyed ? false : next.showExit if (!renderer.isDestroyed && show) { const sessionID = next.sessionID ?? input.sessionID @@ -250,6 +252,7 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise {}) footer.destroy() shutdown(renderer) } diff --git a/packages/opencode/src/cli/cmd/run/runtime.ts b/packages/opencode/src/cli/cmd/run/runtime.ts index 2700f94bc9..34e501a7fd 100644 --- a/packages/opencode/src/cli/cmd/run/runtime.ts +++ b/packages/opencode/src/cli/cmd/run/runtime.ts @@ -35,7 +35,9 @@ type BootContext = Pick< type RunRuntimeInput = { boot: () => Promise afterPaint?: (ctx: BootContext) => Promise | void - resolveSession?: (ctx: BootContext) => Promise<{ sessionID: string; sessionTitle?: string; agent?: string | undefined }> + resolveSession?: ( + ctx: BootContext, + ) => Promise<{ sessionID: string; sessionTitle?: string; agent?: string | undefined }> files: RunInput["files"] initialInput?: string thinking: boolean @@ -384,7 +386,8 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise { if (signal.aborted || footer.isClosed) { return } - const text = stream?.mod.formatUnknownError(error) ?? (error instanceof Error ? error.message : String(error)) + const text = + stream?.mod.formatUnknownError(error) ?? (error instanceof Error ? error.message : String(error)) footer.append({ kind: "error", text, phase: "start", source: "system" }) } }, @@ -393,14 +396,15 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise { await stream?.handle.close() } } finally { - const title = shown && hasSession - ? await ctx.sdk.session - .get({ - sessionID, - }) - .then((x) => x.data?.title) - .catch(() => undefined) - : undefined + const title = + shown && hasSession + ? await ctx.sdk.session + .get({ + sessionID, + }) + .then((x) => x.data?.title) + .catch(() => undefined) + : undefined await shell.close({ showExit: shown && hasSession, diff --git a/packages/opencode/src/cli/cmd/run/scrollback.format.ts b/packages/opencode/src/cli/cmd/run/scrollback.format.ts deleted file mode 100644 index 9433f7c429..0000000000 --- a/packages/opencode/src/cli/cmd/run/scrollback.format.ts +++ /dev/null @@ -1,92 +0,0 @@ -// Text normalization for scrollback entries. -// -// Transforms a StreamCommit into the final text that will be appended to -// terminal scrollback. Each entry kind has its own formatting: -// -// user → prefixed with "› " -// assistant → raw text (progress), empty (start/final unless interrupted) -// reasoning → raw text with [REDACTED] stripped -// tool → delegated to tool.ts for per-tool scrollback formatting -// error/system → raw trimmed text -// -// Returns an empty string when the commit should produce no visible output -// (e.g., assistant start events, empty final events). -import { toolFrame, toolScroll, toolView } from "./tool" -import type { StreamCommit } from "./types" - -export function clean(text: string): string { - return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n") -} - -function toolText(commit: StreamCommit, raw: string): string { - const ctx = toolFrame(commit, raw) - const view = toolView(ctx.name) - - if (commit.phase === "progress" && !view.output) { - return "" - } - - if (commit.phase === "final") { - if (ctx.status === "error") { - return toolScroll("final", ctx) - } - - if (!view.final) { - return "" - } - - if (ctx.status && ctx.status !== "completed") { - return ctx.raw.trim() - } - } - - return toolScroll(commit.phase, ctx) -} - -export function normalizeEntry(commit: StreamCommit): string { - const raw = clean(commit.text) - - if (commit.kind === "user") { - if (!raw.trim()) { - return "" - } - - const lead = raw.match(/^\n+/)?.[0] ?? "" - const body = lead ? raw.slice(lead.length) : raw - return `${lead}› ${body}` - } - - if (commit.kind === "tool") { - return toolText(commit, raw) - } - - if (commit.kind === "assistant") { - if (commit.phase === "start") { - return "" - } - - if (commit.phase === "final") { - return commit.interrupted ? "assistant interrupted" : "" - } - - return raw - } - - if (commit.kind === "reasoning") { - if (commit.phase === "start") { - return "" - } - - if (commit.phase === "final") { - return commit.interrupted ? "reasoning interrupted" : "" - } - - return raw.replace(/\[REDACTED\]/g, "") - } - - if (commit.phase === "start" || commit.phase === "final") { - return raw.trim() - } - - return raw -} diff --git a/packages/opencode/src/cli/cmd/run/scrollback.surface.ts b/packages/opencode/src/cli/cmd/run/scrollback.surface.ts new file mode 100644 index 0000000000..2ec45d3cc1 --- /dev/null +++ b/packages/opencode/src/cli/cmd/run/scrollback.surface.ts @@ -0,0 +1,382 @@ +// 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, +// reasoning, and tool progress entries that need stable markdown/code layout +// while content is still arriving. +import { + CodeRenderable, + MarkdownRenderable, + SyntaxStyle, + TextAttributes, + TextRenderable, + getTreeSitterClient, + type TreeSitterClient, + type CliRenderer, + type ColorInput, + type ScrollbackSurface, +} from "@opentui/core" +import { entryBody, entryCanStream, entryDone, entryFlags } from "./entry.body" +import { entryWriter, sameEntryGroup, spacerWriter } from "./scrollback.writer" +import { type RunEntryTheme, type RunTheme } from "./theme" +import type { RunDiffStyle, RunEntryBody, StreamCommit } from "./types" + +type ActiveBody = Exclude + +type ActiveEntry = { + body: ActiveBody + commit: StreamCommit + surface: ScrollbackSurface + renderable: TextRenderable | CodeRenderable | MarkdownRenderable + content: string + committedRows: number + committedBlocks: number +} + +let bare: SyntaxStyle | undefined +let nextId = 0 + +function syntax(style?: SyntaxStyle): SyntaxStyle { + if (style) { + return style + } + + bare ??= SyntaxStyle.fromTheme([]) + return bare +} + +function syntaxFor(commit: StreamCommit, theme: RunTheme): SyntaxStyle { + if (commit.kind === "reasoning") { + return syntax(theme.block.subtleSyntax ?? theme.block.syntax) + } + + return syntax(theme.block.syntax) +} + +function failed(commit: StreamCommit): boolean { + return commit.kind === "tool" && (commit.toolState === "error" || commit.part?.state.status === "error") +} + +function look(commit: StreamCommit, theme: RunEntryTheme): { fg: ColorInput; attrs?: number } { + if (commit.kind === "user") { + return { + fg: theme.user.body, + attrs: TextAttributes.BOLD, + } + } + + if (failed(commit)) { + return { + fg: theme.error.body, + attrs: TextAttributes.BOLD, + } + } + + if (commit.phase === "final") { + return { + fg: theme.system.body, + attrs: TextAttributes.DIM, + } + } + + if (commit.kind === "tool" && commit.phase === "start") { + return { + fg: theme.tool.start ?? theme.tool.body, + } + } + + if (commit.kind === "assistant") { + return { fg: theme.assistant.body } + } + + if (commit.kind === "reasoning") { + return { + fg: theme.reasoning.body, + attrs: TextAttributes.DIM, + } + } + + if (commit.kind === "error") { + return { + fg: theme.error.body, + attrs: TextAttributes.BOLD, + } + } + + if (commit.kind === "tool") { + return { fg: theme.tool.body } + } + + return { fg: theme.system.body } +} + +function entryColor(commit: StreamCommit, theme: RunTheme): ColorInput { + if (commit.kind === "assistant") { + return theme.entry.assistant.body + } + + if (commit.kind === "reasoning") { + return theme.entry.reasoning.body + } + + if (failed(commit)) { + return theme.entry.error.body + } + + if (commit.kind === "tool") { + return theme.block.text + } + + return look(commit, theme.entry).fg +} + +function commitMarkdownBlocks(input: { + surface: ScrollbackSurface + renderable: MarkdownRenderable + startBlock: number + endBlockExclusive: number + trailingNewline: boolean +}) { + if (input.endBlockExclusive <= input.startBlock) { + return false + } + + const first = input.renderable._blockStates[input.startBlock] + const last = input.renderable._blockStates[input.endBlockExclusive - 1] + if (!first || !last) { + return false + } + + input.surface.commitRows(first.renderable.y, last.renderable.y + last.renderable.height + (last.marginBottom ?? 0), { + trailingNewline: input.trailingNewline, + }) + return true +} + +export class RunScrollbackStream { + private tail: StreamCommit | undefined + private active: ActiveEntry | undefined + private wrote: boolean + private diffStyle: RunDiffStyle | undefined + private treeSitterClient: TreeSitterClient | undefined + + constructor( + private renderer: CliRenderer, + private theme: RunTheme, + options: { + wrote?: boolean + diffStyle?: RunDiffStyle + treeSitterClient?: TreeSitterClient + } = {}, + ) { + this.wrote = options.wrote ?? true + this.diffStyle = options.diffStyle + this.treeSitterClient = options.treeSitterClient ?? getTreeSitterClient() + } + + private createEntry(commit: StreamCommit, body: ActiveBody): ActiveEntry { + const surface = this.renderer.createScrollbackSurface({ + startOnNewLine: entryFlags(commit).startOnNewLine, + }) + const id = `run-scrollback-entry-${nextId++}` + const renderable = + body.type === "text" + ? new TextRenderable(surface.renderContext, { + id, + content: "", + width: "100%", + wrapMode: "word", + fg: look(commit, this.theme.entry).fg, + attributes: look(commit, this.theme.entry).attrs, + }) + : body.type === "code" + ? new CodeRenderable(surface.renderContext, { + id, + content: "", + filetype: body.filetype, + syntaxStyle: syntaxFor(commit, this.theme), + width: "100%", + wrapMode: "word", + drawUnstyledText: false, + streaming: true, + fg: entryColor(commit, this.theme), + treeSitterClient: this.treeSitterClient, + }) + : new MarkdownRenderable(surface.renderContext, { + id, + content: "", + syntaxStyle: syntaxFor(commit, this.theme), + width: "100%", + streaming: true, + internalBlockMode: "top-level", + tableOptions: { widthMode: "content" }, + fg: entryColor(commit, this.theme), + treeSitterClient: this.treeSitterClient, + }) + + surface.root.add(renderable) + + return { + body, + commit, + surface, + renderable, + content: "", + committedRows: 0, + committedBlocks: 0, + } + } + + private async flushActive(done: boolean, trailingNewline: boolean): Promise { + const active = this.active + if (!active) { + return + } + + if (active.body.type === "text") { + const renderable = active.renderable as TextRenderable + 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 + } + return + } + + if (active.body.type === "code") { + const renderable = active.renderable as CodeRenderable + renderable.content = active.content + 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 + } + return + } + + const renderable = active.renderable as MarkdownRenderable + renderable.content = active.content + renderable.streaming = !done + await active.surface.settle() + const targetBlockCount = done ? renderable._blockStates.length : renderable._stableBlockCount + if (targetBlockCount <= active.committedBlocks) { + return + } + + if ( + commitMarkdownBlocks({ + surface: active.surface, + renderable, + startBlock: active.committedBlocks, + endBlockExclusive: targetBlockCount, + trailingNewline: done && targetBlockCount === renderable._blockStates.length ? trailingNewline : false, + }) + ) { + active.committedBlocks = targetBlockCount + } + } + + private async finishActive(trailingNewline: boolean): Promise { + if (!this.active) { + return + } + + const active = this.active + + try { + await this.flushActive(true, trailingNewline) + } finally { + if (this.active === active) { + this.active = undefined + } + + if (!active.surface.isDestroyed) { + active.surface.destroy() + } + } + } + + 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.active = this.createEntry(commit, body) + } + + this.active.body = body + this.active.commit = commit + this.active.content += body.content + await this.flushActive(false, false) + } + + public async append(commit: StreamCommit): Promise { + const same = sameEntryGroup(this.tail, commit) + if (!same) { + await this.finishActive(false) + } + + const body = entryBody(commit) + if (body.type === "none") { + if (entryDone(commit)) { + await this.finishActive(entryFlags(commit).trailingNewline) + } + + this.tail = commit + return + } + + if (this.wrote && !same) { + this.renderer.writeToScrollback(spacerWriter()) + } + + if (body.type !== "structured" && entryCanStream(commit, body)) { + await this.writeStreaming(commit, body) + this.wrote = true + this.tail = commit + return + } + + if (same) { + await this.finishActive(false) + } + + this.renderer.writeToScrollback( + entryWriter({ + commit, + theme: this.theme, + opts: { + diffStyle: this.diffStyle, + }, + }), + ) + this.wrote = true + this.tail = commit + } + + private resetActive(): void { + if (!this.active) { + return + } + + if (!this.active.surface.isDestroyed) { + this.active.surface.destroy() + } + + this.active = undefined + } + + public async complete(trailingNewline = true): Promise { + await this.finishActive(trailingNewline) + } + + public destroy(): void { + this.resetActive() + } +} diff --git a/packages/opencode/src/cli/cmd/run/scrollback.tsx b/packages/opencode/src/cli/cmd/run/scrollback.tsx deleted file mode 100644 index 63215fcf26..0000000000 --- a/packages/opencode/src/cli/cmd/run/scrollback.tsx +++ /dev/null @@ -1,26 +0,0 @@ -// Entry writer routing for scrollback commits. -// -// Decides whether a commit should render as plain text or as a rich snapshot -// (code block, diff view, task card, etc.). Completed tool parts whose tool -// rule has a "snap" mode get routed to snapEntryWriter, which produces a -// structured JSX snapshot. Everything else goes through textEntryWriter. -import type { ScrollbackWriter } from "@opentui/core" -import { toolView } from "./tool" -import { snapEntryWriter, textEntryWriter } from "./scrollback.writer" -import { RUN_THEME_FALLBACK, type RunTheme } from "./theme" -import type { ScrollbackOptions, StreamCommit } from "./types" - -export function entryWriter( - commit: StreamCommit, - theme: RunTheme = RUN_THEME_FALLBACK, - opts: ScrollbackOptions = {}, -): ScrollbackWriter { - const state = commit.toolState ?? commit.part?.state.status - if (commit.kind === "tool" && commit.phase === "final" && state === "completed") { - if (toolView(commit.tool).snap) { - return snapEntryWriter(commit, theme, opts) - } - } - - return textEntryWriter(commit, theme.entry) -} diff --git a/packages/opencode/src/cli/cmd/run/scrollback.writer.tsx b/packages/opencode/src/cli/cmd/run/scrollback.writer.tsx index 1bb4cc5c79..97c67a06d4 100644 --- a/packages/opencode/src/cli/cmd/run/scrollback.writer.tsx +++ b/packages/opencode/src/cli/cmd/run/scrollback.writer.tsx @@ -1,140 +1,12 @@ -// JSX-based scrollback snapshot writers for rich tool output. -// -// When a tool commit has a "snap" mode (code, diff, task, todo, question), -// snapEntryWriter renders it as a structured JSX tree that OpenTUI converts -// into a ScrollbackSnapshot. These snapshots support syntax highlighting, -// unified/split diffs, line numbers, and LSP diagnostics. -// -// The writers use OpenTUI's createScrollbackWriter to produce snapshots. -// OpenTUI measures and reflows them when the terminal resizes. The fit() -// helper measures actual rendered width so narrow content doesn't claim -// the full terminal width. -// -// Plain text entries (textEntryWriter) also go through here -- they just -// produce a simple element with the right color and attributes. /** @jsxImportSource @opentui/solid */ -import { - SyntaxStyle, - TextAttributes, - type ColorInput, - type ScrollbackRenderContext, - type ScrollbackSnapshot, - type ScrollbackWriter, -} from "@opentui/core" -import { createScrollbackWriter, type JSX } from "@opentui/solid" -import { For, Show } from "solid-js" -import * as Filesystem from "../../../util/filesystem" -import { toolDiffView, toolFiletype, toolFrame, toolSnapshot, toolView } from "./tool" -import { clean, normalizeEntry } from "./scrollback.format" +import { createScrollbackWriter } from "@opentui/solid" +import { SyntaxStyle, TextAttributes, TextRenderable, type ColorInput, type ScrollbackWriter } from "@opentui/core" +import { entryBody, entryFlags } from "./entry.body" +import { toolDiffView, toolFiletype, toolStructuredFinal } from "./tool" import { RUN_THEME_FALLBACK, type RunEntryTheme, type RunTheme } from "./theme" import type { ScrollbackOptions, StreamCommit } from "./types" -type ToolDict = Record - -function dict(v: unknown): ToolDict { - if (!v || typeof v !== "object") { - return {} - } - - return v as ToolDict -} - -function text(v: unknown): string { - return typeof v === "string" ? v : "" -} - -function arr(v: unknown): unknown[] { - return Array.isArray(v) ? v : [] -} - -function num(v: unknown): number | undefined { - if (typeof v !== "number" || !Number.isFinite(v)) { - return - } - - return v -} - -function diagnostics(meta: ToolDict, file: string): string[] { - const all = dict(meta.diagnostics) - const key = Filesystem.normalizePath(file) - const list = arr(all[key]).map(dict) - return list - .filter((item) => item.severity === 1) - .slice(0, 3) - .map((item) => { - const range = dict(item.range) - const start = dict(range.start) - const line = num(start.line) - const char = num(start.character) - const msg = text(item.message) - if (line === undefined || char === undefined) { - return `Error ${msg}`.trim() - } - - return `Error [${line + 1}:${char + 1}] ${msg}`.trim() - }) -} - -type Flags = { - startOnNewLine: boolean - trailingNewline: boolean -} - -type Paint = { - fg: ColorInput - attrs?: number -} - -type CodeInput = { - title: string - content: string - filetype?: string - diagnostics: string[] -} - -type DiffInput = { - title: string - diff?: string - filetype?: string - deletions?: number - diagnostics: string[] -} - -type TaskInput = { - title: string - rows: string[] - tail: string -} - -type TodoInput = { - items: Array<{ - status: string - content: string - }> - tail: string -} - -type QuestionInput = { - items: Array<{ - question: string - answer: string - }> - tail: string -} - -type Measure = { - widthColsMax: number -} - -type MeasureNode = { - textBufferView?: { - measureForDimensions(width: number, height: number): Measure | null - } - getChildren?: () => unknown[] -} - let bare: SyntaxStyle | undefined function syntax(style?: SyntaxStyle): SyntaxStyle { @@ -146,11 +18,19 @@ function syntax(style?: SyntaxStyle): SyntaxStyle { return bare } +function syntaxFor(commit: StreamCommit, theme: RunTheme): SyntaxStyle { + if (commit.kind === "reasoning") { + return syntax(theme.block.subtleSyntax ?? theme.block.syntax) + } + + return syntax(theme.block.syntax) +} + function failed(commit: StreamCommit): boolean { return commit.kind === "tool" && (commit.toolState === "error" || commit.part?.state.status === "error") } -function look(commit: StreamCommit, theme: RunEntryTheme): Paint { +function look(commit: StreamCommit, theme: RunEntryTheme): { fg: ColorInput; attrs?: number } { if (commit.kind === "user") { return { fg: theme.user.body, @@ -203,237 +83,55 @@ function look(commit: StreamCommit, theme: RunEntryTheme): Paint { return { fg: theme.system.body } } -function cols(ctx: ScrollbackRenderContext): number { - return Math.max(1, Math.trunc(ctx.width)) -} - -function leaf(node: unknown): MeasureNode | undefined { - if (!node || typeof node !== "object") { - return +function entryColor(commit: StreamCommit, theme: RunTheme): ColorInput { + if (commit.kind === "assistant") { + return theme.entry.assistant.body } - const next = node as MeasureNode - if (next.textBufferView) { - return next + if (commit.kind === "reasoning") { + return theme.entry.reasoning.body } - const list = next.getChildren?.() ?? [] - for (const child of list) { - const out = leaf(child) - if (out) { - return out - } - } -} - -function fit(snapshot: ScrollbackSnapshot, ctx: ScrollbackRenderContext) { - const node = leaf(snapshot.root) - const width = cols(ctx) - const box = node?.textBufferView?.measureForDimensions(width, Math.max(1, snapshot.height ?? 1)) - const rowColumns = Math.max(1, Math.min(width, box?.widthColsMax ?? 0)) - - snapshot.width = width - snapshot.rowColumns = rowColumns - return snapshot -} - -function full(node: () => JSX.Element, ctx: ScrollbackRenderContext, flags: Flags) { - return createScrollbackWriter(node, { - width: cols(ctx), - rowColumns: cols(ctx), - startOnNewLine: flags.startOnNewLine, - trailingNewline: flags.trailingNewline, - })(ctx) -} - -function TextEntry(props: { body: string; fg: ColorInput; attrs?: number }) { - return ( - - {props.body} - - ) -} - -function thinking(body: string) { - const mark = "Thinking: " - if (body.startsWith(mark)) { - return { - head: mark, - tail: body.slice(mark.length), - } + if (failed(commit)) { + return theme.entry.error.body } - return { - tail: body, + if (commit.kind === "tool") { + return theme.block.text } + + return look(commit, theme.entry).fg } -function ReasoningEntry(props: { body: string; theme: RunEntryTheme }) { - const part = thinking(props.body) - return ( - - {part.head} - {part.tail} - - ) +function blankWriter(): ScrollbackWriter { + return (ctx) => ({ + root: new TextRenderable(ctx.renderContext, { + id: "run-scrollback-spacer", + width: Math.max(1, Math.trunc(ctx.width)), + height: 1, + content: "", + }), + width: Math.max(1, Math.trunc(ctx.width)), + height: 1, + startOnNewLine: true, + trailingNewline: true, + }) } -function Diagnostics(props: { theme: RunTheme; lines: string[] }) { - return ( - 0}> - - {(line) => {line}} - - - ) -} - -function BlockTool(props: { theme: RunTheme; title: string; children: JSX.Element }) { - return ( - - - {props.title} - - {props.children} - - ) -} - -function CodeTool(props: { theme: RunTheme; data: CodeInput }) { - return ( - - - - - - - ) -} - -function DiffTool(props: { theme: RunTheme; data: DiffInput; view: "unified" | "split" }) { - return ( - - - -{props.data.deletions ?? 0} line{props.data.deletions === 1 ? "" : "s"} - - } - > - - - - - - - ) -} - -function TaskTool(props: { theme: RunTheme; data: TaskInput }) { - return ( - - - {(line) => {line}} - - - {props.data.tail} - - - ) -} - -function todoMark(status: string): string { - if (status === "completed") { - return "[x]" +function todoText(item: { status: string; content: string }): string { + if (item.status === "completed") { + return `[x] ${item.content}` } - if (status === "in_progress") { - return "[>]" + + if (item.status === "cancelled") { + return `[ ] ${item.content} (cancelled)` } - if (status === "cancelled") { - return "[-]" + + if (item.status === "in_progress") { + return `[ ] ${item.content} (in progress)` } - return "[ ]" -} -function TodoTool(props: { theme: RunTheme; data: TodoInput }) { - return ( - - - - {(item) => ( - - {todoMark(item.status)} {item.content} - - )} - - - - {props.data.tail} - - - ) -} - -function QuestionTool(props: { theme: RunTheme; data: QuestionInput }) { - return ( - - - {props.data.tail} - - - - {(item) => ( - - {item.question} - {item.answer} - - )} - - - - ) -} - -function snapCommit(commit: StreamCommit) { - const state = commit.toolState ?? commit.part?.state.status - return ( - commit.kind === "tool" && - commit.phase === "final" && - state === "completed" && - Boolean(toolView(commit.tool ?? commit.part?.tool).snap) - ) + return `[ ] ${item.content}` } export function entryGroupKey(commit: StreamCommit): string | undefined { @@ -441,7 +139,7 @@ export function entryGroupKey(commit: StreamCommit): string | undefined { return } - if (snapCommit(commit)) { + if (toolStructuredFinal(commit)) { return `tool:${commit.partID}:final` } @@ -462,111 +160,6 @@ export function sameEntryGroup(left: StreamCommit | undefined, right: StreamComm return left.kind === "tool" && left.phase === "start" && right.kind === "tool" && right.phase === "start" } -export function RunEntryTextContent(props: { commit: StreamCommit; theme: RunEntryTheme }) { - const body = normalizeEntry(props.commit) - if (props.commit.kind === "reasoning") { - return - } - - const style = look(props.commit, props.theme) - return -} - -export function RunEntrySnapContent(props: { - commit: StreamCommit - theme: RunTheme - opts?: ScrollbackOptions - width?: number -}) { - const raw = clean(props.commit.text) - const snap = toolSnapshot(props.commit, raw) - if (!snap) { - return - } - - const info = toolFrame(props.commit, raw) - if (snap.kind === "code") { - return ( - - ) - } - - if (snap.kind === "diff") { - const list = snap.items - .map((item) => { - if (!item.diff.trim()) { - return - } - - return { - title: item.title, - diff: item.diff, - filetype: toolFiletype(item.file), - deletions: item.deletions, - diagnostics: diagnostics(info.meta, item.file ?? ""), - } - }) - .filter((item): item is NonNullable => Boolean(item)) - - if (list.length === 0) { - return - } - - return ( - - - {(item) => ( - - )} - - - ) - } - - if (snap.kind === "task") { - return ( - - ) - } - - if (snap.kind === "todo") { - return ( - - ) - } - - return ( - - ) -} - export function RunEntryContent(props: { commit: StreamCommit theme?: RunTheme @@ -574,102 +167,195 @@ export function RunEntryContent(props: { width?: number }) { const theme = props.theme ?? RUN_THEME_FALLBACK - if (snapCommit(props.commit)) { - return + const body = entryBody(props.commit) + if (body.type === "none") { + return null } - return -} - -function textWriter(commit: StreamCommit, theme: RunEntryTheme, flags: Flags): ScrollbackWriter { - return (ctx) => - fit( - createScrollbackWriter(() => , { - width: cols(ctx), - startOnNewLine: flags.startOnNewLine, - trailingNewline: flags.trailingNewline, - })(ctx), - ctx, + if (body.type === "text") { + const style = look(props.commit, theme.entry) + return ( + + {body.content} + ) -} - -function blankWriter(): ScrollbackWriter { - return (ctx) => - createScrollbackWriter(() => , { - width: cols(ctx), - startOnNewLine: true, - trailingNewline: true, - })(ctx) -} - -function textBlockWriter(body: string, theme: RunEntryTheme): ScrollbackWriter { - return (ctx) => - full(() => , ctx, { - startOnNewLine: true, - trailingNewline: false, - }) -} - -function flags(commit: StreamCommit): Flags { - if (commit.kind === "user") { - return { - startOnNewLine: true, - trailingNewline: false, - } } - if (commit.kind === "tool") { - if (commit.phase === "progress") { - return { - startOnNewLine: false, - trailingNewline: false, - } - } - - return { - startOnNewLine: true, - trailingNewline: true, - } + if (body.type === "code") { + return ( + + ) } - if (commit.kind === "assistant" || commit.kind === "reasoning") { - if (commit.phase === "progress") { - return { - startOnNewLine: false, - trailingNewline: false, - } + if (body.type === "structured") { + const width = Math.max(1, Math.trunc(props.width ?? 80)) + + if (body.snapshot.kind === "code") { + return ( + + + {body.snapshot.title} + + + + + + + + ) } - return { - startOnNewLine: true, - trailingNewline: true, + if (body.snapshot.kind === "diff") { + const view = toolDiffView(width, props.opts?.diffStyle) + return ( + + {body.snapshot.items.map((item) => ( + + + {item.title} + + {item.diff.trim() ? ( + + + + ) : ( + + -{item.deletions ?? 0} line{item.deletions === 1 ? "" : "s"} + + )} + + ))} + + ) } + + if (body.snapshot.kind === "task") { + return ( + + + {body.snapshot.title} + + + {body.snapshot.rows.map((row) => ( + + {row} + + ))} + {body.snapshot.tail ? ( + + {body.snapshot.tail} + + ) : null} + + + ) + } + + if (body.snapshot.kind === "todo") { + return ( + + + # Todos + + + {body.snapshot.items.map((item) => ( + + {todoText(item)} + + ))} + {body.snapshot.tail ? ( + + {body.snapshot.tail} + + ) : null} + + + ) + } + + return ( + + + # Questions + + + {body.snapshot.items.map((item) => ( + + + {item.question} + + + {item.answer} + + + ))} + {body.snapshot.tail ? ( + + {body.snapshot.tail} + + ) : null} + + + ) } - return { - startOnNewLine: true, - trailingNewline: true, - } + return ( + + ) } -export function textEntryWriter(commit: StreamCommit, theme: RunEntryTheme): ScrollbackWriter { - return textWriter(commit, theme, flags(commit)) -} - -export function snapEntryWriter(commit: StreamCommit, theme: RunTheme, opts: ScrollbackOptions): ScrollbackWriter { - const snap = toolSnapshot(commit, clean(commit.text)) - if (!snap) { - return textEntryWriter(commit, theme.entry) - } - - const style = flags(commit) - - return (ctx) => - full(() => , ctx, style) -} - -export function blockWriter(text: string, theme: RunEntryTheme = RUN_THEME_FALLBACK.entry): ScrollbackWriter { - return textBlockWriter(clean(text), theme) +export function entryWriter(input: { + commit: StreamCommit + theme?: RunTheme + opts?: ScrollbackOptions +}): ScrollbackWriter { + return createScrollbackWriter( + // @ts-expect-error @opentui/solid scrollback helper still exposes solid-js JSX types + (ctx) => , + entryFlags(input.commit), + ) } export function spacerWriter(): ScrollbackWriter { diff --git a/packages/opencode/src/cli/cmd/run/theme.ts b/packages/opencode/src/cli/cmd/run/theme.ts index cb83b7a909..344fb9f287 100644 --- a/packages/opencode/src/cli/cmd/run/theme.ts +++ b/packages/opencode/src/cli/cmd/run/theme.ts @@ -38,6 +38,7 @@ export type RunBlockTheme = { text: ColorInput muted: ColorInput syntax?: SyntaxStyle + subtleSyntax?: SyntaxStyle diffAdded: ColorInput diffRemoved: ColorInput diffAddedBg: ColorInput @@ -98,7 +99,7 @@ function fade(color: RGBA, base: RGBA, fallback: number, scale: number, limit: n ) } -function map(theme: TuiThemeCurrent, syntax?: SyntaxStyle): RunTheme { +function map(theme: TuiThemeCurrent, syntax?: SyntaxStyle, subtleSyntax?: SyntaxStyle): RunTheme { const bg = theme.background const pane = theme.backgroundElement const shade = fade(pane, bg, 0.12, 0.56, 0.72) @@ -145,6 +146,7 @@ function map(theme: TuiThemeCurrent, syntax?: SyntaxStyle): RunTheme { text: theme.text, muted: theme.textMuted, syntax, + subtleSyntax, diffAdded: theme.diffAdded, diffRemoved: theme.diffRemoved, diffAddedBg: theme.diffAddedBg, @@ -229,7 +231,7 @@ export async function resolveRunTheme(renderer: CliRenderer): Promise const mod = await import("../tui/context/theme") const theme = mod.resolveTheme(mod.generateSystem(colors, pick), pick) as TuiThemeCurrent try { - return map(theme, mod.generateSyntax(theme)) + return map(theme, mod.generateSyntax(theme), mod.generateSubtleSyntax(theme)) } catch { return map(theme) } diff --git a/packages/opencode/src/cli/cmd/run/tool.ts b/packages/opencode/src/cli/cmd/run/tool.ts index 0e82eadef4..3321eb4a2b 100644 --- a/packages/opencode/src/cli/cmd/run/tool.ts +++ b/packages/opencode/src/cli/cmd/run/tool.ts @@ -18,16 +18,14 @@ import os from "os" import path from "path" import stripAnsi from "strip-ansi" import type { ToolPart } from "@opencode-ai/sdk/v2" -import type { Tool } from "../../../tool/tool" +import type * as Tool from "../../../tool/tool" import type { ApplyPatchTool } from "../../../tool/apply_patch" -import type { BatchTool } from "../../../tool/batch" import type { BashTool } from "../../../tool/bash" import type { CodeSearchTool } from "../../../tool/codesearch" import type { EditTool } from "../../../tool/edit" import type { GlobTool } from "../../../tool/glob" import type { GrepTool } from "../../../tool/grep" import type { InvalidTool } from "../../../tool/invalid" -import type { ListTool } from "../../../tool/ls" import type { LspTool } from "../../../tool/lsp" import type { PlanExitTool } from "../../../tool/plan" import type { QuestionTool } from "../../../tool/question" @@ -40,7 +38,7 @@ import type { WebSearchTool } from "../../../tool/websearch" import type { WriteTool } from "../../../tool/write" import { LANGUAGE_EXTENSIONS } from "../../../lsp/language" import * as Locale from "../../../util/locale" -import type { RunDiffStyle, StreamCommit } from "./types" +import type { RunDiffStyle, RunEntryBody, StreamCommit, ToolSnapshot } from "./types" export type ToolView = { output: boolean @@ -78,55 +76,6 @@ export type ToolPermissionInfo = { file?: string } -export type ToolCodeSnapshot = { - kind: "code" - title: string - content: string - file?: string -} - -export type ToolDiffSnapshot = { - kind: "diff" - items: Array<{ - title: string - diff: string - file?: string - deletions?: number - }> -} - -export type ToolTaskSnapshot = { - kind: "task" - title: string - rows: string[] - tail: string -} - -export type ToolTodoSnapshot = { - kind: "todo" - items: Array<{ - status: string - content: string - }> - tail: string -} - -export type ToolQuestionSnapshot = { - kind: "question" - items: Array<{ - question: string - answer: string - }> - tail: string -} - -export type ToolSnapshot = - | ToolCodeSnapshot - | ToolDiffSnapshot - | ToolTaskSnapshot - | ToolTodoSnapshot - | ToolQuestionSnapshot - export type ToolProps = { input: Partial> metadata: Partial> @@ -151,14 +100,14 @@ type ToolDefs = { write: typeof WriteTool edit: typeof EditTool apply_patch: typeof ApplyPatchTool - batch: typeof BatchTool + batch: Tool.Info task: typeof TaskTool todowrite: typeof TodoWriteTool question: typeof QuestionTool read: typeof ReadTool glob: typeof GlobTool grep: typeof GrepTool - list: typeof ListTool + list: Tool.Info lsp: typeof LspTool webfetch: typeof WebFetchTool codesearch: typeof CodeSearchTool @@ -361,8 +310,8 @@ function runGrep(p: ToolProps): ToolInline { } } -function runList(p: ToolProps): ToolInline { - const dir = p.input.path ?? "" +function runList(p: ToolProps): ToolInline { + const dir = text(dict(p.input).path) return { icon: "→", title: dir ? `List ${toolPath(dir)}` : "List", @@ -487,8 +436,8 @@ function runInvalid(p: ToolProps): ToolInline { } } -function runBatch(p: ToolProps): ToolInline { - const calls = list(p.input.tool_calls).length +function runBatch(p: ToolProps): ToolInline { + const calls = list(dict(p.input).tool_calls).length return { icon: "#", title: text(p.frame.state.title) || (calls > 0 ? `Batch ${calls} tool${calls === 1 ? "" : "s"}` : "Batch"), @@ -600,7 +549,7 @@ function snapPatch(p: ToolProps): ToolSnapshot | undefine return } - const diff = typeof file.diff === "string" ? file.diff : "" + const diff = typeof file.patch === "string" ? file.patch : "" if (!diff.trim()) { return } @@ -865,15 +814,15 @@ function scrollTodoStart(p: ToolProps): string { } function scrollTodoFinal(p: ToolProps): string { - const list = p.input.todos ?? [] - if (list.length === 0) { + const items = list<{ status?: string }>(p.input.todos) + if (items.length === 0) { return done("todos", span(p.frame.state)) } - const doneN = list.filter((item) => item.status === "completed").length - const runN = list.filter((item) => item.status === "in_progress").length - const left = list.length - doneN - runN - const tail = [`${list.length} total`] + 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`) } @@ -944,8 +893,8 @@ function scrollGrepStart(p: ToolProps): string { return `${head} in ${toolPath(dir)}` } -function scrollListStart(p: ToolProps): string { - const dir = p.input.path ?? "" +function scrollListStart(p: ToolProps): string { + const dir = text(dict(p.input).path) if (!dir) { return "→ List" } @@ -1019,8 +968,8 @@ function permGrep(p: ToolPermissionProps): ToolPermissionInfo { } } -function permList(p: ToolPermissionProps): ToolPermissionInfo { - const dir = p.input.path || p.patterns[0] || "" +function permList(p: ToolPermissionProps): ToolPermissionInfo { + const dir = text(dict(p.input).path) || p.patterns[0] || "" return { icon: "→", title: `List ${toolPath(dir, { home: true })}`, @@ -1369,6 +1318,16 @@ export function toolView(name?: string): ToolView { ) } +export function toolStructuredFinal(commit: StreamCommit): boolean { + const state = commit.toolState ?? commit.part?.state.status + return ( + commit.kind === "tool" && + commit.phase === "final" && + state === "completed" && + Boolean(toolView(commit.tool ?? commit.part?.tool).snap) + ) +} + export function toolInlineInfo(part: ToolPart): ToolInline { const ctx = frame(part) const draw = rule(ctx.name)?.run @@ -1442,6 +1401,58 @@ export function toolSnapshot(commit: StreamCommit, raw: string): ToolSnapshot | } } +function textBody(content: string): RunEntryBody | undefined { + if (!content) { + return + } + + return { + type: "text", + content, + } +} + +function structuredBody(commit: StreamCommit, raw: string): RunEntryBody | undefined { + const snap = toolSnapshot(commit, raw) + if (!snap) { + return + } + + return { + type: "structured", + snapshot: snap, + } +} + +export function toolEntryBody(commit: StreamCommit, raw: string): RunEntryBody | undefined { + const ctx = toolFrame(commit, raw) + const view = toolView(ctx.name) + + if (commit.phase === "progress" && !view.output) { + return + } + + if (commit.phase === "final") { + if (ctx.status === "error") { + return textBody(toolScroll("final", ctx)) + } + + if (!view.final) { + return + } + + if (ctx.status && ctx.status !== "completed") { + return textBody(ctx.raw.trim()) + } + + if (toolStructuredFinal(commit)) { + return structuredBody(commit, raw) ?? textBody(toolScroll("final", ctx)) + } + } + + return textBody(toolScroll(commit.phase, ctx)) +} + export function toolFiletype(input?: string): string | undefined { if (!input) { return diff --git a/packages/opencode/src/cli/cmd/run/types.ts b/packages/opencode/src/cli/cmd/run/types.ts index 8a22c107ac..b2f5f50fd2 100644 --- a/packages/opencode/src/cli/cmd/run/types.ts +++ b/packages/opencode/src/cli/cmd/run/types.ts @@ -83,6 +83,62 @@ export type ScrollbackOptions = { diffStyle?: RunDiffStyle } +export type ToolCodeSnapshot = { + kind: "code" + title: string + content: string + file?: string +} + +export type ToolDiffSnapshot = { + kind: "diff" + items: Array<{ + title: string + diff: string + file?: string + deletions?: number + }> +} + +export type ToolTaskSnapshot = { + kind: "task" + title: string + rows: string[] + tail: string +} + +export type ToolTodoSnapshot = { + kind: "todo" + items: Array<{ + status: string + content: string + }> + tail: string +} + +export type ToolQuestionSnapshot = { + kind: "question" + items: Array<{ + question: string + answer: string + }> + tail: string +} + +export type ToolSnapshot = + | ToolCodeSnapshot + | ToolDiffSnapshot + | ToolTaskSnapshot + | ToolTodoSnapshot + | ToolQuestionSnapshot + +export type RunEntryBody = + | { type: "none" } + | { type: "text"; content: string } + | { type: "code"; content: string; filetype?: string } + | { type: "markdown"; content: string } + | { type: "structured"; snapshot: ToolSnapshot } + // Which interactive surface the footer is showing. Only one view is active at // a time. The reducer drives transitions: when a permission arrives the view // switches to "permission", and when the permission resolves it falls back to diff --git a/packages/opencode/src/cli/cmd/tui/context/theme.tsx b/packages/opencode/src/cli/cmd/tui/context/theme.tsx index ef4a6bb7ba..acbaa682ae 100644 --- a/packages/opencode/src/cli/cmd/tui/context/theme.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/theme.tsx @@ -715,7 +715,7 @@ export function generateSyntax(theme: TuiThemeCurrent) { return SyntaxStyle.fromTheme(getSyntaxRules(theme)) } -function generateSubtleSyntax(theme: TuiThemeCurrent) { +export function generateSubtleSyntax(theme: TuiThemeCurrent) { const rules = getSyntaxRules(theme) return SyntaxStyle.fromTheme( rules.map((rule) => { diff --git a/packages/opencode/test/cli/run/entry.body.test.ts b/packages/opencode/test/cli/run/entry.body.test.ts new file mode 100644 index 0000000000..be6df3ad6e --- /dev/null +++ b/packages/opencode/test/cli/run/entry.body.test.ts @@ -0,0 +1,153 @@ +import { describe, expect, test } from "bun:test" +import { entryBody, entryCanStream, entryDone } from "../../../src/cli/cmd/run/entry.body" +import type { StreamCommit } from "../../../src/cli/cmd/run/types" + +function commit(input: Partial & Pick): StreamCommit { + return input +} + +describe("run entry body", () => { + test("renders assistant progress as markdown", () => { + expect( + entryBody( + commit({ + kind: "assistant", + text: "# Title\n\nHello **world**", + phase: "progress", + source: "assistant", + partID: "part-1", + }), + ), + ).toEqual({ + type: "markdown", + content: "# Title\n\nHello **world**", + }) + }) + + test("renders reasoning as markdown-highlighted code like the tui", () => { + const body = entryBody( + commit({ + kind: "reasoning", + text: "Thinking: plan next steps", + phase: "progress", + source: "reasoning", + partID: "reason-1", + }), + ) + + expect(body).toEqual({ + type: "code", + filetype: "markdown", + content: "_Thinking:_ plan next steps", + }) + expect(entryCanStream(commit({ kind: "reasoning", text: "Thinking: plan next steps", phase: "progress", source: "reasoning" }), body)).toBe(true) + }) + + test("prefixes user entries in text mode", () => { + expect( + entryBody( + commit({ + kind: "user", + text: "Inspect footer tabs", + phase: "start", + source: "system", + }), + ), + ).toEqual({ + type: "text", + content: "› Inspect footer tabs", + }) + }) + + test("keeps completed write tool finals structured", () => { + const body = entryBody( + commit({ + kind: "tool", + text: "", + phase: "final", + source: "tool", + 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, + }), + ) + + expect(body.type).toBe("structured") + if (body.type !== "structured") { + throw new Error("expected structured body") + } + + expect(body.snapshot).toEqual({ + kind: "code", + title: "# Wrote src/a.ts", + content: "const x = 1\n", + file: "src/a.ts", + }) + expect(entryDone( + commit({ + kind: "tool", + text: "output", + phase: "progress", + source: "tool", + tool: "bash", + toolState: "completed", + }), + )).toBe(true) + }) + + test("streams tool progress text", () => { + const body = entryBody( + commit({ + kind: "tool", + text: "partial output", + phase: "progress", + source: "tool", + tool: "bash", + partID: "tool-2", + }), + ) + + expect(body).toEqual({ + type: "text", + content: "partial output", + }) + expect(entryCanStream(commit({ kind: "tool", text: "partial output", phase: "progress", source: "tool", tool: "bash" }), body)).toBe(true) + }) + + test("renders interrupted assistant finals as text", () => { + expect( + entryBody( + commit({ + kind: "assistant", + text: "", + phase: "final", + source: "assistant", + interrupted: true, + partID: "part-1", + }), + ), + ).toEqual({ + type: "text", + content: "assistant interrupted", + }) + }) +}) diff --git a/packages/opencode/test/cli/run/footer.test.ts b/packages/opencode/test/cli/run/footer.test.ts index 779c575cf8..ac47f81b31 100644 --- a/packages/opencode/test/cli/run/footer.test.ts +++ b/packages/opencode/test/cli/run/footer.test.ts @@ -1,6 +1,150 @@ -import { expect, test } from "bun:test" +import { afterEach, expect, test } from "bun:test" +import { MockTreeSitterClient, createTestRenderer, type TestRenderer } from "@opentui/core/testing" import { RunFooter } from "../../../src/cli/cmd/run/footer" +import { RUN_THEME_FALLBACK } from "../../../src/cli/cmd/run/theme" + +const decoder = new TextDecoder() +const active: Array<{ footer?: RunFooter; renderer: TestRenderer }> = [] + +afterEach(() => { + for (const item of active.splice(0)) { + item.footer?.destroy() + item.renderer.destroy() + } +}) + +function createFooter(renderer: TestRenderer) { + const treeSitterClient = new MockTreeSitterClient({ autoResolveTimeout: 0 }) + treeSitterClient.setMockResult({ highlights: [] }) + + return new RunFooter(renderer, { + directory: "/tmp", + findFiles: async () => [], + agents: [], + resources: [], + agentLabel: "Build", + modelLabel: "Model default", + first: false, + history: [], + theme: RUN_THEME_FALLBACK, + keybinds: { + leader: "", + variantCycle: "tab", + interrupt: "esc", + historyPrevious: "up", + historyNext: "down", + inputSubmit: "enter", + inputNewline: "shift+enter", + }, + diffStyle: "auto", + onPermissionReply: () => {}, + onQuestionReply: () => {}, + onQuestionReject: () => {}, + treeSitterClient, + }) +} test("run footer class loads", () => { expect(typeof RunFooter).toBe("function") }) + +test("run footer finalizes streamed markdown tables when the turn goes idle", async () => { + const out = await createTestRenderer({ + width: 80, + height: 24, + screenMode: "split-footer", + footerHeight: 6, + externalOutputMode: "capture-stdout", + consoleMode: "disabled", + }) + const footer = createFooter(out.renderer) + active.push({ footer, renderer: out.renderer }) + const lib = Reflect.get(out.renderer, "lib") as { + commitSplitFooterSnapshot: (...args: unknown[]) => unknown + } + const originalCommitSplitFooterSnapshot = lib.commitSplitFooterSnapshot.bind(lib) + let payload = "" + + lib.commitSplitFooterSnapshot = (...args: unknown[]) => { + const snapshot = args[1] as { + getRealCharBytes(addLineBreaks?: boolean): Uint8Array + } + payload += decoder.decode(snapshot.getRealCharBytes(true)) + return originalCommitSplitFooterSnapshot(...args) + } + + try { + footer.event({ type: "turn.send", queue: 0 }) + + const text = "| Column 1 | Column 2 | Column 3 |\n|---|---|---|\n| Row 1 | Value 1 | Value 2 |\n| Row 2 | Value 3 | Value 4 |" + for (const chunk of text) { + footer.append({ + kind: "assistant", + text: chunk, + phase: "progress", + source: "assistant", + messageID: "msg-1", + partID: "part-1", + }) + } + + footer.event({ type: "turn.idle", queue: 0 }) + await footer.idle() + + expect(payload).toContain("Column 1") + expect(payload).toContain("Row 2") + expect(payload).toContain("Value 4") + } finally { + lib.commitSplitFooterSnapshot = originalCommitSplitFooterSnapshot + } +}) + +test("run footer keeps active streamed assistant content across width resize", async () => { + const out = await createTestRenderer({ + width: 40, + height: 24, + screenMode: "split-footer", + footerHeight: 6, + externalOutputMode: "capture-stdout", + consoleMode: "disabled", + }) + const footer = createFooter(out.renderer) + active.push({ footer, renderer: out.renderer }) + const lib = Reflect.get(out.renderer, "lib") as { + commitSplitFooterSnapshot: (...args: unknown[]) => unknown + } + const originalCommitSplitFooterSnapshot = lib.commitSplitFooterSnapshot.bind(lib) + let payload = "" + + lib.commitSplitFooterSnapshot = (...args: unknown[]) => { + const snapshot = args[1] as { + getRealCharBytes(addLineBreaks?: boolean): Uint8Array + } + payload += decoder.decode(snapshot.getRealCharBytes(true)) + return originalCommitSplitFooterSnapshot(...args) + } + + try { + footer.event({ type: "turn.send", queue: 0 }) + + footer.append({ + kind: "assistant", + text: "This paragraph only existed in the active surface until finalization.", + phase: "progress", + source: "assistant", + messageID: "msg-2", + partID: "part-2", + }) + + out.resize(60, 24) + + footer.event({ type: "turn.idle", queue: 0 }) + await footer.idle() + + expect(payload.replace(/\s+/g, " ").trim()).toContain( + "This paragraph only existed in the active surface until finalization.", + ) + } finally { + lib.commitSplitFooterSnapshot = originalCommitSplitFooterSnapshot + } +}) diff --git a/packages/opencode/test/cli/run/scrollback.surface.test.ts b/packages/opencode/test/cli/run/scrollback.surface.test.ts new file mode 100644 index 0000000000..c4fc26ac9a --- /dev/null +++ b/packages/opencode/test/cli/run/scrollback.surface.test.ts @@ -0,0 +1,225 @@ +import { afterEach, expect, test } from "bun:test" +import { MockTreeSitterClient, createTestRenderer, type TestRenderer } from "@opentui/core/testing" +import { RunScrollbackStream } from "../../../src/cli/cmd/run/scrollback.surface" +import { RUN_THEME_FALLBACK } from "../../../src/cli/cmd/run/theme" + +type ClaimedCommit = { + snapshot: { + getRealCharBytes(addLineBreaks?: boolean): Uint8Array + destroy(): void + } +} + +const decoder = new TextDecoder() +const active: TestRenderer[] = [] + +afterEach(() => { + for (const renderer of active.splice(0)) { + renderer.destroy() + } +}) + +function claimCommits(renderer: TestRenderer): ClaimedCommit[] { + return (renderer as any).externalOutputQueue.claim() as ClaimedCommit[] +} + +function destroyCommits(commits: ClaimedCommit[]) { + for (const commit of commits) { + commit.snapshot.destroy() + } +} + +test("completes finely streamed markdown tables when the turn goes idle", async () => { + const out = await createTestRenderer({ + 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, + }) + + const text = "| Column 1 | Column 2 | Column 3 |\n|---|---|---|\n| Row 1 | Value 1 | Value 2 |\n| Row 2 | Value 3 | Value 4 |" + + for (const chunk of text) { + await scrollback.append({ + kind: "assistant", + text: chunk, + phase: "progress", + source: "assistant", + messageID: "msg-1", + partID: "part-1", + }) + } + + await scrollback.complete() + + const commits = claimCommits(out.renderer) + try { + expect(commits.length).toBeGreaterThan(0) + const rendered = commits.map((item) => decoder.decode(item.snapshot.getRealCharBytes(true))).join("\n") + expect(rendered).toContain("Column 1") + expect(rendered).toContain("Row 2") + expect(rendered).toContain("Value 4") + } finally { + destroyCommits(commits) + } +}) + +test("completes coalesced markdown tables after one progress append", async () => { + const out = await createTestRenderer({ + 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, + }) + + await scrollback.append({ + kind: "assistant", + text: "| Column 1 | Column 2 | Column 3 |\n|---|---|---|\n| Row 1 | Value 1 | Value 2 |\n| Row 2 | Value 3 | Value 4 |", + phase: "progress", + source: "assistant", + messageID: "msg-1", + partID: "part-1", + }) + + await scrollback.complete() + + const commits = claimCommits(out.renderer) + try { + expect(commits.length).toBeGreaterThan(0) + const rendered = commits.map((item) => decoder.decode(item.snapshot.getRealCharBytes(true))).join("\n") + expect(rendered).toContain("Column 1") + expect(rendered).toContain("Row 2") + expect(rendered).toContain("Value 4") + } finally { + destroyCommits(commits) + } +}) + +test("coalesces same-line tool progress into one snapshot", 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: "abc", + phase: "progress", + source: "tool", + partID: "tool-1", + messageID: "msg-1", + tool: "bash", + }) + await scrollback.append({ + kind: "tool", + text: "def", + phase: "progress", + source: "tool", + partID: "tool-1", + messageID: "msg-1", + tool: "bash", + }) + await scrollback.append({ + kind: "tool", + text: "", + phase: "final", + source: "tool", + partID: "tool-1", + messageID: "msg-1", + tool: "bash", + toolState: "completed", + }) + + const commits = claimCommits(out.renderer) + try { + expect(commits).toHaveLength(1) + expect(decoder.decode(commits[0]!.snapshot.getRealCharBytes(true))).toContain("abcdef") + } finally { + destroyCommits(commits) + } +}) + +test("renders structured write finals as native code blocks", 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: "final", + source: "tool", + partID: "tool-2", + messageID: "msg-2", + tool: "write", + toolState: "completed", + part: { + id: "tool-2", + sessionID: "session-1", + messageID: "msg-2", + type: "tool", + callID: "call-2", + tool: "write", + state: { + status: "completed", + input: { + filePath: "src/a.ts", + content: "const x = 1\nconst y = 2\n", + }, + metadata: {}, + time: { + start: 1, + end: 2, + }, + }, + } as never, + }) + + const commits = claimCommits(out.renderer) + try { + expect(commits).toHaveLength(1) + const rendered = decoder.decode(commits[0]!.snapshot.getRealCharBytes(true)).replace(/ +/g, " ") + expect(rendered).toContain("# Wrote src/a.ts") + expect(rendered).toMatch(/1\s+const x = 1/) + expect(rendered).toMatch(/2\s+const y = 2/) + } finally { + destroyCommits(commits) + } +}) diff --git a/packages/opencode/test/cli/run/subagent-data.test.ts b/packages/opencode/test/cli/run/subagent-data.test.ts index 0fcdb6bdda..7e80db899b 100644 --- a/packages/opencode/test/cli/run/subagent-data.test.ts +++ b/packages/opencode/test/cli/run/subagent-data.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import { normalizeEntry } from "../../../src/cli/cmd/run/scrollback.format" +import { entryBody } from "../../../src/cli/cmd/run/entry.body" import { bootstrapSubagentData, clearFinishedSubagents, @@ -9,6 +9,33 @@ import { snapshotSubagentData, } from "../../../src/cli/cmd/run/subagent-data" +function visible(commits: Array[0]>) { + return commits.flatMap((item) => { + const body = entryBody(item) + if (body.type === "none") { + return [] + } + + if (body.type === "structured") { + if (body.snapshot.kind === "code" || body.snapshot.kind === "task") { + return [body.snapshot.title] + } + + if (body.snapshot.kind === "diff") { + return body.snapshot.items.map((item) => item.title) + } + + if (body.snapshot.kind === "todo") { + return ["# Todos"] + } + + return ["# Questions"] + } + + return [body.content] + }) +} + function taskMessage(sessionID: string, status: "running" | "completed" | "error" = "completed") { return { info: { @@ -243,9 +270,9 @@ describe("run subagent data", () => { sessionID: "child-1", commits: expect.any(Array), }) - expect(snapshot.details["child-1"]?.commits.map((item) => normalizeEntry(item))).toEqual([ + expect(visible(snapshot.details["child-1"]?.commits ?? [])).toEqual([ "› Inspect footer tabs", - "Thinking: planning next steps", + "_Thinking:_ planning next steps", "# Shell\n$ git status --short", ]) expect(snapshot.permissions).toEqual([ @@ -343,9 +370,9 @@ describe("run subagent data", () => { } as never, }) - expect( - snapshotSelectedSubagentData(data, "child-1").details["child-1"]?.commits.map((item) => normalizeEntry(item)), - ).toEqual(["hello world"]) + expect(visible(snapshotSelectedSubagentData(data, "child-1").details["child-1"]?.commits ?? [])).toEqual([ + "hello world", + ]) }) test("clears finished tabs on the next parent prompt", () => {