feat(tui): discover slash commands

This commit is contained in:
Xubin Ren
2026-08-17 20:56:10 +08:00
parent f3386d965b
commit d094fd7f4d
8 changed files with 398 additions and 2 deletions
+1 -1
View File
@@ -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`.
+3
View File
@@ -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.
+34
View File
@@ -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)
+77
View File
@@ -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<void> {
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<void> {
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<void> {
if (!text) return
try {
+67
View File
@@ -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)
})
})
+133
View File
@@ -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()
}
}
}
+40 -1
View File
@@ -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
}
})
})
+43
View File
@@ -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<SlashCommand[]> {
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 = ""