mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-31 08:13:11 +03:00
feat(tui): discover slash commands
This commit is contained in:
@@ -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`.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 = ""
|
||||
|
||||
Reference in New Issue
Block a user