mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-09-01 00:31:51 +03:00
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:
co-authored by
Bingxi Zhao
chengyongru
parent
c27b1f14c3
commit
ce070c832d
@@ -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.
|
||||
@@ -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=="],
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 }
|
||||
@@ -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()
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noEmit": true,
|
||||
"types": ["bun"]
|
||||
},
|
||||
"include": ["src", "scripts"]
|
||||
}
|
||||
Reference in New Issue
Block a user