import { BoxRenderable, MarkdownRenderable, ScrollBoxRenderable, SyntaxStyle, TextAttributes, TextRenderable, type CliRenderer, type TreeSitterClient, } from "@opentui/core" import type { FileEditEvent, HistoryMessage, ToolProgressEvent } from "./protocol" export interface TranscriptTheme { text: string muted: string error: string user: string border: string syntax: SyntaxStyle } export interface TranscriptHeader { model: string workspace: string version: string access: string } interface Activity { text: TextRenderable lines: string[] keys: Map expanded: boolean } const ACTIVITY_PREVIEW_LINES = 6 /** Projects gateway events into retained, reflowable conversation cells. */ export class Transcript { readonly root: ScrollBoxRenderable private live: { row: BoxRenderable; markdown: MarkdownRenderable; content: string } | null = null private activity: Activity | null = null private readonly styledText: Array<{ renderable: TextRenderable tone: "text" | "muted" | "error" | "user" }> = [] private readonly markdown = new Set() private readonly frames = new Set() private readonly activities = new Set() private wrote = false private nextId = 0 constructor( private readonly renderer: CliRenderer, private theme: TranscriptTheme, private readonly treeSitterClient: TreeSitterClient, ) { this.root = new ScrollBoxRenderable(renderer, { id: "nanobot-tui-transcript", width: "100%", minHeight: 0, flexGrow: 1, scrollX: false, scrollY: true, stickyScroll: true, stickyStart: "bottom", viewportCulling: true, contentOptions: { flexDirection: "column", paddingTop: 1, paddingBottom: 1, paddingLeft: 1, paddingRight: 1, }, verticalScrollbarOptions: { visible: false }, horizontalScrollbarOptions: { visible: false }, }) this.root.verticalScrollBar.visible = false this.root.horizontalScrollBar.visible = false } setTheme(theme: TranscriptTheme): void { const previousSyntax = this.theme.syntax this.theme = theme for (const { renderable, tone } of this.styledText) renderable.fg = theme[tone] for (const renderable of this.markdown) renderable.syntaxStyle = theme.syntax for (const frame of this.frames) frame.borderColor = theme.border // Markdown may still be rendering this frame. Release the prior native // style only after the renderer reaches idle, matching OpenCode's retained // theme lifecycle and avoiding both leaks and use-after-free transitions. void this.renderer.idle().catch(() => {}).finally(() => previousSyntax.destroy()) } header(options: TranscriptHeader): void { this.writeText([ `>_ nanobot v${options.version}`, `${options.model} · ${options.access}`, options.workspace, ].join("\n"), "text", true, true) } reset(header: TranscriptHeader): void { for (const child of [...this.root.getChildren()]) { this.root.remove(child) child.destroyRecursively() } this.live = null this.activity = null this.styledText.length = 0 this.markdown.clear() this.frames.clear() this.activities.clear() this.wrote = false this.nextId = 0 this.header(header) } history(messages: HistoryMessage[]): void { for (const message of messages) { if (message.role === "user") this.user(message.content) else if (message.role === "assistant") this.assistant(message.content) else if (message.fileEdits?.length) this.fileEdits(message.fileEdits) else this.progress(message.content, message.toolEvents) } this.finishActivity() } user(content: string): void { this.finishActivity() this.writeText(`› ${content}`, "user", true) } assistant(content: string): void { if (!content.trim()) return this.finishActivity() this.writeMarkdown(content, false) } notice(content: string, error = false): void { this.finishActivity() this.writeText(content, error ? "error" : "muted") } stream(delta: string): void { if (!delta) return if (!this.live) { this.finishActivity() const row = this.createRow() const markdown = this.createMarkdown("", true, "assistant-stream") row.add(markdown) this.root.add(row) this.live = { row, markdown, content: "" } this.wrote = true } this.live.content += delta this.live.markdown.content = this.live.content } finishStream(fallback = ""): void { if (this.live) { const content = fallback || this.live.content // Finalize the retained Markdown node in place. This preserves scroll // anchors and avoids the one-frame jump caused by replacing the row. this.live.markdown.content = content this.live.markdown.streaming = false this.live = null } else if (fallback.trim()) { this.assistant(fallback) } } reconcileStream(content: string): void { if (!content || !this.live) return this.live.content = content this.live.markdown.content = content } progress(content: string, events: ToolProgressEvent[] = []): string { const lines = events.length > 0 ? events.map(formatToolEvent).filter(Boolean) : content.split("\n").map(cleanProgress).filter(Boolean) if (lines.length === 0) return "" if (!this.activity) this.activity = this.createActivity() for (const [index, line] of lines.entries()) { const key = events[index]?.call_id ? `tool:${events[index]?.call_id}` : undefined const existing = key ? this.activity.keys.get(key) : undefined if (existing !== undefined) { this.activity.lines[existing] = line } else if (line !== this.activity.lines.at(-1)) { if (key) this.activity.keys.set(key, this.activity.lines.length) this.activity.lines.push(line) } } this.renderActivity(this.activity) return lines.at(-1) || "" } fileEdits(edits: FileEditEvent[]): string { return this.progress("", edits.map((edit) => ({ call_id: `file:${edit.call_id || edit.path || "unknown"}`, phase: edit.status === "error" ? "error" : edit.phase, name: edit.path ? `${edit.tool || "edit"} ${edit.path}` : "edit file", arguments: edit.error || formatDiffStat(edit), }))) } finishActivity(): void { this.activity = null } toggleActivityDetails(): boolean | null { const activity = [...this.activities] .filter((item) => item.lines.length > ACTIVITY_PREVIEW_LINES) .at(-1) if (!activity) return null activity.expanded = !activity.expanded this.renderActivity(activity) return activity.expanded } scrollByPage(direction: -1 | 1): void { this.root.scrollBy(direction * Math.max(3, Math.floor(this.root.height * 0.7))) } scrollToEdge(edge: "top" | "bottom"): void { this.root.scrollTo(edge === "top" ? 0 : this.root.scrollHeight) } destroy(): void { this.live = null this.activity = null this.theme.syntax.destroy() } private id(prefix: string): string { this.nextId += 1 return `${prefix}-${this.nextId}` } private createRow(framed = false): BoxRenderable { const row = new BoxRenderable(this.renderer, { id: this.id(framed ? "text-frame" : "text-row"), width: "100%", marginTop: this.wrote ? 1 : 0, border: framed, borderStyle: "rounded", borderColor: this.theme.border, paddingLeft: 1, paddingRight: 1, flexDirection: "column", }) if (framed) this.frames.add(row) return row } private createActivity(): Activity { const row = this.createRow() const text = new TextRenderable(this.renderer, { id: this.id("agent-activity"), content: "", width: "100%", wrapMode: "word", fg: this.theme.muted, }) row.add(text) this.root.add(row) this.styledText.push({ renderable: text, tone: "muted" }) this.wrote = true const activity = { text, lines: [], keys: new Map(), expanded: false } this.activities.add(activity) return activity } private renderActivity(activity: Activity): void { if (activity.expanded || activity.lines.length <= ACTIVITY_PREVIEW_LINES) { activity.text.content = activity.lines.join("\n") return } const visible = activity.lines.slice(-(ACTIVITY_PREVIEW_LINES - 1)) const hidden = activity.lines.length - visible.length activity.text.content = [` … ${hidden} earlier steps · Ctrl+O expand`, ...visible].join("\n") } private writeText( content: string, tone: "text" | "muted" | "error" | "user", bold = false, framed = false, ): void { const row = this.createRow(framed) const text = new TextRenderable(this.renderer, { id: this.id("text"), content, width: "100%", wrapMode: "word", fg: this.theme[tone], attributes: bold ? TextAttributes.BOLD : 0, }) row.add(text) this.root.add(row) this.styledText.push({ renderable: text, tone }) this.wrote = true } private createMarkdown(content: string, streaming: boolean, id = "markdown"): MarkdownRenderable { const markdown = new MarkdownRenderable(this.renderer, { id: this.id(id), content, width: "100%", syntaxStyle: this.theme.syntax, streaming, internalBlockMode: "top-level", treeSitterClient: this.treeSitterClient, }) this.markdown.add(markdown) return markdown } private writeMarkdown(content: string, streaming: boolean): void { const row = this.createRow() row.add(this.createMarkdown(content, streaming)) this.root.add(row) this.wrote = true } } function cleanProgress(value: string): string { const text = value.trim().replace(/^\*\*(.*?)\*\*$/u, "$1").replace(/\s+/gu, " ") return text ? ` · ${text}` : "" } function formatToolEvent(event: ToolProgressEvent): string { const phase = event.phase || "start" const marker = phase === "error" ? "×" : phase === "end" ? "✓" : "›" const name = event.name?.trim() || "tool" const detail = phase === "error" ? compactValue(event.error) : phase === "start" ? compactValue(event.arguments) : "" return ` ${marker} ${name}${detail ? ` ${detail}` : ""}` } function compactValue(value: unknown): string { if (value == null || value === "") return "" const text = typeof value === "string" ? value : JSON.stringify(value) return text.length > 72 ? `${text.slice(0, 69)}…` : text } function formatDiffStat(edit: FileEditEvent): string { const added = typeof edit.added === "number" ? `+${edit.added}` : "" const deleted = typeof edit.deleted === "number" ? `-${edit.deleted}` : "" return [added, deleted].filter(Boolean).join(" ") }