mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-09-01 00:31:51 +03:00
fix(tui): harden the interactive terminal experience
This commit is contained in:
+491
-4
@@ -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
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
@@ -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(" ")
|
||||
}
|
||||
Reference in New Issue
Block a user