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
+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 = ""