fix(tui): preserve slash command semantics

This commit is contained in:
Xubin Ren
2026-08-17 20:56:10 +08:00
parent 2b666934ba
commit b3c3a82075
9 changed files with 349 additions and 57 deletions
+1 -1
View File
@@ -202,7 +202,7 @@ Use `nanobot gateway --background` for the same direct entry point without keepi
nanobot agent nanobot agent
``` ```
This opens the native terminal client with the same configured model, workspace, tools, streaming protocol, and session engine as the WebUI. Use `/sessions` to switch saved conversations or `/new` to start another one. It starts a local gateway only when needed and releases that gateway when you exit. Type `exit` or press `Ctrl+C` when you are done. Use `nanobot agent --classic` for the legacy Python prompt. This opens the native terminal client with the same configured model, workspace, tools, streaming protocol, and session engine as the WebUI. Use `/sessions` to switch saved conversations or `/new-chat` to preserve this conversation and start another one. The existing nanobot `/new` command keeps its original behavior: it resets the current chat. The client starts a local gateway only when needed and releases it when you exit. Type `exit` or press `Ctrl+C` when you are done. Use `nanobot agent --classic` for the legacy Python prompt.
For one request and an immediate exit, use: For one request and an immediate exit, use:
+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. 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. Type `/` to discover gateway-provided commands, use the arrow keys to choose one, and press `Tab` to complete it. `/sessions` and `/resume` open a searchable conversation picker; `/new` starts a separate conversation without deleting the current one. `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 nanobot commands and terminal navigation in one palette, use the arrow keys to choose one, and press `Tab` to complete it. `/sessions` opens a searchable conversation picker, while `/new-chat` preserves the current conversation and starts another one. The core `/new` command retains its cross-channel behavior and resets the current chat. `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`.
+3 -2
View File
@@ -16,5 +16,6 @@ The renderer uses OpenTUI's retained full-screen layout: the transcript reflows
Type `/` to discover slash commands published by the connected gateway. Use the arrow keys 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. to move, `Tab` to complete, and `Esc` to close the menu.
Use `/sessions` or `/resume` to search and switch persisted conversations without leaving the Use `/sessions` to search and switch persisted conversations without leaving the terminal.
terminal. `/new` starts a separate conversation while preserving the current one in history. `/new-chat` preserves the current conversation and starts another one; nanobot's existing `/new`
command keeps its cross-channel behavior and resets the current chat.
+98 -9
View File
@@ -7,6 +7,7 @@ import {
} from "@opentui/core/testing" } from "@opentui/core/testing"
import { NanobotTui, type AppOptions } from "./app" import { NanobotTui, type AppOptions } from "./app"
import type { SlashCommand } from "./protocol"
const options: AppOptions = { const options: AppOptions = {
wsUrl: "ws://localhost.invalid/ws", wsUrl: "ws://localhost.invalid/ws",
@@ -211,13 +212,7 @@ describe("NanobotTui layout", () => {
composer: TextareaRenderable composer: TextareaRenderable
commandMenu: { commandMenu: {
visible: boolean visible: boolean
setCommands(commands: Array<{ setCommands(commands: SlashCommand[]): void
command: string
title: string
description: string
argHint: string
acceptsArgs: boolean
}>): void
} }
} }
ui.commandMenu.setCommands([{ ui.commandMenu.setCommands([{
@@ -225,6 +220,7 @@ describe("NanobotTui layout", () => {
title: "History", title: "History",
description: "Show recent messages", description: "Show recent messages",
argHint: "[n]", argHint: "[n]",
lifecycle: "side_channel",
acceptsArgs: true, acceptsArgs: true,
}]) }])
@@ -237,7 +233,7 @@ describe("NanobotTui layout", () => {
expect(sent).toEqual([]) expect(sent).toEqual([])
}) })
test("switches and creates gateway chats without sending agent commands", async () => { test("switches and creates gateway chats without replacing core slash commands", async () => {
setup = await createRenderer({ width: 80, height: 24, screenMode: "alternate-screen" }) setup = await createRenderer({ width: 80, height: 24, screenMode: "alternate-screen" })
const original = globalThis.fetch const original = globalThis.fetch
globalThis.fetch = (() => Promise.resolve(new Response(JSON.stringify({ globalThis.fetch = (() => Promise.resolve(new Response(JSON.stringify({
@@ -285,7 +281,7 @@ describe("NanobotTui layout", () => {
app.accept({ event: "attached", chat_id: "other" }) app.accept({ event: "attached", chat_id: "other" })
await Bun.sleep(1) await Bun.sleep(1)
ui.composer.setText("/new") ui.composer.setText("/new-chat")
ui.composer.submit() ui.composer.submit()
await waitUntil(() => newChats.length === 1) await waitUntil(() => newChats.length === 1)
expect(newChats).toEqual(["new"]) expect(newChats).toEqual(["new"])
@@ -294,6 +290,58 @@ describe("NanobotTui layout", () => {
} }
}) })
test("preserves gateway slash lifecycle while local navigation stays in the same menu", async () => {
setup = await createRenderer({ width: 80, height: 24, screenMode: "alternate-screen" })
const sent: string[] = []
const app = mount(setup, sent)
app.accept({ event: "attached", chat_id: "chat" })
await Bun.sleep(1)
const ui = app as unknown as {
composer: TextareaRenderable
commandMenu: {
setCommands(commands: SlashCommand[]): void
}
activeTurn: boolean
}
ui.commandMenu.setCommands([{
command: "/new",
title: "New chat",
description: "Reset this chat",
argHint: "",
lifecycle: "finalize_active_turn",
acceptsArgs: false,
}, {
command: "/status",
title: "Status",
description: "Show status",
argHint: "",
lifecycle: "side_channel",
acceptsArgs: false,
}])
app.accept({ event: "goal_status", chat_id: "chat", status: "running" })
ui.composer.setText("/status")
ui.composer.submit()
await waitUntil(() => sent.includes("/status"))
expect(ui.activeTurn).toBe(true)
app.accept({
event: "message",
chat_id: "chat",
text: "Runtime healthy",
turn_id: "turn",
})
await setup.flush()
expect(setup.captureCharFrame()).toContain("Runtime healthy")
ui.composer.setText("/new")
ui.composer.submit()
await waitUntil(() => sent.includes("/new"))
expect(ui.activeTurn).toBe(false)
expect(sent).toEqual(["/status", "/new"])
await setup.flush()
expect(setup.captureCharFrame()).toContain("/new")
})
test("blocks sends and ignores late session results after closing the picker", async () => { test("blocks sends and ignores late session results after closing the picker", async () => {
setup = await createRenderer({ width: 80, height: 24, screenMode: "alternate-screen" }) setup = await createRenderer({ width: 80, height: 24, screenMode: "alternate-screen" })
const original = globalThis.fetch const original = globalThis.fetch
@@ -336,6 +384,47 @@ describe("NanobotTui layout", () => {
} }
}) })
test("applies a query typed while sessions are still loading", async () => {
setup = await createRenderer({ width: 80, height: 24, screenMode: "alternate-screen" })
const original = globalThis.fetch
let resolveFetch: ((response: Response) => void) | undefined
globalThis.fetch = (() => new Promise<Response>((resolve) => {
resolveFetch = resolve
})) as unknown as typeof fetch
const app = NanobotTui.mount(
setup.renderer,
{ ...options, apiUrl: "http://nanobot.test", apiToken: "secret" },
client(),
new MockTreeSitterClient({ autoResolveTimeout: 0 }),
)
app.accept({ event: "attached", chat_id: "chat" })
await Bun.sleep(1)
const ui = app as unknown as {
composer: TextareaRenderable
sessionMenu: { visible: boolean }
}
try {
ui.composer.setText("/sessions")
ui.composer.submit()
await Bun.sleep(10)
ui.composer.setText("release")
resolveFetch?.(new Response(JSON.stringify({
sessions: [
{ key: "websocket:chat", title: "Current chat", preview: "Current work" },
{ key: "websocket:other", title: "Release checklist", preview: "Ship it" },
],
})))
await waitUntil(() => ui.sessionMenu.visible)
await setup.flush()
const frame = setup.captureCharFrame()
expect(frame).toContain("Release checklist")
expect(frame).not.toContain("Current chat")
} finally {
globalThis.fetch = original
}
})
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)
+100 -29
View File
@@ -22,7 +22,13 @@ import {
type InboundEvent, type InboundEvent,
type SlashCommand, type SlashCommand,
} from "./protocol" } from "./protocol"
import { CommandMenu, type CommandMenuTheme } from "./command-menu" import {
CommandMenu,
resolveSlashCommandLifecycle,
type CommandMenuTheme,
type ResolvedSlashCommandLifecycle,
type TuiCommand,
} from "./command-menu"
import { SessionMenu } from "./session-menu" import { SessionMenu } from "./session-menu"
import { Transcript, type TranscriptTheme } from "./transcript" import { Transcript, type TranscriptTheme } from "./transcript"
@@ -90,27 +96,18 @@ const LIGHT: Palette = {
} }
const COMPOSER_PLACEHOLDER = "Ask nanobot anything" const COMPOSER_PLACEHOLDER = "Ask nanobot anything"
const LOCAL_COMMANDS: SlashCommand[] = [ const LOCAL_COMMANDS: TuiCommand[] = [
{ {
command: "/sessions", command: "/sessions",
title: "Sessions", title: "Sessions",
description: "Find and switch conversations", description: "Find and switch conversations",
argHint: "", action: "sessions",
acceptsArgs: false,
}, },
{ {
command: "/resume", command: "/new-chat",
title: "Resume", title: "New saved chat",
description: "Find and switch conversations", description: "Keep this conversation and start another",
argHint: "", action: "new-chat",
acceptsArgs: false,
},
{
command: "/new",
title: "New chat",
description: "Start a separate conversation",
argHint: "",
acceptsArgs: false,
}, },
] ]
@@ -221,6 +218,7 @@ export class NanobotTui {
private quitting = false private quitting = false
private sessionLoadId = 0 private sessionLoadId = 0
private sessionLoading = false private sessionLoading = false
private readonly commandTurns = new Map<string, ResolvedSlashCommandLifecycle>()
private constructor( private constructor(
renderer: CliRenderer, renderer: CliRenderer,
@@ -234,6 +232,7 @@ export class NanobotTui {
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.commandMenu = new CommandMenu(renderer, commandMenuTheme(this.palette))
this.commandMenu.setCommands([], LOCAL_COMMANDS)
this.sessionMenu = new SessionMenu(renderer, commandMenuTheme(this.palette)) this.sessionMenu = new SessionMenu(renderer, commandMenuTheme(this.palette))
this.client = client || new NanobotClient({ this.client = client || new NanobotClient({
url: options.wsUrl, url: options.wsUrl,
@@ -423,12 +422,15 @@ export class NanobotTui {
this.updateMeta() this.updateMeta()
return return
} }
if (["/sessions", "/resume", "/continue"].includes(content.toLocaleLowerCase())) { const command = this.commandMenu.resolve(content)
void this.openSessions() if (command?.source === "tui") {
if (command.command.action === "sessions") void this.openSessions()
else this.startNewChat()
return return
} }
if (content.toLocaleLowerCase() === "/new") { if (command?.source === "gateway") {
this.startNewChat() const lifecycle = resolveSlashCommandLifecycle(content, command.command)
if (lifecycle) this.sendGatewayCommand(content, lifecycle)
return return
} }
if (!this.ready) { if (!this.ready) {
@@ -451,10 +453,7 @@ export class NanobotTui {
} }
this.composer.setText("") this.composer.setText("")
this.commandMenu.hide() this.commandMenu.hide()
if (this.promptHistory.at(-1) !== content) this.promptHistory.push(content) this.recordPrompt(content)
if (this.promptHistory.length > 50) this.promptHistory.shift()
this.historyCursor = this.promptHistory.length
this.historyDraft = ""
this.transcript.user(content) this.transcript.user(content)
this.finalMessage = "" this.finalMessage = ""
this.turnHadAnswer = false this.turnHadAnswer = false
@@ -465,6 +464,7 @@ export class NanobotTui {
accept(event: InboundEvent): void { accept(event: InboundEvent): void {
if (event.event === "attached") { if (event.event === "attached") {
this.commandTurns.clear()
const restoring = this.attachedOnce const restoring = this.attachedOnce
this.attachedOnce = true this.attachedOnce = true
if (restoring) this.setActive(false) if (restoring) this.setActive(false)
@@ -500,6 +500,15 @@ export class NanobotTui {
this.transcript.stream(event.text) this.transcript.stream(event.text)
return return
case "message": case "message":
if (event.turn_id && this.commandTurns.has(event.turn_id) && !event.kind) {
const lifecycle = this.commandTurns.get(event.turn_id)
if (lifecycle !== "agent_turn") {
this.commandTurns.delete(event.turn_id)
this.transcript.assistant(event.text)
if (!this.activeTurn) this.status.content = "Ready"
return
}
}
if (event.kind) { if (event.kind) {
this.activeLabel = event.kind === "tool_hint" ? "Working" : "Thinking" this.activeLabel = event.kind === "tool_hint" ? "Working" : "Thinking"
this.lastProgress = this.transcript.progress(event.text, event.tool_events) this.lastProgress = this.transcript.progress(event.text, event.tool_events)
@@ -528,6 +537,7 @@ export class NanobotTui {
} }
return return
case "turn_end": case "turn_end":
if (event.turn_id) this.commandTurns.delete(event.turn_id)
this.transcript.finishStream(this.turnHadAnswer ? "" : this.finalMessage) this.transcript.finishStream(this.turnHadAnswer ? "" : this.finalMessage)
this.transcript.finishActivity() this.transcript.finishActivity()
this.finalMessage = "" this.finalMessage = ""
@@ -554,6 +564,7 @@ export class NanobotTui {
this.setModel(event.model_name) this.setModel(event.model_name)
return return
case "error": case "error":
if (event.turn_id) this.commandTurns.delete(event.turn_id)
this.transcript.finishStream(this.turnHadAnswer ? "" : this.finalMessage) this.transcript.finishStream(this.turnHadAnswer ? "" : this.finalMessage)
this.transcript.notice(event.reason || event.detail || "Unknown gateway error", true) this.transcript.notice(event.reason || event.detail || "Unknown gateway error", true)
this.finalMessage = "" this.finalMessage = ""
@@ -870,8 +881,7 @@ export class NanobotTui {
// Local navigation remains available against older gateways. // Local navigation remains available against older gateways.
} }
const commands = new Map(discovered.map((command) => [command.command, command])) const commands = new Map(discovered.map((command) => [command.command, command]))
for (const command of LOCAL_COMMANDS) commands.set(command.command, command) this.commandMenu.setCommands([...commands.values()], LOCAL_COMMANDS)
this.commandMenu.setCommands([...commands.values()])
this.syncCommandMenu() this.syncCommandMenu()
} }
@@ -891,6 +901,7 @@ export class NanobotTui {
this.sessionLoading = false this.sessionLoading = false
const limit = this.renderer.height >= 20 ? 8 : 4 const limit = this.renderer.height >= 20 ? 8 : 4
this.sessionMenu.open(sessions, this.client.activeChatId, limit) this.sessionMenu.open(sessions, this.client.activeChatId, limit)
this.sessionMenu.update(this.composer.plainText, limit)
this.syncComposerPlaceholder() this.syncComposerPlaceholder()
this.updateMeta() this.updateMeta()
this.status.content = sessions.length ? `${sessions.length} sessions` : "No saved sessions" this.status.content = sessions.length ? `${sessions.length} sessions` : "No saved sessions"
@@ -906,17 +917,21 @@ export class NanobotTui {
this.status.content = "Wait for the current turn or press Ctrl+C" this.status.content = "Wait for the current turn or press Ctrl+C"
return return
} }
this.closeSessions()
if (chatId === this.client.activeChatId) { if (chatId === this.client.activeChatId) {
this.closeSessions()
this.status.content = "Ready" this.status.content = "Ready"
return return
} }
if (!this.ready) {
this.status.content = "Preparing chat…"
return
}
this.closeSessions()
try { try {
this.ready = false this.ready = false
this.status.content = "Opening session…" this.status.content = "Opening session…"
this.client.attach(chatId) this.client.attach(chatId)
} catch (error) { } catch (error) {
this.ready = true
this.status.content = error instanceof Error ? error.message : String(error) this.status.content = error instanceof Error ? error.message : String(error)
} }
} }
@@ -926,6 +941,10 @@ export class NanobotTui {
this.status.content = "Wait for the current turn or press Ctrl+C" this.status.content = "Wait for the current turn or press Ctrl+C"
return return
} }
if (!this.ready) {
this.status.content = "Preparing chat…"
return
}
this.commandMenu.hide() this.commandMenu.hide()
this.sessionMenu.hide() this.sessionMenu.hide()
this.composer.setText("") this.composer.setText("")
@@ -934,11 +953,63 @@ export class NanobotTui {
this.status.content = "Starting a new chat…" this.status.content = "Starting a new chat…"
this.client.newChat() this.client.newChat()
} catch (error) { } catch (error) {
this.ready = true
this.status.content = error instanceof Error ? error.message : String(error) this.status.content = error instanceof Error ? error.message : String(error)
} }
} }
private sendGatewayCommand(
content: string,
lifecycle: ResolvedSlashCommandLifecycle,
): void {
if (!this.ready) {
this.status.content = "Preparing chat…"
return
}
if (this.activeTurn && lifecycle === "agent_turn") {
this.status.content = "A turn is already running · Ctrl+C to stop"
return
}
let turnId: string
try {
turnId = this.client.send(content)
} catch (error) {
this.status.content = error instanceof Error ? error.message : String(error)
return
}
this.commandTurns.set(turnId, lifecycle)
this.composer.setText("")
this.commandMenu.hide()
if (lifecycle !== "stop_active_turn") this.transcript.user(content)
this.recordPrompt(content)
if (lifecycle === "agent_turn") {
this.finalMessage = ""
this.turnHadAnswer = false
this.lastProgress = ""
this.activeLabel = "Thinking"
this.setActive(true)
} else if (lifecycle === "finalize_active_turn") {
this.transcript.finishStream(this.turnHadAnswer ? "" : this.finalMessage)
this.transcript.finishActivity()
this.finalMessage = ""
this.turnHadAnswer = false
this.setActive(false)
this.status.content = "Resetting chat…"
} else if (lifecycle === "stop_active_turn") {
this.setActive(false)
this.status.content = "Stopping…"
} else if (!this.activeTurn) {
this.status.content = `Running ${content.split(/\s+/u, 1)[0]}`
}
}
private recordPrompt(content: string): void {
if (this.promptHistory.at(-1) !== content) this.promptHistory.push(content)
if (this.promptHistory.length > 50) this.promptHistory.shift()
this.historyCursor = this.promptHistory.length
this.historyDraft = ""
}
private closeSessions(): void { private closeSessions(): void {
this.sessionLoadId += 1 this.sessionLoadId += 1
this.sessionLoading = false this.sessionLoading = false
+44 -1
View File
@@ -1,7 +1,7 @@
import { afterEach, describe, expect, test } from "bun:test" import { afterEach, describe, expect, test } from "bun:test"
import { createTestRenderer, type TestRendererSetup } from "@opentui/core/testing" import { createTestRenderer, type TestRendererSetup } from "@opentui/core/testing"
import { CommandMenu } from "./command-menu" import { CommandMenu, resolveSlashCommandLifecycle } from "./command-menu"
import type { SlashCommand } from "./protocol" import type { SlashCommand } from "./protocol"
const commands: SlashCommand[] = [ const commands: SlashCommand[] = [
@@ -10,6 +10,7 @@ const commands: SlashCommand[] = [
title: "Help", title: "Help",
description: "Show available commands", description: "Show available commands",
argHint: "", argHint: "",
lifecycle: "side_channel",
acceptsArgs: false, acceptsArgs: false,
}, },
{ {
@@ -17,6 +18,7 @@ const commands: SlashCommand[] = [
title: "History", title: "History",
description: "Show recent messages", description: "Show recent messages",
argHint: "[n]", argHint: "[n]",
lifecycle: "side_channel",
acceptsArgs: true, acceptsArgs: true,
}, },
] ]
@@ -64,4 +66,45 @@ describe("CommandMenu", () => {
menu.update("/history 5") menu.update("/history 5")
expect(menu.visible).toBe(false) expect(menu.visible).toBe(false)
}) })
test("keeps gateway commands authoritative over local actions", 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, [{
command: "/help",
title: "Local help",
description: "Must not replace nanobot help",
action: "sessions",
}, {
command: "/sessions",
title: "Sessions",
description: "Switch conversations",
action: "sessions",
}])
expect(menu.resolve("/help")?.source).toBe("gateway")
expect(menu.resolve("/sessions")).toMatchObject({
source: "tui",
command: { action: "sessions" },
})
})
test("resolves argument-sensitive command lifecycles", () => {
const goal: SlashCommand = {
command: "/goal",
title: "Goal",
description: "Start a long-running goal",
argHint: "<goal>",
lifecycle: "agent_turn_with_args",
acceptsArgs: true,
}
expect(resolveSlashCommandLifecycle("/goal", goal)).toBe("side_channel")
expect(resolveSlashCommandLifecycle("/goal ship it", goal)).toBe("agent_turn")
expect(resolveSlashCommandLifecycle("/help extra", commands[0]!)).toBeNull()
})
}) })
+74 -14
View File
@@ -1,15 +1,48 @@
import { type BoxRenderable, type CliRenderer } from "@opentui/core" import { type BoxRenderable, type CliRenderer } from "@opentui/core"
import type { SlashCommand } from "./protocol" import type { SlashCommand, SlashCommandLifecycle } from "./protocol"
import { PickerMenu, type PickerMenuTheme } from "./picker-menu" import { PickerMenu, type PickerMenuTheme } from "./picker-menu"
export type CommandMenuTheme = PickerMenuTheme export type CommandMenuTheme = PickerMenuTheme
export type TuiCommandAction = "sessions" | "new-chat"
export interface TuiCommand {
command: string
title: string
description: string
action: TuiCommandAction
}
export type CommandChoice =
| { source: "gateway"; command: SlashCommand }
| { source: "tui"; command: TuiCommand }
export type ResolvedSlashCommandLifecycle = Exclude<SlashCommandLifecycle, "agent_turn_with_args">
function descriptor(choice: CommandChoice): SlashCommand | TuiCommand {
return choice.command
}
export function resolveSlashCommandLifecycle(
input: string,
command: SlashCommand,
): ResolvedSlashCommandLifecycle | null {
const name = input.split(/\s+/u, 1)[0] || ""
if (name.toLocaleLowerCase() !== command.command.toLocaleLowerCase()) return null
const args = input.slice(name.length).trim()
if (args && !command.acceptsArgs) return null
if (command.lifecycle === "agent_turn_with_args") {
return args ? "agent_turn" : "side_channel"
}
return command.lifecycle
}
/** Retained slash-command discovery with one small completion interface. */ /** Retained slash-command discovery with one small completion interface. */
export class CommandMenu { export class CommandMenu {
readonly root: BoxRenderable readonly root: BoxRenderable
private readonly picker: PickerMenu<SlashCommand> private readonly picker: PickerMenu<CommandChoice>
private commands: SlashCommand[] = [] private commands: CommandChoice[] = []
private query = "" private query = ""
constructor( constructor(
@@ -18,9 +51,14 @@ export class CommandMenu {
) { ) {
this.picker = new PickerMenu(renderer, theme, { this.picker = new PickerMenu(renderer, theme, {
id: "nanobot-tui-command-menu", id: "nanobot-tui-command-menu",
searchText: (command) => `${command.command} ${command.title}`, searchText: (choice) => {
render: (command) => { const command = descriptor(choice)
const hint = command.argHint ? ` ${command.argHint}` : "" return `${command.command} ${command.title}`
},
render: (choice) => {
const command = descriptor(choice)
const argHint = "argHint" in command ? command.argHint : ""
const hint = argHint ? ` ${argHint}` : ""
const detail = (command.description || command.title).replace(/\s+/gu, " ") const detail = (command.description || command.title).replace(/\s+/gu, " ")
return `${command.command}${hint} ${detail}` return `${command.command}${hint} ${detail}`
}, },
@@ -32,11 +70,28 @@ export class CommandMenu {
return this.picker.visible return this.picker.visible
} }
setCommands(commands: SlashCommand[]): void { setCommands(commands: SlashCommand[], local: TuiCommand[] = []): void {
this.commands = [...commands].sort((left, right) => left.command.localeCompare(right.command)) const gatewayNames = new Set(commands.map((command) => command.command.toLocaleLowerCase()))
this.commands = [
...commands.map((command): CommandChoice => ({ source: "gateway", command })),
...local
.filter((command) => !gatewayNames.has(command.command.toLocaleLowerCase()))
.map((command): CommandChoice => ({ source: "tui", command })),
].sort((left, right) => descriptor(left).command.localeCompare(descriptor(right).command))
this.update(this.query) this.update(this.query)
} }
resolve(input: string): CommandChoice | null {
const name = input.trim().split(/\s+/u, 1)[0]?.toLocaleLowerCase()
if (!name) return null
return this.commands.find((choice) => {
const command = descriptor(choice)
if (command.command.toLocaleLowerCase() !== name) return false
if (choice.source === "tui") return input.trim().length === command.command.length
return resolveSlashCommandLifecycle(input.trim(), choice.command) !== null
}) || null
}
update(input: string, limit = 6): void { update(input: string, limit = 6): void {
const changed = input !== this.query const changed = input !== this.query
this.query = input this.query = input
@@ -55,17 +110,22 @@ export class CommandMenu {
completion(input: string): string | null { completion(input: string): string | null {
if (!this.visible) return null if (!this.visible) return null
const command = this.picker.current() const choice = this.picker.current()
if (!command || input.trim() === command.command) return null if (!choice) return null
return `${command.command}${command.acceptsArgs ? " " : ""}` const command = descriptor(choice)
if (input.trim() === command.command) return null
const acceptsArgs = choice.source === "gateway" && choice.command.acceptsArgs
return `${command.command}${acceptsArgs ? " " : ""}`
} }
complete(): string | null { complete(): string | null {
if (!this.visible) return null if (!this.visible) return null
const command = this.picker.current() const choice = this.picker.current()
if (!command) return null if (!choice) return null
const command = descriptor(choice)
this.hide() this.hide()
return `${command.command}${command.acceptsArgs ? " " : ""}` const acceptsArgs = choice.source === "gateway" && choice.command.acceptsArgs
return `${command.command}${acceptsArgs ? " " : ""}`
} }
hide(): void { hide(): void {
+19
View File
@@ -238,6 +238,7 @@ describe("gateway protocol", () => {
title: "History", title: "History",
description: "Show recent messages", description: "Show recent messages",
argHint: "[n]", argHint: "[n]",
lifecycle: "side_channel",
acceptsArgs: true, acceptsArgs: true,
}]) }])
expect(authorization).toBe("Bearer secret") expect(authorization).toBe("Bearer secret")
@@ -246,6 +247,24 @@ describe("gateway protocol", () => {
} }
}) })
test("drops slash commands with unknown lifecycle metadata", async () => {
const original = globalThis.fetch
globalThis.fetch = (() => Promise.resolve(new Response(JSON.stringify({
commands: [{
command: "/future",
title: "Future",
description: "Unknown lifecycle",
lifecycle: "future_mode",
}],
})))) as unknown as typeof fetch
try {
expect(await fetchSlashCommands("http://nanobot.test", "secret")).toEqual([])
} finally {
globalThis.fetch = original
}
})
test("loads and normalizes WebUI sessions", async () => { test("loads and normalizes WebUI sessions", async () => {
const original = globalThis.fetch const original = globalThis.fetch
globalThis.fetch = (() => Promise.resolve(new Response(JSON.stringify({ globalThis.fetch = (() => Promise.resolve(new Response(JSON.stringify({
+9
View File
@@ -92,9 +92,17 @@ export interface SlashCommand {
title: string title: string
description: string description: string
argHint: string argHint: string
lifecycle: SlashCommandLifecycle
acceptsArgs: boolean acceptsArgs: boolean
} }
export type SlashCommandLifecycle =
| "side_channel"
| "finalize_active_turn"
| "stop_active_turn"
| "agent_turn"
| "agent_turn_with_args"
export interface SessionSummary { export interface SessionSummary {
chatId: string chatId: string
title: string title: string
@@ -275,6 +283,7 @@ export async function fetchSlashCommands(
title: typeof value.title === "string" ? value.title : value.command, title: typeof value.title === "string" ? value.title : value.command,
description: typeof value.description === "string" ? value.description : "", description: typeof value.description === "string" ? value.description : "",
argHint: typeof value.arg_hint === "string" ? value.arg_hint : "", argHint: typeof value.arg_hint === "string" ? value.arg_hint : "",
lifecycle: value.lifecycle as SlashCommandLifecycle,
acceptsArgs: value.accepts_args === true, acceptsArgs: value.accepts_args === true,
}] }]
}) })