/** * pi-tui transcript components: the startup banner, user/assistant messages, * per-step timing footer, streaming assistant buffer, tool cards, and the todo * panel. Each is a pure function of its inputs and the active palette. * @module @deepseek-ai/dsh-tui/components/transcript */ import { Container, Markdown, Spacer, Text, truncateToWidth, wrapTextWithAnsi, type Component, type MarkdownTheme, } from '@earendil-works/pi-tui' import { diffLines as compareLines } from 'diff' import type { Agent } from '@deepseek-ai/dsh-agent' import type { ContentBlock, StreamChunk } from '@deepseek-ai/dsh-llm' import type { JsonValue, SessionEvent, TodoItem } from '@deepseek-ai/dsh-session' import type { TerminalCallView, ToolCallView, ToolDefinition, ToolResultView, } from '@deepseek-ai/dsh-tools' import type { FileDiff } from '@deepseek-ai/dsh-tools' import { preview, renderUnknownXml } from './xml-tool-output.ts' import { displayInlineText, displayText } from './text.ts' import { gradientText, type Palette } from './theme.ts' import { contentText, type ParsedArguments } from './content.ts' import { formatCompletionTime, formatTimingTotals, stepTimingAt, type StepPosition, } from '../chat/timing.ts' /** Concatenate the text of every block of one type, separated by blank lines. */ function textBlocks(content: readonly ContentBlock[], type: 'text' | 'reasoning'): string { return content .filter((block): block is Extract => block.type === type) .map(block => block.text) .join('\n\n') } /** Render a value as terminal-safe text: strings escaped, other values as pretty JSON. */ function pretty(value: unknown): string { if (typeof value === 'string') return displayText(value) // JSON.stringify is typed to return string but yields undefined for e.g. symbols. const serialized = JSON.stringify(value, null, 2) as string | undefined return displayText(serialized ?? String(value)) } interface RenderedDiff { lines: string[] added: number removed: number approximate: boolean } /** * A side's content lines under the terminator rule the Web DiffBlock also * applies: empty text is zero lines, a trailing newline terminates the last * line, and an interior blank line survives. */ function diffContentLines(text: string): string[] { if (text === '') return [] const body = text.endsWith('\n') ? text.slice(0, -1) : text return body.split('\n') } /** * A file diff whose unchanged context stays neutral and does not affect exact * change totals. Comparisons beyond the edit-distance budget fall back to * whole-side rendering so a model-authored pending edit cannot stall the TUI. */ function renderDiff(diff: FileDiff, maxDiffEditLength: number, palette: Palette): RenderedDiff { // The card header is a fixed `Tool / ` frame that never names a file, so // each hunk always carries its own path header (no redundancy to suppress). const lines = [palette.bold(displayText(diff.path))] let added = 0 let removed = 0 if (diff.oldText === null) { const newLines = diffContentLines(displayText(diff.newText)) added = newLines.length for (const line of newLines) lines.push(palette.success(`+ ${line}`)) return { lines, added, removed, approximate: false } } const changes = compareLines(diff.oldText, diff.newText, { maxEditLength: maxDiffEditLength }) if (changes === undefined) { const oldLines = diffContentLines(displayText(diff.oldText)) const newLines = diffContentLines(displayText(diff.newText)) lines.push(palette.dim(`[exact line diff omitted: >${maxDiffEditLength} changed lines]`)) removed = oldLines.length added = newLines.length for (const line of oldLines) lines.push(palette.error(`- ${line}`)) for (const line of newLines) lines.push(palette.success(`+ ${line}`)) return { lines, added, removed, approximate: true } } for (const change of changes) { const changedLines = diffContentLines(displayText(change.value)) if (change.added) { added += changedLines.length for (const line of changedLines) lines.push(palette.success(`+ ${line}`)) } else if (change.removed) { removed += changedLines.length for (const line of changedLines) lines.push(palette.error(`- ${line}`)) } else { for (const line of changedLines) lines.push(palette.dim(` ${line}`)) } } return { lines, added, removed, approximate: false } } /** * A message's bold, underlined role header in the role color. The underline * bands each role without a background fill or per-line prefix, so it reads on * any theme and a body drag-select copies the message text verbatim. */ function messageHeader(label: string, color: (text: string) => string, palette: Palette): string { return palette.bold(palette.underline(color(displayText(label)))) } /** * Borderless startup banner: product title, an optional configured subtitle, * and the session id. No box frame — each line renders as plain left-padded * text (matching transcript notices) so it reads on any theme. */ export class HeaderComponent implements Component { /** Columns of the banner currently revealed; `undefined` renders it whole. */ private revealWidth: number | undefined constructor( private readonly agent: Agent, private readonly subtitle: () => string | undefined, private readonly palette: Palette, private readonly gradient: boolean, ) {} /** * Clip the banner to `width` columns (the sweep reveal); `undefined` restores it. * @param width - Revealed banner width in columns, or `undefined` for the whole banner. */ setRevealWidth(width: number | undefined): void { this.revealWidth = width } invalidate(): void {} render(width: number): string[] { const usable = Math.max(1, width - 2) const name = this.gradient ? this.palette.bold(gradientText('DEEPSEEK')) : this.palette.bold(this.palette.accent('DEEPSEEK')) const title = `${name} ${this.palette.bold('HARNESS')}` const detail = displayText(this.agent.session.id) const subtitle = this.subtitle() const lines = [ title, ...subtitle === undefined ? [] : [this.palette.dim(displayText(subtitle))], this.palette.dim(detail), ] .flatMap(line => wrapTextWithAnsi(line, usable)) .map(line => ` ${truncateToWidth(line, usable, '')}`) if (this.revealWidth === undefined) return lines const revealed = this.revealWidth return lines.map(line => truncateToWidth(line, revealed, '')) } } /** * A user or steering prompt in the transcript. An underlined accent role header * plus blank-line spacing separate it from surrounding blocks; body lines carry * no prefix or indent, so a terminal drag-select copies the prompt verbatim. */ export class UserMessageComponent extends Container { constructor(text: string, palette: Palette, mdTheme: MarkdownTheme, label = 'You') { super() this.addChild(new Text(messageHeader(label, palette.accent, palette), 0, 0)) this.addChild(new Markdown(displayText(text), 0, 0, mdTheme, { color: value => palette.text(value) }, { preserveOrderedListMarkers: true, preserveBackslashEscapes: true, })) } } /** * Children of a settled assistant message: optional reasoning block then the * response text. A folded continuation (a later step of a turn while tool cards * are hidden) drops the `Assistant` header and renders nothing when it has no * visible body, so tool-only steps leave no blank segment behind. */ function assistantMessageChildren( content: readonly ContentBlock[], showReasoning: boolean, foldedContinuation: boolean, palette: Palette, mdTheme: MarkdownTheme, ): Component[] { const reasoning = displayText(textBlocks(content, 'reasoning').trim()) const text = displayText(textBlocks(content, 'text').trim()) const showsReasoning = reasoning !== '' && showReasoning if (foldedContinuation && !showsReasoning && text === '') return [] const children: Component[] = [new Spacer(1)] if (!foldedContinuation) { children.push(new Text(messageHeader('Assistant', palette.accent, palette), 0, 0)) } if (showsReasoning) { children.push( new Text(palette.italic(palette.dim('Reasoning')), 0, 0), new Markdown(reasoning, 0, 0, mdTheme, { color: value => palette.dim(value), italic: true }), ) } if (text) children.push(new Markdown(text, 0, 0, mdTheme, { color: value => palette.text(value) })) return children } /** * A step's timing summary, rendered as a self-refreshing footer that stays at * the tail of the step's output. Kept separate from the assistant message so * the timing line trails any tool cards the step appends after its message. */ class StepTimingComponent extends Container { private completionTime: number | undefined constructor( private readonly position: StepPosition, private readonly events: () => readonly SessionEvent[], private readonly now: () => number, private readonly palette: Palette, ) { super() this.rebuild() } complete(time: number): void { this.completionTime = time this.rebuild() } override invalidate(): void { this.rebuild() super.invalidate() } private rebuild(): void { this.clear() const totals = stepTimingAt(this.events(), this.position, this.completionTime ?? this.now()) const timing = formatTimingTotals(totals, true) const header = this.completionTime === undefined ? timing : `${timing} · Completed ${formatCompletionTime(this.completionTime)}` this.addChild(new Text(this.palette.dim(header), 0, 0)) } } interface StreamingBlock { type: string text: string } /** A live assistant step: streamed reasoning/text blocks until the message settles. */ export class StreamingAssistantComponent extends Container { private readonly blocks = new Map() private settledContent: readonly ContentBlock[] | undefined private foldedContinuation = false /** * The step's timing footer. The renderer keeps it at the tail of the chat so * it trails any tool cards the step appends after this assistant message; it * is not a child of this component. */ readonly timing: StepTimingComponent constructor( /** The step's turn/step coordinates, used to group steps into their turn. */ readonly position: StepPosition, events: () => readonly SessionEvent[], now: () => number, private showReasoning: boolean, private readonly palette: Palette, private readonly mdTheme: MarkdownTheme, ) { super() this.timing = new StepTimingComponent(position, events, now, palette) this.rebuild() } /** * Replace the streamed blocks with the step's settled content. * @param content - The settled assistant content blocks. */ settle(content: readonly ContentBlock[]): void { this.settledContent = content this.rebuild() } /** * Whether this step's assistant message has settled. * @returns `true` once {@link settle} has run. */ isSettled(): boolean { return this.settledContent !== undefined } /** * Pin the step's timing footer to its completion time. * @param time - Step completion time in epoch milliseconds. */ complete(time: number): void { this.timing.complete(time) } override invalidate(): void { this.rebuild() this.timing.invalidate() super.invalidate() } /** * Fold one streamed chunk into the live block buffer and re-render. * @param chunk - The streamed assistant chunk. */ update(chunk: StreamChunk): void { if (chunk.type === 'block-start') { this.blocks.set(chunk.index, { type: chunk.blockType, text: '' }) } else if (chunk.type === 'text-delta' || chunk.type === 'reasoning-delta') { const type = chunk.type === 'text-delta' ? 'text' : 'reasoning' const block = this.blocks.get(chunk.index) ?? { type, text: '' } block.text += chunk.text this.blocks.set(chunk.index, block) } else if (chunk.type === 'block-end' && (chunk.block.type === 'text' || chunk.block.type === 'reasoning')) { this.blocks.set(chunk.index, { type: chunk.block.type, text: chunk.block.text }) } this.rebuild() this.timing.invalidate() } /** * Toggle whether reasoning blocks render, then re-render. * @param show - Whether to show reasoning blocks. */ setShowReasoning(show: boolean): void { this.showReasoning = show this.rebuild() } /** * Mark this step as a folded continuation of its turn: no `Assistant` header, * and no output at all while the step has no visible body. Used while tool * cards are hidden so a turn reads as one assistant message. * @param folded - Whether to render as a headerless continuation. */ setFoldedContinuation(folded: boolean): void { if (this.foldedContinuation === folded) return this.foldedContinuation = folded this.rebuild() } /** * Whether the step currently renders visible reasoning or text. * @returns `true` when a header-owning render would show a body. */ hasVisibleBody(): boolean { const content = this.presentedContent() return textBlocks(content, 'text').trim() !== '' || (this.showReasoning && textBlocks(content, 'reasoning').trim() !== '') } /** The settled content when available, otherwise the streamed blocks in model order. */ private presentedContent(): readonly ContentBlock[] { return this.settledContent ?? [...this.blocks.entries()] .sort(([left], [right]) => left - right) .flatMap(([, block]) => { if (block.type === 'text') return [{ type: 'text', text: block.text }] if (block.type === 'reasoning') return [{ type: 'reasoning', text: block.text }] return [] }) } private rebuild(): void { this.clear() const children = assistantMessageChildren( this.presentedContent(), this.showReasoning, this.foldedContinuation, this.palette, this.mdTheme, ) for (const child of children) this.addChild(child) } } /** * A tool card's body split at the Markdown boundary. `prelude` rows are already * styled and render verbatim (a terminal `$` command, its cwd, a diff's hunks); * `lines` is the tool's own text. A generic card renders both as one Markdown * document under the dim body tone. */ interface CardBody { readonly prelude: readonly string[] readonly lines: readonly string[] } /** * Ctrl+O card-visibility cycle: `hidden` drops tool cards from the transcript, * `collapsed` previews the first body lines, `expanded` shows everything. */ export type ToolCardVisibility = 'hidden' | 'collapsed' | 'expanded' /** A tool call and its result, rendered as a collapsible status card. */ export class ToolCardComponent implements Component { private result: { content: ContentBlock[]; isError: boolean; meta?: JsonValue } | undefined private visibility: ToolCardVisibility = 'collapsed' private callView: ToolCallView private resultView: ToolResultView | undefined private diffBodyCache: { view: ToolCallView | ToolResultView; body: CardBody } | undefined constructor( private readonly name: string, private readonly parsed: ParsedArguments, private readonly definition: ToolDefinition | undefined, private readonly maxOutputLines: number, private readonly maxDiffEditLength: number, private readonly palette: Palette, private readonly mdTheme: MarkdownTheme, ) { this.callView = this.presentCall() } private presentCall(): ToolCallView { if (this.parsed.valid && this.definition?.presentCall) { try { const view = this.definition.presentCall(this.parsed.value) if (view !== undefined) return view } catch (error: unknown) { return { card: 'generic', title: displayText(this.name), rawInput: `Presenter failed: ${String(error)}` } } } return { card: 'generic', title: displayText(this.name), rawInput: this.parsed.value } } /** * Record the tool result and derive its result view. * @param event - The `tool/result` event payload. */ updateResult(event: Extract['data']): void { this.diffBodyCache = undefined const result = event.message.content[0] this.result = { content: [...result.content], isError: result.isError === true, ...event.meta !== undefined ? { meta: event.meta } : {}, } if (this.parsed.valid && this.definition?.presentResult) { try { const view = this.definition.presentResult(this.parsed.value, this.result) if (view !== undefined) this.resultView = view } catch (error: unknown) { this.resultView = { card: 'generic', content: [{ type: 'text', text: `Presenter failed: ${String(error)}` }] } } } } /** * Set the card's visibility state. * @param visibility - Hidden, collapsed preview, or full body. */ setVisibility(visibility: ToolCardVisibility): void { this.visibility = visibility } invalidate(): void {} render(width: number): string[] { // Hidden renders nothing — not even the leading gap — so the transcript // keeps only the conversation, the way Codex hides tool calls. if (this.visibility === 'hidden') return [] const isError = this.result?.isError ?? false // A ring marker: hollow while the call is pending, filled once it settles; // the header color (warning/success/error) tells pending from ok from error. const glyph = this.result === undefined ? '○' : '●' const rawBody = this.renderBody() const view = this.resultView ?? this.callView // A generic card's own content, a read card's `content` fallback (the // envelope-stripped file text — the TUI has no dedicated read rendering, so a // read renders exactly as before the read card existed), or a search/web // card's fallback to the raw result content (neither the `search` nor the // `web` view carries a `content` copy), all render as one dim Markdown block // below, so links/lists/headings keep the unified dim styling rather than // reading as bare text. A search card thus stays byte-identical to the // pre-search-card generic fallback. Terminal and diff cards own their body // styling, so they are excluded (mirrors renderBody's post-terminal/diff fallback). const markdownContent = view.card === 'generic' || view.card === 'read' ? view.content ?? this.result?.content : view.card === 'search' ? this.result?.content : view.card === 'web' // A web resultView is only assigned alongside this.result (the result // handler sets both) and the pending callView is never a web card, so // the optional-chain undefined side is unreachable here. /* v8 ignore next */ ? this.result?.content : undefined const unknownXml = this.definition === undefined && markdownContent !== undefined ? renderUnknownXml( displayText(contentText(markdownContent)), this.maxOutputLines, this.visibility === 'expanded', displayText, text => this.palette.dim(text), text => this.palette.dim(text), /* v8 ignore next -- renderUnknownXml calls the collapsed summary only when hidden XML children exceed this card's limit. */ count => this.palette.dim(` … +${count} lines (Ctrl+O to expand)`), ) : undefined // A generic card renders title and result as one Markdown document, so the // document's own block spacing is preserved, then dims every row — the whole // card body reads as one dim block under the status-colored header. const body = unknownXml ?? (markdownContent !== undefined && rawBody.lines.length > 0 ? this.dimBody(rawBody, width) : [...rawBody.prelude, ...rawBody.lines]) const visibleBody = unknownXml !== undefined || this.visibility === 'expanded' ? body : preview(body, this.maxOutputLines, count => this.palette.dim(`… +${count} lines (Ctrl+O to expand)`)) // The header is a fixed `Tool / ` frame in the status color (warning // pending / success ok / error), flat — no bold or underline, so one color // reads consistently across the whole row. Every tool-specific detail (a // read's path, a diff, command output) lives in the body below; the sole // header extra is a bash card's model-authored description, appended as a // `/ ` segment. The body stays unprefixed so a drag-select copies only // the tool text; body lines pass through Text so overlong output wraps. const statusColor = this.result === undefined ? this.palette.warning : isError ? this.palette.error : this.palette.success // The header is a single card row: collapse an embedded newline in the // description to an inline escape so it cannot break onto extra rows and // collide with the body lines that follow. const desc = this.headerDescription() const headerText = `${glyph} Tool / ${displayText(this.name)}${desc === undefined ? '' : ` / ${displayInlineText(desc)}`}` const header = truncateToWidth(headerText, Math.max(1, width - 2), '') // The blank first row is the card's own paragraph gap (no external Spacer), // so the hidden state removes the gap together with the card. const lines: string[] = ['', statusColor(header)] if (visibleBody.length > 0) lines.push(...new Text(visibleBody.join('\n'), 0, 0).render(width)) return lines } /** The pending terminal call view, when this row is a terminal card. */ private terminalPending(): TerminalCallView | undefined { return this.callView.card === 'terminal' ? this.callView : undefined } /** * The optional header `/ ` segment: a bash (terminal) card's * model-authored description. Non-terminal tools contribute no header detail — * their presenter title moves into the body instead. */ private headerDescription(): string | undefined { const description = this.terminalPending()?.description return description !== undefined && description !== '' ? description : undefined } /** * The presenter's title for a non-terminal card, shown as the first body line * (a read's `Read src/foo.ts`, a diff's `Edit files`) now that the header is a * fixed `Tool / ` frame. The result-state title replaces the pending one. */ private bodyTitle(): string { return this.resultView?.title ?? this.callView.title } private renderBody(): CardBody { const view = this.resultView ?? this.callView if (view.card === 'terminal') { const pending = this.terminalPending() const prelude: string[] = [] const lines: string[] = [] // The command shows as a $-line here whenever it is not the header: either a // description headlines the row (the command still belongs somewhere) or the row // is a pending undescribed call (the classic running-command echo). A completed // undescribed row keeps the command only in the header. // The command and cwd are each a single card row, so escape a multi-line // command inline (displayInlineText) — a real newline would break onto extra // rows and collide with the output below. const headlined = pending?.description !== undefined && pending.description !== '' const commandInBody = pending !== undefined && (headlined || this.result === undefined) if (commandInBody) prelude.push(this.palette.dim(`$ ${displayInlineText(pending.title)}`)) if (pending?.cwd) prelude.push(this.palette.dim(displayInlineText(pending.cwd))) if (this.resultView?.card === 'terminal') { if (this.resultView.output) lines.push(...this.dimOutput(this.resultView.output)) if (this.resultView.exitCode !== undefined) lines.push(this.palette.dim(`[exit ${this.resultView.exitCode}]`)) if (this.resultView.signal !== undefined) { lines.push(this.palette.error(`[signal ${displayText(this.resultView.signal)}]`)) } } else if (this.result !== undefined) { lines.push(...this.dimOutput(contentText(this.result.content))) } return { prelude: prelude.filter(Boolean), lines: lines.filter(Boolean) } } if (view.card === 'diff') { if (this.diffBodyCache?.view === view) return this.diffBodyCache.body // The header no longer names the file, so each diff keeps its own path // header. A trailing footer summarizes the exact changed rows when the // bounded comparison succeeds (`+A -R · N file(s)`). const renderedDiffs = view.diffs.map(diff => renderDiff(diff, this.maxDiffEditLength, this.palette), ) const added = renderedDiffs.reduce((total, rendered) => total + rendered.added, 0) const removed = renderedDiffs.reduce((total, rendered) => total + rendered.removed, 0) const approximate = renderedDiffs.some(rendered => rendered.approximate) const hunks = renderedDiffs.flatMap((rendered, index) => { return [...index > 0 ? [''] : [], ...rendered.lines] }) const files = new Set(view.diffs.map(diff => diff.path)).size const footer = this.palette.dim( `└ +${added} -${removed} · ${files} file${files === 1 ? '' : 's'}${approximate ? ' · approximate' : ''}`, ) // A diff's own `+`/`-` colors carry its meaning, so it renders verbatim // rather than under the dim result-output color. const body = { prelude: [...hunks, footer], lines: [] } this.diffBodyCache = { view, body } return body } // A generic or read card carries its own envelope-stripped `content`; a // search or web card carries no `content` copy and falls back to the raw // result content here. (Mirrors the `markdownContent` selection in render(); // a read card has no dedicated TUI rendering, so its `content` takes the same // body path, keeping read output as it was before the read card existed, and // a search card stays byte-identical to the pre-search-card fallback.) const content = (view.card === 'generic' || view.card === 'read' ? view.content : undefined) ?? this.result?.content const prelude: string[] = [] const lines: string[] = [] // The presenter title headlines the body now that the header is a fixed // `Tool / ` frame (a terminal card keeps its command $-line instead). // Skip it when it only repeats the tool name (the fallback presenter for a // tool with no presentCall, or an unknown tool), which the header already shows. const bodyTitle = this.bodyTitle() if (bodyTitle !== displayText(this.name)) prelude.push(displayInlineText(bodyTitle)) if (content !== undefined) lines.push(...displayText(contentText(content)).split('\n')) const rawInput = this.result === undefined && this.callView.card === 'generic' ? this.callView.rawInput : undefined if (rawInput !== undefined) lines.push(...pretty(rawInput).split('\n')) // Blank-line trimming spans the whole body, so the title counts as a row: // interior blanks (a result's own paragraph break) survive while the body's // leading and trailing ones are dropped. const total = prelude.length + lines.length return { prelude, lines: lines.filter((line, index) => { const row = prelude.length + index return line.length > 0 || (row > 0 && row < total - 1) }), } } /** * A tool's own output text as dim rows — the card's result-output color, which * separates what the tool produced from the card's own framing. A blank row * stays the empty string so the terminal branch's blank-row filter still reads * it as blank instead of as an ANSI-wrapped value. */ private dimOutput(text: string): string[] { return displayText(text).split('\n').map(line => line === '' ? line : this.palette.dim(line)) } /** * Render a generic card's prelude and result as one Markdown document under the * dim body tone. Rendering both together preserves the document's own block * spacing (Markdown's blank row before a heading); dimming every row keeps the * card body one uniform tone, so only the status-colored header carries color. */ private dimBody(body: CardBody, width: number): string[] { const rows = new Markdown([...body.prelude, ...body.lines].join('\n'), 0, 0, this.mdTheme, { color: value => this.palette.text(value), }).render(width) // A whitespace-only row carries no output to dim; leaving it unwrapped keeps // Markdown's padding out of the styled ranges. return rows.map(row => row.trim() === '' ? row : this.palette.dim(row)) } } /** * Matches a lone reminder-frame tag on its own line, capturing the element name. * Producers emit the frame as whole lines (`workspace-context`, `dsh-tool-skill`), * so anchoring the whole line keeps a tag mentioned inside prose from matching. */ const REMINDER_FRAME_LINE = /^<(\/?)([a-zA-Z][\w:.-]*)>$/u /** * Drop a producer's outer reminder frame, keeping the instruction body verbatim. * The card header already names the source, so the frame lines carry nothing. * Only a matched open/close pair on the first and last lines is removed, so a * body that merely starts with a tag-like line is left intact. * @param text - Complete model-facing context text. * @returns The body without its outer frame lines, trimmed of the blank lines they leave. */ function stripReminderFrame(text: string): string { // A frame needs an open line and a distinct close line, so anything shorter than // two lines is already frameless. const [first = '', ...rest] = text.split('\n') const last = rest.at(-1) if (last === undefined) return text const open = REMINDER_FRAME_LINE.exec(first.trim()) const close = REMINDER_FRAME_LINE.exec(last.trim()) if (open?.[1] !== '' || close?.[1] !== '/' || open[2] !== close[2]) return text return rest.slice(0, -1).join('\n').replace(/^\n+|\n+$/gu, '') } /** * Injected context (plugin/goal source, e.g. `workspace-context`), rendered as a * collapsible dim card that shares the tool-card `Ctrl+O` toggle. The header is * `Context ·