mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-31 16:21:50 +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.
|
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`.
|
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.
|
`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.
|
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)
|
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 () => {
|
test("survives rapid narrow resizes with long CJK and code", async () => {
|
||||||
setup = await createRenderer({ width: 100, height: 30, screenMode: "alternate-screen" })
|
setup = await createRenderer({ width: 100, height: 30, screenMode: "alternate-screen" })
|
||||||
const app = mount(setup)
|
const app = mount(setup)
|
||||||
|
|||||||
@@ -16,9 +16,11 @@ import {
|
|||||||
import {
|
import {
|
||||||
NanobotClient,
|
NanobotClient,
|
||||||
fetchHistory,
|
fetchHistory,
|
||||||
|
fetchSlashCommands,
|
||||||
type ConnectionStatus,
|
type ConnectionStatus,
|
||||||
type InboundEvent,
|
type InboundEvent,
|
||||||
} from "./protocol"
|
} from "./protocol"
|
||||||
|
import { CommandMenu, type CommandMenuTheme } from "./command-menu"
|
||||||
import { Transcript, type TranscriptTheme } from "./transcript"
|
import { Transcript, type TranscriptTheme } from "./transcript"
|
||||||
|
|
||||||
interface AppOptions {
|
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 {
|
function formatElapsed(milliseconds: number): string {
|
||||||
const seconds = Math.max(0, Math.floor(milliseconds / 1000))
|
const seconds = Math.max(0, Math.floor(milliseconds / 1000))
|
||||||
if (seconds < 60) return `${seconds}s`
|
if (seconds < 60) return `${seconds}s`
|
||||||
@@ -150,6 +160,7 @@ async function copyWithSystemClipboard(text: string): Promise<void> {
|
|||||||
export class NanobotTui {
|
export class NanobotTui {
|
||||||
private readonly renderer: CliRenderer
|
private readonly renderer: CliRenderer
|
||||||
private readonly transcript: Transcript
|
private readonly transcript: Transcript
|
||||||
|
private readonly commandMenu: CommandMenu
|
||||||
private readonly client: ChatClient
|
private readonly client: ChatClient
|
||||||
private readonly shell: BoxRenderable
|
private readonly shell: BoxRenderable
|
||||||
private readonly title: TextRenderable
|
private readonly title: TextRenderable
|
||||||
@@ -191,6 +202,7 @@ export class NanobotTui {
|
|||||||
this.activeThemeMode = this.resolveThemeMode(renderer.themeMode)
|
this.activeThemeMode = this.resolveThemeMode(renderer.themeMode)
|
||||||
this.palette = this.activeThemeMode === "light" ? LIGHT : DARK
|
this.palette = this.activeThemeMode === "light" ? LIGHT : DARK
|
||||||
this.transcript = new Transcript(renderer, transcriptTheme(this.palette), treeSitterClient)
|
this.transcript = new Transcript(renderer, transcriptTheme(this.palette), treeSitterClient)
|
||||||
|
this.commandMenu = new CommandMenu(renderer, commandMenuTheme(this.palette))
|
||||||
this.client = client || new NanobotClient({
|
this.client = client || new NanobotClient({
|
||||||
url: options.wsUrl,
|
url: options.wsUrl,
|
||||||
chatId: options.chatId,
|
chatId: options.chatId,
|
||||||
@@ -250,6 +262,7 @@ export class NanobotTui {
|
|||||||
],
|
],
|
||||||
onContentChange: () => {
|
onContentChange: () => {
|
||||||
this.syncComposerPlaceholder()
|
this.syncComposerPlaceholder()
|
||||||
|
this.syncCommandMenu()
|
||||||
this.resizeComposer()
|
this.resizeComposer()
|
||||||
},
|
},
|
||||||
// IMEs may commit their final composed glyph after Enter. Matching the
|
// 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.status)
|
||||||
statusRow.add(this.meta)
|
statusRow.add(this.meta)
|
||||||
this.shell.add(this.transcript.root)
|
this.shell.add(this.transcript.root)
|
||||||
|
this.shell.add(this.commandMenu.root)
|
||||||
this.shell.add(this.title)
|
this.shell.add(this.title)
|
||||||
this.shell.add(this.composerFrame)
|
this.shell.add(this.composerFrame)
|
||||||
this.shell.add(statusRow)
|
this.shell.add(statusRow)
|
||||||
@@ -335,6 +349,7 @@ export class NanobotTui {
|
|||||||
this.applyTheme(this.renderer.themeMode)
|
this.applyTheme(this.renderer.themeMode)
|
||||||
}
|
}
|
||||||
this.client.connect()
|
this.client.connect()
|
||||||
|
void this.loadCommands()
|
||||||
this.renderer.start()
|
this.renderer.start()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -358,6 +373,13 @@ export class NanobotTui {
|
|||||||
if (this.quitting || this.composer.isDestroyed) return
|
if (this.quitting || this.composer.isDestroyed) return
|
||||||
const content = this.composer.plainText.trim()
|
const content = this.composer.plainText.trim()
|
||||||
if (!content) return
|
if (!content) return
|
||||||
|
const completion = this.commandMenu.completion(content)
|
||||||
|
if (completion) {
|
||||||
|
this.setComposer(completion)
|
||||||
|
this.commandMenu.hide()
|
||||||
|
this.updateMeta()
|
||||||
|
return
|
||||||
|
}
|
||||||
if (!this.ready) {
|
if (!this.ready) {
|
||||||
this.status.content = "Preparing chat…"
|
this.status.content = "Preparing chat…"
|
||||||
return
|
return
|
||||||
@@ -377,6 +399,7 @@ export class NanobotTui {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
this.composer.setText("")
|
this.composer.setText("")
|
||||||
|
this.commandMenu.hide()
|
||||||
if (this.promptHistory.at(-1) !== content) this.promptHistory.push(content)
|
if (this.promptHistory.at(-1) !== content) this.promptHistory.push(content)
|
||||||
if (this.promptHistory.length > 50) this.promptHistory.shift()
|
if (this.promptHistory.length > 50) this.promptHistory.shift()
|
||||||
this.historyCursor = this.promptHistory.length
|
this.historyCursor = this.promptHistory.length
|
||||||
@@ -573,6 +596,29 @@ export class NanobotTui {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private handleKey = (key: KeyEvent): void => {
|
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") {
|
if (key.ctrl && key.name === "o") {
|
||||||
const expanded = this.transcript.toggleActivityDetails()
|
const expanded = this.transcript.toggleActivityDetails()
|
||||||
if (expanded === null) return
|
if (expanded === null) return
|
||||||
@@ -663,6 +709,7 @@ export class NanobotTui {
|
|||||||
this.activeThemeMode = mode
|
this.activeThemeMode = mode
|
||||||
this.palette = mode === "light" ? LIGHT : DARK
|
this.palette = mode === "light" ? LIGHT : DARK
|
||||||
this.transcript.setTheme(transcriptTheme(this.palette))
|
this.transcript.setTheme(transcriptTheme(this.palette))
|
||||||
|
this.commandMenu.setTheme(commandMenuTheme(this.palette))
|
||||||
this.composerFrame.borderColor = this.palette.border
|
this.composerFrame.borderColor = this.palette.border
|
||||||
this.composer.textColor = this.palette.text
|
this.composer.textColor = this.palette.text
|
||||||
this.composer.focusedTextColor = 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" : ""
|
this.meta.content = this.renderer.width >= 48 ? "ctrl+c stop" : ""
|
||||||
return
|
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
|
this.meta.content = this.renderer.width >= 112
|
||||||
? "enter send · alt+enter newline · pgup/pgdn scroll · ctrl+o tools · ctrl+c stop"
|
? "enter send · alt+enter newline · pgup/pgdn scroll · ctrl+o tools · ctrl+c stop"
|
||||||
: this.renderer.width >= 72
|
: this.renderer.width >= 72
|
||||||
@@ -711,6 +764,30 @@ export class NanobotTui {
|
|||||||
if (this.composer.placeholder !== placeholder) this.composer.placeholder = placeholder
|
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> {
|
private async copySelection(text: string): Promise<void> {
|
||||||
if (!text) return
|
if (!text) return
|
||||||
try {
|
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 { describe, expect, test } from "bun:test"
|
||||||
|
|
||||||
import { NanobotClient, fetchHistory, type InboundEvent } from "./protocol"
|
import {
|
||||||
|
NanobotClient,
|
||||||
|
fetchHistory,
|
||||||
|
fetchSlashCommands,
|
||||||
|
type InboundEvent,
|
||||||
|
} from "./protocol"
|
||||||
|
|
||||||
class FakeSocket {
|
class FakeSocket {
|
||||||
static readonly OPEN = 1
|
static readonly OPEN = 1
|
||||||
@@ -201,4 +206,38 @@ describe("gateway protocol", () => {
|
|||||||
globalThis.fetch = original
|
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
|
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([
|
const CHAT_EVENTS = new Set([
|
||||||
"attached",
|
"attached",
|
||||||
"message_accepted",
|
"message_accepted",
|
||||||
@@ -228,6 +244,33 @@ export async function fetchHistory(
|
|||||||
return { messages, truncated: payload.page?.has_more_before === true }
|
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 {
|
export class NanobotClient {
|
||||||
private socket: WebSocket | null = null
|
private socket: WebSocket | null = null
|
||||||
private chatId = ""
|
private chatId = ""
|
||||||
|
|||||||
Reference in New Issue
Block a user