mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
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:
parent
1e32942eab
commit
8b819bff29
@ -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()
|
||||
})
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
|
||||
@ -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' })
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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' },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@ -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 ''
|
||||
|
||||
54
src/utils/messages/systemInit.test.ts
Normal file
54
src/utils/messages/systemInit.test.ts
Normal 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.',
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user