diff --git a/desktop/src/__tests__/pages.test.tsx b/desktop/src/__tests__/pages.test.tsx index 0a6a659f..bddd4857 100644 --- a/desktop/src/__tests__/pages.test.tsx +++ b/desktop/src/__tests__/pages.test.tsx @@ -609,7 +609,7 @@ describe('Content-only pages render without errors', () => { expect(screen.getByText('Slash commands')).toBeInTheDocument() expect(screen.getByText('/clear')).toBeInTheDocument() expect(screen.getByText('/cost')).toBeInTheDocument() - expect(screen.getByText('13 more commands available. Type / to search the full command list.')).toBeInTheDocument() + expect(screen.getByText('14 more commands available. Type / to search the full command list.')).toBeInTheDocument() resetPageStores() }) diff --git a/desktop/src/components/chat/ChatInput.tsx b/desktop/src/components/chat/ChatInput.tsx index 67d9bcac..1e74add3 100644 --- a/desktop/src/components/chat/ChatInput.tsx +++ b/desktop/src/components/chat/ChatInput.tsx @@ -355,7 +355,8 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro const lower = slashFilter.toLowerCase() return source.filter((command) => ( command.name.toLowerCase().includes(lower) || - command.description.toLowerCase().includes(lower) + command.description.toLowerCase().includes(lower) || + command.argumentHint?.toLowerCase().includes(lower) )) }, [allSlashCommands, slashFilter]) @@ -888,8 +889,15 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro : 'hover:bg-[var(--color-surface-hover)]' }`} > - - /{command.name} + + + /{command.name} + + {command.argumentHint ? ( + + {command.argumentHint} + + ) : null} {command.description} diff --git a/desktop/src/components/chat/LocalSlashCommandPanel.tsx b/desktop/src/components/chat/LocalSlashCommandPanel.tsx index ef0aa179..ce4916dd 100644 --- a/desktop/src/components/chat/LocalSlashCommandPanel.tsx +++ b/desktop/src/components/chat/LocalSlashCommandPanel.tsx @@ -987,7 +987,12 @@ function HelpPanel({ const renderCommand = (command: SlashCommandOption) => (
-
/{command.name}
+
+ /{command.name} + {command.argumentHint ? ( + {command.argumentHint} + ) : null} +
{command.description}
) diff --git a/desktop/src/components/chat/composerUtils.test.ts b/desktop/src/components/chat/composerUtils.test.ts index 2900a66a..47513900 100644 --- a/desktop/src/components/chat/composerUtils.test.ts +++ b/desktop/src/components/chat/composerUtils.test.ts @@ -55,6 +55,26 @@ describe('composerUtils', () => { ) }) + it('keeps slash command argument hints and fills missing fallback hints', () => { + expect( + mergeSlashCommands([ + { + name: 'goal', + description: '', + argumentHint: '', + }, + ]), + ).toEqual( + expect.arrayContaining([ + { + name: 'goal', + description: 'Create or manage an autonomous completion goal', + argumentHint: '|status|pause|resume|clear|complete', + }, + ]), + ) + }) + it('resolves hidden settings aliases without displaying duplicate fallback rows', () => { expect(resolveSlashUiAction('plugins')).toEqual({ type: 'settings', tab: 'plugins' }) expect(resolveSlashUiAction('doctor')).toEqual({ type: 'settings', tab: 'diagnostics' }) diff --git a/desktop/src/components/chat/composerUtils.ts b/desktop/src/components/chat/composerUtils.ts index 7a896690..02eace9f 100644 --- a/desktop/src/components/chat/composerUtils.ts +++ b/desktop/src/components/chat/composerUtils.ts @@ -23,6 +23,11 @@ export const FALLBACK_SLASH_COMMANDS = [ ...SETTINGS_SLASH_COMMANDS.map(({ name, description }) => ({ name, description })), { name: 'compact', description: 'Compact conversation context' }, { name: 'clear', description: 'Clear conversation history' }, + { + name: 'goal', + description: 'Create or manage an autonomous completion goal', + argumentHint: '|status|pause|resume|clear|complete', + }, { name: 'review', description: 'Review code changes' }, { name: 'commit', description: 'Create a git commit' }, { name: 'pr', description: 'Create a pull request' }, @@ -41,6 +46,7 @@ export const FALLBACK_SLASH_COMMANDS = [ export type SlashCommandOption = { name: string description: string + argumentHint?: string } export type SlashUiAction = @@ -79,6 +85,7 @@ export function mergeSlashCommands( merged.set(command.name, { name: command.name, description: command.description?.trim() || '', + ...(command.argumentHint?.trim() && { argumentHint: command.argumentHint.trim() }), }) } @@ -86,10 +93,11 @@ export function mergeSlashCommands( if (!command?.name) continue const existing = merged.get(command.name) if (existing) { - if (!existing.description && command.description) { + if ((!existing.description && command.description) || (!existing.argumentHint && command.argumentHint)) { merged.set(command.name, { ...existing, - description: command.description, + description: existing.description || command.description, + argumentHint: existing.argumentHint || command.argumentHint, }) } continue diff --git a/desktop/src/stores/chatStore.ts b/desktop/src/stores/chatStore.ts index 9292e662..3e8be2b7 100644 --- a/desktop/src/stores/chatStore.ts +++ b/desktop/src/stores/chatStore.ts @@ -50,7 +50,7 @@ export type PerSessionState = { tokenUsage: TokenUsage elapsedSeconds: number statusVerb: string - slashCommands: Array<{ name: string; description: string }> + slashCommands: Array<{ name: string; description: string; argumentHint?: string }> agentTaskNotifications: Record elapsedTimer: ReturnType | null composerPrefill?: { @@ -879,7 +879,7 @@ export const useChatStore = create((set, get) => ({ break case 'system_notification': if (msg.subtype === 'slash_commands' && Array.isArray(msg.data)) { - update(() => ({ slashCommands: msg.data as Array<{ name: string; description: string }> })) + update(() => ({ slashCommands: msg.data as Array<{ name: string; description: string; argumentHint?: string }> })) } if (msg.subtype === 'session_cleared') { const session = get().sessions[sessionId] diff --git a/src/commands/goal/index.ts b/src/commands/goal/index.ts index 534df503..0e1039d5 100644 --- a/src/commands/goal/index.ts +++ b/src/commands/goal/index.ts @@ -4,8 +4,9 @@ const goal = { type: 'local-jsx', supportsNonInteractive: true, name: 'goal', - description: 'Set or manage a completion goal that keeps the session working until it is met', - argumentHint: '[status|clear|pause|resume|--tokens |]', + description: 'Create or manage an autonomous completion goal', + argumentHint: '|status|pause|resume|clear|complete', + whenToUse: 'Use when you want the session to keep iterating on a concrete objective until it is complete.', load: () => import('./goal.js'), } satisfies Command diff --git a/src/entrypoints/sdk/coreSchemas.ts b/src/entrypoints/sdk/coreSchemas.ts index 2d4dadda..fd6f9c28 100644 --- a/src/entrypoints/sdk/coreSchemas.ts +++ b/src/entrypoints/sdk/coreSchemas.ts @@ -1473,6 +1473,16 @@ export const SDKSystemMessageSchema = lazySchema(() => model: z.string(), permissionMode: PermissionModeSchema(), slash_commands: z.array(z.string()), + slash_commands_metadata: z + .array( + z.object({ + name: z.string(), + description: z.string(), + argumentHint: z.string().optional(), + whenToUse: z.string().optional(), + }), + ) + .optional(), output_style: z.string(), skills: z.array(z.string()), plugins: z.array( diff --git a/src/server/__tests__/websocket-handler.test.ts b/src/server/__tests__/websocket-handler.test.ts index d12f4074..3a75f4c4 100644 --- a/src/server/__tests__/websocket-handler.test.ts +++ b/src/server/__tests__/websocket-handler.test.ts @@ -5,6 +5,7 @@ import { closeSessionConnection, getActiveSessionIds, handleWebSocket, + normalizeSlashCommandMetadata, type WebSocketData, } from '../ws/handler.js' import { conversationService } from '../services/conversationService.js' @@ -69,4 +70,27 @@ describe('WebSocket handler session isolation', () => { expect(clearCallbacks).toHaveBeenCalledWith(sessionId) expect(cancelComputerUse).toHaveBeenCalledWith(sessionId) }) + + it('normalizes slash command metadata while preserving legacy string payloads', () => { + expect(normalizeSlashCommandMetadata([ + { + name: 'goal', + description: 'Create or manage an autonomous completion goal', + argumentHint: '|status|pause|resume|clear|complete', + whenToUse: 'Use when the session should keep iterating until done.', + }, + 'clear', + { command: 'compact', description: 'Compact context' }, + { name: ' ' }, + ])).toEqual([ + { + name: 'goal', + description: 'Create or manage an autonomous completion goal', + argumentHint: '|status|pause|resume|clear|complete', + whenToUse: 'Use when the session should keep iterating until done.', + }, + { name: 'clear', description: '' }, + { name: 'compact', description: 'Compact context' }, + ]) + }) }) diff --git a/src/server/ws/handler.ts b/src/server/ws/handler.ts index 0afa6c9a..dfb22ed7 100644 --- a/src/server/ws/handler.ts +++ b/src/server/ws/handler.ts @@ -32,7 +32,14 @@ const providerService = new ProviderService() /** * Cache slash commands from CLI init messages, keyed by sessionId. */ -const sessionSlashCommands = new Map>() +type SlashCommandMetadata = { + name: string + description: string + argumentHint?: string + whenToUse?: string +} + +const sessionSlashCommands = new Map() /** * Timers for delayed session cleanup after client disconnect. @@ -86,7 +93,7 @@ async function sendRepositoryStartupStatus( } } -export function getSlashCommands(sessionId: string): Array<{ name: string; description: string }> { +export function getSlashCommands(sessionId: string): SlashCommandMetadata[] { return sessionSlashCommands.get(sessionId) || [] } @@ -784,14 +791,41 @@ function cacheSessionInitMetadata(sessionId: string, cliMsg: any) { await sessionService.deletePlaceholderSessionFiles(sessionId, cliMsg.cwd) })() } - if (cliMsg.slash_commands && Array.isArray(cliMsg.slash_commands)) { - sessionSlashCommands.set(sessionId, cliMsg.slash_commands.map((cmd: any) => ({ - name: typeof cmd === 'string' ? cmd : (cmd.name || cmd.command || ''), - description: typeof cmd === 'string' ? '' : (cmd.description || ''), - }))) + const slashCommandMetadata = normalizeSlashCommandMetadata(cliMsg.slash_commands_metadata) + const legacySlashCommands = normalizeSlashCommandMetadata(cliMsg.slash_commands) + const commands = slashCommandMetadata.length > 0 ? slashCommandMetadata : legacySlashCommands + if (commands.length > 0) { + sessionSlashCommands.set(sessionId, commands) } } +export function normalizeSlashCommandMetadata(value: unknown): SlashCommandMetadata[] { + if (!Array.isArray(value)) return [] + return value.flatMap((cmd): SlashCommandMetadata[] => { + if (typeof cmd === 'string') { + return cmd.trim() ? [{ name: cmd, description: '' }] : [] + } + if (!cmd || typeof cmd !== 'object') return [] + const record = cmd as Record + const rawName = typeof record.name === 'string' + ? record.name + : typeof record.command === 'string' + ? record.command + : '' + const name = rawName.trim() + if (!name) return [] + const description = typeof record.description === 'string' ? record.description.trim() : '' + const argumentHint = typeof record.argumentHint === 'string' ? record.argumentHint.trim() : '' + const whenToUse = typeof record.whenToUse === 'string' ? record.whenToUse.trim() : '' + return [{ + name, + description, + ...(argumentHint && { argumentHint }), + ...(whenToUse && { whenToUse }), + }] + }) +} + function extractAssistantText(cliMsg: any): string { const content = cliMsg?.message?.content if (!Array.isArray(content)) return '' diff --git a/src/utils/messages/systemInit.test.ts b/src/utils/messages/systemInit.test.ts new file mode 100644 index 00000000..ce1da748 --- /dev/null +++ b/src/utils/messages/systemInit.test.ts @@ -0,0 +1,54 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { buildSystemInitMessage } from './systemInit' + +describe('buildSystemInitMessage', () => { + const previousAnthropicApiKey = process.env.ANTHROPIC_API_KEY + + beforeEach(() => { + process.env.ANTHROPIC_API_KEY = 'test-key' + ;(globalThis as typeof globalThis & { MACRO?: { VERSION: string } }).MACRO = { VERSION: 'test-version' } + }) + + afterEach(() => { + if (previousAnthropicApiKey === undefined) { + delete process.env.ANTHROPIC_API_KEY + } else { + process.env.ANTHROPIC_API_KEY = previousAnthropicApiKey + } + }) + + const baseInputs = { + tools: [], + mcpClients: [], + model: 'test-model', + permissionMode: 'default' as const, + agents: [], + skills: [], + plugins: [], + fastMode: false, + } + + it('keeps slash command names compatible while exposing command descriptions and usage hints', () => { + const message = buildSystemInitMessage({ + ...baseInputs, + commands: [ + { + name: 'goal', + description: 'Create or manage an autonomous completion goal', + argumentHint: '|status|pause|resume|clear', + whenToUse: 'Use when the session should keep iterating until a concrete outcome is complete.', + }, + ], + }) + + expect(message.slash_commands).toEqual(['goal']) + expect(message.slash_commands_metadata).toEqual([ + { + name: 'goal', + description: 'Create or manage an autonomous completion goal', + argumentHint: '|status|pause|resume|clear', + whenToUse: 'Use when the session should keep iterating until a concrete outcome is complete.', + }, + ]) + }) +}) diff --git a/src/utils/messages/systemInit.ts b/src/utils/messages/systemInit.ts index 0530b870..8ad1d698 100644 --- a/src/utils/messages/systemInit.ts +++ b/src/utils/messages/systemInit.ts @@ -24,7 +24,20 @@ export function sdkCompatToolName(name: string): string { return name === AGENT_TOOL_NAME ? LEGACY_AGENT_TOOL_NAME : name } -type CommandLike = { name: string; userInvocable?: boolean } +type CommandLike = { + name: string + description?: string + argumentHint?: string + whenToUse?: string + userInvocable?: boolean +} + +type SlashCommandMetadata = { + name: string + description: string + argumentHint?: string + whenToUse?: string +} export type SystemInitInputs = { tools: ReadonlyArray<{ name: string }> @@ -53,6 +66,7 @@ export type SystemInitInputs = { export function buildSystemInitMessage(inputs: SystemInitInputs): SDKMessage { const settings = getSettings_DEPRECATED() const outputStyle = settings?.outputStyle ?? DEFAULT_OUTPUT_STYLE_NAME + const userInvocableCommands = inputs.commands.filter(c => c.userInvocable !== false) const initMessage: SDKMessage = { type: 'system', @@ -66,9 +80,17 @@ export function buildSystemInitMessage(inputs: SystemInitInputs): SDKMessage { })), model: inputs.model, permissionMode: inputs.permissionMode, - slash_commands: inputs.commands - .filter(c => c.userInvocable !== false) - .map(c => c.name), + slash_commands: userInvocableCommands.map(c => c.name), + slash_commands_metadata: userInvocableCommands.map(command => ({ + name: command.name, + description: command.description?.trim() ?? '', + ...(command.argumentHint?.trim() && { + argumentHint: command.argumentHint.trim(), + }), + ...(command.whenToUse?.trim() && { + whenToUse: command.whenToUse.trim(), + }), + } satisfies SlashCommandMetadata)), apiKeySource: getAnthropicApiKeyWithSource().source as ApiKeySource, betas: getSdkBetas(), claude_code_version: MACRO.VERSION,