From d094fd7f4d0cf70b768a65e155ddf6f3ea8e4801 Mon Sep 17 00:00:00 2001 From: Xubin Ren <52506698+Re-bin@users.noreply.github.com> Date: Thu, 13 Aug 2026 12:55:26 +0900 Subject: [PATCH] feat(tui): discover slash commands --- docs/cli-reference.md | 2 +- tui/README.md | 3 + tui/src/app.test.ts | 34 +++++++++ tui/src/app.ts | 77 ++++++++++++++++++++ tui/src/command-menu.test.ts | 67 ++++++++++++++++++ tui/src/command-menu.ts | 133 +++++++++++++++++++++++++++++++++++ tui/src/protocol.test.ts | 41 ++++++++++- tui/src/protocol.ts | 43 +++++++++++ 8 files changed, 398 insertions(+), 2 deletions(-) create mode 100644 tui/src/command-menu.test.ts create mode 100644 tui/src/command-menu.ts diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 7ddda2d1a..4b483bcc7 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -118,7 +118,7 @@ Interactive mode uses nanobot's native TypeScript terminal UI. It talks to the s The default `--theme auto` mode probes the terminal's real foreground and background colors before first paint and follows supported live appearance changes. Use `--theme light` or `--theme dark` when a terminal or multiplexer does not report its colors reliably. -`Enter` sends the current message. Press `Alt+Enter` to add a newline and use `Up`/`Down` at the composer edge to recall recent prompts. `Ctrl+C` copies a selection, stops a running turn, clears a non-empty composer, or exits when idle. Use `PageUp`/`PageDown` to scroll, `Ctrl+Home`/`Ctrl+End` to jump to the transcript edges, and `Ctrl+O` to expand or collapse long tool traces. Selections copy through OSC 52 when the terminal supports it. The transcript reflows when the terminal is resized, and exiting restores the previous screen. +`Enter` sends the current message. Press `Alt+Enter` to add a newline and use `Up`/`Down` at the composer edge to recall recent prompts. Type `/` to discover gateway-provided commands, use the arrow keys to choose one, and press `Tab` to complete it. `Ctrl+C` copies a selection, stops a running turn, clears a non-empty composer, or exits when idle. Use `PageUp`/`PageDown` to scroll, `Ctrl+Home`/`Ctrl+End` to jump to the transcript edges, and `Ctrl+O` to expand or collapse long tool traces. Selections copy through OSC 52 when the terminal supports it. The transcript reflows when the terminal is resized, and exiting restores the previous screen. Packaged releases fetch a version-matched, checksummed terminal binary for macOS (Apple Silicon and Intel), Linux (x64 and ARM64), or Windows x64 on first use and cache it under the nanobot data directory. Windows ARM64 currently falls back to the classic prompt because the Bun runtime disables the FFI required by OpenTUI on that platform. Set `NANOBOT_TUI_NO_DOWNLOAD=1` or pass `--classic` to keep the Python-only path. A source checkout can run the client with Bun after `bun install --cwd tui`. diff --git a/tui/README.md b/tui/README.md index 639faa9c0..c1fe06e0b 100644 --- a/tui/README.md +++ b/tui/README.md @@ -12,3 +12,6 @@ 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 retained full-screen layout: the transcript reflows with the terminal while the composer stays fixed at the bottom. Mouse and keyboard scrolling operate inside the transcript, and leaving the TUI restores the previous terminal screen. + +Type `/` to discover slash commands published by the connected gateway. Use the arrow keys +to move, `Tab` to complete, and `Esc` to close the menu. diff --git a/tui/src/app.test.ts b/tui/src/app.test.ts index 0c87c17b7..927912d32 100644 --- a/tui/src/app.test.ts +++ b/tui/src/app.test.ts @@ -197,6 +197,40 @@ describe("NanobotTui layout", () => { expect(composer.plainText).toBe(wrapped) }) + test("discovers and completes gateway slash commands without sending them", async () => { + setup = await createRenderer({ width: 80, height: 24, screenMode: "alternate-screen" }) + const sent: string[] = [] + const app = mount(setup, sent) + const ui = app as unknown as { + composer: TextareaRenderable + commandMenu: { + visible: boolean + setCommands(commands: Array<{ + command: string + title: string + description: string + argHint: string + acceptsArgs: boolean + }>): void + } + } + ui.commandMenu.setCommands([{ + command: "/history", + title: "History", + description: "Show recent messages", + argHint: "[n]", + acceptsArgs: true, + }]) + + await setup.mockInput.typeText("/h") + expect(ui.commandMenu.visible).toBe(true) + setup.mockInput.pressTab() + + expect(ui.composer.plainText).toBe("/history ") + expect(ui.commandMenu.visible).toBe(false) + expect(sent).toEqual([]) + }) + test("survives rapid narrow resizes with long CJK and code", async () => { setup = await createRenderer({ width: 100, height: 30, screenMode: "alternate-screen" }) const app = mount(setup) diff --git a/tui/src/app.ts b/tui/src/app.ts index dbb0438c6..0bf20a58f 100644 --- a/tui/src/app.ts +++ b/tui/src/app.ts @@ -16,9 +16,11 @@ import { import { NanobotClient, fetchHistory, + fetchSlashCommands, type ConnectionStatus, type InboundEvent, } from "./protocol" +import { CommandMenu, type CommandMenuTheme } from "./command-menu" import { Transcript, type TranscriptTheme } from "./transcript" interface AppOptions { @@ -122,6 +124,14 @@ function transcriptTheme(palette: Palette): TranscriptTheme { } } +function commandMenuTheme(palette: Palette): CommandMenuTheme { + return { + text: palette.text, + muted: palette.muted, + border: palette.border, + } +} + function formatElapsed(milliseconds: number): string { const seconds = Math.max(0, Math.floor(milliseconds / 1000)) if (seconds < 60) return `${seconds}s` @@ -150,6 +160,7 @@ async function copyWithSystemClipboard(text: string): Promise { export class NanobotTui { private readonly renderer: CliRenderer private readonly transcript: Transcript + private readonly commandMenu: CommandMenu private readonly client: ChatClient private readonly shell: BoxRenderable private readonly title: TextRenderable @@ -191,6 +202,7 @@ export class NanobotTui { this.activeThemeMode = this.resolveThemeMode(renderer.themeMode) this.palette = this.activeThemeMode === "light" ? LIGHT : DARK this.transcript = new Transcript(renderer, transcriptTheme(this.palette), treeSitterClient) + this.commandMenu = new CommandMenu(renderer, commandMenuTheme(this.palette)) this.client = client || new NanobotClient({ url: options.wsUrl, chatId: options.chatId, @@ -250,6 +262,7 @@ export class NanobotTui { ], onContentChange: () => { this.syncComposerPlaceholder() + this.syncCommandMenu() this.resizeComposer() }, // IMEs may commit their final composed glyph after Enter. Matching the @@ -288,6 +301,7 @@ export class NanobotTui { statusRow.add(this.status) statusRow.add(this.meta) this.shell.add(this.transcript.root) + this.shell.add(this.commandMenu.root) this.shell.add(this.title) this.shell.add(this.composerFrame) this.shell.add(statusRow) @@ -335,6 +349,7 @@ export class NanobotTui { this.applyTheme(this.renderer.themeMode) } this.client.connect() + void this.loadCommands() this.renderer.start() } @@ -358,6 +373,13 @@ export class NanobotTui { if (this.quitting || this.composer.isDestroyed) return const content = this.composer.plainText.trim() if (!content) return + const completion = this.commandMenu.completion(content) + if (completion) { + this.setComposer(completion) + this.commandMenu.hide() + this.updateMeta() + return + } if (!this.ready) { this.status.content = "Preparing chat…" return @@ -377,6 +399,7 @@ export class NanobotTui { return } this.composer.setText("") + this.commandMenu.hide() if (this.promptHistory.at(-1) !== content) this.promptHistory.push(content) if (this.promptHistory.length > 50) this.promptHistory.shift() this.historyCursor = this.promptHistory.length @@ -573,6 +596,29 @@ export class NanobotTui { } private handleKey = (key: KeyEvent): void => { + if (this.commandMenu.visible) { + if (!key.ctrl && !key.meta && (key.name === "up" || key.name === "down")) { + this.commandMenu.move(key.name === "up" ? -1 : 1) + key.preventDefault() + return + } + if (!key.ctrl && !key.meta && key.name === "tab") { + const completion = this.commandMenu.complete() + if (completion) { + this.setComposer(completion) + this.commandMenu.hide() + } + this.updateMeta() + key.preventDefault() + return + } + if (key.name === "escape") { + this.commandMenu.hide() + this.updateMeta() + key.preventDefault() + return + } + } if (key.ctrl && key.name === "o") { const expanded = this.transcript.toggleActivityDetails() if (expanded === null) return @@ -663,6 +709,7 @@ export class NanobotTui { this.activeThemeMode = mode this.palette = mode === "light" ? LIGHT : DARK this.transcript.setTheme(transcriptTheme(this.palette)) + this.commandMenu.setTheme(commandMenuTheme(this.palette)) this.composerFrame.borderColor = this.palette.border this.composer.textColor = this.palette.text this.composer.focusedTextColor = this.palette.text @@ -683,6 +730,12 @@ export class NanobotTui { this.meta.content = this.renderer.width >= 48 ? "ctrl+c stop" : "" return } + if (this.commandMenu.visible) { + this.meta.content = this.renderer.width >= 72 + ? "↑↓ choose · tab complete · esc close" + : "tab complete · esc close" + return + } this.meta.content = this.renderer.width >= 112 ? "enter send · alt+enter newline · pgup/pgdn scroll · ctrl+o tools · ctrl+c stop" : this.renderer.width >= 72 @@ -711,6 +764,30 @@ export class NanobotTui { if (this.composer.placeholder !== placeholder) this.composer.placeholder = placeholder } + private syncCommandMenu(): void { + const limit = this.renderer.height >= 20 ? 6 : 3 + this.commandMenu.update(this.composer.plainText, limit) + this.updateMeta() + } + + private setComposer(content: string): void { + this.composer.setText(content) + this.composer.cursorOffset = content.length + } + + private async loadCommands(): Promise { + try { + this.commandMenu.setCommands(await fetchSlashCommands( + this.options.apiUrl, + this.options.apiToken, + )) + this.syncCommandMenu() + } catch { + // Command discovery is an enhancement; chat stays usable if the + // version-matched gateway does not expose the catalog yet. + } + } + private async copySelection(text: string): Promise { if (!text) return try { diff --git a/tui/src/command-menu.test.ts b/tui/src/command-menu.test.ts new file mode 100644 index 000000000..b1cd561a0 --- /dev/null +++ b/tui/src/command-menu.test.ts @@ -0,0 +1,67 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { createTestRenderer, type TestRendererSetup } from "@opentui/core/testing" + +import { CommandMenu } from "./command-menu" +import type { SlashCommand } from "./protocol" + +const commands: SlashCommand[] = [ + { + command: "/help", + title: "Help", + description: "Show available commands", + argHint: "", + acceptsArgs: false, + }, + { + command: "/history", + title: "History", + description: "Show recent messages", + argHint: "[n]", + acceptsArgs: true, + }, +] + +describe("CommandMenu", () => { + let setup: TestRendererSetup | undefined + + afterEach(() => { + if (setup && !setup.renderer.isDestroyed) setup.renderer.destroy() + setup = undefined + }) + + test("discovers, navigates, and completes backend commands", async () => { + setup = await createTestRenderer({ width: 80, height: 18, screenMode: "alternate-screen" }) + const menu = new CommandMenu(setup.renderer, { + text: "#FFFFFF", + muted: "#999999", + border: "#555555", + }) + setup.renderer.root.add(menu.root) + menu.setCommands(commands) + menu.update("/h") + await setup.renderOnce() + + const frame = setup.captureCharFrame() + expect(frame).toContain("› /help") + expect(frame).toContain("/history [n]") + expect(menu.completion("/h")).toBe("/help") + + expect(menu.move(1)).toBe(true) + expect(menu.complete()).toBe("/history ") + expect(menu.visible).toBe(false) + }) + + test("hides outside the leading command token", async () => { + setup = await createTestRenderer({ width: 60, height: 12, screenMode: "alternate-screen" }) + const menu = new CommandMenu(setup.renderer, { + text: "#FFFFFF", + muted: "#999999", + border: "#555555", + }) + menu.setCommands(commands) + menu.update("explain /help") + expect(menu.visible).toBe(false) + menu.update("/history 5") + expect(menu.visible).toBe(false) + }) +}) diff --git a/tui/src/command-menu.ts b/tui/src/command-menu.ts new file mode 100644 index 000000000..380458034 --- /dev/null +++ b/tui/src/command-menu.ts @@ -0,0 +1,133 @@ +import { + BoxRenderable, + RGBA, + TextAttributes, + TextRenderable, + type CliRenderer, +} from "@opentui/core" + +import type { SlashCommand } from "./protocol" + +export interface CommandMenuTheme { + text: string + muted: string + border: string +} + +/** Retained slash-command discovery with one small completion interface. */ +export class CommandMenu { + readonly root: BoxRenderable + private commands: SlashCommand[] = [] + private matches: SlashCommand[] = [] + private selected = 0 + private query = "" + + constructor( + private readonly renderer: CliRenderer, + private theme: CommandMenuTheme, + ) { + this.root = new BoxRenderable(renderer, { + id: "nanobot-tui-command-menu", + width: "100%", + flexShrink: 0, + flexDirection: "column", + border: true, + borderStyle: "rounded", + borderColor: theme.border, + paddingLeft: 1, + paddingRight: 1, + backgroundColor: RGBA.defaultBackground(), + visible: false, + }) + } + + get visible(): boolean { + return this.root.visible + } + + setCommands(commands: SlashCommand[]): void { + this.commands = [...commands].sort((left, right) => left.command.localeCompare(right.command)) + this.update(this.query) + } + + update(input: string, limit = 6): void { + const changed = input !== this.query + this.query = input + const token = /^\/[^\s]*$/u.test(input) ? input.toLocaleLowerCase() : "" + if (!token) { + this.hide() + return + } + const words = token.slice(1) + this.matches = this.commands + .filter((command) => ( + command.command.toLocaleLowerCase().startsWith(token) + || command.title.toLocaleLowerCase().includes(words) + )) + .slice(0, Math.max(1, limit)) + this.selected = changed ? 0 : Math.min(this.selected, Math.max(0, this.matches.length - 1)) + this.render() + } + + move(direction: -1 | 1): boolean { + if (!this.visible || this.matches.length < 2) return false + this.selected = (this.selected + direction + this.matches.length) % this.matches.length + this.render() + return true + } + + completion(input: string): string | null { + if (!this.visible) return null + const command = this.matches[this.selected] + if (!command || input.trim() === command.command) return null + return `${command.command}${command.acceptsArgs ? " " : ""}` + } + + complete(): string | null { + if (!this.visible) return null + const command = this.matches[this.selected] + if (!command) return null + this.hide() + return `${command.command}${command.acceptsArgs ? " " : ""}` + } + + hide(): void { + this.matches = [] + this.selected = 0 + this.root.visible = false + this.clear() + } + + setTheme(theme: CommandMenuTheme): void { + this.theme = theme + this.root.borderColor = theme.border + if (this.visible) this.render() + } + + private render(): void { + this.clear() + this.root.visible = this.matches.length > 0 + for (const [index, command] of this.matches.entries()) { + const selected = index === this.selected + const hint = command.argHint ? ` ${command.argHint}` : "" + const detail = (command.description || command.title).replace(/\s+/gu, " ") + const line = new TextRenderable(this.renderer, { + id: `nanobot-tui-command-${index}`, + content: `${selected ? "›" : " "} ${command.command}${hint} ${detail}`, + width: "100%", + height: 1, + wrapMode: "none", + fg: selected ? this.theme.text : this.theme.muted, + attributes: selected ? TextAttributes.BOLD : 0, + }) + this.root.add(line) + } + } + + private clear(): void { + for (const child of [...this.root.getChildren()]) { + this.root.remove(child) + child.destroyRecursively() + } + } +} diff --git a/tui/src/protocol.test.ts b/tui/src/protocol.test.ts index 0dd41435a..88ee4a3f8 100644 --- a/tui/src/protocol.test.ts +++ b/tui/src/protocol.test.ts @@ -1,6 +1,11 @@ import { describe, expect, test } from "bun:test" -import { NanobotClient, fetchHistory, type InboundEvent } from "./protocol" +import { + NanobotClient, + fetchHistory, + fetchSlashCommands, + type InboundEvent, +} from "./protocol" class FakeSocket { static readonly OPEN = 1 @@ -201,4 +206,38 @@ describe("gateway protocol", () => { globalThis.fetch = original } }) + + test("loads the gateway-owned slash command catalog", async () => { + const original = globalThis.fetch + let authorization = "" + globalThis.fetch = ((_: string | URL | Request, init?: RequestInit) => { + authorization = new Headers(init?.headers).get("Authorization") || "" + return Promise.resolve(new Response(JSON.stringify({ + commands: [ + { + command: "/history", + title: "History", + description: "Show recent messages", + arg_hint: "[n]", + accepts_args: true, + lifecycle: "side_channel", + }, + { title: "invalid" }, + ], + }))) + }) as typeof fetch + + try { + expect(await fetchSlashCommands("http://nanobot.test", "secret")).toEqual([{ + command: "/history", + title: "History", + description: "Show recent messages", + argHint: "[n]", + acceptsArgs: true, + }]) + expect(authorization).toBe("Bearer secret") + } finally { + globalThis.fetch = original + } + }) }) diff --git a/tui/src/protocol.ts b/tui/src/protocol.ts index 3bbd88c6a..be0f2765d 100644 --- a/tui/src/protocol.ts +++ b/tui/src/protocol.ts @@ -87,6 +87,22 @@ export interface HistorySnapshot { truncated: boolean } +export interface SlashCommand { + command: string + title: string + description: string + argHint: string + acceptsArgs: boolean +} + +const SLASH_COMMAND_LIFECYCLES = new Set([ + "side_channel", + "finalize_active_turn", + "stop_active_turn", + "agent_turn", + "agent_turn_with_args", +]) + const CHAT_EVENTS = new Set([ "attached", "message_accepted", @@ -228,6 +244,33 @@ export async function fetchHistory( return { messages, truncated: payload.page?.has_more_before === true } } +export async function fetchSlashCommands( + apiUrl: string, + apiToken: string, +): Promise { + if (!apiUrl || !apiToken) return [] + const response = await fetch(`${apiUrl}/api/commands`, { + headers: { Authorization: `Bearer ${apiToken}` }, + }) + if (!response.ok) throw new Error(`command request failed: HTTP ${response.status}`) + const payload = await response.json() as { commands?: unknown[] } + return (payload.commands || []).flatMap((value) => { + if ( + !isRecord(value) + || typeof value.command !== "string" + || typeof value.lifecycle !== "string" + || !SLASH_COMMAND_LIFECYCLES.has(value.lifecycle) + ) return [] + return [{ + command: value.command, + title: typeof value.title === "string" ? value.title : value.command, + description: typeof value.description === "string" ? value.description : "", + argHint: typeof value.arg_hint === "string" ? value.arg_hint : "", + acceptsArgs: value.accepts_args === true, + }] + }) +} + export class NanobotClient { private socket: WebSocket | null = null private chatId = ""