feat(cli): add native TypeScript terminal UI

Rebuild the terminal client on OpenTUI while keeping the Python gateway as the single agent, session, tool, and memory runtime. Preserve a classic prompt fallback and publish version-matched native sidecars for supported platforms.

Co-authored-by: Bingxi Zhao <150592536+pancacake@users.noreply.github.com>
Co-authored-by: chengyongru <2755839590@qq.com>
This commit is contained in:
Xubin Ren
2026-08-17 20:56:10 +08:00
co-authored by Bingxi Zhao chengyongru
parent c27b1f14c3
commit ce070c832d
19 changed files with 1536 additions and 8 deletions
+14
View File
@@ -0,0 +1,14 @@
# nanobot Terminal UI
The terminal UI is a TypeScript client for nanobot's existing WebSocket gateway. It owns presentation and input only; the Python gateway remains the single implementation of sessions, the agent loop, tools, memory, and security policy.
```bash
bun install --cwd tui
bun run --cwd tui check
bun run --cwd tui test
bun run --cwd tui build
```
`nanobot agent` launches this client, attaches to an existing local gateway or leases one for the process lifetime, and passes an authenticated local endpoint through environment variables. Use `nanobot agent --classic` to run the legacy Python prompt.
The renderer uses OpenTUI's split-footer mode: transcript rows are committed to native terminal scrollback while the composer remains fixed at the bottom. This preserves normal terminal selection and scrolling instead of implementing a second scroll model.
+63
View File
@@ -0,0 +1,63 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "@nanobot/tui",
"dependencies": {
"@opentui/core": "0.5.1",
},
"devDependencies": {
"@types/bun": "^1.3.13",
"typescript": "^5.9.3",
},
},
},
"packages": {
"@opentui/core": ["@opentui/core@0.5.1", "", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.5.1", "@opentui/core-darwin-x64": "0.5.1", "@opentui/core-linux-arm64": "0.5.1", "@opentui/core-linux-arm64-musl": "0.5.1", "@opentui/core-linux-x64": "0.5.1", "@opentui/core-linux-x64-musl": "0.5.1", "@opentui/core-win32-arm64": "0.5.1", "@opentui/core-win32-x64": "0.5.1" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-mIBFyqIP4rkhQ35uldLXWawWQ6S9tvNWvmxGmDJ7W9cLXjegG6gKEfZ/4NyIMma755ERs/sqO/pIh3Ytf3DDFg=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.5.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Yl3JBLYRrBN+SxXY/gYaqCT/JNrN50K4xO7hYC+/Si8/FgOrBlbRmfJIUNQdZMMLUvOMA+I813+hDw3xfarBzQ=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.5.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-kqMVu+LGuHSCxYFkVJtmuyLLLTMztILSNnlx1eSpHHUiDV4PMc+zkxwRIXO+o0TFTW3gNUKleKUkggriYje7Vw=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.5.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-PpE1nCHRkxEvSYyZFMToPHjQoVh50A7+BbgetlTX/5ImXzo6iSO83a+7M/1WgZnNu+uZJf5GZKAAcLoRrvQl3Q=="],
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.5.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-rmFMtiCm8I0fESB834sTN/ewoI+QDSber588ZO+i08JR6mbv7hkiKW2H/MhiAY1GxGK4nXApleBMyGVOlVDvgQ=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.5.1", "", { "os": "linux", "cpu": "x64" }, "sha512-/CxFxFv+ffMof2nYQrpgEfNkWKkKxYUSfwdt2RdDN5fZRhcxjE949743rV0Oovw5Az63qxPgbyfcZVNVO2HVNg=="],
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.5.1", "", { "os": "linux", "cpu": "x64" }, "sha512-WO8RjhqKyqW/7P0xHdEVT8JGfU2MO7RlK0kdkNnRSnAEVwsTNd2ibhmKDPLGpo/DKaLuA00CsnrNiLGZZiQJKQ=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.5.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-AgeTjZbdMxSiuBjyLvcug91qd1Ds6Dlg5z4lCInqL7mPQicDEnKZs5lF2FAaktcU7RPi2wLybbQ/vM0NbpXYmw=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.5.1", "", { "os": "win32", "cpu": "x64" }, "sha512-VttbQHVoZQ5uW5IcQeUHPEx/WFQ2mMflukhhbBjpNSdZOPdzmmC4QGFPQznJVwuzXTnjQ2Nll4AY0ROJ/Q3nkw=="],
"@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
"@types/node": ["@types/node@26.2.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg=="],
"ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
"bun-ffi-structs": ["bun-ffi-structs@0.3.1", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-3gM7PpVWLyrwxWjcilSiGuhWanhZivvo6l0u573NziPH6f/gwk6McbaYgn7oJWov6pKGRTDbrg94W5DcJsKTtQ=="],
"bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
"diff": ["diff@9.0.0", "", {}, "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="],
"emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
"get-east-asian-width": ["get-east-asian-width@1.6.0", "", {}, "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA=="],
"marked": ["marked@17.0.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg=="],
"string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
"strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="],
"web-tree-sitter": ["web-tree-sitter@0.25.10", "", { "peerDependencies": { "@types/emscripten": "^1.40.0" }, "optionalPeers": ["@types/emscripten"] }, "sha512-Y09sF44/13XvgVKgO2cNDw5rGk6s26MgoZPXLESvMXeefBf7i6/73eFurre0IsTW6E14Y0ArIzhUMmjoc7xyzA=="],
}
}
+19
View File
@@ -0,0 +1,19 @@
{
"name": "@nanobot/tui",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"start": "bun src/index.ts",
"build": "bun scripts/build.ts",
"check": "tsc --noEmit",
"test": "bun test"
},
"dependencies": {
"@opentui/core": "0.5.1"
},
"devDependencies": {
"@types/bun": "^1.3.13",
"typescript": "^5.9.3"
}
}
+34
View File
@@ -0,0 +1,34 @@
import { chmod, mkdir } from "node:fs/promises"
import { basename, join } from "node:path"
const target = process.argv[2] || `${process.platform}-${process.arch}`
const [platform, arch] = target.split("-")
if (!platform || !arch || !["darwin", "linux", "win32"].includes(platform)) {
throw new Error(`unsupported target: ${target}`)
}
const bunPlatform = platform === "win32" ? "windows" : platform
const extension = platform === "win32" ? ".exe" : ""
const outputDir = join(import.meta.dir, "..", "dist")
const output = join(outputDir, `nanobot-tui-${platform}-${arch}${extension}`)
await mkdir(outputDir, { recursive: true })
const result = await Bun.build({
entrypoints: [join(import.meta.dir, "..", "src", "index.ts")],
compile: {
target: `bun-${bunPlatform}-${arch}` as Bun.Build.CompileTarget,
outfile: output,
},
})
if (!result.success) {
for (const log of result.logs) console.error(log)
process.exit(1)
}
if (platform !== "win32") await chmod(output, 0o755)
const digest = new Bun.CryptoHasher("sha256")
.update(await Bun.file(output).arrayBuffer())
.digest("hex")
await Bun.write(`${output}.sha256`, `${digest} ${basename(output)}\n`)
console.log(output)
+592
View File
@@ -0,0 +1,592 @@
import {
BoxRenderable,
CliRenderEvents,
MarkdownRenderable,
RGBA,
SyntaxStyle,
TextareaRenderable,
TextAttributes,
TextRenderable,
createCliRenderer,
getTreeSitterClient,
type CliRenderer,
type KeyEvent,
type ScrollbackSurface,
} from "@opentui/core"
import {
NanobotClient,
fetchHistory,
type ConnectionStatus,
type HistoryMessage,
type InboundEvent,
} from "./protocol"
interface AppOptions {
wsUrl: string
apiUrl: string
apiToken: string
chatId?: string
model: string
workspace: string
version: string
access: string
}
interface Palette {
background: string
panel: string
text: string
muted: string
faint: string
border: string
accent: string
success: string
error: string
user: string
}
const DARK: Palette = {
background: "#0E0F11",
panel: "#17181B",
text: "#ECEDEE",
muted: "#A1A1AA",
faint: "#71717A",
border: "#3F3F46",
accent: "#8B7CF6",
success: "#5CC489",
error: "#F87171",
user: "#60A5FA",
}
const LIGHT: Palette = {
background: "#FAFAFA",
panel: "#F4F4F5",
text: "#18181B",
muted: "#71717A",
faint: "#A1A1AA",
border: "#D4D4D8",
accent: "#6D5BD0",
success: "#218358",
error: "#DC2626",
user: "#2563EB",
}
function syntaxStyle(palette: Palette): SyntaxStyle {
const color = (value: string) => {
const parsed = RGBA.fromHex(value)
return { fg: parsed }
}
return SyntaxStyle.fromStyles({
default: color(palette.text),
keyword: { ...color(palette.accent), bold: true },
string: color(palette.success),
comment: { ...color(palette.muted), italic: true },
number: color(palette.user),
function: color("#C26A25"),
type: color("#168A96"),
variable: color(palette.text),
property: color(palette.user),
"markup.heading": { ...color(palette.accent), bold: true },
"markup.strong": { ...color(palette.text), bold: true },
"markup.italic": { ...color(palette.muted), italic: true },
"markup.link": { ...color(palette.user), underline: true },
"markup.link.label": { ...color(palette.user), underline: true },
"markup.link.url": { ...color(palette.user), underline: true },
"markup.raw": color("#C26A25"),
conceal: color(palette.faint),
})
}
class Transcript {
private writeChain = Promise.resolve()
private live: { surface: ScrollbackSurface; text: TextRenderable; content: string } | null = null
private wrote = false
constructor(
private readonly renderer: CliRenderer,
private palette: Palette,
) {}
setPalette(palette: Palette): void {
this.palette = palette
}
header(options: AppOptions): void {
this.enqueue(async () => {
const lines = [
`>_ nanobot v${options.version}`,
`${options.model} · ${options.access}`,
options.workspace,
]
await 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)
}
await this.writeChain
}
user(content: string): void {
this.enqueue(() => this.writeText(` ${content}`, this.palette.user, true))
}
assistant(content: string): void {
if (!content.trim()) return
this.enqueue(() => this.writeMarkdown(content))
}
notice(content: string, error = false): void {
this.enqueue(() => this.writeText(content, error ? this.palette.error : this.palette.muted))
}
stream(delta: string): void {
if (!delta) return
if (!this.live) {
const surface = this.renderer.createScrollbackSurface({ startOnNewLine: this.wrote })
const text = new TextRenderable(surface.renderContext, {
id: `assistant-stream-${Date.now()}`,
content: "",
width: "100%",
wrapMode: "word",
fg: this.palette.text,
})
surface.root.add(text)
this.live = { surface, text, content: "" }
}
this.live.content += delta
this.live.text.content = this.live.content
this.live.surface.render()
}
finishStream(fallback = ""): void {
const content = this.live?.content || fallback
if (this.live) {
this.live.surface.destroy()
this.live = null
}
if (content.trim()) this.assistant(content)
}
destroy(): void {
this.live?.surface.destroy()
this.live = null
}
private enqueue(operation: () => Promise<void>): void {
this.writeChain = this.writeChain.then(operation).catch((error) => {
console.error("transcript render failed", error)
})
}
private async writeText(
content: string,
color: string,
bold = false,
framed = false,
): Promise<void> {
this.spacer()
const surface = this.renderer.createScrollbackSurface({ startOnNewLine: this.wrote })
const root = framed
? new BoxRenderable(surface.renderContext, {
id: `text-frame-${Date.now()}`,
width: "100%",
border: true,
borderStyle: "rounded",
borderColor: this.palette.border,
paddingLeft: 1,
paddingRight: 1,
flexDirection: "column",
})
: new BoxRenderable(surface.renderContext, {
id: `text-row-${Date.now()}`,
width: "100%",
paddingLeft: 1,
paddingRight: 1,
flexDirection: "column",
})
root.add(
new TextRenderable(surface.renderContext, {
id: `text-${Date.now()}`,
content,
width: "100%",
wrapMode: "word",
fg: color,
attributes: bold ? TextAttributes.BOLD : 0,
}),
)
surface.root.add(root)
surface.render()
surface.commitRows(0, surface.height, { trailingNewline: true })
surface.destroy()
this.wrote = true
}
private async writeMarkdown(content: string): Promise<void> {
this.spacer()
const surface = this.renderer.createScrollbackSurface({ startOnNewLine: this.wrote })
const markdown = new MarkdownRenderable(surface.renderContext, {
id: `markdown-${Date.now()}`,
content,
width: "100%",
syntaxStyle: syntaxStyle(this.palette),
streaming: false,
internalBlockMode: "top-level",
treeSitterClient: getTreeSitterClient(),
})
surface.root.add(markdown)
await surface.settle()
surface.commitRows(0, surface.height, { trailingNewline: true })
surface.destroy()
this.wrote = true
}
private spacer(): void {
if (!this.wrote) return
this.renderer.writeToScrollback((context) => {
const root = new TextRenderable(context.renderContext, {
id: `spacer-${Date.now()}`,
content: "",
width: Math.max(1, context.width),
height: 1,
})
return { root, width: Math.max(1, context.width), height: 1, startOnNewLine: true, trailingNewline: true }
})
}
}
export class NanobotTui {
private readonly renderer: CliRenderer
private readonly transcript: Transcript
private readonly client: NanobotClient
private readonly shell: BoxRenderable
private readonly title: TextRenderable
private readonly composerFrame: BoxRenderable
private readonly composer: TextareaRenderable
private readonly status: TextRenderable
private readonly meta: TextRenderable
private palette: Palette
private activeTurn = false
private lastProgress = ""
private finalMessage = ""
private historyLoaded = false
private ready = false
private shimmerFrame = 0
private shimmerTimer: ReturnType<typeof setInterval> | null = null
private quitting = false
private constructor(renderer: CliRenderer, private readonly options: AppOptions) {
this.renderer = renderer
this.palette = renderer.themeMode === "light" ? LIGHT : DARK
this.transcript = new Transcript(renderer, this.palette)
this.client = new NanobotClient({
url: options.wsUrl,
chatId: options.chatId,
onEvent: (event) => this.handleEvent(event),
onStatus: (status, detail) => this.handleStatus(status, detail),
})
this.renderer.setBackgroundColor(this.palette.background)
this.shell = new BoxRenderable(renderer, {
id: "nanobot-tui-footer",
width: "100%",
height: "100%",
paddingLeft: 1,
paddingRight: 1,
flexDirection: "column",
backgroundColor: this.palette.background,
})
this.title = new TextRenderable(renderer, {
id: "nanobot-tui-title",
content: `nanobot · ${options.model}`,
height: 1,
fg: this.palette.muted,
})
this.composerFrame = new BoxRenderable(renderer, {
id: "nanobot-tui-composer-frame",
width: "100%",
minHeight: 3,
flexGrow: 1,
border: true,
borderStyle: "rounded",
borderColor: this.palette.border,
paddingLeft: 1,
paddingRight: 1,
backgroundColor: this.palette.panel,
})
this.composer = new TextareaRenderable(renderer, {
id: "nanobot-tui-composer",
width: "100%",
minHeight: 1,
flexGrow: 1,
wrapMode: "word",
placeholder: "Ask nanobot anything",
placeholderColor: this.palette.faint,
textColor: this.palette.text,
focusedTextColor: this.palette.text,
backgroundColor: this.palette.panel,
focusedBackgroundColor: this.palette.panel,
cursorColor: this.palette.accent,
showCursor: true,
keyBindings: [
{ name: "return", action: "submit" },
{ name: "return", meta: true, action: "newline" },
],
onSubmit: () => this.submit(),
})
this.status = new TextRenderable(renderer, {
id: "nanobot-tui-status",
content: "Connecting…",
fg: this.palette.muted,
height: 1,
flexGrow: 1,
})
this.meta = new TextRenderable(renderer, {
id: "nanobot-tui-meta",
content: "enter send · alt+enter newline · ctrl+c stop",
fg: this.palette.faint,
height: 1,
})
const statusRow = new BoxRenderable(renderer, {
id: "nanobot-tui-status-row",
width: "100%",
height: 1,
flexDirection: "row",
justifyContent: "space-between",
})
this.composerFrame.add(this.composer)
statusRow.add(this.status)
statusRow.add(this.meta)
this.shell.add(this.title)
this.shell.add(this.composerFrame)
this.shell.add(statusRow)
this.renderer.root.add(this.shell)
this.renderer.keyInput.on("keypress", this.handleKey)
this.renderer.on(CliRenderEvents.THEME_MODE, this.handleTheme)
this.renderer.on(CliRenderEvents.RESIZE, this.handleResize)
this.renderer.on(CliRenderEvents.DESTROY, this.handleDestroy)
this.handleResize()
this.composer.focus()
this.transcript.header(options)
this.client.connect()
}
static async create(options: AppOptions): Promise<NanobotTui> {
const renderer = await createCliRenderer({
targetFps: 30,
exitOnCtrlC: false,
useMouse: true,
screenMode: "split-footer",
footerHeight: 7,
externalOutputMode: "capture-stdout",
consoleMode: "disabled",
})
return new NanobotTui(renderer, options)
}
start(): void {
this.renderer.start()
}
private submit(): void {
const content = this.composer.plainText.trim()
if (!content) return
if (!this.ready) {
this.status.content = "Preparing chat…"
return
}
if (["exit", "quit", "/exit", "/quit", ":q"].includes(content.toLowerCase())) {
this.quit()
return
}
if (this.activeTurn) {
this.status.content = "A turn is already running · Ctrl+C to stop"
return
}
try {
this.client.send(content)
} catch (error) {
this.status.content = error instanceof Error ? error.message : String(error)
return
}
this.composer.setText("")
this.transcript.user(content)
this.finalMessage = ""
this.lastProgress = ""
this.setActive(true)
}
private handleEvent(event: InboundEvent): void {
if (event.event === "attached") {
void this.prepareChat(event.chat_id)
return
}
if (
"chat_id" in event
&& event.chat_id
&& this.client.activeChatId
&& event.chat_id !== this.client.activeChatId
) return
switch (event.event) {
case "message_accepted":
return
case "delta":
this.setActive(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.setActive(true)
} else {
this.finalMessage = event.text
}
return
case "reasoning_delta":
this.setActive(true)
return
case "stream_end":
if (event.text) this.finalMessage = event.text
return
case "turn_end":
this.transcript.finishStream(this.finalMessage)
this.finalMessage = ""
this.setActive(false)
if (typeof event.latency_ms === "number") {
this.status.content = `Ready · ${(event.latency_ms / 1000).toFixed(1)}s`
}
return
case "turn_model_updated":
this.title.content = `nanobot · ${event.model_name}`
return
case "runtime_model_updated":
this.title.content = `nanobot · ${event.model_name}`
return
case "error":
this.transcript.finishStream(this.finalMessage)
this.transcript.notice(event.reason || event.detail || "Unknown gateway error", true)
this.setActive(false)
return
}
}
private async prepareChat(chatId: string): Promise<void> {
try {
if (!this.historyLoaded && this.options.chatId) {
this.historyLoaded = true
const messages = await fetchHistory(this.options.apiUrl, this.options.apiToken, chatId)
await this.transcript.history(messages)
}
} catch (error) {
this.transcript.notice(error instanceof Error ? error.message : String(error), true)
} finally {
this.ready = true
this.status.content = "Ready"
}
}
private handleStatus(status: ConnectionStatus, detail?: string): void {
if (status === "connected") {
this.status.content = "Connected · preparing chat…"
return
}
if (status === "connecting") {
this.status.content = "Connecting…"
return
}
if (status === "error") {
this.status.content = detail || "Connection error"
return
}
if (!this.quitting) this.status.content = "Disconnected"
}
private setActive(active: boolean): void {
if (this.activeTurn === active) return
this.activeTurn = active
if (active) {
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)
return
}
if (this.shimmerTimer) clearInterval(this.shimmerTimer)
this.shimmerTimer = null
this.lastProgress = ""
this.status.content = "Ready"
}
private handleKey = (key: KeyEvent): void => {
if (key.ctrl && key.name === "c") {
key.preventDefault()
if (this.activeTurn) {
try {
this.client.send("/stop")
this.status.content = "Stopping…"
} catch {
this.setActive(false)
}
} else if (this.composer.plainText) {
this.composer.setText("")
} else {
this.quit()
}
return
}
if (key.ctrl && key.name === "d" && !this.composer.plainText) {
key.preventDefault()
this.quit()
}
}
private handleTheme = (): void => {
this.palette = this.renderer.themeMode === "light" ? LIGHT : DARK
this.transcript.setPalette(this.palette)
this.renderer.setBackgroundColor(this.palette.background)
this.shell.backgroundColor = this.palette.background
this.composerFrame.backgroundColor = this.palette.panel
this.composerFrame.borderColor = this.palette.border
this.composer.backgroundColor = this.palette.panel
this.composer.focusedBackgroundColor = this.palette.panel
this.composer.textColor = this.palette.text
this.composer.focusedTextColor = this.palette.text
this.composer.cursorColor = this.palette.accent
this.title.fg = this.palette.muted
this.status.fg = this.palette.muted
this.meta.fg = this.palette.faint
}
private handleResize = (): void => {
this.meta.content = this.renderer.width >= 72
? "enter send · alt+enter newline · ctrl+c stop"
: this.renderer.width >= 48
? "enter send · alt+enter newline"
: ""
}
private quit(): void {
if (this.quitting) return
this.quitting = true
this.client.close()
this.renderer.destroy()
}
private handleDestroy = (): void => {
if (this.shimmerTimer) clearInterval(this.shimmerTimer)
this.transcript.destroy()
this.client.close()
}
}
export type { AppOptions }
+21
View File
@@ -0,0 +1,21 @@
import { NanobotTui, type AppOptions } from "./app"
function required(name: string): string {
const value = process.env[name]?.trim()
if (!value) throw new Error(`${name} is required`)
return value
}
const options: AppOptions = {
wsUrl: required("NANOBOT_TUI_WS_URL"),
apiUrl: process.env.NANOBOT_TUI_API_URL?.trim() || "",
apiToken: process.env.NANOBOT_TUI_API_TOKEN?.trim() || "",
chatId: process.env.NANOBOT_TUI_CHAT_ID?.trim() || undefined,
model: process.env.NANOBOT_TUI_MODEL?.trim() || "unknown model",
workspace: process.env.NANOBOT_TUI_WORKSPACE?.trim() || "",
version: process.env.NANOBOT_TUI_VERSION?.trim() || "dev",
access: process.env.NANOBOT_TUI_ACCESS?.trim() || "workspace access",
}
const app = await NanobotTui.create(options)
app.start()
+108
View File
@@ -0,0 +1,108 @@
import { describe, expect, test } from "bun:test"
import { NanobotClient, type InboundEvent } from "./protocol"
class FakeSocket {
static readonly OPEN = 1
readonly sent: string[] = []
readyState = FakeSocket.OPEN
private readonly listeners = new Map<string, Array<(event: { data?: string }) => void>>()
addEventListener(name: string, listener: (event: { data?: string }) => void): void {
const listeners = this.listeners.get(name) || []
listeners.push(listener)
this.listeners.set(name, listeners)
}
close(): void {
this.readyState = 3
}
send(value: string): void {
this.sent.push(value)
}
emit(name: string, event: { data?: string } = {}): void {
for (const listener of this.listeners.get(name) || []) listener(event)
}
}
describe("gateway protocol", () => {
test("represents lifecycle frames without browser state", () => {
const events: InboundEvent[] = [
{ event: "delta", chat_id: "one", text: "hello" },
{ event: "stream_end", chat_id: "one", resuming: false },
{ event: "turn_end", chat_id: "one", latency_ms: 12 },
]
expect(events.map((event) => event.event)).toEqual(["delta", "stream_end", "turn_end"])
})
test("attaches and sends turns through the gateway envelope", () => {
const original = globalThis.WebSocket
let socket: FakeSocket | undefined
Object.defineProperty(globalThis, "WebSocket", {
configurable: true,
value: class extends FakeSocket {
constructor() {
super()
socket = this
}
},
})
try {
const events: InboundEvent[] = []
const client = new NanobotClient({
url: "ws://nanobot.test/ws",
chatId: "terminal",
onEvent: (event) => events.push(event),
onStatus: () => undefined,
})
client.connect()
if (!socket) throw new Error("socket was not created")
socket.emit("message", {
data: JSON.stringify({ event: "ready", chat_id: "", client_id: "client" }),
})
socket.emit("message", { data: JSON.stringify({ event: "attached", chat_id: "terminal" }) })
client.send("hello")
const outbound = socket.sent.map((value) => JSON.parse(value) as Record<string, unknown>)
expect(outbound[0]).toEqual({ type: "attach", chat_id: "terminal" })
expect(outbound[1]?.type).toBe("message")
expect(outbound[1]?.chat_id).toBe("terminal")
expect(outbound[1]?.content).toBe("hello")
expect(events.map((event) => event.event)).toEqual(["ready", "attached"])
} finally {
Object.defineProperty(globalThis, "WebSocket", { configurable: true, value: original })
}
})
test("rejects malformed gateway events", () => {
const original = globalThis.WebSocket
let socket: FakeSocket | undefined
Object.defineProperty(globalThis, "WebSocket", {
configurable: true,
value: class extends FakeSocket {
constructor() {
super()
socket = this
}
},
})
try {
const statuses: string[] = []
const client = new NanobotClient({
url: "ws://nanobot.test/ws",
onEvent: () => undefined,
onStatus: (status, detail) => statuses.push(`${status}:${detail || ""}`),
})
client.connect()
if (!socket) throw new Error("socket was not created")
socket.emit("message", { data: "[]" })
expect(statuses).toContain("error:gateway sent an invalid event")
} finally {
Object.defineProperty(globalThis, "WebSocket", { configurable: true, value: original })
}
})
})
+142
View File
@@ -0,0 +1,142 @@
export type ConnectionStatus = "connecting" | "connected" | "closed" | "error"
export type InboundEvent =
| { event: "ready"; chat_id: string; client_id: string }
| { event: "attached"; chat_id: string }
| { event: "message_accepted"; chat_id: string; turn_id: string }
| {
event: "message"
chat_id: string
text: string
kind?: "tool_hint" | "progress" | "reasoning"
turn_id?: string
}
| { event: "delta"; chat_id: string; text: string; stream_id?: string; turn_id?: string }
| {
event: "stream_end"
chat_id: string
text?: string
stream_id?: string
resuming?: boolean
merge_next?: boolean
turn_id?: string
}
| { 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: "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 }
type OutboundEvent =
| { type: "new_chat" }
| { type: "attach"; chat_id: string }
| { type: "message"; chat_id: string; content: string; turn_id: string; webui: true }
export interface ClientOptions {
url: string
chatId?: string
onEvent: (event: InboundEvent) => void
onStatus: (status: ConnectionStatus, detail?: string) => void
}
export interface HistoryMessage {
role: "user" | "assistant"
content: string
}
export async function fetchHistory(
apiUrl: string,
apiToken: string,
chatId: string,
): Promise<HistoryMessage[]> {
if (!apiUrl || !apiToken) return []
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.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 role = message.role
const content = message.content
if ((role !== "user" && role !== "assistant") || typeof content !== "string" || !content.trim()) {
return []
}
return [{ role, content }]
})
}
export class NanobotClient {
private socket: WebSocket | null = null
private chatId = ""
constructor(private readonly options: ClientOptions) {}
get activeChatId(): string {
return this.chatId
}
connect(): void {
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"))
}
close(): void {
this.socket?.close()
this.socket = null
}
send(content: string): string {
if (!this.chatId) throw new Error("chat is not ready")
const turnId = crypto.randomUUID()
this.write({
type: "message",
chat_id: this.chatId,
content,
turn_id: turnId,
webui: true,
})
return turnId
}
private handleMessage(raw: string): void {
let value: unknown
try {
value = JSON.parse(raw) as unknown
} catch {
this.options.onStatus("error", "gateway sent invalid JSON")
return
}
if (!value || typeof value !== "object" || !("event" in value)) {
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
this.write({ type: "attach", chat_id: this.chatId })
} else {
this.write({ type: "new_chat" })
}
} else if (event.event === "attached") {
this.chatId = event.chat_id
}
this.options.onEvent(event)
}
private write(event: OutboundEvent): void {
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
throw new Error("gateway connection is not open")
}
this.socket.send(JSON.stringify(event))
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"skipLibCheck": true,
"noUncheckedIndexedAccess": true,
"noEmit": true,
"types": ["bun"]
},
"include": ["src", "scripts"]
}