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