2026-08-12 23:12:44 +09:00
|
|
|
|
import {
|
|
|
|
|
|
BoxRenderable,
|
|
|
|
|
|
MarkdownRenderable,
|
2026-08-14 18:59:50 +08:00
|
|
|
|
RGBA,
|
2026-08-12 23:12:44 +09:00
|
|
|
|
ScrollBoxRenderable,
|
|
|
|
|
|
SyntaxStyle,
|
|
|
|
|
|
TextAttributes,
|
|
|
|
|
|
TextRenderable,
|
|
|
|
|
|
type CliRenderer,
|
|
|
|
|
|
type TreeSitterClient,
|
|
|
|
|
|
} from "@opentui/core"
|
|
|
|
|
|
|
|
|
|
|
|
import type { FileEditEvent, HistoryMessage, ToolProgressEvent } from "./protocol"
|
2026-08-15 17:05:35 +08:00
|
|
|
|
import { hideScrollbars } from "./scrollbox"
|
2026-08-16 16:34:11 +08:00
|
|
|
|
import { mergeToolEvent, renderToolEvent } from "./tool-renderers"
|
2026-08-12 23:12:44 +09:00
|
|
|
|
|
|
|
|
|
|
export interface TranscriptTheme {
|
|
|
|
|
|
text: string
|
|
|
|
|
|
muted: string
|
|
|
|
|
|
error: string
|
|
|
|
|
|
user: string
|
2026-08-14 18:59:50 +08:00
|
|
|
|
userBackground: string | null
|
2026-08-13 02:31:17 +09:00
|
|
|
|
border: string
|
2026-08-12 23:12:44 +09:00
|
|
|
|
syntax: SyntaxStyle
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export interface TranscriptHeader {
|
|
|
|
|
|
model: string
|
|
|
|
|
|
workspace: string
|
|
|
|
|
|
version: string
|
|
|
|
|
|
access: string
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-15 01:34:15 +08:00
|
|
|
|
export interface TranscriptNavigation {
|
|
|
|
|
|
awayFromBottom: boolean
|
|
|
|
|
|
unseenOutput: boolean
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-12 23:12:44 +09:00
|
|
|
|
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-12 23:12:44 +09:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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
|
2026-08-14 18:59:50 +08:00
|
|
|
|
tone: "text" | "muted" | "error" | "user"
|
2026-08-12 23:12:44 +09:00
|
|
|
|
}> = []
|
|
|
|
|
|
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>()
|
2026-08-14 18:59:50 +08:00
|
|
|
|
private readonly userRows = new Set<BoxRenderable>()
|
2026-08-16 19:36:34 +08:00
|
|
|
|
private readonly userTurnIds = new Set<string>()
|
2026-08-12 23:12:44 +09:00
|
|
|
|
private wrote = false
|
|
|
|
|
|
private nextId = 0
|
2026-08-15 01:34:15 +08:00
|
|
|
|
private navigation: TranscriptNavigation = { awayFromBottom: false, unseenOutput: false }
|
|
|
|
|
|
private navigationTimer: ReturnType<typeof setTimeout> | null = null
|
2026-08-12 23:12:44 +09:00
|
|
|
|
|
|
|
|
|
|
constructor(
|
|
|
|
|
|
private readonly renderer: CliRenderer,
|
|
|
|
|
|
private theme: TranscriptTheme,
|
|
|
|
|
|
private readonly treeSitterClient: TreeSitterClient,
|
2026-08-15 01:34:15 +08:00
|
|
|
|
private readonly onNavigationChange?: (state: TranscriptNavigation) => void,
|
2026-08-17 01:08:23 +08:00
|
|
|
|
private readonly showHeader = true,
|
2026-08-12 23:12:44 +09:00
|
|
|
|
) {
|
|
|
|
|
|
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 },
|
2026-08-15 01:34:15 +08:00
|
|
|
|
onMouseScroll: () => this.scheduleNavigationUpdate(),
|
2026-08-12 23:12:44 +09:00
|
|
|
|
})
|
2026-08-15 17:05:35 +08:00
|
|
|
|
hideScrollbars(this.root)
|
2026-08-12 23:12:44 +09:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
setTheme(theme: TranscriptTheme): void {
|
2026-08-12 23:44:49 +09:00
|
|
|
|
const previousSyntax = this.theme.syntax
|
2026-08-12 23:12:44 +09:00
|
|
|
|
this.theme = theme
|
|
|
|
|
|
for (const { renderable, tone } of this.styledText) renderable.fg = theme[tone]
|
|
|
|
|
|
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
|
2026-08-14 18:59:50 +08:00
|
|
|
|
for (const row of this.userRows) {
|
|
|
|
|
|
row.backgroundColor = theme.userBackground
|
|
|
|
|
|
? RGBA.fromHex(theme.userBackground)
|
|
|
|
|
|
: RGBA.defaultBackground()
|
|
|
|
|
|
}
|
2026-08-12 23:44:49 +09:00
|
|
|
|
// 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())
|
2026-08-12 23:12:44 +09:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
header(options: TranscriptHeader): void {
|
2026-08-17 01:08:23 +08:00
|
|
|
|
if (!this.showHeader) return
|
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,
|
|
|
|
|
|
})
|
2026-08-13 02:10:14 +09:00
|
|
|
|
const title = this.createText(`>_ nanobot v${options.version}`, "text", true)
|
|
|
|
|
|
const context = this.createText([
|
2026-08-13 02:31:17 +09:00
|
|
|
|
"",
|
2026-08-12 23:12:44 +09:00
|
|
|
|
`${options.model} · ${options.access}`,
|
|
|
|
|
|
options.workspace,
|
2026-08-13 02:10:14 +09:00
|
|
|
|
].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)
|
2026-08-13 02:10:14 +09:00
|
|
|
|
this.wrote = true
|
2026-08-12 23:12:44 +09:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
reset(header: TranscriptHeader): void {
|
2026-08-15 01:34:15 +08:00
|
|
|
|
if (this.navigationTimer) clearTimeout(this.navigationTimer)
|
|
|
|
|
|
this.navigationTimer = null
|
2026-08-12 23:12:44 +09:00
|
|
|
|
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()
|
2026-08-14 18:59:50 +08:00
|
|
|
|
this.userRows.clear()
|
2026-08-16 19:36:34 +08:00
|
|
|
|
this.userTurnIds.clear()
|
2026-08-12 23:12:44 +09:00
|
|
|
|
this.wrote = false
|
|
|
|
|
|
this.nextId = 0
|
2026-08-15 01:34:15 +08:00
|
|
|
|
this.navigation = { awayFromBottom: false, unseenOutput: false }
|
2026-08-15 17:05:35 +08:00
|
|
|
|
hideScrollbars(this.root)
|
2026-08-12 23:12:44 +09:00
|
|
|
|
this.header(header)
|
2026-08-15 01:34:15 +08:00
|
|
|
|
this.emitNavigation()
|
2026-08-12 23:12:44 +09:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
history(messages: HistoryMessage[]): void {
|
|
|
|
|
|
for (const message of messages) {
|
2026-08-16 19:36:34 +08:00
|
|
|
|
if (message.role === "user") this.user(message.content, message.turnId)
|
2026-08-12 23:12:44 +09:00
|
|
|
|
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()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-13 15:22:06 +09:00
|
|
|
|
async prependHistory(messages: HistoryMessage[]): Promise<void> {
|
|
|
|
|
|
if (messages.length === 0) return
|
|
|
|
|
|
const previousTop = this.root.scrollTop
|
|
|
|
|
|
const previousHeight = this.root.scrollHeight
|
2026-08-17 01:08:23 +08:00
|
|
|
|
let index = this.showHeader ? 1 : 0
|
2026-08-13 15:22:06 +09:00
|
|
|
|
for (const message of messages) {
|
|
|
|
|
|
if (message.role === "user") {
|
2026-08-16 19:36:34 +08:00
|
|
|
|
if (message.turnId && this.userTurnIds.has(message.turnId)) continue
|
2026-08-13 15:22:06 +09:00
|
|
|
|
this.writeRole("›", message.content, "user", index++)
|
2026-08-16 19:36:34 +08:00
|
|
|
|
if (message.turnId) this.userTurnIds.add(message.turnId)
|
2026-08-13 15:22:06 +09:00
|
|
|
|
} 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) },
|
2026-08-13 15:22:06 +09:00
|
|
|
|
}))
|
|
|
|
|
|
: 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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-16 19:36:34 +08:00
|
|
|
|
user(content: string, turnId?: string): boolean {
|
|
|
|
|
|
if (turnId && this.userTurnIds.has(turnId)) return false
|
2026-08-15 01:34:15 +08:00
|
|
|
|
this.noteOutput()
|
2026-08-12 23:12:44 +09:00
|
|
|
|
this.finishActivity()
|
2026-08-13 02:10:14 +09:00
|
|
|
|
this.writeRole("›", content, "user")
|
2026-08-16 19:36:34 +08:00
|
|
|
|
if (turnId) this.userTurnIds.add(turnId)
|
|
|
|
|
|
return true
|
2026-08-12 23:12:44 +09:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
assistant(content: string): void {
|
|
|
|
|
|
if (!content.trim()) return
|
2026-08-15 01:34:15 +08:00
|
|
|
|
this.noteOutput()
|
2026-08-12 23:12:44 +09:00
|
|
|
|
this.finishActivity()
|
|
|
|
|
|
this.writeMarkdown(content, false)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
notice(content: string, error = false): void {
|
2026-08-15 01:34:15 +08:00
|
|
|
|
this.noteOutput()
|
2026-08-12 23:12:44 +09:00
|
|
|
|
this.finishActivity()
|
2026-08-13 02:10:14 +09:00
|
|
|
|
this.writeRole(error ? "×" : "·", content, error ? "error" : "muted")
|
2026-08-12 23:12:44 +09:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
stream(delta: string): void {
|
|
|
|
|
|
if (!delta) return
|
2026-08-15 01:34:15 +08:00
|
|
|
|
this.noteOutput()
|
2026-08-12 23:12:44 +09:00
|
|
|
|
if (!this.live) {
|
|
|
|
|
|
this.finishActivity()
|
|
|
|
|
|
const markdown = this.createMarkdown("", true, "assistant-stream")
|
2026-08-13 02:10:14 +09:00
|
|
|
|
const row = this.writeAssistant(markdown)
|
2026-08-12 23:12:44 +09:00
|
|
|
|
this.live = { row, markdown, content: "" }
|
|
|
|
|
|
}
|
|
|
|
|
|
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 {
|
2026-08-13 15:22:06 +09:00
|
|
|
|
if (events.length === 0 && !content.split("\n").some((line) => cleanProgress(line))) return ""
|
2026-08-15 01:34:15 +08:00
|
|
|
|
this.noteOutput()
|
2026-08-12 23:12:44 +09:00
|
|
|
|
if (!this.activity) this.activity = this.createActivity()
|
2026-08-13 15:22:06 +09:00
|
|
|
|
return this.updateActivity(this.activity, content, events)
|
2026-08-12 23:12:44 +09:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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) },
|
2026-08-12 23:12:44 +09:00
|
|
|
|
})))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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)))
|
2026-08-15 01:34:15 +08:00
|
|
|
|
this.scheduleNavigationUpdate()
|
2026-08-12 23:12:44 +09:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
scrollToEdge(edge: "top" | "bottom"): void {
|
|
|
|
|
|
this.root.scrollTo(edge === "top" ? 0 : this.root.scrollHeight)
|
2026-08-15 01:34:15 +08:00
|
|
|
|
if (edge === "bottom") this.updateNavigation(false, true)
|
|
|
|
|
|
else this.scheduleNavigationUpdate()
|
2026-08-12 23:12:44 +09:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
destroy(): void {
|
2026-08-15 01:34:15 +08:00
|
|
|
|
if (this.navigationTimer) clearTimeout(this.navigationTimer)
|
2026-08-12 23:12:44 +09:00
|
|
|
|
this.live = null
|
|
|
|
|
|
this.activity = null
|
2026-08-13 02:31:17 +09:00
|
|
|
|
this.frames.clear()
|
2026-08-14 18:59:50 +08:00
|
|
|
|
this.userRows.clear()
|
2026-08-12 23:44:49 +09:00
|
|
|
|
this.theme.syntax.destroy()
|
2026-08-12 23:12:44 +09:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-15 01:34:15 +08:00
|
|
|
|
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 })
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-12 23:12:44 +09:00
|
|
|
|
private id(prefix: string): string {
|
|
|
|
|
|
this.nextId += 1
|
|
|
|
|
|
return `${prefix}-${this.nextId}`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-13 02:10:14 +09:00
|
|
|
|
private createRow(kind = "row", direction: "column" | "row" = "column"): BoxRenderable {
|
|
|
|
|
|
return new BoxRenderable(this.renderer, {
|
|
|
|
|
|
id: this.id(`${kind}-row`),
|
2026-08-12 23:12:44 +09:00
|
|
|
|
width: "100%",
|
|
|
|
|
|
marginTop: this.wrote ? 1 : 0,
|
2026-08-13 02:10:14 +09:00
|
|
|
|
flexDirection: direction,
|
2026-08-12 23:12:44 +09:00
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-13 15:22:06 +09:00
|
|
|
|
private createActivity(index?: number): Activity {
|
2026-08-13 02:10:14 +09:00
|
|
|
|
const row = this.createRow("activity")
|
2026-08-12 23:12:44 +09:00
|
|
|
|
const text = new TextRenderable(this.renderer, {
|
|
|
|
|
|
id: this.id("agent-activity"),
|
|
|
|
|
|
content: "",
|
|
|
|
|
|
width: "100%",
|
|
|
|
|
|
wrapMode: "word",
|
|
|
|
|
|
fg: this.theme.muted,
|
|
|
|
|
|
})
|
|
|
|
|
|
row.add(text)
|
2026-08-13 15:22:06 +09:00
|
|
|
|
this.root.add(row, index)
|
2026-08-12 23:12:44 +09:00
|
|
|
|
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>(),
|
|
|
|
|
|
}
|
2026-08-12 23:12:44 +09:00
|
|
|
|
this.activities.add(activity)
|
|
|
|
|
|
return activity
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-13 15:22:06 +09:00
|
|
|
|
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)
|
|
|
|
|
|
return { key, line: renderToolEvent(merged) }
|
|
|
|
|
|
})
|
2026-08-13 15:22:06 +09:00
|
|
|
|
const lines = events.length > 0
|
2026-08-16 16:34:11 +08:00
|
|
|
|
? projected.map(({ line }) => line).filter(Boolean)
|
2026-08-13 15:22:06 +09:00
|
|
|
|
: 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
|
2026-08-13 15:22:06 +09:00
|
|
|
|
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) || ""
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-12 23:12:44 +09:00
|
|
|
|
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")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-13 02:10:14 +09:00
|
|
|
|
private createText(
|
2026-08-12 23:12:44 +09:00
|
|
|
|
content: string,
|
2026-08-14 18:59:50 +08:00
|
|
|
|
tone: "text" | "muted" | "error" | "user",
|
2026-08-12 23:12:44 +09:00
|
|
|
|
bold = false,
|
2026-08-13 02:10:14 +09:00
|
|
|
|
id = "text",
|
|
|
|
|
|
): TextRenderable {
|
2026-08-12 23:12:44 +09:00
|
|
|
|
const text = new TextRenderable(this.renderer, {
|
2026-08-13 02:10:14 +09:00
|
|
|
|
id: this.id(id),
|
2026-08-12 23:12:44 +09:00
|
|
|
|
content,
|
|
|
|
|
|
width: "100%",
|
|
|
|
|
|
wrapMode: "word",
|
|
|
|
|
|
fg: this.theme[tone],
|
|
|
|
|
|
attributes: bold ? TextAttributes.BOLD : 0,
|
|
|
|
|
|
})
|
2026-08-13 02:10:14 +09:00
|
|
|
|
this.styledText.push({ renderable: text, tone })
|
|
|
|
|
|
return text
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private writeRole(
|
|
|
|
|
|
marker: string,
|
|
|
|
|
|
content: string,
|
|
|
|
|
|
tone: "muted" | "error" | "user",
|
2026-08-13 15:22:06 +09:00
|
|
|
|
index?: number,
|
2026-08-13 02:10:14 +09:00
|
|
|
|
): void {
|
|
|
|
|
|
const row = this.createRow(tone === "user" ? "user" : "notice", "row")
|
2026-08-14 18:59:50 +08:00
|
|
|
|
if (tone === "user") {
|
|
|
|
|
|
row.backgroundColor = this.theme.userBackground
|
|
|
|
|
|
? RGBA.fromHex(this.theme.userBackground)
|
|
|
|
|
|
: RGBA.defaultBackground()
|
|
|
|
|
|
this.userRows.add(row)
|
|
|
|
|
|
}
|
2026-08-13 02:10:14 +09:00
|
|
|
|
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)
|
2026-08-12 23:12:44 +09:00
|
|
|
|
row.add(text)
|
2026-08-13 15:22:06 +09:00
|
|
|
|
this.root.add(row, index)
|
2026-08-12 23:12:44 +09:00
|
|
|
|
this.wrote = true
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private createMarkdown(content: string, streaming: boolean, id = "markdown"): MarkdownRenderable {
|
|
|
|
|
|
const markdown = new MarkdownRenderable(this.renderer, {
|
|
|
|
|
|
id: this.id(id),
|
|
|
|
|
|
content,
|
2026-08-13 02:10:14 +09:00
|
|
|
|
width: "auto",
|
|
|
|
|
|
minWidth: 0,
|
|
|
|
|
|
flexGrow: 1,
|
2026-08-16 21:58:47 +08:00
|
|
|
|
flexShrink: 1,
|
2026-08-12 23:12:44 +09:00
|
|
|
|
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",
|
|
|
|
|
|
},
|
2026-08-12 23:12:44 +09:00
|
|
|
|
treeSitterClient: this.treeSitterClient,
|
|
|
|
|
|
})
|
|
|
|
|
|
this.markdown.add(markdown)
|
|
|
|
|
|
return markdown
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-13 15:22:06 +09:00
|
|
|
|
private writeMarkdown(content: string, streaming: boolean, index?: number): void {
|
|
|
|
|
|
this.writeAssistant(this.createMarkdown(content, streaming), index)
|
2026-08-13 02:10:14 +09:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-13 15:22:06 +09:00
|
|
|
|
private writeAssistant(markdown: MarkdownRenderable, index?: number): BoxRenderable {
|
2026-08-13 02:10:14 +09:00
|
|
|
|
const row = this.createRow("assistant", "row")
|
2026-08-14 18:59:50 +08:00
|
|
|
|
const prefix = this.createText("•", "muted", false, "role-marker")
|
2026-08-13 02:10:14 +09:00
|
|
|
|
prefix.width = 2
|
|
|
|
|
|
prefix.flexShrink = 0
|
|
|
|
|
|
row.add(prefix)
|
|
|
|
|
|
row.add(markdown)
|
2026-08-13 15:22:06 +09:00
|
|
|
|
this.root.add(row, index)
|
2026-08-12 23:12:44 +09:00
|
|
|
|
this.wrote = true
|
2026-08-13 02:10:14 +09:00
|
|
|
|
return row
|
2026-08-12 23:12:44 +09:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function cleanProgress(value: string): string {
|
|
|
|
|
|
const text = value.trim().replace(/^\*\*(.*?)\*\*$/u, "$1").replace(/\s+/gu, " ")
|
|
|
|
|
|
return text ? ` · ${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(" ")
|
|
|
|
|
|
}
|