Files
nanobot/tui/src/transcript.ts
T

725 lines
23 KiB
TypeScript
Raw Normal View History

import {
BoxRenderable,
MarkdownRenderable,
RGBA,
ScrollBoxRenderable,
StyledText,
SyntaxStyle,
TextAttributes,
TextRenderable,
type CliRenderer,
type TextChunk,
type TreeSitterClient,
} from "@opentui/core"
import type {
FileEditEvent,
HistoryMessage,
MediaAttachment,
ToolProgressEvent,
} from "./protocol"
2026-08-21 23:41:38 +08:00
import { renderLatexAsUnicode } from "./latex"
import { hideScrollbars } from "./scrollbox"
2026-08-16 16:34:11 +08:00
import { mergeToolEvent, renderToolEvent } from "./tool-renderers"
export interface TranscriptTheme {
text: string
muted: string
error: string
user: string
userBackground: string | null
2026-08-13 02:31:17 +09:00
border: string
syntax: SyntaxStyle
}
export interface TranscriptHeader {
model: string
workspace: string
version: string
access: string
}
export interface TranscriptNavigation {
awayFromBottom: boolean
unseenOutput: boolean
}
interface Activity {
text: TextRenderable
lines: string[]
keys: Map<string, number>
expanded: boolean
2026-08-16 16:34:11 +08:00
events: Map<string, ToolProgressEvent>
}
2026-08-21 16:18:35 +08:00
interface ActivityPreviewItem {
text: string
steps: number
group?: string
}
const ACTIVITY_PREVIEW_LINES = 4
2026-08-17 20:10:37 +08:00
// OpenTUI renders at 30 FPS. Re-parsing the entire Markdown buffer for every
// provider token turns long answers into quadratic work without producing any
// additional visible frames. Paint the first token immediately, then coalesce
// subsequent deltas to the renderer cadence.
const STREAM_FLUSH_MS = 32
export interface UserMessageMedia {
kind?: MediaAttachment["kind"]
name?: string
}
interface UserMessageProjection {
imageLabels: string[]
attachmentNames: string[]
}
function projectUserMessage(media: readonly UserMessageMedia[]): UserMessageProjection {
const imageNames: Array<string | undefined> = []
const attachmentNames: string[] = []
for (const item of media) {
// Outbound TUI media has no explicit kind because this path currently only
// sends clipboard images. Gateway and history media carry the kind.
if (item.kind === undefined || item.kind === "image") imageNames.push(item.name)
else if (item.name) attachmentNames.push(item.name)
}
const used = new Set<number>()
let next = 1
const imageLabels = imageNames.map((name) => {
const match = name?.match(/^clipboard-image-(\d+)\.[^.]+$/iu)
const preferred = match ? Number(match[1]) : 0
let index = Number.isSafeInteger(preferred) && preferred > 0 && !used.has(preferred)
? preferred
: next
while (used.has(index)) index += 1
used.add(index)
while (used.has(next)) next += 1
return `[Image #${index}]`
})
return { imageLabels, attachmentNames }
}
export function userMessageText(
content: string,
media: readonly UserMessageMedia[] = [],
displayContent?: string,
): string {
const { imageLabels, attachmentNames } = projectUserMessage(media)
return [
displayContent ?? [content, imageLabels.join(" ")].filter(Boolean).join(" "),
attachmentNames.length ? `Attachments: ${attachmentNames.join(", ")}` : "",
].filter(Boolean).join("\n")
}
/** 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 activities = new Set<Activity>()
2026-08-13 02:31:17 +09:00
private readonly frames = new Set<BoxRenderable>()
private readonly userRows = new Set<BoxRenderable>()
private readonly userMessages = new Set<{
renderable: TextRenderable
content: string
media: UserMessageMedia[]
displayContent?: string
}>()
private readonly userTurnIds = new Set<string>()
private wrote = false
private nextId = 0
private navigation: TranscriptNavigation = { awayFromBottom: false, unseenOutput: false }
private navigationTimer: ReturnType<typeof setTimeout> | null = null
2026-08-17 20:10:37 +08:00
private pendingStream = ""
private streamTimer: ReturnType<typeof setTimeout> | null = null
constructor(
private readonly renderer: CliRenderer,
private theme: TranscriptTheme,
private readonly treeSitterClient: TreeSitterClient,
private readonly onNavigationChange?: (state: TranscriptNavigation) => void,
2026-08-21 16:18:35 +08:00
private readonly workspace = "",
) {
this.root = new ScrollBoxRenderable(renderer, {
id: "nanobot-tui-transcript",
width: "100%",
minHeight: 0,
flexGrow: 0,
scrollX: false,
scrollY: true,
stickyScroll: true,
stickyStart: "bottom",
viewportCulling: true,
contentOptions: {
flexDirection: "column",
minHeight: 0,
paddingTop: 1,
paddingBottom: 1,
paddingLeft: 1,
paddingRight: 1,
},
verticalScrollbarOptions: { visible: false },
horizontalScrollbarOptions: { visible: false },
onMouseScroll: () => this.scheduleNavigationUpdate(),
})
hideScrollbars(this.root)
}
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 message of this.userMessages) {
message.renderable.content = this.userMessageContent(
message.content,
message.media,
message.displayContent,
)
}
for (const renderable of this.markdown) renderable.syntaxStyle = theme.syntax
2026-08-13 02:31:17 +09:00
for (const frame of this.frames) frame.borderColor = theme.border
for (const row of this.userRows) {
row.backgroundColor = theme.userBackground
? RGBA.fromHex(theme.userBackground)
: RGBA.defaultBackground()
}
// 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 {
2026-08-13 02:31:17 +09:00
const row = new BoxRenderable(this.renderer, {
id: this.id("header-row"),
width: "100%",
maxWidth: 62,
flexDirection: "column",
border: true,
borderStyle: "rounded",
borderColor: this.theme.border,
paddingLeft: 1,
paddingRight: 1,
})
const title = this.createText(`>_ nanobot v${options.version}`, "text", true)
const context = this.createText([
2026-08-13 02:31:17 +09:00
"",
`${options.model} · ${options.access}`,
options.workspace,
].join("\n"), "muted")
row.add(title)
row.add(context)
this.root.add(row)
2026-08-13 02:31:17 +09:00
this.frames.add(row)
this.wrote = true
}
reset(header: TranscriptHeader): void {
if (this.navigationTimer) clearTimeout(this.navigationTimer)
this.navigationTimer = null
2026-08-17 20:10:37 +08:00
this.clearStreamTimer()
this.pendingStream = ""
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.activities.clear()
2026-08-13 02:31:17 +09:00
this.frames.clear()
this.userRows.clear()
this.userMessages.clear()
this.userTurnIds.clear()
this.wrote = false
this.nextId = 0
this.navigation = { awayFromBottom: false, unseenOutput: false }
hideScrollbars(this.root)
this.header(header)
this.emitNavigation()
}
history(messages: HistoryMessage[]): void {
for (const message of messages) {
if (message.role === "user") {
this.user(message.content, message.turnId, message.media)
}
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()
}
async prependHistory(messages: HistoryMessage[]): Promise<void> {
if (messages.length === 0) return
const previousTop = this.root.scrollTop
const previousHeight = this.root.scrollHeight
2026-08-28 15:11:43 +08:00
let index = 1 // Keep the launch header first.
for (const message of messages) {
if (message.role === "user") {
if (message.turnId && this.userTurnIds.has(message.turnId)) continue
this.writeUser(message.content, message.media, index++)
if (message.turnId) this.userTurnIds.add(message.turnId)
} else if (message.role === "assistant") {
this.writeMarkdown(message.content, false, index++)
} else {
const activity = this.createActivity(index++)
const events: ToolProgressEvent[] = message.fileEdits?.length
? message.fileEdits.map((edit) => ({
call_id: `file:${edit.call_id || edit.path || "unknown"}`,
phase: edit.status === "error" ? "error" : edit.phase,
2026-08-16 16:34:11 +08:00
name: edit.tool || "edit_file",
arguments: { path: edit.path, stat: edit.error || formatDiffStat(edit) },
}))
: message.toolEvents || []
this.updateActivity(activity, message.content, events)
}
}
this.renderer.requestRender()
await this.renderer.idle()
this.root.scrollTop = previousTop + Math.max(0, this.root.scrollHeight - previousHeight)
}
get atTop(): boolean {
return this.root.scrollTop <= 0
}
user(
content: string,
turnId?: string,
media: readonly UserMessageMedia[] = [],
displayContent?: string,
): boolean {
if (turnId && this.userTurnIds.has(turnId)) return false
this.noteOutput()
this.finishActivity()
this.writeUser(content, media, undefined, displayContent)
if (turnId) this.userTurnIds.add(turnId)
return true
}
assistant(content: string): void {
if (!content.trim()) return
this.noteOutput()
this.finishActivity()
this.writeMarkdown(content, false)
}
notice(content: string, error = false): void {
this.noteOutput()
this.finishActivity()
this.writeRole(error ? "×" : "·", content, error ? "error" : "muted")
}
stream(delta: string): void {
if (!delta) return
this.noteOutput()
if (!this.live) {
this.finishActivity()
const markdown = this.createMarkdown("", true, "assistant-stream")
const row = this.writeAssistant(markdown)
this.live = { row, markdown, content: "" }
}
2026-08-17 20:10:37 +08:00
if (!this.live.content && !this.pendingStream) {
this.live.content = delta
2026-08-21 23:41:38 +08:00
this.live.markdown.content = renderLatexAsUnicode(delta)
2026-08-17 20:10:37 +08:00
return
}
this.pendingStream += delta
if (this.streamTimer) return
this.streamTimer = setTimeout(() => this.flushStream(), STREAM_FLUSH_MS)
}
finishStream(fallback = ""): void {
2026-08-17 20:10:37 +08:00
this.flushStream()
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.
2026-08-21 23:41:38 +08:00
this.live.markdown.content = renderLatexAsUnicode(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
2026-08-17 20:10:37 +08:00
this.clearStreamTimer()
this.pendingStream = ""
this.live.content = content
2026-08-21 23:41:38 +08:00
this.live.markdown.content = renderLatexAsUnicode(content)
}
progress(content: string, events: ToolProgressEvent[] = []): string {
if (events.length === 0 && !content.split("\n").some((line) => cleanProgress(line))) return ""
this.noteOutput()
if (!this.activity) this.activity = this.createActivity()
return this.updateActivity(this.activity, content, events)
}
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,
2026-08-16 16:34:11 +08:00
name: edit.tool || "edit_file",
arguments: { path: edit.path, stat: 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)))
this.scheduleNavigationUpdate()
}
scrollToEdge(edge: "top" | "bottom"): void {
this.root.scrollTo(edge === "top" ? 0 : this.root.scrollHeight)
if (edge === "bottom") this.updateNavigation(false, true)
else this.scheduleNavigationUpdate()
}
destroy(): void {
if (this.navigationTimer) clearTimeout(this.navigationTimer)
2026-08-17 20:10:37 +08:00
this.clearStreamTimer()
this.pendingStream = ""
this.live = null
this.activity = null
2026-08-13 02:31:17 +09:00
this.frames.clear()
this.userRows.clear()
this.userMessages.clear()
this.theme.syntax.destroy()
}
2026-08-17 20:10:37 +08:00
private flushStream(): void {
this.clearStreamTimer()
if (!this.live || !this.pendingStream) return
this.live.content += this.pendingStream
this.pendingStream = ""
2026-08-21 23:41:38 +08:00
this.live.markdown.content = renderLatexAsUnicode(this.live.content)
2026-08-17 20:10:37 +08:00
}
private clearStreamTimer(): void {
if (this.streamTimer) clearTimeout(this.streamTimer)
this.streamTimer = null
}
private noteOutput(): void {
this.updateNavigation(true)
}
private scheduleNavigationUpdate(): void {
if (this.navigationTimer) clearTimeout(this.navigationTimer)
this.navigationTimer = setTimeout(() => {
this.navigationTimer = null
this.updateNavigation(false)
}, 0)
}
private updateNavigation(output: boolean, forceBottom = false): void {
const awayFromBottom = forceBottom ? false : !this.isAtBottom()
const unseenOutput = awayFromBottom
? this.navigation.unseenOutput || output
: false
if (
awayFromBottom === this.navigation.awayFromBottom
&& unseenOutput === this.navigation.unseenOutput
) return
this.navigation = { awayFromBottom, unseenOutput }
this.emitNavigation()
}
private isAtBottom(): boolean {
const bottom = Math.max(0, this.root.scrollHeight - this.root.height)
return this.root.scrollTop >= bottom - 1
}
private emitNavigation(): void {
this.onNavigationChange?.({ ...this.navigation })
}
private id(prefix: string): string {
this.nextId += 1
return `${prefix}-${this.nextId}`
}
private createRow(kind = "row", direction: "column" | "row" = "column"): BoxRenderable {
return new BoxRenderable(this.renderer, {
id: this.id(`${kind}-row`),
width: "100%",
marginTop: this.wrote ? 1 : 0,
flexDirection: direction,
})
}
private createActivity(index?: number): Activity {
const row = this.createRow("activity")
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, index)
this.styledText.push({ renderable: text, tone: "muted" })
this.wrote = true
2026-08-16 16:34:11 +08:00
const activity = {
text,
lines: [],
keys: new Map<string, number>(),
expanded: false,
events: new Map<string, ToolProgressEvent>(),
}
this.activities.add(activity)
return activity
}
private updateActivity(
activity: Activity,
content: string,
events: ToolProgressEvent[] = [],
): string {
2026-08-16 16:34:11 +08:00
const projected = events.map((event) => {
const key = event.call_id ? `tool:${event.call_id}` : ""
const merged = key ? mergeToolEvent(activity.events.get(key), event) : event
if (key) activity.events.set(key, merged)
2026-08-21 16:18:35 +08:00
return { key, line: renderToolEvent(merged, { workspace: this.workspace }) }
2026-08-16 16:34:11 +08:00
})
const lines = events.length > 0
2026-08-16 16:34:11 +08:00
? projected.map(({ line }) => line).filter(Boolean)
: content.split("\n").map(cleanProgress).filter(Boolean)
for (const [index, line] of lines.entries()) {
2026-08-16 16:34:11 +08:00
const key = projected[index]?.key || undefined
const existing = key ? activity.keys.get(key) : undefined
if (existing !== undefined) {
activity.lines[existing] = line
} else if (line !== activity.lines.at(-1)) {
if (key) activity.keys.set(key, activity.lines.length)
activity.lines.push(line)
}
}
this.renderActivity(activity)
return lines.at(-1) || ""
}
private renderActivity(activity: Activity): void {
if (activity.expanded || activity.lines.length <= ACTIVITY_PREVIEW_LINES) {
activity.text.content = activity.lines.join("\n")
return
}
2026-08-21 16:18:35 +08:00
const visible = activityPreview(activity.lines).slice(-(ACTIVITY_PREVIEW_LINES - 1))
const visibleSteps = visible.reduce((total, item) => total + item.steps, 0)
const hidden = activity.lines.length - visibleSteps
const disclosure = hidden > 0 ? `${hidden} earlier steps` : `${activity.lines.length} steps`
activity.text.content = [
` … ${disclosure} · Ctrl+O expand`,
...visible.map((item) => item.text),
].join("\n")
}
private createText(
content: string | StyledText,
tone: "text" | "muted" | "error" | "user",
bold = false,
id = "text",
): TextRenderable {
const text = new TextRenderable(this.renderer, {
id: this.id(id),
content,
width: "100%",
wrapMode: "word",
fg: this.theme[tone],
attributes: bold ? TextAttributes.BOLD : 0,
})
this.styledText.push({ renderable: text, tone })
return text
}
private writeRole(
marker: string,
content: string | StyledText,
tone: "muted" | "error" | "user",
index?: number,
): TextRenderable {
const row = this.createRow(tone === "user" ? "user" : "notice", "row")
if (tone === "user") {
row.backgroundColor = this.theme.userBackground
? RGBA.fromHex(this.theme.userBackground)
: RGBA.defaultBackground()
this.userRows.add(row)
}
const prefix = this.createText(marker, tone, true, "role-marker")
prefix.width = 2
prefix.flexShrink = 0
const text = this.createText(content, tone === "user" ? "text" : tone, false, "role-content")
text.width = "auto"
text.minWidth = 0
text.flexGrow = 1
row.add(prefix)
row.add(text)
this.root.add(row, index)
this.wrote = true
return text
}
private writeUser(
content: string,
media: readonly UserMessageMedia[] = [],
index?: number,
displayContent?: string,
): void {
const retainedMedia = [...media]
const renderable = this.writeRole(
"",
this.userMessageContent(content, retainedMedia, displayContent),
"user",
index,
)
this.userMessages.add({ renderable, content, media: retainedMedia, displayContent })
}
private userMessageContent(
content: string,
media: readonly UserMessageMedia[],
displayContent?: string,
): StyledText {
const { imageLabels, attachmentNames } = projectUserMessage(media)
const chunks: TextChunk[] = []
const append = (text: string) => {
if (text) chunks.push({ __isChunk: true, text })
}
const nextLine = () => {
if (chunks.length) append("\n")
}
if (displayContent !== undefined) {
const ranges = imageLabels
.map((label) => ({ label, start: displayContent.indexOf(label) }))
.filter(({ start }) => start >= 0)
.sort((left, right) => left.start - right.start)
let cursor = 0
for (const { label, start } of ranges) {
append(displayContent.slice(cursor, start))
chunks.push({
__isChunk: true,
text: label,
fg: RGBA.fromHex(this.theme.user),
attributes: TextAttributes.BOLD,
})
cursor = start + label.length
}
append(displayContent.slice(cursor))
} else {
append(content)
}
if (displayContent === undefined && imageLabels.length) {
if (chunks.length) append(" ")
for (const [index, label] of imageLabels.entries()) {
if (index > 0) append(" ")
chunks.push({
__isChunk: true,
text: label,
fg: RGBA.fromHex(this.theme.user),
attributes: TextAttributes.BOLD,
})
}
}
if (attachmentNames.length) {
nextLine()
append(`Attachments: ${attachmentNames.join(", ")}`)
}
return new StyledText(chunks)
}
private createMarkdown(content: string, streaming: boolean, id = "markdown"): MarkdownRenderable {
const markdown = new MarkdownRenderable(this.renderer, {
id: this.id(id),
2026-08-21 23:41:38 +08:00
content: renderLatexAsUnicode(content),
width: "auto",
minWidth: 0,
flexGrow: 1,
2026-08-16 21:58:47 +08:00
flexShrink: 1,
syntaxStyle: this.theme.syntax,
streaming,
internalBlockMode: "top-level",
2026-08-16 21:58:47 +08:00
tableOptions: {
style: "columns",
widthMode: "full",
columnFitter: "balanced",
wrapMode: "word",
},
treeSitterClient: this.treeSitterClient,
})
this.markdown.add(markdown)
return markdown
}
private writeMarkdown(content: string, streaming: boolean, index?: number): void {
this.writeAssistant(this.createMarkdown(content, streaming), index)
}
private writeAssistant(markdown: MarkdownRenderable, index?: number): BoxRenderable {
const row = this.createRow("assistant", "row")
const prefix = this.createText("•", "muted", false, "role-marker")
prefix.width = 2
prefix.flexShrink = 0
row.add(prefix)
row.add(markdown)
this.root.add(row, index)
this.wrote = true
return row
}
}
function cleanProgress(value: string): string {
const text = value.trim().replace(/^\*\*(.*?)\*\*$/u, "$1").replace(/\s+/gu, " ")
return text ? ` · ${text}` : ""
}
2026-08-21 16:18:35 +08:00
function activityPreview(lines: readonly string[]): ActivityPreviewItem[] {
const preview: ActivityPreviewItem[] = []
for (const line of lines) {
const match = line.match(/^ ([›✓]) (Read|Edited|Editing) /u)
const group = match ? `${match[1]}:${match[2]}` : undefined
const previous = preview.at(-1)
if (match && group && previous?.group === group) {
previous.steps += 1
previous.text = ` ${match[1]} ${match[2]} ${previous.steps} files`
continue
}
preview.push({ text: line, steps: 1, ...(group ? { group } : {}) })
}
return preview
}
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(" ")
}