mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-09-04 10:11:46 +03:00
feat(tui): discover slash commands
This commit is contained in:
@@ -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