mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-31 00:03:01 +03:00
fix(tui): preserve slash command semantics
This commit is contained in:
@@ -202,7 +202,7 @@ Use `nanobot gateway --background` for the same direct entry point without keepi
|
||||
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:
|
||||
|
||||
|
||||
@@ -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. 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`.
|
||||
|
||||
|
||||
+3
-2
@@ -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
|
||||
to move, `Tab` to complete, and `Esc` to close the menu.
|
||||
|
||||
Use `/sessions` or `/resume` to search and switch persisted conversations without leaving the
|
||||
terminal. `/new` starts a separate conversation while preserving the current one in history.
|
||||
Use `/sessions` to search and switch persisted conversations without leaving the terminal.
|
||||
`/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
@@ -7,6 +7,7 @@ import {
|
||||
} from "@opentui/core/testing"
|
||||
|
||||
import { NanobotTui, type AppOptions } from "./app"
|
||||
import type { SlashCommand } from "./protocol"
|
||||
|
||||
const options: AppOptions = {
|
||||
wsUrl: "ws://localhost.invalid/ws",
|
||||
@@ -211,13 +212,7 @@ describe("NanobotTui layout", () => {
|
||||
composer: TextareaRenderable
|
||||
commandMenu: {
|
||||
visible: boolean
|
||||
setCommands(commands: Array<{
|
||||
command: string
|
||||
title: string
|
||||
description: string
|
||||
argHint: string
|
||||
acceptsArgs: boolean
|
||||
}>): void
|
||||
setCommands(commands: SlashCommand[]): void
|
||||
}
|
||||
}
|
||||
ui.commandMenu.setCommands([{
|
||||
@@ -225,6 +220,7 @@ describe("NanobotTui layout", () => {
|
||||
title: "History",
|
||||
description: "Show recent messages",
|
||||
argHint: "[n]",
|
||||
lifecycle: "side_channel",
|
||||
acceptsArgs: true,
|
||||
}])
|
||||
|
||||
@@ -237,7 +233,7 @@ describe("NanobotTui layout", () => {
|
||||
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" })
|
||||
const original = globalThis.fetch
|
||||
globalThis.fetch = (() => Promise.resolve(new Response(JSON.stringify({
|
||||
@@ -285,7 +281,7 @@ describe("NanobotTui layout", () => {
|
||||
|
||||
app.accept({ event: "attached", chat_id: "other" })
|
||||
await Bun.sleep(1)
|
||||
ui.composer.setText("/new")
|
||||
ui.composer.setText("/new-chat")
|
||||
ui.composer.submit()
|
||||
await waitUntil(() => newChats.length === 1)
|
||||
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 () => {
|
||||
setup = await createRenderer({ width: 80, height: 24, screenMode: "alternate-screen" })
|
||||
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 () => {
|
||||
setup = await createRenderer({ width: 100, height: 30, screenMode: "alternate-screen" })
|
||||
const app = mount(setup)
|
||||
|
||||
+100
-29
@@ -22,7 +22,13 @@ import {
|
||||
type InboundEvent,
|
||||
type SlashCommand,
|
||||
} 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 { Transcript, type TranscriptTheme } from "./transcript"
|
||||
|
||||
@@ -90,27 +96,18 @@ const LIGHT: Palette = {
|
||||
}
|
||||
|
||||
const COMPOSER_PLACEHOLDER = "Ask nanobot anything"
|
||||
const LOCAL_COMMANDS: SlashCommand[] = [
|
||||
const LOCAL_COMMANDS: TuiCommand[] = [
|
||||
{
|
||||
command: "/sessions",
|
||||
title: "Sessions",
|
||||
description: "Find and switch conversations",
|
||||
argHint: "",
|
||||
acceptsArgs: false,
|
||||
action: "sessions",
|
||||
},
|
||||
{
|
||||
command: "/resume",
|
||||
title: "Resume",
|
||||
description: "Find and switch conversations",
|
||||
argHint: "",
|
||||
acceptsArgs: false,
|
||||
},
|
||||
{
|
||||
command: "/new",
|
||||
title: "New chat",
|
||||
description: "Start a separate conversation",
|
||||
argHint: "",
|
||||
acceptsArgs: false,
|
||||
command: "/new-chat",
|
||||
title: "New saved chat",
|
||||
description: "Keep this conversation and start another",
|
||||
action: "new-chat",
|
||||
},
|
||||
]
|
||||
|
||||
@@ -221,6 +218,7 @@ export class NanobotTui {
|
||||
private quitting = false
|
||||
private sessionLoadId = 0
|
||||
private sessionLoading = false
|
||||
private readonly commandTurns = new Map<string, ResolvedSlashCommandLifecycle>()
|
||||
|
||||
private constructor(
|
||||
renderer: CliRenderer,
|
||||
@@ -234,6 +232,7 @@ export class NanobotTui {
|
||||
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.commandMenu.setCommands([], LOCAL_COMMANDS)
|
||||
this.sessionMenu = new SessionMenu(renderer, commandMenuTheme(this.palette))
|
||||
this.client = client || new NanobotClient({
|
||||
url: options.wsUrl,
|
||||
@@ -423,12 +422,15 @@ export class NanobotTui {
|
||||
this.updateMeta()
|
||||
return
|
||||
}
|
||||
if (["/sessions", "/resume", "/continue"].includes(content.toLocaleLowerCase())) {
|
||||
void this.openSessions()
|
||||
const command = this.commandMenu.resolve(content)
|
||||
if (command?.source === "tui") {
|
||||
if (command.command.action === "sessions") void this.openSessions()
|
||||
else this.startNewChat()
|
||||
return
|
||||
}
|
||||
if (content.toLocaleLowerCase() === "/new") {
|
||||
this.startNewChat()
|
||||
if (command?.source === "gateway") {
|
||||
const lifecycle = resolveSlashCommandLifecycle(content, command.command)
|
||||
if (lifecycle) this.sendGatewayCommand(content, lifecycle)
|
||||
return
|
||||
}
|
||||
if (!this.ready) {
|
||||
@@ -451,10 +453,7 @@ export class NanobotTui {
|
||||
}
|
||||
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
|
||||
this.historyDraft = ""
|
||||
this.recordPrompt(content)
|
||||
this.transcript.user(content)
|
||||
this.finalMessage = ""
|
||||
this.turnHadAnswer = false
|
||||
@@ -465,6 +464,7 @@ export class NanobotTui {
|
||||
|
||||
accept(event: InboundEvent): void {
|
||||
if (event.event === "attached") {
|
||||
this.commandTurns.clear()
|
||||
const restoring = this.attachedOnce
|
||||
this.attachedOnce = true
|
||||
if (restoring) this.setActive(false)
|
||||
@@ -500,6 +500,15 @@ export class NanobotTui {
|
||||
this.transcript.stream(event.text)
|
||||
return
|
||||
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) {
|
||||
this.activeLabel = event.kind === "tool_hint" ? "Working" : "Thinking"
|
||||
this.lastProgress = this.transcript.progress(event.text, event.tool_events)
|
||||
@@ -528,6 +537,7 @@ export class NanobotTui {
|
||||
}
|
||||
return
|
||||
case "turn_end":
|
||||
if (event.turn_id) this.commandTurns.delete(event.turn_id)
|
||||
this.transcript.finishStream(this.turnHadAnswer ? "" : this.finalMessage)
|
||||
this.transcript.finishActivity()
|
||||
this.finalMessage = ""
|
||||
@@ -554,6 +564,7 @@ export class NanobotTui {
|
||||
this.setModel(event.model_name)
|
||||
return
|
||||
case "error":
|
||||
if (event.turn_id) this.commandTurns.delete(event.turn_id)
|
||||
this.transcript.finishStream(this.turnHadAnswer ? "" : this.finalMessage)
|
||||
this.transcript.notice(event.reason || event.detail || "Unknown gateway error", true)
|
||||
this.finalMessage = ""
|
||||
@@ -870,8 +881,7 @@ export class NanobotTui {
|
||||
// Local navigation remains available against older gateways.
|
||||
}
|
||||
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()])
|
||||
this.commandMenu.setCommands([...commands.values()], LOCAL_COMMANDS)
|
||||
this.syncCommandMenu()
|
||||
}
|
||||
|
||||
@@ -891,6 +901,7 @@ export class NanobotTui {
|
||||
this.sessionLoading = false
|
||||
const limit = this.renderer.height >= 20 ? 8 : 4
|
||||
this.sessionMenu.open(sessions, this.client.activeChatId, limit)
|
||||
this.sessionMenu.update(this.composer.plainText, limit)
|
||||
this.syncComposerPlaceholder()
|
||||
this.updateMeta()
|
||||
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"
|
||||
return
|
||||
}
|
||||
this.closeSessions()
|
||||
if (chatId === this.client.activeChatId) {
|
||||
this.closeSessions()
|
||||
this.status.content = "Ready"
|
||||
return
|
||||
}
|
||||
if (!this.ready) {
|
||||
this.status.content = "Preparing chat…"
|
||||
return
|
||||
}
|
||||
this.closeSessions()
|
||||
try {
|
||||
this.ready = false
|
||||
this.status.content = "Opening session…"
|
||||
this.client.attach(chatId)
|
||||
} catch (error) {
|
||||
this.ready = true
|
||||
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"
|
||||
return
|
||||
}
|
||||
if (!this.ready) {
|
||||
this.status.content = "Preparing chat…"
|
||||
return
|
||||
}
|
||||
this.commandMenu.hide()
|
||||
this.sessionMenu.hide()
|
||||
this.composer.setText("")
|
||||
@@ -934,11 +953,63 @@ export class NanobotTui {
|
||||
this.status.content = "Starting a new chat…"
|
||||
this.client.newChat()
|
||||
} catch (error) {
|
||||
this.ready = true
|
||||
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 {
|
||||
this.sessionLoadId += 1
|
||||
this.sessionLoading = false
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import { createTestRenderer, type TestRendererSetup } from "@opentui/core/testing"
|
||||
|
||||
import { CommandMenu } from "./command-menu"
|
||||
import { CommandMenu, resolveSlashCommandLifecycle } from "./command-menu"
|
||||
import type { SlashCommand } from "./protocol"
|
||||
|
||||
const commands: SlashCommand[] = [
|
||||
@@ -10,6 +10,7 @@ const commands: SlashCommand[] = [
|
||||
title: "Help",
|
||||
description: "Show available commands",
|
||||
argHint: "",
|
||||
lifecycle: "side_channel",
|
||||
acceptsArgs: false,
|
||||
},
|
||||
{
|
||||
@@ -17,6 +18,7 @@ const commands: SlashCommand[] = [
|
||||
title: "History",
|
||||
description: "Show recent messages",
|
||||
argHint: "[n]",
|
||||
lifecycle: "side_channel",
|
||||
acceptsArgs: true,
|
||||
},
|
||||
]
|
||||
@@ -64,4 +66,45 @@ describe("CommandMenu", () => {
|
||||
menu.update("/history 5")
|
||||
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
@@ -1,15 +1,48 @@
|
||||
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"
|
||||
|
||||
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. */
|
||||
export class CommandMenu {
|
||||
readonly root: BoxRenderable
|
||||
private readonly picker: PickerMenu<SlashCommand>
|
||||
private commands: SlashCommand[] = []
|
||||
private readonly picker: PickerMenu<CommandChoice>
|
||||
private commands: CommandChoice[] = []
|
||||
private query = ""
|
||||
|
||||
constructor(
|
||||
@@ -18,9 +51,14 @@ export class CommandMenu {
|
||||
) {
|
||||
this.picker = new PickerMenu(renderer, theme, {
|
||||
id: "nanobot-tui-command-menu",
|
||||
searchText: (command) => `${command.command} ${command.title}`,
|
||||
render: (command) => {
|
||||
const hint = command.argHint ? ` ${command.argHint}` : ""
|
||||
searchText: (choice) => {
|
||||
const command = descriptor(choice)
|
||||
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, " ")
|
||||
return `${command.command}${hint} ${detail}`
|
||||
},
|
||||
@@ -32,11 +70,28 @@ export class CommandMenu {
|
||||
return this.picker.visible
|
||||
}
|
||||
|
||||
setCommands(commands: SlashCommand[]): void {
|
||||
this.commands = [...commands].sort((left, right) => left.command.localeCompare(right.command))
|
||||
setCommands(commands: SlashCommand[], local: TuiCommand[] = []): void {
|
||||
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)
|
||||
}
|
||||
|
||||
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 {
|
||||
const changed = input !== this.query
|
||||
this.query = input
|
||||
@@ -55,17 +110,22 @@ export class CommandMenu {
|
||||
|
||||
completion(input: string): string | null {
|
||||
if (!this.visible) return null
|
||||
const command = this.picker.current()
|
||||
if (!command || input.trim() === command.command) return null
|
||||
return `${command.command}${command.acceptsArgs ? " " : ""}`
|
||||
const choice = this.picker.current()
|
||||
if (!choice) return null
|
||||
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 {
|
||||
if (!this.visible) return null
|
||||
const command = this.picker.current()
|
||||
if (!command) return null
|
||||
const choice = this.picker.current()
|
||||
if (!choice) return null
|
||||
const command = descriptor(choice)
|
||||
this.hide()
|
||||
return `${command.command}${command.acceptsArgs ? " " : ""}`
|
||||
const acceptsArgs = choice.source === "gateway" && choice.command.acceptsArgs
|
||||
return `${command.command}${acceptsArgs ? " " : ""}`
|
||||
}
|
||||
|
||||
hide(): void {
|
||||
|
||||
@@ -238,6 +238,7 @@ describe("gateway protocol", () => {
|
||||
title: "History",
|
||||
description: "Show recent messages",
|
||||
argHint: "[n]",
|
||||
lifecycle: "side_channel",
|
||||
acceptsArgs: true,
|
||||
}])
|
||||
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 () => {
|
||||
const original = globalThis.fetch
|
||||
globalThis.fetch = (() => Promise.resolve(new Response(JSON.stringify({
|
||||
|
||||
@@ -92,9 +92,17 @@ export interface SlashCommand {
|
||||
title: string
|
||||
description: string
|
||||
argHint: string
|
||||
lifecycle: SlashCommandLifecycle
|
||||
acceptsArgs: boolean
|
||||
}
|
||||
|
||||
export type SlashCommandLifecycle =
|
||||
| "side_channel"
|
||||
| "finalize_active_turn"
|
||||
| "stop_active_turn"
|
||||
| "agent_turn"
|
||||
| "agent_turn_with_args"
|
||||
|
||||
export interface SessionSummary {
|
||||
chatId: string
|
||||
title: string
|
||||
@@ -275,6 +283,7 @@ export async function fetchSlashCommands(
|
||||
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 : "",
|
||||
lifecycle: value.lifecycle as SlashCommandLifecycle,
|
||||
acceptsArgs: value.accepts_args === true,
|
||||
}]
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user