fix(tui): harden the interactive terminal experience

This commit is contained in:
Xubin Ren
2026-08-17 20:56:10 +08:00
parent 5feb21543c
commit 4f12e15237
8 changed files with 1454 additions and 216 deletions
+491 -4
View File
@@ -1,5 +1,10 @@
import { afterEach, describe, expect, test } from "bun:test"
import { createTestRenderer, type TestRendererSetup } from "@opentui/core/testing"
import { TextareaRenderable } from "@opentui/core"
import {
MockTreeSitterClient,
createTestRenderer,
type TestRendererSetup,
} from "@opentui/core/testing"
import { NanobotTui, type AppOptions } from "./app"
@@ -17,19 +22,43 @@ function occurrences(frame: string, value: string): number {
return frame.split(value).length - 1
}
function client(sent: string[] = []) {
return {
activeChatId: "chat",
connect() {},
close() {},
send(content: string) {
sent.push(content)
return "turn"
},
}
}
const mount = (setup: TestRendererSetup, sent: string[] = []) => NanobotTui.mount(
setup.renderer,
options,
client(sent),
new MockTreeSitterClient({ autoResolveTimeout: 0 }),
)
describe("NanobotTui layout", () => {
let setup: TestRendererSetup | undefined
afterEach(() => setup?.renderer.destroy())
afterEach(() => {
if (setup && !setup.renderer.isDestroyed) setup.renderer.destroy()
setup = undefined
})
const createRenderer = (options: Parameters<typeof createTestRenderer>[0]) => createTestRenderer(options)
test("reflows a single retained layout across terminal resizes", async () => {
setup = await createTestRenderer({
setup = await createRenderer({
width: 100,
height: 30,
screenMode: "alternate-screen",
consoleMode: "disabled",
})
NanobotTui.mount(setup.renderer, options)
const app = mount(setup)
for (const [width, height] of [[100, 30], [56, 18], [118, 36]] as const) {
setup.resize(width, height)
@@ -43,5 +72,463 @@ describe("NanobotTui layout", () => {
expect(occurrences(frame, "Connecting…")).toBe(1)
expect(occurrences(frame, "nanobot · test/model")).toBe(1)
}
app.accept({ event: "attached", chat_id: "chat" })
app.accept({ event: "delta", chat_id: "chat", text: "First **answer**." })
app.accept({ event: "stream_end", chat_id: "chat", resuming: true })
app.accept({
event: "message",
chat_id: "chat",
text: "read_file(config.json)",
kind: "tool_hint",
tool_events: [
{ phase: "start", call_id: "read-1", name: "read_file", arguments: { path: "config.json" } },
{ phase: "end", call_id: "read-1", name: "read_file" },
],
})
app.accept({ event: "reasoning_delta", chat_id: "chat", text: "private chain of thought" })
app.accept({ event: "reasoning_end", chat_id: "chat" })
app.accept({ event: "delta", chat_id: "chat", text: "Second answer." })
app.accept({ event: "stream_end", chat_id: "chat" })
app.accept({ event: "turn_end", chat_id: "chat", latency_ms: 1200 })
await setup.flush()
const frame = setup.captureCharFrame()
expect(occurrences(frame, "First **answer**.")).toBe(1)
expect(occurrences(frame, "Second answer.")).toBe(1)
expect(frame).toContain("✓ read_file")
expect(frame).not.toContain(" read_file")
expect(frame).not.toContain("private chain of thought")
expect(frame).toContain("Ready · 1.2s")
})
test("waits for an IME commit before reading the submitted text", async () => {
const sent: string[] = []
setup = await createRenderer({ width: 72, height: 20, screenMode: "alternate-screen" })
const app = mount(setup, sent)
app.accept({ event: "attached", chat_id: "chat" })
await Bun.sleep(1)
const composer = (app as unknown as { composer: TextareaRenderable }).composer
composer.setText("你")
composer.submit()
setTimeout(() => composer.setText("你好"), 0)
await Bun.sleep(10)
expect(sent).toEqual(["你好"])
})
test("recalls submitted prompts without stealing multiline cursor movement", async () => {
const sent: string[] = []
setup = await createRenderer({ width: 72, height: 20, screenMode: "alternate-screen" })
const app = mount(setup, sent)
app.accept({ event: "attached", chat_id: "chat" })
await Bun.sleep(1)
const composer = (app as unknown as { composer: TextareaRenderable }).composer
for (const value of ["first prompt", "second prompt"]) {
composer.setText(value)
composer.submit()
await Bun.sleep(5)
app.accept({ event: "turn_end", chat_id: "chat" })
}
setup.mockInput.pressArrow("up")
expect(composer.plainText).toBe("second prompt")
setup.mockInput.pressArrow("up")
expect(composer.plainText).toBe("first prompt")
setup.mockInput.pressArrow("down")
expect(composer.plainText).toBe("first prompt")
setup.mockInput.pressArrow("down")
expect(composer.plainText).toBe("second prompt")
setup.mockInput.pressArrow("down")
expect(composer.plainText).toBe("")
setup.resize(36, 20)
const wrapped = "这是一段会在狭窄输入框中自动换行而不是显式换行的中文内容"
composer.setText(wrapped)
composer.cursorOffset = wrapped.length
await setup.renderOnce()
expect(composer.virtualLineCount).toBeGreaterThan(1)
setup.mockInput.pressArrow("up")
expect(composer.plainText).toBe(wrapped)
})
test("survives rapid narrow resizes with long CJK and code", async () => {
setup = await createRenderer({ width: 100, height: 30, screenMode: "alternate-screen" })
const app = mount(setup)
app.accept({
event: "delta",
chat_id: "chat",
text: "中文会随着终端宽度重新排版。\n\n```ts\nconst greeting = '你好,nanobot'\n```",
})
app.accept({ event: "stream_end", chat_id: "chat" })
for (const [width, height] of [[42, 12], [30, 9], [84, 24], [48, 14], [110, 32]] as const) {
setup.resize(width, height)
await setup.renderOnce()
const frame = setup.captureCharFrame()
expect(occurrences(frame, "Ask nanobot anything")).toBe(1)
expect(occurrences(frame, "nanobot · test/model")).toBe(height >= 12 ? 1 : 0)
}
})
test("replaces streamed drafts with canonical stream-end text", async () => {
setup = await createRenderer({ width: 80, height: 20, screenMode: "alternate-screen" })
const app = mount(setup)
app.accept({ event: "delta", chat_id: "chat", text: "draft signed://expired" })
app.accept({
event: "stream_end",
chat_id: "chat",
text: "canonical https://nanobot.test/signed/current",
resuming: true,
merge_next: true,
})
app.accept({ event: "delta", chat_id: "chat", text: " tail" })
app.accept({
event: "stream_end",
chat_id: "chat",
text: "final https://nanobot.test/signed/current tail",
})
app.accept({ event: "turn_end", chat_id: "chat" })
await setup.flush()
const frame = setup.captureCharFrame()
expect(frame).toContain("final https://nanobot.test/signed/current tail")
expect(frame).not.toContain("canonical https://nanobot.test/signed/current")
expect(frame).not.toContain("draft signed://expired")
})
test("copies full-screen selections through OSC 52", async () => {
setup = await createRenderer({ width: 72, height: 20, screenMode: "alternate-screen" })
const app = mount(setup)
let copied = ""
setup.renderer.copyToClipboardOSC52 = (text: string) => {
copied = text
return true
}
await setup.renderOnce()
app.accept({ event: "delta", chat_id: "chat", text: "selected answer" })
app.accept({ event: "stream_end", chat_id: "chat" })
await setup.flush()
const rows = setup.captureCharFrame().split("\n")
const y = rows.findIndex((row) => row.includes("selected answer"))
const x = rows[y]?.indexOf("selected answer") ?? -1
expect(x).toBeGreaterThanOrEqual(0)
expect(y).toBeGreaterThanOrEqual(0)
await setup.mockMouse.drag(x, y, x + "selected answer".length, y)
expect(setup.renderer.getSelection()?.getSelectedText()).toBe("selected answer")
setup.mockInput.pressCtrlC()
await Bun.sleep(10)
await setup.flush()
expect(copied).toBe("selected answer")
expect(setup.renderer.getSelection()).toBeNull()
})
test("animates one stable status line while the agent works", async () => {
setup = await createRenderer({ width: 88, height: 24, screenMode: "alternate-screen" })
const app = mount(setup)
app.accept({ event: "attached", chat_id: "chat" })
app.accept({ event: "reasoning_delta", chat_id: "chat", text: "hidden reasoning" })
await Bun.sleep(130)
await setup.renderOnce()
let frame = setup.captureCharFrame()
expect(frame).toMatch(/[] Thinking\s+0s/u)
expect(frame).not.toContain("hidden reasoning")
app.accept({
event: "message",
chat_id: "chat",
text: "running shell",
kind: "tool_hint",
tool_events: [{ phase: "start", name: "exec", arguments: { cmd: "pwd" } }],
})
await Bun.sleep(130)
await setup.renderOnce()
frame = setup.captureCharFrame()
expect(frame).toMatch(/[] Working\s+0s/u)
expect(frame).toContain(" exec")
app.accept({ event: "turn_end", chat_id: "chat" })
})
test("folds long tool traces without discarding their details", async () => {
setup = await createRenderer({ width: 88, height: 24, screenMode: "alternate-screen" })
const app = mount(setup)
app.accept({
event: "message",
chat_id: "chat",
text: "tool progress",
kind: "tool_hint",
tool_events: Array.from({ length: 10 }, (_, index) => ({
phase: "end",
call_id: `call-${index}`,
name: `tool_${index}`,
})),
})
await setup.renderOnce()
let frame = setup.captureCharFrame()
expect(frame).toContain("5 earlier steps · Ctrl+O expand")
expect(frame).not.toContain("tool_0")
expect(frame).toContain("tool_9")
setup.mockInput.pressKey("O", { ctrl: true })
await setup.renderOnce()
frame = setup.captureCharFrame()
expect(frame).not.toContain("earlier steps")
expect(frame).toContain("tool_0")
expect(frame).toContain("tool_9")
app.accept({ event: "turn_end", chat_id: "chat" })
app.accept({
event: "message",
chat_id: "chat",
text: "second tool group",
kind: "tool_hint",
tool_events: Array.from({ length: 8 }, (_, index) => ({
phase: "end",
call_id: `later-${index}`,
name: `later_${index}`,
})),
})
setup.mockInput.pressKey("O", { ctrl: true })
await setup.renderOnce()
const activities = [...(app as unknown as {
transcript: { activities: Set<{ expanded: boolean }> }
}).transcript.activities]
expect(activities.map((activity) => activity.expanded)).toEqual([true, true])
setup.mockInput.pressKey("O", { ctrl: true })
await setup.renderOnce()
expect(activities.map((activity) => activity.expanded)).toEqual([true, false])
app.accept({ event: "turn_end", chat_id: "chat" })
})
test("supports keyboard transcript navigation without rebuilding the layout", async () => {
setup = await createRenderer({ width: 64, height: 16, screenMode: "alternate-screen" })
const app = mount(setup)
for (let index = 0; index < 24; index += 1) {
app.accept({ event: "delta", chat_id: "chat", text: `answer ${index}` })
app.accept({ event: "stream_end", chat_id: "chat" })
}
await setup.flush()
const scroll = (app as unknown as {
transcript: { root: { scrollTop: number; scrollHeight: number; height: number } }
}).transcript.root
setup.mockInput.pressKey("HOME", { ctrl: true })
await setup.renderOnce()
expect(scroll.scrollTop).toBe(0)
app.accept({ event: "delta", chat_id: "chat", text: "new answer while reading above" })
app.accept({ event: "stream_end", chat_id: "chat" })
await setup.renderOnce()
expect(scroll.scrollTop).toBe(0)
setup.mockInput.pressKey("\u001B[6~")
await setup.renderOnce()
expect(scroll.scrollTop).toBeGreaterThan(0)
setup.mockInput.pressKey("END", { ctrl: true })
await setup.renderOnce()
expect(scroll.scrollTop).toBeGreaterThanOrEqual(scroll.scrollHeight - scroll.height)
})
test("reconciles active state from attach hydration after reconnect", async () => {
setup = await createRenderer({ width: 72, height: 20, screenMode: "alternate-screen" })
const app = mount(setup)
const state = () => (app as unknown as { activeTurn: boolean }).activeTurn
app.accept({ event: "attached", chat_id: "chat" })
app.accept({ event: "delta", chat_id: "chat", text: "stale partial response" })
expect(state()).toBe(true)
app.accept({ event: "attached", chat_id: "chat" })
await Bun.sleep(1)
await setup.flush()
expect(state()).toBe(false)
const restored = setup.captureCharFrame()
expect(restored).not.toContain("stale partial response")
expect(occurrences(restored, ">_ nanobot")).toBe(1)
app.accept({
event: "goal_status",
chat_id: "chat",
status: "running",
started_at: Date.now() / 1000 - 2,
})
expect(state()).toBe(true)
app.accept({ event: "goal_status", chat_id: "chat", status: "idle" })
expect(state()).toBe(false)
})
test("replays events after asynchronous history hydration", async () => {
setup = await createRenderer({ width: 80, height: 22, screenMode: "alternate-screen" })
const original = globalThis.fetch
let resolveFetch: (value: Response) => void = () => undefined
globalThis.fetch = (() => new Promise<Response>((resolve) => {
resolveFetch = resolve
})) as unknown as typeof fetch
const app = NanobotTui.mount(
setup.renderer,
{ ...options, apiUrl: "http://nanobot.test", apiToken: "token", chatId: "chat" },
client(),
new MockTreeSitterClient({ autoResolveTimeout: 0 }),
)
try {
app.accept({ event: "attached", chat_id: "chat" })
app.accept({ event: "delta", chat_id: "chat", text: "live after reconnect" })
expect((app as unknown as { activeTurn: boolean }).activeTurn).toBe(false)
resolveFetch(new Response(JSON.stringify({
messages: [{ role: "assistant", content: "persisted before reconnect" }],
page: { has_more_before: false },
})))
await Bun.sleep(5)
await setup.flush()
const frame = setup.captureCharFrame()
expect(frame.indexOf("persisted before reconnect")).toBeLessThan(
frame.indexOf("live after reconnect"),
)
expect((app as unknown as { activeTurn: boolean }).activeTurn).toBe(true)
app.accept({ event: "turn_end", chat_id: "chat" })
} finally {
globalThis.fetch = original
}
})
test("blocks submission until reconnect history is hydrated", async () => {
const sent: string[] = []
setup = await createRenderer({ width: 80, height: 22, screenMode: "alternate-screen" })
const original = globalThis.fetch
let request = 0
let resolveReconnect: (value: Response) => void = () => undefined
globalThis.fetch = (() => {
request += 1
if (request === 1) {
return Promise.resolve(new Response(JSON.stringify({
messages: [{ role: "assistant", content: "initial history" }],
page: { has_more_before: false },
})))
}
return new Promise<Response>((resolve) => {
resolveReconnect = resolve
})
}) as unknown as typeof fetch
const app = NanobotTui.mount(
setup.renderer,
{ ...options, apiUrl: "http://nanobot.test", apiToken: "token", chatId: "chat" },
client(sent),
new MockTreeSitterClient({ autoResolveTimeout: 0 }),
)
const composer = (app as unknown as { composer: TextareaRenderable }).composer
try {
app.accept({ event: "attached", chat_id: "chat" })
await Bun.sleep(5)
app.accept({ event: "attached", chat_id: "chat" })
composer.setText("sent during reconnect")
composer.submit()
await Bun.sleep(5)
expect(sent).toEqual([])
expect(composer.plainText).toBe("sent during reconnect")
resolveReconnect(new Response(JSON.stringify({
messages: [{ role: "assistant", content: "restored history" }],
page: { has_more_before: false },
})))
await Bun.sleep(5)
composer.submit()
await Bun.sleep(5)
await setup.flush()
const frame = setup.captureCharFrame()
expect(sent).toEqual(["sent during reconnect"])
expect(frame.indexOf("restored history")).toBeLessThan(frame.indexOf("sent during reconnect"))
} finally {
globalThis.fetch = original
}
})
test("preserves drafts while a reconnected socket waits to attach", async () => {
const sent: string[] = []
setup = await createRenderer({ width: 72, height: 20, screenMode: "alternate-screen" })
const app = mount(setup, sent)
const composer = (app as unknown as { composer: TextareaRenderable }).composer
const connection = app as unknown as {
handleStatus(status: "connecting" | "connected", detail?: string): void
}
app.accept({ event: "attached", chat_id: "chat" })
await Bun.sleep(1)
connection.handleStatus("connecting", "reconnecting")
connection.handleStatus("connected")
composer.setText("draft before attach")
composer.submit()
await Bun.sleep(5)
expect(sent).toEqual([])
expect(composer.plainText).toBe("draft before attach")
app.accept({ event: "attached", chat_id: "chat" })
await Bun.sleep(5)
composer.submit()
await Bun.sleep(5)
expect(sent).toEqual(["draft before attach"])
})
test("destroys the renderer and transport together", async () => {
setup = await createRenderer({ width: 72, height: 20, screenMode: "alternate-screen" })
let closed = false
const transport = client()
transport.close = () => { closed = true }
const app = NanobotTui.mount(
setup.renderer,
options,
transport,
new MockTreeSitterClient({ autoResolveTimeout: 0 }),
)
app.stop()
expect(closed).toBe(true)
expect(setup.renderer.isDestroyed).toBe(true)
})
})
if (process.platform !== "win32") {
test("restores the terminal after SIGTERM", async () => {
const child = Bun.spawn(["bun", "src/index.ts"], {
cwd: import.meta.dir.replace(/\/src$/u, ""),
env: {
...process.env,
NANOBOT_TUI_WS_URL: "ws://127.0.0.1:9/ws",
NANOBOT_TUI_API_URL: "",
NANOBOT_TUI_API_TOKEN: "",
NANOBOT_TUI_MODEL: "test/model",
NANOBOT_TUI_WORKSPACE: "/tmp/nanobot-test",
NANOBOT_TUI_VERSION: "test",
NANOBOT_TUI_ACCESS: "workspace access",
},
stdout: "pipe",
stderr: "pipe",
})
await Bun.sleep(250)
child.kill("SIGTERM")
const exitCode = await child.exited
const output = await new Response(child.stdout).text()
const error = await new Response(child.stderr).text()
expect(exitCode).toBe(0)
expect(error).toBe("")
expect(output).toContain("\x1b[?1049h")
expect(output).toContain("\x1b[?1049l")
})
}
+265 -190
View File
@@ -1,26 +1,24 @@
import {
BoxRenderable,
CliRenderEvents,
MarkdownRenderable,
RGBA,
ScrollBoxRenderable,
SyntaxStyle,
TextareaRenderable,
TextAttributes,
TextRenderable,
createCliRenderer,
getTreeSitterClient,
type CliRenderer,
type KeyEvent,
type TreeSitterClient,
} from "@opentui/core"
import {
NanobotClient,
fetchHistory,
type ConnectionStatus,
type HistoryMessage,
type InboundEvent,
} from "./protocol"
import { Transcript, type TranscriptTheme } from "./transcript"
interface AppOptions {
wsUrl: string
@@ -33,6 +31,13 @@ interface AppOptions {
access: string
}
interface ChatClient {
readonly activeChatId: string
connect(): void
close(): void
send(content: string): string
}
interface Palette {
background: string
panel: string
@@ -98,170 +103,46 @@ function syntaxStyle(palette: Palette): SyntaxStyle {
})
}
class Transcript {
readonly root: ScrollBoxRenderable
private live: { row: BoxRenderable; text: TextRenderable; content: string } | null = null
private wrote = false
private nextId = 0
constructor(
private readonly renderer: CliRenderer,
private palette: Palette,
) {
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 },
})
// Constructor options are applied before ScrollBarRenderable starts managing
// its own visibility. Assigning through the setters keeps both bars hidden.
this.root.verticalScrollBar.visible = false
this.root.horizontalScrollBar.visible = false
function transcriptTheme(palette: Palette): TranscriptTheme {
return {
text: palette.text,
muted: palette.muted,
error: palette.error,
user: palette.user,
border: palette.border,
syntax: syntaxStyle(palette),
}
}
setPalette(palette: Palette): void {
this.palette = palette
}
function formatElapsed(milliseconds: number): string {
const seconds = Math.max(0, Math.floor(milliseconds / 1000))
if (seconds < 60) return `${seconds}s`
return `${Math.floor(seconds / 60)}m ${String(seconds % 60).padStart(2, "0")}s`
}
header(options: AppOptions): void {
const lines = [
`>_ nanobot v${options.version}`,
`${options.model} · ${options.access}`,
options.workspace,
]
this.writeText(lines.join("\n"), this.palette.text, true, true)
}
async history(messages: HistoryMessage[]): Promise<void> {
for (const message of messages) {
if (message.role === "user") this.user(message.content)
else this.assistant(message.content)
async function copyWithSystemClipboard(text: string): Promise<void> {
const commands = process.platform === "darwin"
? [["pbcopy"]]
: process.platform === "win32"
? [["clip.exe"]]
: [["wl-copy"], ["xclip", "-selection", "clipboard"], ["xsel", "--clipboard", "--input"]]
for (const command of commands) {
try {
const child = Bun.spawn(command, { stdin: "pipe", stdout: "ignore", stderr: "ignore" })
child.stdin.write(text)
child.stdin.end()
if (await child.exited === 0) return
} catch {
// Try the next platform clipboard provider.
}
}
user(content: string): void {
this.writeText(` ${content}`, this.palette.user, true)
}
assistant(content: string): void {
if (!content.trim()) return
this.writeMarkdown(content)
}
notice(content: string, error = false): void {
this.writeText(content, error ? this.palette.error : this.palette.muted)
}
stream(delta: string): void {
if (!delta) return
if (!this.live) {
const row = this.createRow()
const text = new TextRenderable(this.renderer, {
id: this.id("assistant-stream"),
content: "",
width: "100%",
wrapMode: "word",
fg: this.palette.text,
})
row.add(text)
this.root.add(row)
this.live = { row, text, content: "" }
this.wrote = true
}
this.live.content += delta
this.live.text.content = this.live.content
}
finishStream(fallback = ""): void {
const content = this.live?.content || fallback
if (this.live) {
this.root.remove(this.live.row)
this.live.row.destroy()
this.live = null
}
if (content.trim()) this.assistant(content)
}
destroy(): void {
this.live = null
}
private id(prefix: string): string {
this.nextId += 1
return `${prefix}-${this.nextId}`
}
private createRow(framed = false): BoxRenderable {
return 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.palette.border,
paddingLeft: 1,
paddingRight: 1,
flexDirection: "column",
})
}
private writeText(
content: string,
color: string,
bold = false,
framed = false,
): void {
const row = this.createRow(framed)
row.add(
new TextRenderable(this.renderer, {
id: this.id("text"),
content,
width: "100%",
wrapMode: "word",
fg: color,
attributes: bold ? TextAttributes.BOLD : 0,
}),
)
this.root.add(row)
this.wrote = true
}
private writeMarkdown(content: string): void {
const row = this.createRow()
const markdown = new MarkdownRenderable(this.renderer, {
id: this.id("markdown"),
content,
width: "100%",
syntaxStyle: syntaxStyle(this.palette),
streaming: false,
internalBlockMode: "top-level",
treeSitterClient: getTreeSitterClient(),
})
row.add(markdown)
this.root.add(row)
this.wrote = true
}
throw new Error("no clipboard provider available")
}
export class NanobotTui {
private readonly renderer: CliRenderer
private readonly transcript: Transcript
private readonly client: NanobotClient
private readonly client: ChatClient
private readonly shell: BoxRenderable
private readonly title: TextRenderable
private readonly composerFrame: BoxRenderable
@@ -270,22 +151,37 @@ export class NanobotTui {
private readonly meta: TextRenderable
private palette: Palette
private activeTurn = false
private activeLabel = "Thinking"
private activeStartedAt = 0
private lastProgress = ""
private finalMessage = ""
private turnHadAnswer = false
private historyLoaded = false
private attachedOnce = false
private pendingEvents: InboundEvent[] | null = null
private hydrationId = 0
private ready = false
private shimmerFrame = 0
private shimmerTimer: ReturnType<typeof setInterval> | null = null
private submitPending = false
private readonly promptHistory: string[] = []
private historyCursor = 0
private historyDraft = ""
private quitting = false
private constructor(renderer: CliRenderer, private readonly options: AppOptions) {
private constructor(
renderer: CliRenderer,
private readonly options: AppOptions,
client?: ChatClient,
treeSitterClient = getTreeSitterClient(),
) {
this.renderer = renderer
this.palette = renderer.themeMode === "light" ? LIGHT : DARK
this.transcript = new Transcript(renderer, this.palette)
this.client = new NanobotClient({
this.transcript = new Transcript(renderer, transcriptTheme(this.palette), treeSitterClient)
this.client = client || new NanobotClient({
url: options.wsUrl,
chatId: options.chatId,
onEvent: (event) => this.handleEvent(event),
onEvent: (event) => this.accept(event),
onStatus: (status, detail) => this.handleStatus(status, detail),
})
@@ -309,7 +205,7 @@ export class NanobotTui {
this.composerFrame = new BoxRenderable(renderer, {
id: "nanobot-tui-composer-frame",
width: "100%",
height: 3,
minHeight: 3,
flexShrink: 0,
border: true,
borderStyle: "rounded",
@@ -322,7 +218,7 @@ export class NanobotTui {
id: "nanobot-tui-composer",
width: "100%",
minHeight: 1,
flexGrow: 1,
maxHeight: 8,
wrapMode: "word",
placeholder: "Ask nanobot anything",
placeholderColor: this.palette.faint,
@@ -336,7 +232,10 @@ export class NanobotTui {
{ name: "return", action: "submit" },
{ name: "return", meta: true, action: "newline" },
],
onSubmit: () => this.submit(),
onContentChange: () => this.resizeComposer(),
// IMEs may commit their final composed glyph after Enter. Matching the
// OpenCode/OpenTUI integration, defer twice before reading plainText.
onSubmit: () => this.deferSubmit(),
})
this.status = new TextRenderable(renderer, {
id: "nanobot-tui-status",
@@ -373,6 +272,7 @@ export class NanobotTui {
this.renderer.on(CliRenderEvents.THEME_MODE, this.handleTheme)
this.renderer.on(CliRenderEvents.RESIZE, this.handleResize)
this.renderer.on(CliRenderEvents.DESTROY, this.handleDestroy)
this.renderer.console.onCopySelection = (text) => void this.copySelection(text)
this.handleResize()
this.composer.focus()
this.transcript.header(options)
@@ -390,8 +290,13 @@ export class NanobotTui {
return NanobotTui.mount(renderer, options)
}
static mount(renderer: CliRenderer, options: AppOptions): NanobotTui {
return new NanobotTui(renderer, options)
static mount(
renderer: CliRenderer,
options: AppOptions,
client?: ChatClient,
treeSitterClient?: TreeSitterClient,
): NanobotTui {
return new NanobotTui(renderer, options, client, treeSitterClient)
}
start(): void {
@@ -399,7 +304,21 @@ export class NanobotTui {
this.renderer.start()
}
stop(): void {
this.quit()
}
private deferSubmit(): void {
if (this.submitPending) return
this.submitPending = true
setTimeout(() => setTimeout(() => {
this.submitPending = false
this.submit()
}, 0), 0)
}
private submit(): void {
if (this.quitting) return
const content = this.composer.plainText.trim()
if (!content) return
if (!this.ready) {
@@ -421,15 +340,32 @@ export class NanobotTui {
return
}
this.composer.setText("")
if (this.promptHistory.at(-1) !== content) this.promptHistory.push(content)
if (this.promptHistory.length > 50) this.promptHistory.shift()
this.historyCursor = this.promptHistory.length
this.historyDraft = ""
this.transcript.user(content)
this.finalMessage = ""
this.turnHadAnswer = false
this.lastProgress = ""
this.activeLabel = "Thinking"
this.setActive(true)
}
private handleEvent(event: InboundEvent): void {
accept(event: InboundEvent): void {
if (event.event === "attached") {
void this.prepareChat(event.chat_id)
const restoring = this.attachedOnce
this.attachedOnce = true
if (restoring) this.setActive(false)
const queuesEvents = restoring || (!this.historyLoaded && Boolean(this.options.chatId))
if (queuesEvents) {
this.ready = false
this.pendingEvents = []
}
const hydrationId = ++this.hydrationId
void this.prepareChat(event.chat_id, restoring, hydrationId).then(() => {
if (hydrationId === this.hydrationId) this.flushPendingEvents()
})
return
}
if (
@@ -438,37 +374,68 @@ export class NanobotTui {
&& this.client.activeChatId
&& event.chat_id !== this.client.activeChatId
) return
if (this.pendingEvents) {
this.pendingEvents.push(event)
return
}
switch (event.event) {
case "message_accepted":
return
case "delta":
this.setActive(true)
this.activeLabel = "Writing"
this.turnHadAnswer = true
this.transcript.stream(event.text)
return
case "message":
if (event.kind) {
this.lastProgress = event.text.trim()
if (this.lastProgress) this.status.content = this.lastProgress
this.activeLabel = event.kind === "tool_hint" ? "Working" : "Thinking"
this.lastProgress = this.transcript.progress(event.text, event.tool_events)
this.setActive(true)
} else {
this.finalMessage = event.text
}
return
case "reasoning_delta":
case "file_edit":
this.activeLabel = "Editing"
this.lastProgress = this.transcript.fileEdits(event.edits)
this.setActive(true)
return
case "reasoning_delta":
this.activeLabel = "Thinking"
this.setActive(true)
return
case "reasoning_end":
return
case "stream_end":
if (event.text) this.finalMessage = event.text
if (event.text && !this.turnHadAnswer) this.turnHadAnswer = true
if (event.resuming && event.merge_next) {
this.transcript.reconcileStream(event.text || "")
} else {
this.transcript.finishStream(event.text || "")
}
return
case "turn_end":
this.transcript.finishStream(this.finalMessage)
this.transcript.finishStream(this.turnHadAnswer ? "" : this.finalMessage)
this.transcript.finishActivity()
this.finalMessage = ""
this.turnHadAnswer = false
this.setActive(false)
if (typeof event.latency_ms === "number") {
this.status.content = `Ready · ${(event.latency_ms / 1000).toFixed(1)}s`
}
return
case "goal_status":
if (event.status === "running") {
this.activeLabel = "Working"
this.setActive(true, typeof event.started_at === "number" ? event.started_at * 1000 : undefined)
} else {
this.setActive(false)
}
return
case "goal_state":
return
case "turn_model_updated":
this.title.content = `nanobot · ${event.model_name}`
return
@@ -476,54 +443,89 @@ export class NanobotTui {
this.title.content = `nanobot · ${event.model_name}`
return
case "error":
this.transcript.finishStream(this.finalMessage)
this.transcript.finishStream(this.turnHadAnswer ? "" : this.finalMessage)
this.transcript.notice(event.reason || event.detail || "Unknown gateway error", true)
this.finalMessage = ""
this.turnHadAnswer = false
this.setActive(false)
return
}
}
private async prepareChat(chatId: string): Promise<void> {
private async prepareChat(chatId: string, restoring: boolean, hydrationId: number): Promise<void> {
try {
if (!this.historyLoaded && this.options.chatId) {
if (restoring) {
this.transcript.reset({
model: this.options.model,
workspace: this.options.workspace,
version: this.options.version,
access: this.options.access,
})
}
if (restoring || (!this.historyLoaded && this.options.chatId)) {
this.historyLoaded = true
const messages = await fetchHistory(this.options.apiUrl, this.options.apiToken, chatId)
await this.transcript.history(messages)
const history = await fetchHistory(this.options.apiUrl, this.options.apiToken, chatId)
if (hydrationId !== this.hydrationId) return
if (history.truncated) {
this.transcript.notice("Earlier messages omitted · open WebUI to load the full history")
}
this.transcript.history(history.messages)
}
} catch (error) {
if (hydrationId !== this.hydrationId) return
this.transcript.notice(error instanceof Error ? error.message : String(error), true)
} finally {
if (hydrationId !== this.hydrationId) return
this.ready = true
this.status.content = "Ready"
if (!this.activeTurn) this.status.content = "Ready"
}
}
private flushPendingEvents(): void {
const events = this.pendingEvents
this.pendingEvents = null
for (const event of events || []) this.accept(event)
}
private handleStatus(status: ConnectionStatus, detail?: string): void {
if (status === "connected") {
this.ready = false
this.status.content = "Connected · preparing chat…"
return
}
if (status === "connecting") {
this.status.content = "Connecting…"
this.ready = false
if (detail) this.setActive(false)
this.status.content = detail ? "Reconnecting…" : "Connecting…"
return
}
if (status === "error") {
this.setActive(false)
this.status.content = detail || "Connection error"
return
}
if (!this.quitting) this.status.content = "Disconnected"
if (!this.quitting) {
this.setActive(false)
this.status.content = "Disconnected"
}
}
private setActive(active: boolean): void {
if (this.activeTurn === active) return
private setActive(active: boolean, startedAt?: number): void {
if (this.activeTurn === active) {
if (active && startedAt !== undefined) this.activeStartedAt = startedAt
return
}
this.activeTurn = active
if (active) {
this.activeStartedAt = startedAt ?? Date.now()
this.shimmerFrame = 0
this.shimmerTimer = setInterval(() => {
const dots = "·".repeat((this.shimmerFrame++ % 3) + 1)
const detail = this.lastProgress ? ` ${this.lastProgress}` : ""
this.status.content = `Working ${dots}${detail}`
}, 260)
const frames = ["", "◓", "◑", "◒"]
const frame = frames[this.shimmerFrame++ % frames.length]
const elapsed = formatElapsed(Date.now() - this.activeStartedAt)
const detail = this.lastProgress ? ` · ${this.lastProgress.replace(/^\s*[·×]\s*/u, "")}` : ""
this.status.content = `${frame} ${this.activeLabel} ${elapsed}${detail}`
}, 120)
return
}
if (this.shimmerTimer) clearInterval(this.shimmerTimer)
@@ -533,8 +535,34 @@ export class NanobotTui {
}
private handleKey = (key: KeyEvent): void => {
if (key.ctrl && key.name === "o") {
const expanded = this.transcript.toggleActivityDetails()
if (expanded === null) return
this.status.content = expanded ? "Tool details expanded" : "Tool details collapsed"
key.preventDefault()
return
}
if (!key.ctrl && !key.meta && (key.name === "up" || key.name === "down")) {
const direction = key.name === "up" ? -1 : 1
const boundary = direction < 0 ? 0 : this.composer.plainText.length
if (this.composer.cursorOffset !== boundary) {
const visualRow = this.composer.scrollY + this.composer.visualCursor.visualRow
const edgeRow = direction < 0 ? 0 : Math.max(0, this.composer.virtualLineCount - 1)
if (visualRow === edgeRow) this.composer.cursorOffset = boundary
return
}
if (this.navigateHistory(direction)) {
key.preventDefault()
return
}
}
if (key.ctrl && key.name === "c") {
key.preventDefault()
const selected = this.renderer.getSelection()?.getSelectedText()
if (selected) {
void this.copySelection(selected)
return
}
if (this.activeTurn) {
try {
this.client.send("/stop")
@@ -552,12 +580,40 @@ export class NanobotTui {
if (key.ctrl && key.name === "d" && !this.composer.plainText) {
key.preventDefault()
this.quit()
return
}
if (key.name === "pageup" || key.name === "pagedown") {
key.preventDefault()
this.transcript.scrollByPage(key.name === "pageup" ? -1 : 1)
return
}
if (key.ctrl && (key.name === "home" || key.name === "end")) {
key.preventDefault()
this.transcript.scrollToEdge(key.name === "home" ? "top" : "bottom")
}
}
private navigateHistory(direction: -1 | 1): boolean {
if (this.promptHistory.length === 0) return false
if (direction < 0) {
if (this.historyCursor === this.promptHistory.length) this.historyDraft = this.composer.plainText
if (this.historyCursor === 0) return false
this.historyCursor -= 1
} else {
if (this.historyCursor >= this.promptHistory.length) return false
this.historyCursor += 1
}
const content = this.historyCursor === this.promptHistory.length
? this.historyDraft
: this.promptHistory[this.historyCursor] || ""
this.composer.setText(content)
this.composer.cursorOffset = direction < 0 ? 0 : content.length
return true
}
private handleTheme = (): void => {
this.palette = this.renderer.themeMode === "light" ? LIGHT : DARK
this.transcript.setPalette(this.palette)
this.transcript.setTheme(transcriptTheme(this.palette))
this.renderer.setBackgroundColor(this.palette.background)
this.shell.backgroundColor = this.palette.background
this.composerFrame.backgroundColor = this.palette.panel
@@ -573,13 +629,32 @@ export class NanobotTui {
}
private handleResize = (): void => {
this.resizeComposer()
this.title.visible = this.renderer.height >= 12
this.meta.content = this.renderer.width >= 72
? "enter send · alt+enter newline · ctrl+c stop"
? "enter send · alt+enter newline · pgup/pgdn scroll · ctrl+o tools · ctrl+c stop"
: this.renderer.width >= 48
? "enter send · alt+enter newline"
: ""
}
private resizeComposer(): void {
const maxHeight = Math.max(1, Math.min(12, Math.floor(this.renderer.height / 3)))
this.composer.maxHeight = maxHeight
this.composerFrame.maxHeight = maxHeight + 2
}
private async copySelection(text: string): Promise<void> {
if (!text) return
try {
if (!this.renderer.copyToClipboardOSC52(text)) await copyWithSystemClipboard(text)
this.renderer.clearSelection()
this.status.content = "Copied"
} catch {
this.status.content = "Copy unavailable"
}
}
private quit(): void {
if (this.quitting) return
this.quitting = true
+19
View File
@@ -18,4 +18,23 @@ const options: AppOptions = {
}
const app = await NanobotTui.create(options)
const shutdown = (code = 0) => {
app.stop()
process.exitCode = code
}
for (const signal of ["SIGHUP", "SIGINT", "SIGTERM"] as const) {
process.once(signal, () => shutdown())
}
process.once("exit", () => app.stop())
process.once("uncaughtException", (error) => {
shutdown(1)
process.stderr.write(`${error instanceof Error ? error.stack || error.message : String(error)}\n`)
})
process.once("unhandledRejection", (error) => {
shutdown(1)
process.stderr.write(`${error instanceof Error ? error.stack || error.message : String(error)}\n`)
})
app.start()
+98 -2
View File
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import { NanobotClient, type InboundEvent } from "./protocol"
import { NanobotClient, fetchHistory, type InboundEvent } from "./protocol"
class FakeSocket {
static readonly OPEN = 1
@@ -92,17 +92,113 @@ describe("gateway protocol", () => {
try {
const statuses: string[] = []
const events: InboundEvent[] = []
const client = new NanobotClient({
url: "ws://nanobot.test/ws",
onEvent: () => undefined,
onEvent: (event) => events.push(event),
onStatus: (status, detail) => statuses.push(`${status}:${detail || ""}`),
})
client.connect()
if (!socket) throw new Error("socket was not created")
socket.emit("message", { data: "[]" })
socket.emit("message", { data: JSON.stringify({ event: "delta", chat_id: "one" }) })
socket.emit("message", {
data: JSON.stringify({
event: "message",
chat_id: "one",
text: "bad tool",
tool_events: [{ call_id: 42 }],
}),
})
socket.emit("message", {
data: JSON.stringify({ event: "stream_end", chat_id: "one", resuming: "yes" }),
})
socket.emit("message", { data: JSON.stringify({ event: "future_gateway_event" }) })
socket.emit("message", { data: JSON.stringify({ event: "error", detail: "global failure" }) })
expect(statuses).toContain("error:gateway sent an invalid event")
expect(statuses.filter((status) => status.includes("invalid event"))).toHaveLength(4)
expect(events).toContainEqual({ event: "error", detail: "global failure" })
} finally {
Object.defineProperty(globalThis, "WebSocket", { configurable: true, value: original })
}
})
test("reattaches the same generated chat after a transient disconnect", async () => {
const original = globalThis.WebSocket
const sockets: FakeSocket[] = []
Object.defineProperty(globalThis, "WebSocket", {
configurable: true,
value: class extends FakeSocket {
constructor() {
super()
sockets.push(this)
}
},
})
try {
const client = new NanobotClient({
url: "ws://nanobot.test/ws",
reconnectDelayMs: 1,
onEvent: () => undefined,
onStatus: () => undefined,
})
client.connect()
sockets[0]?.emit("message", {
data: JSON.stringify({ event: "ready", chat_id: "", client_id: "client" }),
})
sockets[0]?.emit("message", {
data: JSON.stringify({ event: "attached", chat_id: "generated-chat" }),
})
sockets[0]?.emit("close")
await Bun.sleep(5)
expect(sockets).toHaveLength(2)
sockets[1]?.emit("message", {
data: JSON.stringify({ event: "ready", chat_id: "", client_id: "client-2" }),
})
const outbound = sockets[1]?.sent.map((value) => JSON.parse(value)) || []
expect(outbound).toEqual([{ type: "attach", chat_id: "generated-chat" }])
client.close()
} finally {
Object.defineProperty(globalThis, "WebSocket", { configurable: true, value: original })
}
})
test("reports when the bounded history snapshot omits earlier turns", async () => {
const original = globalThis.fetch
globalThis.fetch = (() => Promise.resolve(new Response(JSON.stringify({
messages: [
{ role: "user", content: "hello" },
{
role: "tool",
kind: "trace",
content: "read_file",
traces: ["read_file"],
toolEvents: [{ phase: "end", call_id: "read-1", name: "read_file" }],
},
{ role: "assistant", kind: "reasoning", content: "private thought" },
{ role: "assistant", content: "hi" },
],
page: { has_more_before: true },
})))) as unknown as typeof fetch
try {
const history = await fetchHistory("http://nanobot.test", "token", "chat")
expect(history).toEqual({
messages: [
{ role: "user", content: "hello" },
{
role: "activity",
content: "read_file",
toolEvents: [{ phase: "end", call_id: "read-1", name: "read_file" }],
},
{ role: "assistant", content: "hi" },
],
truncated: true,
})
} finally {
globalThis.fetch = original
}
})
})
+219 -17
View File
@@ -1,5 +1,29 @@
export type ConnectionStatus = "connecting" | "connected" | "closed" | "error"
export interface ToolProgressEvent {
version?: number
phase?: "start" | "end" | "error" | string
call_id?: string
name?: string
arguments?: unknown
result?: unknown
error?: unknown
files?: unknown[]
embeds?: unknown[]
}
export interface FileEditEvent {
version?: number
call_id?: string
tool?: string
path?: string
phase?: "start" | "end" | "error" | string
added?: number
deleted?: number
status?: "editing" | "done" | "error" | string
error?: string
}
export type InboundEvent =
| { event: "ready"; chat_id: string; client_id: string }
| { event: "attached"; chat_id: string }
@@ -9,8 +33,10 @@ export type InboundEvent =
chat_id: string
text: string
kind?: "tool_hint" | "progress" | "reasoning"
tool_events?: ToolProgressEvent[]
turn_id?: string
}
| { event: "file_edit"; chat_id: string; edits: FileEditEvent[]; turn_id?: string }
| { event: "delta"; chat_id: string; text: string; stream_id?: string; turn_id?: string }
| {
event: "stream_end"
@@ -24,6 +50,14 @@ export type InboundEvent =
| { event: "reasoning_delta"; chat_id: string; text: string; turn_id?: string }
| { event: "reasoning_end"; chat_id: string; turn_id?: string }
| { event: "turn_end"; chat_id: string; latency_ms?: number; turn_id?: string }
| {
event: "goal_status"
chat_id: string
status: "running" | "idle"
started_at?: number
turn_id?: string
}
| { event: "goal_state"; chat_id: string; goal_state: Record<string, unknown> }
| { event: "runtime_model_updated"; model_name: string; model_preset?: string | null }
| { event: "turn_model_updated"; chat_id: string; model_name: string }
| { event: "error"; chat_id?: string; detail?: string; reason?: string; turn_id?: string }
@@ -36,41 +70,170 @@ type OutboundEvent =
export interface ClientOptions {
url: string
chatId?: string
reconnectDelayMs?: number
onEvent: (event: InboundEvent) => void
onStatus: (status: ConnectionStatus, detail?: string) => void
}
export interface HistoryMessage {
role: "user" | "assistant"
role: "user" | "assistant" | "activity"
content: string
toolEvents?: ToolProgressEvent[]
fileEdits?: FileEditEvent[]
}
export interface HistorySnapshot {
messages: HistoryMessage[]
truncated: boolean
}
const CHAT_EVENTS = new Set([
"attached",
"message_accepted",
"message",
"file_edit",
"delta",
"stream_end",
"reasoning_delta",
"reasoning_end",
"turn_end",
"goal_status",
"goal_state",
"turn_model_updated",
"error",
])
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value)
}
function optional(value: unknown, type: "boolean" | "number" | "string"): boolean {
return value === undefined || typeof value === type
}
function isToolEvent(value: unknown): value is ToolProgressEvent {
if (!isRecord(value)) return false
return optional(value.version, "number")
&& optional(value.phase, "string")
&& optional(value.call_id, "string")
&& optional(value.name, "string")
&& (value.files === undefined || Array.isArray(value.files))
&& (value.embeds === undefined || Array.isArray(value.embeds))
}
function isFileEdit(value: unknown): value is FileEditEvent {
if (!isRecord(value)) return false
return optional(value.version, "number")
&& optional(value.call_id, "string")
&& optional(value.tool, "string")
&& optional(value.path, "string")
&& optional(value.phase, "string")
&& optional(value.status, "string")
&& optional(value.added, "number")
&& optional(value.deleted, "number")
&& optional(value.error, "string")
}
function decodeInboundEvent(value: unknown): InboundEvent | null | undefined {
if (!isRecord(value)) return null
const record = value
const name = record.event
if (typeof name !== "string") return null
if (name === "ready") {
return typeof record.chat_id === "string" && typeof record.client_id === "string"
? value as InboundEvent
: null
}
if (name === "runtime_model_updated") {
return typeof record.model_name === "string"
&& (record.model_preset === undefined
|| record.model_preset === null
|| typeof record.model_preset === "string")
? value as InboundEvent
: null
}
if (name === "error" && (record.chat_id === undefined || typeof record.chat_id === "string")) {
return optional(record.detail, "string") && optional(record.reason, "string")
? value as InboundEvent
: null
}
if (!CHAT_EVENTS.has(name)) return undefined // Forward-compatible additive event.
if (typeof record.chat_id !== "string") return null
if (["message", "delta", "reasoning_delta"].includes(name) && typeof record.text !== "string") {
return null
}
if (
name === "message"
&& record.tool_events !== undefined
&& (!Array.isArray(record.tool_events) || !record.tool_events.every(isToolEvent))
) return null
if (name === "file_edit" && (!Array.isArray(record.edits) || !record.edits.every(isFileEdit))) {
return null
}
if (
name === "stream_end"
&& (!optional(record.text, "string")
|| !optional(record.resuming, "boolean")
|| !optional(record.merge_next, "boolean"))
) return null
if (name === "turn_end" && !optional(record.latency_ms, "number")) return null
if (name === "goal_status" && record.status !== "running" && record.status !== "idle") return null
if (name === "goal_state" && (!record.goal_state || typeof record.goal_state !== "object")) return null
if (name === "turn_model_updated" && typeof record.model_name !== "string") return null
return value as InboundEvent
}
export async function fetchHistory(
apiUrl: string,
apiToken: string,
chatId: string,
): Promise<HistoryMessage[]> {
if (!apiUrl || !apiToken) return []
): Promise<HistorySnapshot> {
if (!apiUrl || !apiToken) return { messages: [], truncated: false }
const key = encodeURIComponent(`websocket:${chatId}`)
const response = await fetch(`${apiUrl}/api/sessions/${key}/webui-thread?limit=120&direction=latest`, {
headers: { Authorization: `Bearer ${apiToken}` },
})
if (response.status === 404) return []
if (response.status === 404) return { messages: [], truncated: false }
if (!response.ok) throw new Error(`history request failed: HTTP ${response.status}`)
const payload = (await response.json()) as { messages?: Array<Record<string, unknown>> }
return (payload.messages || []).flatMap((message) => {
const payload = (await response.json()) as {
messages?: Array<Record<string, unknown>>
page?: { has_more_before?: boolean }
}
const messages: HistoryMessage[] = (payload.messages || []).flatMap((message) => {
const role = message.role
const content = message.content
if ((role !== "user" && role !== "assistant") || typeof content !== "string" || !content.trim()) {
if (role === "tool" && message.kind === "trace") {
const traces = Array.isArray(message.traces)
? message.traces.filter((value): value is string => typeof value === "string")
: []
const toolEvents = Array.isArray(message.toolEvents)
? message.toolEvents as ToolProgressEvent[]
: undefined
const fileEdits = Array.isArray(message.fileEdits)
? message.fileEdits as FileEditEvent[]
: undefined
const activity = traces.join("\n") || (typeof content === "string" ? content : "")
return [{ role: "activity", content: activity, toolEvents, fileEdits }]
}
if (
(role !== "user" && role !== "assistant")
|| message.kind === "reasoning"
|| typeof content !== "string"
|| !content.trim()
) {
return []
}
return [{ role, content }]
return [{ role: role as HistoryMessage["role"], content }]
})
return { messages, truncated: payload.page?.has_more_before === true }
}
export class NanobotClient {
private socket: WebSocket | null = null
private chatId = ""
private reconnectTimer: ReturnType<typeof setTimeout> | null = null
private reconnectAttempt = 0
private closedByClient = false
constructor(private readonly options: ClientOptions) {}
@@ -79,18 +242,44 @@ export class NanobotClient {
}
connect(): void {
this.closedByClient = false
this.open()
}
private open(): void {
if (this.socket) return
this.options.onStatus("connecting")
const socket = new WebSocket(this.options.url)
this.socket = socket
socket.addEventListener("open", () => this.options.onStatus("connected"))
socket.addEventListener("message", (message) => this.handleMessage(String(message.data)))
socket.addEventListener("error", () => this.options.onStatus("error", "connection failed"))
socket.addEventListener("close", () => this.options.onStatus("closed"))
socket.addEventListener("open", () => {
if (this.socket !== socket) return
this.reconnectAttempt = 0
this.options.onStatus("connected")
})
socket.addEventListener("message", (message) => {
if (this.socket === socket) this.handleMessage(String(message.data))
})
socket.addEventListener("error", () => {
if (this.socket === socket) this.options.onStatus("error", "connection failed")
})
socket.addEventListener("close", () => {
if (this.socket !== socket) return
this.socket = null
if (this.closedByClient) {
this.options.onStatus("closed")
return
}
this.scheduleReconnect()
})
}
close(): void {
this.socket?.close()
this.closedByClient = true
if (this.reconnectTimer) clearTimeout(this.reconnectTimer)
this.reconnectTimer = null
const socket = this.socket
this.socket = null
socket?.close()
}
send(content: string): string {
@@ -114,15 +303,17 @@ export class NanobotClient {
this.options.onStatus("error", "gateway sent invalid JSON")
return
}
if (!value || typeof value !== "object" || !("event" in value)) {
const event = decodeInboundEvent(value)
if (event === undefined) return
if (event === null) {
this.options.onStatus("error", "gateway sent an invalid event")
return
}
const event = value as InboundEvent
if (event.event === "ready") {
if (this.options.chatId) {
this.chatId = this.options.chatId
const requestedChatId = this.chatId || this.options.chatId
if (requestedChatId) {
this.chatId = requestedChatId
this.write({ type: "attach", chat_id: this.chatId })
} else {
this.write({ type: "new_chat" })
@@ -133,6 +324,17 @@ export class NanobotClient {
this.options.onEvent(event)
}
private scheduleReconnect(): void {
if (this.reconnectTimer || this.closedByClient) return
const base = this.options.reconnectDelayMs ?? 500
const delay = Math.min(8_000, base * 2 ** Math.min(this.reconnectAttempt++, 4))
this.options.onStatus("connecting", `reconnecting in ${delay}ms`)
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null
this.open()
}, delay)
}
private write(event: OutboundEvent): void {
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
throw new Error("gateway connection is not open")
+349
View File
@@ -0,0 +1,349 @@
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 {
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
}
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
}
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(" ")
}