mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-31 00:03:01 +03:00
fix(tui): harden the interactive terminal experience
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
@@ -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