Expose slash command usage metadata

Goal commands already had local CLI metadata, but desktop and SDK init consumers only received a name list. Keep the legacy slash_commands string array intact and add explicit metadata so clients can render descriptions and usage hints without breaking older readers.

Constraint: Existing SDK/system init consumers may depend on slash_commands staying string-only.

Rejected: Replace slash_commands with objects | would risk breaking older SDK and desktop consumers.

Confidence: high

Scope-risk: moderate

Directive: Keep slash_commands as the compatibility name list unless all SDK consumers have migrated.

Tested: bun test src/server/__tests__/websocket-handler.test.ts src/utils/messages/systemInit.test.ts src/commands/headless.test.ts src/goals/goalState.test.ts src/goals/goalEvaluator.test.ts

Tested: cd desktop && bun test src/components/chat/composerUtils.test.ts

Tested: bun run check:server

Tested: bun run check:desktop
This commit is contained in:
程序员阿江(Relakkes) 2026-05-13 21:36:01 +08:00
parent 1e32942eab
commit 8b819bff29
12 changed files with 208 additions and 22 deletions

View File

@ -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()
})

View File

@ -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)]'
}`}
>
<span className="shrink-0 text-sm font-semibold text-[var(--color-text-primary)]">
/{command.name}
<span className="flex min-w-0 max-w-[48%] shrink-0 items-baseline gap-1.5">
<span className="truncate text-sm font-semibold text-[var(--color-text-primary)]">
/{command.name}
</span>
{command.argumentHint ? (
<span className="truncate font-mono text-[11px] text-[var(--color-text-tertiary)]">
{command.argumentHint}
</span>
) : null}
</span>
<span className="min-w-0 flex-1 truncate text-xs text-[var(--color-text-tertiary)]">
{command.description}

View File

@ -987,7 +987,12 @@ function HelpPanel({
const renderCommand = (command: SlashCommandOption) => (
<div key={command.name} className="flex min-w-0 items-start gap-3 border-t border-[var(--color-border)] px-4 py-3 first:border-t-0">
<div className="shrink-0 font-mono text-sm font-semibold text-[var(--color-text-primary)]">/{command.name}</div>
<div className="flex min-w-[120px] max-w-[45%] shrink-0 flex-wrap items-baseline gap-x-1.5 font-mono">
<span className="text-sm font-semibold text-[var(--color-text-primary)]">/{command.name}</span>
{command.argumentHint ? (
<span className="text-[11px] leading-5 text-[var(--color-text-tertiary)]">{command.argumentHint}</span>
) : null}
</div>
<div className="min-w-0 flex-1 text-xs leading-5 text-[var(--color-text-tertiary)]">{command.description}</div>
</div>
)

View File

@ -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: '<objective>|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' })

View File

@ -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: '<objective>|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

View File

@ -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<string, AgentTaskNotification>
elapsedTimer: ReturnType<typeof setInterval> | null
composerPrefill?: {
@ -879,7 +879,7 @@ export const useChatStore = create<ChatStore>((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]

View File

@ -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 <budget> <objective>|<objective>]',
description: 'Create or manage an autonomous completion goal',
argumentHint: '<objective>|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

View File

@ -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(

View File

@ -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: '<objective>|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: '<objective>|status|pause|resume|clear|complete',
whenToUse: 'Use when the session should keep iterating until done.',
},
{ name: 'clear', description: '' },
{ name: 'compact', description: 'Compact context' },
])
})
})

View File

@ -32,7 +32,14 @@ const providerService = new ProviderService()
/**
* Cache slash commands from CLI init messages, keyed by sessionId.
*/
const sessionSlashCommands = new Map<string, Array<{ name: string; description: string }>>()
type SlashCommandMetadata = {
name: string
description: string
argumentHint?: string
whenToUse?: string
}
const sessionSlashCommands = new Map<string, SlashCommandMetadata[]>()
/**
* 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<string, unknown>
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 ''

View File

@ -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: '<objective>|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: '<objective>|status|pause|resume|clear',
whenToUse: 'Use when the session should keep iterating until a concrete outcome is complete.',
},
])
})
})

View File

@ -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,