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
+12 -2
View File
@@ -180,9 +180,19 @@ jobs:
run: bun run build
tui:
name: Terminal UI
runs-on: ubuntu-latest
name: ${{ matrix.name }}
runs-on: ${{ matrix.os }}
timeout-minutes: 10
strategy:
fail-fast: false
matrix:
include:
- name: Terminal UI
os: ubuntu-latest
- name: Terminal UI (macOS)
os: macos-latest
- name: Terminal UI (Windows)
os: windows-latest
steps:
- uses: actions/checkout@v4
+1 -1
View File
@@ -115,7 +115,7 @@ workspace file. Back up both the config directory and workspace before changing
Interactive mode uses nanobot's native TypeScript terminal UI. It talks to the same local gateway as the WebUI, so streaming, tool progress, and WebSocket sessions share one protocol instead of maintaining a second agent loop. If no gateway is running, the command starts one for the lifetime of the terminal UI and stops it on exit.
`Enter` sends the current message. Press `Alt+Enter` to add a newline. `Ctrl+C` stops a running turn, clears a non-empty composer, or exits when idle. The transcript scrolls inside the TUI and reflows when the terminal is resized; exiting restores the previous terminal screen.
`Enter` sends the current message. Press `Alt+Enter` to add a newline and use `Up`/`Down` at the composer edge to recall recent prompts. `Ctrl+C` copies a selection, stops a running turn, clears a non-empty composer, or exits when idle. Use `PageUp`/`PageDown` to scroll, `Ctrl+Home`/`Ctrl+End` to jump to the transcript edges, and `Ctrl+O` to expand or collapse long tool traces. Selections copy through OSC 52 when the terminal supports it. The transcript reflows when the terminal is resized, and exiting restores the previous screen.
Packaged releases fetch a version-matched, checksummed terminal binary for macOS, Linux, or Windows on first use and cache it under the nanobot data directory. Set `NANOBOT_TUI_NO_DOWNLOAD=1` or pass `--classic` to keep the Python-only path. A source checkout can run the client with Bun after `bun install --cwd tui`.
+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(" ")
}