From bcf370321722374309294f3a48cfb0220db61681 Mon Sep 17 00:00:00 2001 From: Simon Klee Date: Sat, 18 Apr 2026 14:46:27 +0200 Subject: [PATCH] share style/body decisions --- .../src/cli/cmd/run/scrollback.shared.ts | 92 ++++++++++++ .../src/cli/cmd/run/scrollback.surface.ts | 119 ++------------- .../src/cli/cmd/run/scrollback.writer.tsx | 141 +++--------------- .../test/cli/run/scrollback.surface.test.ts | 54 +++++++ 4 files changed, 183 insertions(+), 223 deletions(-) create mode 100644 packages/opencode/src/cli/cmd/run/scrollback.shared.ts diff --git a/packages/opencode/src/cli/cmd/run/scrollback.shared.ts b/packages/opencode/src/cli/cmd/run/scrollback.shared.ts new file mode 100644 index 0000000000..57af8cde48 --- /dev/null +++ b/packages/opencode/src/cli/cmd/run/scrollback.shared.ts @@ -0,0 +1,92 @@ +import { SyntaxStyle, TextAttributes, type ColorInput } from "@opentui/core" +import { type RunEntryTheme, type RunTheme } from "./theme" +import type { StreamCommit } from "./types" + +function syntax(style?: SyntaxStyle): SyntaxStyle { + return style ?? SyntaxStyle.fromTheme([]) +} + +export function entrySyntax(commit: StreamCommit, theme: RunTheme): SyntaxStyle { + if (commit.kind === "reasoning") { + return syntax(theme.block.subtleSyntax ?? theme.block.syntax) + } + + return syntax(theme.block.syntax) +} + +export function entryFailed(commit: StreamCommit): boolean { + return commit.kind === "tool" && (commit.toolState === "error" || commit.part?.state.status === "error") +} + +export function entryLook(commit: StreamCommit, theme: RunEntryTheme): { fg: ColorInput; attrs?: number } { + if (commit.kind === "user") { + return { + fg: theme.user.body, + attrs: TextAttributes.BOLD, + } + } + + if (entryFailed(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 } +} + +export 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 (entryFailed(commit)) { + return theme.entry.error.body + } + + if (commit.kind === "tool") { + return theme.block.text + } + + return entryLook(commit, theme.entry).fg +} diff --git a/packages/opencode/src/cli/cmd/run/scrollback.surface.ts b/packages/opencode/src/cli/cmd/run/scrollback.surface.ts index 9cebae92f8..39593a3d78 100644 --- a/packages/opencode/src/cli/cmd/run/scrollback.surface.ts +++ b/packages/opencode/src/cli/cmd/run/scrollback.surface.ts @@ -7,18 +7,16 @@ 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 { entryColor, entryLook, entrySyntax } from "./scrollback.shared" import { entryWriter, sameEntryGroup, spacerWriter } from "./scrollback.writer" -import { type RunEntryTheme, type RunTheme } from "./theme" +import { type RunTheme } from "./theme" import type { RunDiffStyle, RunEntryBody, StreamCommit } from "./types" type ActiveBody = Exclude @@ -33,103 +31,8 @@ type ActiveEntry = { 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 @@ -147,7 +50,12 @@ function commitMarkdownBlocks(input: { return false } - input.surface.commitRows(first.renderable.y, last.renderable.y + last.renderable.height + (last.marginBottom ?? 0), { + const prev = input.renderable._blockStates[input.startBlock - 1] + const next = input.renderable._blockStates[input.endBlockExclusive] + const start = Math.max(0, first.renderable.y - (prev?.marginBottom ?? 0)) + const end = last.renderable.y + last.renderable.height + (next ? 0 : (last.marginBottom ?? 0)) + + input.surface.commitRows(start, end, { trailingNewline: input.trailingNewline, }) return true @@ -179,6 +87,7 @@ export class RunScrollbackStream { startOnNewLine: entryFlags(commit).startOnNewLine, }) const id = `run-scrollback-entry-${nextId++}` + const style = entryLook(commit, this.theme.entry) const renderable = body.type === "text" ? new TextRenderable(surface.renderContext, { @@ -186,15 +95,15 @@ export class RunScrollbackStream { content: "", width: "100%", wrapMode: "word", - fg: look(commit, this.theme.entry).fg, - attributes: look(commit, this.theme.entry).attrs, + fg: style.fg, + attributes: style.attrs, }) : body.type === "code" ? new CodeRenderable(surface.renderContext, { id, content: "", filetype: body.filetype, - syntaxStyle: syntaxFor(commit, this.theme), + syntaxStyle: entrySyntax(commit, this.theme), width: "100%", wrapMode: "word", drawUnstyledText: false, @@ -205,7 +114,7 @@ export class RunScrollbackStream { : new MarkdownRenderable(surface.renderContext, { id, content: "", - syntaxStyle: syntaxFor(commit, this.theme), + syntaxStyle: entrySyntax(commit, this.theme), width: "100%", streaming: true, internalBlockMode: "top-level", @@ -372,7 +281,7 @@ export class RunScrollbackStream { this.active = undefined } - public async complete(trailingNewline = true): Promise { + public async complete(trailingNewline = false): Promise { await this.finishActive(trailingNewline) } diff --git a/packages/opencode/src/cli/cmd/run/scrollback.writer.tsx b/packages/opencode/src/cli/cmd/run/scrollback.writer.tsx index ae4e97ab9d..28e64d5330 100644 --- a/packages/opencode/src/cli/cmd/run/scrollback.writer.tsx +++ b/packages/opencode/src/cli/cmd/run/scrollback.writer.tsx @@ -1,108 +1,13 @@ /** @jsxImportSource @opentui/solid */ import { createScrollbackWriter } from "@opentui/solid" -import { SyntaxStyle, TextAttributes, TextRenderable, type ColorInput, type ScrollbackWriter } from "@opentui/core" +import { TextRenderable, type ScrollbackWriter } from "@opentui/core" import { entryBody, entryFlags } from "./entry.body" +import { entryColor, entryLook, entrySyntax } from "./scrollback.shared" import { toolDiffView, toolFiletype, toolStructuredFinal } from "./tool" -import { RUN_THEME_FALLBACK, type RunEntryTheme, type RunTheme } from "./theme" +import { RUN_THEME_FALLBACK, type RunTheme } from "./theme" import type { ScrollbackOptions, StreamCommit } from "./types" -let bare: SyntaxStyle | undefined - -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 todoText(item: { status: string; content: string }): string { if (item.status === "completed") { return `[x] ${item.content}` @@ -158,7 +63,7 @@ export function RunEntryContent(props: { } if (body.type === "text") { - const style = look(props.commit, theme.entry) + const style = entryLook(props.commit, theme.entry) return ( {body.content} @@ -174,7 +79,7 @@ export function RunEntryContent(props: { filetype={body.filetype} drawUnstyledText={false} streaming={props.commit.phase === "progress"} - syntaxStyle={syntaxFor(props.commit, theme)} + syntaxStyle={entrySyntax(props.commit, theme)} content={body.content} fg={entryColor(props.commit, theme)} /> @@ -192,15 +97,15 @@ export function RunEntryContent(props: { - + @@ -218,14 +123,14 @@ export function RunEntryContent(props: { {item.diff.trim() ? ( - { + 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, + wrote: false, + }) + + await scrollback.append({ + kind: "assistant", + text: "# Markdown Sample\n\n- Item 1\n- Item 2\n\n```js\nconst message = \"Hello, markdown\"\nconsole.log(message)\n```", + phase: "progress", + source: "assistant", + messageID: "msg-1", + partID: "part-1", + }) + + const progress = claimCommits(out.renderer) + try { + expect(progress).toHaveLength(1) + expect(progress[0]!.snapshot.height).toBe(4) + const rendered = decoder.decode(progress[0]!.snapshot.getRealCharBytes(true)) + expect(rendered).toContain("Markdown Sample") + expect(rendered).toContain("Item 2") + expect(rendered).not.toContain("console.log(message)") + } finally { + destroyCommits(progress) + } + + await scrollback.complete() + + const final = claimCommits(out.renderer) + try { + expect(final).toHaveLength(1) + expect(final[0]!.trailingNewline).toBe(false) + const rendered = decoder.decode(final[0]!.snapshot.getRealCharBytes(true)) + expect(rendered).toContain('const message = "Hello, markdown"') + expect(rendered).toContain("console.log(message)") + } finally { + destroyCommits(final) + } +}) + test("coalesces same-line tool progress into one snapshot", async () => { const out = await createTestRenderer({ width: 80,