mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-09-01 00:31:51 +03:00
fix(tui): preserve slash command semantics
This commit is contained in:
+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