Files
nanobot/tui/src/transcript.ts
T

356 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, number>
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<MarkdownRenderable>()
private readonly frames = new Set<BoxRenderable>()
private readonly activities = new Set<Activity>()
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(" ")
}