diff --git a/nanobot/command/builtin.py b/nanobot/command/builtin.py index f98ff463..070aba5a 100644 --- a/nanobot/command/builtin.py +++ b/nanobot/command/builtin.py @@ -9,6 +9,7 @@ import sys import time from contextlib import suppress from dataclasses import dataclass +from typing import Literal from nanobot import __version__ from nanobot.bus.events import OutboundMessage @@ -16,6 +17,14 @@ from nanobot.command.router import CommandContext, CommandRouter from nanobot.utils.helpers import build_status_content from nanobot.utils.restart import set_restart_notice_to_env +CommandLifecycle = Literal[ + "side_channel", + "finalize_active_turn", + "stop_active_turn", + "agent_turn", + "agent_turn_with_args", +] + @dataclass(frozen=True) class BuiltinCommandSpec: @@ -24,14 +33,18 @@ class BuiltinCommandSpec: description: str icon: str arg_hint: str = "" + lifecycle: CommandLifecycle = "side_channel" + accepts_args: bool = False - def as_dict(self) -> dict[str, str]: + def as_dict(self) -> dict[str, str | bool]: return { "command": self.command, "title": self.title, "description": self.description, "icon": self.icon, "arg_hint": self.arg_hint, + "lifecycle": self.lifecycle, + "accepts_args": self.accepts_args, } @@ -41,12 +54,14 @@ BUILTIN_COMMAND_SPECS: tuple[BuiltinCommandSpec, ...] = ( "New chat", "Stop the current task and start a fresh conversation.", "square-pen", + lifecycle="finalize_active_turn", ), BuiltinCommandSpec( "/stop", "Stop current task", "Cancel the active agent turn for this chat.", "square", + lifecycle="stop_active_turn", ), BuiltinCommandSpec( "/restart", @@ -66,6 +81,7 @@ BUILTIN_COMMAND_SPECS: tuple[BuiltinCommandSpec, ...] = ( "Show or switch the active model preset.", "brain", "[preset]", + accepts_args=True, ), BuiltinCommandSpec( "/history", @@ -73,6 +89,7 @@ BUILTIN_COMMAND_SPECS: tuple[BuiltinCommandSpec, ...] = ( "Print the last N persisted conversation messages.", "history", "[n]", + accepts_args=True, ), BuiltinCommandSpec( "/goal", @@ -80,6 +97,8 @@ BUILTIN_COMMAND_SPECS: tuple[BuiltinCommandSpec, ...] = ( "Tell the agent to treat the request as a long-running goal.", "activity", "", + lifecycle="agent_turn_with_args", + accepts_args=True, ), BuiltinCommandSpec( "/trigger", @@ -87,6 +106,7 @@ BUILTIN_COMMAND_SPECS: tuple[BuiltinCommandSpec, ...] = ( "Create a named CLI trigger bound to this chat session.", "zap", "", + accepts_args=True, ), BuiltinCommandSpec( "/dream", @@ -99,12 +119,14 @@ BUILTIN_COMMAND_SPECS: tuple[BuiltinCommandSpec, ...] = ( "Show Dream log", "Show what the last Dream consolidation changed.", "book-open", + accepts_args=True, ), BuiltinCommandSpec( "/dream-restore", "Restore memory", "Revert memory to a previous Dream snapshot.", "undo-2", + accepts_args=True, ), BuiltinCommandSpec( "/dream-prompt", @@ -112,6 +134,7 @@ BUILTIN_COMMAND_SPECS: tuple[BuiltinCommandSpec, ...] = ( "Tell Dream how to organize this workspace's memory.", "file-text", "[init]", + accepts_args=True, ), BuiltinCommandSpec( "/skill", @@ -131,11 +154,12 @@ BUILTIN_COMMAND_SPECS: tuple[BuiltinCommandSpec, ...] = ( "List, approve, deny or revoke pairing requests.", "shield", "[list|approve |deny |revoke ]", + accepts_args=True, ), ) -def builtin_command_palette() -> list[dict[str, str]]: +def builtin_command_palette() -> list[dict[str, str | bool]]: """Return structured command metadata for UI command palettes.""" return [spec.as_dict() for spec in BUILTIN_COMMAND_SPECS] diff --git a/tests/channels/test_websocket_channel.py b/tests/channels/test_websocket_channel.py index a5c33892..4225b465 100644 --- a/tests/channels/test_websocket_channel.py +++ b/tests/channels/test_websocket_channel.py @@ -2072,7 +2072,13 @@ async def test_commands_api_returns_slash_command_metadata(bus: MagicMock) -> No body = response.json() commands = {row["command"]: row for row in body["commands"]} assert commands["/stop"]["title"] == "Stop current task" + assert commands["/new"]["lifecycle"] == "finalize_active_turn" + assert commands["/stop"]["lifecycle"] == "stop_active_turn" + assert commands["/history"]["lifecycle"] == "side_channel" assert commands["/history"]["arg_hint"] == "[n]" + assert commands["/history"]["accepts_args"] is True + assert commands["/goal"]["lifecycle"] == "agent_turn_with_args" + assert commands["/goal"]["accepts_args"] is True assert all("description" in row for row in body["commands"]) finally: await channel.stop() diff --git a/tests/command/test_builtin_dream.py b/tests/command/test_builtin_dream.py index b8ba488a..86c092b7 100644 --- a/tests/command/test_builtin_dream.py +++ b/tests/command/test_builtin_dream.py @@ -345,11 +345,11 @@ async def test_dream_prompt_init_recreates_empty_prompt(tmp_path) -> None: def test_dream_prompt_command_in_help_and_palette() -> None: palette = builtin_command_palette() + dream_prompt = next(item for item in palette if item["command"] == "/dream-prompt") - assert any( - item["command"] == "/dream-prompt" and item["arg_hint"] == "[init]" - for item in palette - ) + assert dream_prompt["arg_hint"] == "[init]" + assert dream_prompt["lifecycle"] == "side_channel" + assert dream_prompt["accepts_args"] is True assert "/dream-prompt [init]" in build_help_text() diff --git a/tests/command/test_model_command.py b/tests/command/test_model_command.py index 34a8b0ab..36e8872d 100644 --- a/tests/command/test_model_command.py +++ b/tests/command/test_model_command.py @@ -149,7 +149,10 @@ async def test_model_command_registered_as_exact_and_prefix(tmp_path) -> None: def test_model_command_in_help_and_palette() -> None: palette = builtin_command_palette() - assert any(item["command"] == "/model" and item["arg_hint"] == "[preset]" for item in palette) + model = next(item for item in palette if item["command"] == "/model") + assert model["arg_hint"] == "[preset]" + assert model["lifecycle"] == "side_channel" + assert model["accepts_args"] is True assert "/model [preset]" in build_help_text() @@ -204,5 +207,8 @@ async def test_goal_command_registered_on_router(tmp_path) -> None: def test_goal_command_in_help_and_palette() -> None: palette = builtin_command_palette() - assert any(item["command"] == "/goal" and item["arg_hint"] == "" for item in palette) + goal = next(item for item in palette if item["command"] == "/goal") + assert goal["arg_hint"] == "" + assert goal["lifecycle"] == "agent_turn_with_args" + assert goal["accepts_args"] is True assert "/goal " in build_help_text() diff --git a/webui/src/components/thread/ThreadComposer.tsx b/webui/src/components/thread/ThreadComposer.tsx index b0e48f18..7c77a040 100644 --- a/webui/src/components/thread/ThreadComposer.tsx +++ b/webui/src/components/thread/ThreadComposer.tsx @@ -90,47 +90,49 @@ import { cn } from "@/lib/utils"; const ACCEPT_ATTR = "image/png,image/jpeg,image/webp,image/gif"; const VOICE_SHORTCUT_CODE = "KeyD"; const VOICE_SHORTCUT_ARIA = "Control+Shift+D"; -const FALLBACK_SIDE_CHANNEL_COMMANDS = new Set([ - "/new", - "/stop", - "/restart", - "/status", - "/model", - "/history", - "/goal", - "/trigger", - "/dream", - "/dream-log", - "/dream-restore", - "/dream-prompt", - "/skill", - "/help", - "/pairing", -]); type VoiceShortcutPlatform = "apple" | "chromeos" | "linux" | "other" | "windows"; +type ResolvedSlashCommandLifecycle = + | "side_channel" + | "finalize_active_turn" + | "stop_active_turn" + | "agent_turn"; function slashCommandName(content: string): string { return content.split(/\s+/, 1)[0]; } -function isExactSlashCommand(content: string, commandName: string): boolean { - if (slashCommandName(content) !== commandName) return false; - return content.slice(commandName.length).trim().length === 0; +function slashCommandArgs(content: string, commandName: string): string { + return content.slice(commandName.length).trim(); } -function shouldFinalizeActiveTurn(content: string): boolean { - return isExactSlashCommand(content, "/new"); -} - -function isSlashCommandSideChannel(content: string, visibleSlashCommands: SlashCommand[]): boolean { +function matchingSlashCommand(content: string, slashCommands: SlashCommand[]): SlashCommand | null { const commandName = slashCommandName(content); - if (!commandName.startsWith("/")) return false; - if (commandName === "/goal" && content.slice(commandName.length).trim().length > 0) { - return false; + if (!commandName.startsWith("/")) return null; + const command = slashCommands.find((item) => item.command === commandName); + if (!command) return null; + if (slashCommandArgs(content, command.command).length > 0 && !command.acceptsArgs) return null; + return command; +} + +function slashCommandLifecycle( + content: string, + slashCommands: SlashCommand[], +): ResolvedSlashCommandLifecycle | null { + const command = matchingSlashCommand(content, slashCommands); + if (!command) return null; + if (command.lifecycle === "agent_turn_with_args") { + return slashCommandArgs(content, command.command).length > 0 + ? "agent_turn" + : "side_channel"; } + return command.lifecycle; +} + +function isSideChannelLifecycle(lifecycle: ResolvedSlashCommandLifecycle | null): boolean { return ( - FALLBACK_SIDE_CHANNEL_COMMANDS.has(commandName) - || visibleSlashCommands.some((command) => command.command === commandName) + lifecycle === "side_channel" + || lifecycle === "finalize_active_turn" + || lifecycle === "stop_active_turn" ); } @@ -310,7 +312,12 @@ type MentionCandidate = | { kind: "cli"; name: string; app: CliAppInfo } | { kind: "mcp"; name: string; preset: McpPresetInfo }; -interface SlashPaletteCommand extends SlashCommand { +interface SlashPaletteCommand { + command: string; + title: string; + description: string; + icon: string; + argHint?: string; detail: string; badge?: string; recent: boolean; @@ -968,18 +975,12 @@ export function ThreadComposer({ }, [cursorPosition, disabled, slashMenuDismissed, value]); const visibleSlashCommands = useMemo(() => { - const baseCommands = slashCommands.filter((command) => command.command !== "/stop"); + const baseCommands = slashCommands.filter( + (command) => command.command !== "/stop" && command.command !== "/restart", + ); if (!(isStreaming && onStop)) return baseCommands; - const stopCommand = slashCommands.find((command) => command.command === "/stop") ?? { - command: "/stop", - title: "Stop current task", - description: "Cancel the active agent turn for this chat.", - icon: "square", - }; - return [ - stopCommand, - ...baseCommands, - ]; + const stopCommand = slashCommands.find((command) => command.command === "/stop"); + return stopCommand ? [stopCommand, ...baseCommands] : baseCommands; }, [isStreaming, onStop, slashCommands]); const filteredSlashCommands = useMemo(() => { @@ -1526,11 +1527,13 @@ export function ThreadComposer({ payload === undefined && attachedCliApps.length === 0 && attachedMcpPresets.length === 0; + const slashLifecycle = hasPlainTextCommandPayload + ? slashCommandLifecycle(content, slashCommands) + : null; if ( - hasPlainTextCommandPayload + slashLifecycle === "stop_active_turn" && isStreaming && onStop - && isExactSlashCommand(content, "/stop") ) { handleStop(); setQueuedPrompts([]); @@ -1538,11 +1541,9 @@ export function ThreadComposer({ clearComposerText(); return; } - const isSlashSideChannel = - hasPlainTextCommandPayload - && isSlashCommandSideChannel(content, visibleSlashCommands); + const isSlashSideChannel = isSideChannelLifecycle(slashLifecycle); const finalizeActiveTurn = - isSlashSideChannel && shouldFinalizeActiveTurn(content); + slashLifecycle === "finalize_active_turn"; onSend( content, payload, @@ -1572,8 +1573,8 @@ export function ThreadComposer({ onSend, onStop, readyImages, + slashCommands, value, - visibleSlashCommands, ]); const onKeyDown = (e: ReactKeyboardEvent) => { diff --git a/webui/src/lib/api.ts b/webui/src/lib/api.ts index aea422cb..d492c3ff 100644 --- a/webui/src/lib/api.ts +++ b/webui/src/lib/api.ts @@ -20,6 +20,7 @@ import type { SkillDetail, SkillsPayload, SlashCommand, + SlashCommandLifecycle, TranscriptionSettingsUpdate, WebSearchSettingsUpdate, WorkspacesPayload, @@ -29,6 +30,20 @@ import type { import { fetchWithTimeout } from "./http"; const API_READ_TIMEOUT_MS = 20_000; +const SLASH_COMMAND_LIFECYCLES = new Set([ + "side_channel", + "finalize_active_turn", + "stop_active_turn", + "agent_turn", + "agent_turn_with_args", +]); + +function isSlashCommandLifecycle(value: unknown): value is SlashCommandLifecycle { + return ( + typeof value === "string" + && SLASH_COMMAND_LIFECYCLES.has(value as SlashCommandLifecycle) + ); +} export class ApiError extends Error { status: number; @@ -498,6 +513,8 @@ export async function listSlashCommands( description: string; icon: string; arg_hint?: string; + lifecycle?: unknown; + accepts_args?: unknown; }; const body = await request<{ commands: Row[] }>( `${base}/api/commands`, @@ -506,14 +523,18 @@ export async function listSlashCommands( API_READ_TIMEOUT_MS, ); return body.commands - .filter((command) => !["/stop", "/restart"].includes(command.command)) - .map((command) => ({ - command: command.command, - title: command.title, - description: command.description, - icon: command.icon, - argHint: command.arg_hint ?? "", - })); + .flatMap((command) => { + if (!isSlashCommandLifecycle(command.lifecycle)) return []; + return [{ + command: command.command, + title: command.title, + description: command.description, + icon: command.icon, + argHint: command.arg_hint ?? "", + lifecycle: command.lifecycle, + acceptsArgs: command.accepts_args === true, + }]; + }); } export async function fetchSidebarState( diff --git a/webui/src/lib/types.ts b/webui/src/lib/types.ts index 35ba111c..8733b8d4 100644 --- a/webui/src/lib/types.ts +++ b/webui/src/lib/types.ts @@ -781,12 +781,21 @@ export interface TranscriptionSettingsUpdate { maxUploadMb: number; } +export type SlashCommandLifecycle = + | "side_channel" + | "finalize_active_turn" + | "stop_active_turn" + | "agent_turn" + | "agent_turn_with_args"; + export interface SlashCommand { command: string; title: string; description: string; icon: string; argHint?: string; + lifecycle: SlashCommandLifecycle; + acceptsArgs: boolean; } export type ConnectionStatus = diff --git a/webui/src/tests/api.test.ts b/webui/src/tests/api.test.ts index 26665a15..6e8b8b7e 100644 --- a/webui/src/tests/api.test.ts +++ b/webui/src/tests/api.test.ts @@ -674,12 +674,16 @@ describe("webui API helpers", () => { title: "Stop current task", description: "Cancel the active task.", icon: "square", + lifecycle: "stop_active_turn", + accepts_args: false, }, { command: "/restart", title: "Restart nanobot", description: "Restart the bot process.", icon: "rotate-cw", + lifecycle: "side_channel", + accepts_args: false, }, { command: "/history", @@ -687,18 +691,46 @@ describe("webui API helpers", () => { description: "Print the last N messages.", icon: "history", arg_hint: "[n]", + lifecycle: "side_channel", + accepts_args: true, + }, + { + command: "/legacy", + title: "Legacy row", + description: "Old metadata should not be guessed.", + icon: "circle-help", }, ], }), } as Response); await expect(listSlashCommands("tok")).resolves.toEqual([ + { + command: "/stop", + title: "Stop current task", + description: "Cancel the active task.", + icon: "square", + argHint: "", + lifecycle: "stop_active_turn", + acceptsArgs: false, + }, + { + command: "/restart", + title: "Restart nanobot", + description: "Restart the bot process.", + icon: "rotate-cw", + argHint: "", + lifecycle: "side_channel", + acceptsArgs: false, + }, { command: "/history", title: "Show conversation history", description: "Print the last N messages.", icon: "history", argHint: "[n]", + lifecycle: "side_channel", + acceptsArgs: true, }, ]); expect(fetch).toHaveBeenCalledWith( diff --git a/webui/src/tests/thread-composer.test.tsx b/webui/src/tests/thread-composer.test.tsx index c86b3f59..4c0d376a 100644 --- a/webui/src/tests/thread-composer.test.tsx +++ b/webui/src/tests/thread-composer.test.tsx @@ -19,6 +19,8 @@ const COMMANDS: SlashCommand[] = [ title: "Stop current task", description: "Cancel the active agent turn.", icon: "square", + lifecycle: "stop_active_turn", + acceptsArgs: false, }, { command: "/history", @@ -26,6 +28,8 @@ const COMMANDS: SlashCommand[] = [ description: "Print the last N persisted messages.", icon: "history", argHint: "[n]", + lifecycle: "side_channel", + acceptsArgs: true, }, ]; @@ -863,6 +867,8 @@ describe("ThreadComposer", () => { description: "Show or switch the active model preset.", icon: "brain", argHint: "[preset]", + lifecycle: "side_channel", + acceptsArgs: true, }, COMMANDS[1], ]} @@ -886,7 +892,7 @@ describe("ThreadComposer", () => { onStop={onStop} isStreaming placeholder="Type your message..." - slashCommands={[COMMANDS[1]]} + slashCommands={COMMANDS} />, ); @@ -940,6 +946,8 @@ describe("ThreadComposer", () => { title: `Command ${index}`, description: `Description ${index}`, icon: "activity", + lifecycle: "side_channel", + acceptsArgs: false, }))} />, ); @@ -1329,7 +1337,7 @@ describe("ThreadComposer", () => { expect(onSend).toHaveBeenCalledWith("/history", undefined, { sideChannel: true }); }); - it("marks builtin slash commands as side-channel sends before command metadata loads", () => { + it("does not infer side-channel behavior before command metadata loads", () => { const onSend = vi.fn(); render( { fireEvent.change(input, { target: { value: "/status" } }); fireEvent.click(screen.getByRole("button", { name: "Send message" })); - expect(onSend).toHaveBeenCalledWith("/status", undefined, { sideChannel: true }); + expect(onSend).toHaveBeenCalledWith("/status", undefined, undefined); }); it("marks new chat commands as side-channel sends that finalize the active turn", () => { @@ -1351,6 +1359,16 @@ describe("ThreadComposer", () => { , ); @@ -1365,6 +1383,32 @@ describe("ThreadComposer", () => { ); }); + it("does not classify exact-only slash commands with arguments", () => { + const onSend = vi.fn(); + render( + , + ); + + const input = screen.getByLabelText("Message input"); + fireEvent.change(input, { target: { value: "/new with a title" } }); + fireEvent.click(screen.getByRole("button", { name: "Send message" })); + + expect(onSend).toHaveBeenCalledWith("/new with a title", undefined, undefined); + }); + it("routes a manually submitted stop command through the stop handler", () => { const onSend = vi.fn(); const onStop = vi.fn(); @@ -1374,6 +1418,7 @@ describe("ThreadComposer", () => { onStop={onStop} isStreaming placeholder="Type your message..." + slashCommands={COMMANDS} />, ); @@ -1399,6 +1444,8 @@ describe("ThreadComposer", () => { description: "Tell the agent to treat the request as a long-running goal.", icon: "activity", argHint: "", + lifecycle: "agent_turn_with_args", + acceptsArgs: true, }, ]} />, @@ -1415,6 +1462,33 @@ describe("ThreadComposer", () => { ); }); + it("keeps goal usage commands on the side-channel path", () => { + const onSend = vi.fn(); + render( + ", + lifecycle: "agent_turn_with_args", + acceptsArgs: true, + }, + ]} + />, + ); + + const input = screen.getByLabelText("Message input"); + fireEvent.change(input, { target: { value: "/goal" } }); + fireEvent.click(screen.getByRole("button", { name: "Send message" })); + + expect(onSend).toHaveBeenCalledWith("/goal", undefined, { sideChannel: true }); + }); + it("shows a stop button while streaming", () => { const onStop = vi.fn(); render( diff --git a/webui/src/tests/thread-shell.test.tsx b/webui/src/tests/thread-shell.test.tsx index b30268a4..c7490be0 100644 --- a/webui/src/tests/thread-shell.test.tsx +++ b/webui/src/tests/thread-shell.test.tsx @@ -1252,6 +1252,8 @@ describe("ThreadShell", () => { description: "Print the last N persisted messages.", icon: "history", arg_hint: "[n]", + lifecycle: "side_channel", + accepts_args: true, }, ], });