share style/body decisions

This commit is contained in:
Simon Klee 2026-04-18 14:46:27 +02:00
parent c7532b20d7
commit bcf3703217
No known key found for this signature in database
GPG key ID: B91696044D47BEA3
4 changed files with 183 additions and 223 deletions

View file

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

View file

@ -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<RunEntryBody, { type: "none" | "structured" }>
@ -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<void> {
public async complete(trailingNewline = false): Promise<void> {
await this.finishActive(trailingNewline)
}

View file

@ -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 (
<text width="100%" wrapMode="word" fg={style.fg} attributes={style.attrs}>
{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: {
</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={syntaxFor(props.commit, theme)}
content={body.snapshot.content}
fg={theme.block.text}
/>
<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}
/>
</line_number>
</box>
</box>
@ -218,14 +123,14 @@ export function RunEntryContent(props: {
</text>
{item.diff.trim() ? (
<box width="100%" paddingLeft={1}>
<diff
diff={item.diff}
view={view}
filetype={toolFiletype(item.file)}
syntaxStyle={syntaxFor(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}
@ -322,7 +227,7 @@ export function RunEntryContent(props: {
return (
<markdown
width="100%"
syntaxStyle={syntaxFor(props.commit, theme)}
syntaxStyle={entrySyntax(props.commit, theme)}
streaming={props.commit.phase === "progress"}
content={body.content}
fg={entryColor(props.commit, theme)}

View file

@ -5,9 +5,11 @@ import { RUN_THEME_FALLBACK } from "../../../src/cli/cmd/run/theme"
type ClaimedCommit = {
snapshot: {
height: number
getRealCharBytes(addLineBreaks?: boolean): Uint8Array
destroy(): void
}
trailingNewline: boolean
}
const decoder = new TextDecoder()
@ -111,6 +113,58 @@ test("completes coalesced markdown tables after one progress append", async () =
}
})
test("completes markdown replies without adding a second blank line above the footer", 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,
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,