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
+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))
}
}