markdown rendering pass 1

This commit is contained in:
Simon Klee
2026-04-17 23:08:47 +02:00
parent 39a4792970
commit 6208f49750
19 changed files with 1570 additions and 795 deletions

View File

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

View File

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

View File

@@ -292,12 +292,7 @@ export function RunQuestionBody(props: {
backgroundColor={props.theme.surface}
>
<Show when={!single()}>
<box id="run-direct-footer-question-tabs"
flexDirection="row"
gap={1}
paddingLeft={1}
flexShrink={0}
>
<box id="run-direct-footer-question-tabs" flexDirection="row" gap={1} paddingLeft={1} flexShrink={0}>
<For each={props.request.questions}>
{(item, index) => {
const active = () => state().tab === index()

View File

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

View File

@@ -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<void> = 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(() => {})
}
}

View File

@@ -230,6 +230,8 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
process.off("SIGINT", sigint)
try {
await footer.idle().catch(() => {})
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<Lif
}
} finally {
footer.close()
await footer.idle().catch(() => {})
footer.destroy()
shutdown(renderer)
}

View File

@@ -35,7 +35,9 @@ type BootContext = Pick<
type RunRuntimeInput = {
boot: () => Promise<BootContext>
afterPaint?: (ctx: BootContext) => Promise<void> | 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<void> {
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<void> {
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,

View File

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

View File

@@ -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<RunEntryBody, { type: "none" | "structured" }>
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
await this.finishActive(trailingNewline)
}
public destroy(): void {
this.resetActive()
}
}

View File

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

View File

@@ -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 <text> 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<string, unknown>
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 (
<text width="100%" wrapMode="word" fg={props.fg} attributes={props.attrs}>
{props.body}
</text>
)
}
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 (
<text
width="100%"
wrapMode="word"
fg={props.theme.reasoning.body}
attributes={TextAttributes.DIM | TextAttributes.ITALIC}
>
<Show when={part.head}>{part.head}</Show>
{part.tail}
</text>
)
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 (
<Show when={props.lines.length > 0}>
<box>
<For each={props.lines}>{(line) => <text fg={props.theme.entry.error.body}>{line}</text>}</For>
</box>
</Show>
)
}
function BlockTool(props: { theme: RunTheme; title: string; children: JSX.Element }) {
return (
<box flexDirection="column" gap={1}>
<text fg={props.theme.block.muted} attributes={TextAttributes.DIM}>
{props.title}
</text>
{props.children}
</box>
)
}
function CodeTool(props: { theme: RunTheme; data: CodeInput }) {
return (
<BlockTool theme={props.theme} title={props.data.title}>
<line_number fg={props.theme.block.muted} minWidth={3} paddingRight={1}>
<code
conceal={false}
fg={props.theme.block.text}
filetype={props.data.filetype}
syntaxStyle={syntax(props.theme.block.syntax)}
content={props.data.content}
drawUnstyledText={true}
wrapMode="word"
/>
</line_number>
<Diagnostics theme={props.theme} lines={props.data.diagnostics} />
</BlockTool>
)
}
function DiffTool(props: { theme: RunTheme; data: DiffInput; view: "unified" | "split" }) {
return (
<BlockTool theme={props.theme} title={props.data.title}>
<Show
when={props.data.diff?.trim()}
fallback={
<text fg={props.theme.block.diffRemoved}>
-{props.data.deletions ?? 0} line{props.data.deletions === 1 ? "" : "s"}
</text>
}
>
<box>
<diff
diff={props.data.diff ?? ""}
view={props.view}
filetype={props.data.filetype}
syntaxStyle={syntax(props.theme.block.syntax)}
showLineNumbers={true}
width="100%"
wrapMode="word"
fg={props.theme.block.text}
addedBg={props.theme.block.diffAddedBg}
removedBg={props.theme.block.diffRemovedBg}
contextBg={props.theme.block.diffContextBg}
addedSignColor={props.theme.block.diffHighlightAdded}
removedSignColor={props.theme.block.diffHighlightRemoved}
lineNumberFg={props.theme.block.diffLineNumber}
lineNumberBg={props.theme.block.diffContextBg}
addedLineNumberBg={props.theme.block.diffAddedLineNumberBg}
removedLineNumberBg={props.theme.block.diffRemovedLineNumberBg}
/>
</box>
</Show>
<Diagnostics theme={props.theme} lines={props.data.diagnostics} />
</BlockTool>
)
}
function TaskTool(props: { theme: RunTheme; data: TaskInput }) {
return (
<BlockTool theme={props.theme} title={props.data.title}>
<box>
<For each={props.data.rows}>{(line) => <text fg={props.theme.block.text}>{line}</text>}</For>
</box>
<text fg={props.theme.block.muted} attributes={TextAttributes.DIM}>
{props.data.tail}
</text>
</BlockTool>
)
}
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 (
<BlockTool theme={props.theme} title="# Todos">
<box>
<For each={props.data.items}>
{(item) => (
<text fg={props.theme.block.text}>
{todoMark(item.status)} {item.content}
</text>
)}
</For>
</box>
<text fg={props.theme.block.muted} attributes={TextAttributes.DIM}>
{props.data.tail}
</text>
</BlockTool>
)
}
function QuestionTool(props: { theme: RunTheme; data: QuestionInput }) {
return (
<BlockTool theme={props.theme} title="# Questions">
<text fg={props.theme.block.muted} attributes={TextAttributes.DIM}>
{props.data.tail}
</text>
<box gap={1}>
<For each={props.data.items}>
{(item) => (
<box flexDirection="column">
<text fg={props.theme.block.muted}>{item.question}</text>
<text fg={props.theme.block.text}>{item.answer}</text>
</box>
)}
</For>
</box>
</BlockTool>
)
}
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 <ReasoningEntry body={body} theme={props.theme} />
}
const style = look(props.commit, props.theme)
return <TextEntry body={body} fg={style.fg} attrs={style.attrs} />
}
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 <RunEntryTextContent commit={props.commit} theme={props.theme.entry} />
}
const info = toolFrame(props.commit, raw)
if (snap.kind === "code") {
return (
<CodeTool
theme={props.theme}
data={{
title: snap.title,
content: snap.content,
filetype: toolFiletype(snap.file),
diagnostics: diagnostics(info.meta, snap.file ?? ""),
}}
/>
)
}
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<typeof item> => Boolean(item))
if (list.length === 0) {
return <RunEntryTextContent commit={props.commit} theme={props.theme.entry} />
}
return (
<box flexDirection="column" gap={1}>
<For each={list}>
{(item) => (
<DiffTool theme={props.theme} data={item} view={toolDiffView(props.width ?? 80, props.opts?.diffStyle)} />
)}
</For>
</box>
)
}
if (snap.kind === "task") {
return (
<TaskTool
theme={props.theme}
data={{
title: snap.title,
rows: snap.rows,
tail: snap.tail,
}}
/>
)
}
if (snap.kind === "todo") {
return (
<TodoTool
theme={props.theme}
data={{
items: snap.items,
tail: snap.tail,
}}
/>
)
}
return (
<QuestionTool
theme={props.theme}
data={{
items: snap.items,
tail: snap.tail,
}}
/>
)
}
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 <RunEntrySnapContent commit={props.commit} theme={theme} opts={props.opts} width={props.width} />
const body = entryBody(props.commit)
if (body.type === "none") {
return null
}
return <RunEntryTextContent commit={props.commit} theme={theme.entry} />
}
function textWriter(commit: StreamCommit, theme: RunEntryTheme, flags: Flags): ScrollbackWriter {
return (ctx) =>
fit(
createScrollbackWriter(() => <RunEntryTextContent commit={commit} theme={theme} />, {
width: cols(ctx),
startOnNewLine: flags.startOnNewLine,
trailingNewline: flags.trailingNewline,
})(ctx),
ctx,
if (body.type === "text") {
const style = look(props.commit, theme.entry)
return (
<text width="100%" wrapMode="word" fg={style.fg} attributes={style.attrs}>
{body.content}
</text>
)
}
function blankWriter(): ScrollbackWriter {
return (ctx) =>
createScrollbackWriter(() => <text width="100%" />, {
width: cols(ctx),
startOnNewLine: true,
trailingNewline: true,
})(ctx)
}
function textBlockWriter(body: string, theme: RunEntryTheme): ScrollbackWriter {
return (ctx) =>
full(() => <TextEntry body={body.endsWith("\n") ? body : `${body}\n`} fg={theme.system.body} />, 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 (
<code
width="100%"
wrapMode="word"
filetype={body.filetype}
drawUnstyledText={false}
streaming={props.commit.phase === "progress"}
syntaxStyle={syntaxFor(props.commit, theme)}
content={body.content}
fg={entryColor(props.commit, theme)}
/>
)
}
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 (
<box width="100%" flexDirection="column" gap={1}>
<text width="100%" wrapMode="word" fg={theme.block.muted}>
{body.snapshot.title}
</text>
<box width="100%" paddingLeft={1}>
<line_number width="100%" fg={theme.block.muted} minWidth={3} paddingRight={1}>
<code
width="100%"
wrapMode="char"
filetype={toolFiletype(body.snapshot.file)}
streaming={false}
syntaxStyle={syntaxFor(props.commit, theme)}
content={body.snapshot.content}
fg={theme.block.text}
/>
</line_number>
</box>
</box>
)
}
return {
startOnNewLine: true,
trailingNewline: true,
if (body.snapshot.kind === "diff") {
const view = toolDiffView(width, props.opts?.diffStyle)
return (
<box width="100%" flexDirection="column" gap={1}>
{body.snapshot.items.map((item) => (
<box width="100%" flexDirection="column" gap={1}>
<text width="100%" wrapMode="word" fg={theme.block.muted}>
{item.title}
</text>
{item.diff.trim() ? (
<box width="100%" paddingLeft={1}>
<diff
diff={item.diff}
view={view}
filetype={toolFiletype(item.file)}
syntaxStyle={syntaxFor(props.commit, theme)}
showLineNumbers={true}
width="100%"
wrapMode="word"
fg={theme.block.text}
addedBg={theme.block.diffAddedBg}
removedBg={theme.block.diffRemovedBg}
contextBg={theme.block.diffContextBg}
addedSignColor={theme.block.diffHighlightAdded}
removedSignColor={theme.block.diffHighlightRemoved}
lineNumberFg={theme.block.diffLineNumber}
lineNumberBg={theme.block.diffContextBg}
addedLineNumberBg={theme.block.diffAddedLineNumberBg}
removedLineNumberBg={theme.block.diffRemovedLineNumberBg}
/>
</box>
) : (
<text width="100%" wrapMode="word" fg={theme.block.diffRemoved}>
-{item.deletions ?? 0} line{item.deletions === 1 ? "" : "s"}
</text>
)}
</box>
))}
</box>
)
}
if (body.snapshot.kind === "task") {
return (
<box width="100%" flexDirection="column" gap={1}>
<text width="100%" wrapMode="word" fg={theme.block.muted}>
{body.snapshot.title}
</text>
<box width="100%" flexDirection="column" gap={0} paddingLeft={1}>
{body.snapshot.rows.map((row) => (
<text width="100%" wrapMode="word" fg={theme.block.text}>
{row}
</text>
))}
{body.snapshot.tail ? (
<text width="100%" wrapMode="word" fg={theme.block.muted}>
{body.snapshot.tail}
</text>
) : null}
</box>
</box>
)
}
if (body.snapshot.kind === "todo") {
return (
<box width="100%" flexDirection="column" gap={1}>
<text width="100%" wrapMode="word" fg={theme.block.muted}>
# Todos
</text>
<box width="100%" flexDirection="column" gap={0} paddingLeft={1}>
{body.snapshot.items.map((item) => (
<text width="100%" wrapMode="word" fg={theme.block.text}>
{todoText(item)}
</text>
))}
{body.snapshot.tail ? (
<text width="100%" wrapMode="word" fg={theme.block.muted}>
{body.snapshot.tail}
</text>
) : null}
</box>
</box>
)
}
return (
<box width="100%" flexDirection="column" gap={1}>
<text width="100%" wrapMode="word" fg={theme.block.muted}>
# Questions
</text>
<box width="100%" flexDirection="column" gap={1} paddingLeft={1}>
{body.snapshot.items.map((item) => (
<box width="100%" flexDirection="column" gap={0}>
<text width="100%" wrapMode="word" fg={theme.block.muted}>
{item.question}
</text>
<text width="100%" wrapMode="word" fg={theme.block.text}>
{item.answer}
</text>
</box>
))}
{body.snapshot.tail ? (
<text width="100%" wrapMode="word" fg={theme.block.muted}>
{body.snapshot.tail}
</text>
) : null}
</box>
</box>
)
}
return {
startOnNewLine: true,
trailingNewline: true,
}
return (
<markdown
width="100%"
syntaxStyle={syntaxFor(props.commit, theme)}
streaming={props.commit.phase === "progress"}
content={body.content}
fg={entryColor(props.commit, theme)}
tableOptions={{ widthMode: "content" }}
/>
)
}
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(() => <RunEntrySnapContent commit={commit} theme={theme} opts={opts} width={cols(ctx)} />, 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) => <RunEntryContent commit={input.commit} theme={input.theme} opts={input.opts} width={ctx.width} />,
entryFlags(input.commit),
)
}
export function spacerWriter(): ScrollbackWriter {

View File

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

View File

@@ -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<T = Tool.Info> = {
input: Partial<Tool.InferParameters<T>>
metadata: Partial<Tool.InferMetadata<T>>
@@ -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<typeof GrepTool>): ToolInline {
}
}
function runList(p: ToolProps<typeof ListTool>): 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<typeof InvalidTool>): ToolInline {
}
}
function runBatch(p: ToolProps<typeof BatchTool>): 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<typeof ApplyPatchTool>): 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<typeof TodoWriteTool>): string {
}
function scrollTodoFinal(p: ToolProps<typeof TodoWriteTool>): 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<typeof GrepTool>): string {
return `${head} in ${toolPath(dir)}`
}
function scrollListStart(p: ToolProps<typeof ListTool>): 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<typeof GrepTool>): ToolPermissionInfo {
}
}
function permList(p: ToolPermissionProps<typeof ListTool>): 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

View File

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

View File

@@ -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) => {

View File

@@ -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<StreamCommit> & Pick<StreamCommit, "kind" | "text" | "phase" | "source">): 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",
})
})
})

View File

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

View File

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

View File

@@ -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<Parameters<typeof entryBody>[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", () => {