From ea7c2bf443b7b7ad47bbb563ec561d1146cf979c Mon Sep 17 00:00:00 2001 From: Merge Sim Date: Thu, 10 Sep 2026 14:00:22 -0700 Subject: [PATCH] feat(native-chat): describe slash commands from the provider's own report MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Claude session reports a description and argument hint for every command it can run, but the catalog kept only the name, so the `/` picker described the handful of commands our curated map covers and left the rest — `/goal` included — with a blank row. Carry `description`/`argumentHint` through the catalog and the session wire (both optional, so mixed-version hosts are unaffected), and let a reported description win over the curated one, which stays as the fallback for the name-only report shape. The curated maps are untouched, so structured dispatch still claims exactly the commands it claimed before. --- .../claude-slash-command-catalog.test.ts | 106 +++++++++++++++++- .../claude/claude-slash-command-catalog.ts | 81 ++++++++++--- ...claude-structured-session-commands.test.ts | 13 ++- src/shared/agent-session-wire.ts | 4 + src/shared/native-chat-slash-commands.test.ts | 27 +++++ src/shared/native-chat-slash-commands.ts | 9 +- 6 files changed, 218 insertions(+), 22 deletions(-) diff --git a/src/main/claude/claude-slash-command-catalog.test.ts b/src/main/claude/claude-slash-command-catalog.test.ts index 20d79f9f53d..50b103d7f6d 100644 --- a/src/main/claude/claude-slash-command-catalog.test.ts +++ b/src/main/claude/claude-slash-command-catalog.test.ts @@ -86,8 +86,8 @@ it('accepts descriptor reloads, removing old skills while retaining terminal fil } expect(catalog.observe(reload)).toBe(true) expect(catalog.commands).toEqual([ - { name: 'clear', kind: 'command' }, - { name: 'new-skill', kind: 'skill' } + { name: 'clear', kind: 'command', description: 'Clear' }, + { name: 'new-skill', kind: 'skill', description: 'New' } ]) expect(catalog.observe(reload)).toBe(false) expect(catalog.observe({ ...reload, commands: [] })).toBe(true) @@ -130,3 +130,105 @@ it('publishes classification becoming authoritative even when the name and kind expect(catalog.observe(init({ slash_commands: ['clear'], skills: [] }))).toBe(true) expect(catalog.commands).toEqual([{ name: 'clear', kind: 'command' }]) }) + +it('keeps the description and argument hint a descriptor report authored', () => { + const catalog = new ClaudeSlashCommandCatalog(undefined, { + commands: [ + { name: 'goal', description: 'Set or view the goal', argumentHint: '' }, + { name: 'quiet', description: '', argumentHint: '' } + ] + }) + expect(catalog.commands).toEqual([ + { + name: 'goal', + kind: 'command', + kindUnspecified: true, + description: 'Set or view the goal', + argumentHint: '' + }, + { name: 'quiet', kind: 'command', kindUnspecified: true } + ]) +}) + +it('bounds the row text a provider can put in the picker', () => { + const catalog = new ClaudeSlashCommandCatalog(undefined, { + commands: [ + { name: 'long', description: 'x'.repeat(201), argumentHint: 'y'.repeat(101) }, + { name: 'wrong-type', description: 42, argumentHint: { text: 'no' } }, + { name: 'blank', description: ' ' }, + { name: 'wrapped', description: 'first line\n second line' } + ] + }) + expect(catalog.commands).toEqual([ + { name: 'long', kind: 'command', kindUnspecified: true }, + { name: 'wrong-type', kind: 'command', kindUnspecified: true }, + { name: 'blank', kind: 'command', kindUnspecified: true }, + { + name: 'wrapped', + kind: 'command', + kindUnspecified: true, + description: 'first line second line' + } + ]) +}) + +it('carries descriptor text across the name-only stream init that classifies it', () => { + const catalog = new ClaudeSlashCommandCatalog(undefined, { + commands: [ + { name: 'clear', description: 'Clear conversation' }, + { name: 'ref-oss', description: 'A skill' } + ] + }) + expect(catalog.observe(init({ slash_commands: ['clear', 'ref-oss'], skills: ['ref-oss'] }))).toBe( + true + ) + expect(catalog.commands).toEqual([ + { name: 'clear', kind: 'command', description: 'Clear conversation' }, + { name: 'ref-oss', kind: 'skill', description: 'A skill' } + ]) +}) + +it('reports a description-only change and lets a later report drop the text', () => { + const catalog = new ClaudeSlashCommandCatalog(init({ slash_commands: ['clear'], skills: [] })) + expect(catalog.commands).toEqual([{ name: 'clear', kind: 'command' }]) + const changed = { + type: 'system', + subtype: 'commands_changed', + commands: [{ name: 'clear', description: 'Clear conversation history' }] + } + expect(catalog.observe(changed)).toBe(true) + expect(catalog.commands).toEqual([ + { name: 'clear', kind: 'command', description: 'Clear conversation history' } + ]) + expect(catalog.observe(changed)).toBe(false) + expect(catalog.observe({ ...changed, commands: [{ name: 'clear' }] })).toBe(true) + expect(catalog.commands).toEqual([{ name: 'clear', kind: 'command' }]) +}) + +it('describes nothing when the session reported names only', () => { + expect(readClaudeSlashCommands(init())).toEqual([ + { name: 'clear', kind: 'command' }, + { name: 'ref-oss', kind: 'skill' }, + { name: 'opsx:apply', kind: 'command' } + ]) + expect(new ClaudeSlashCommandCatalog(init()).commands).toEqual([ + { name: 'clear', kind: 'command' }, + { name: 'ref-oss', kind: 'skill' }, + { name: 'opsx:apply', kind: 'command' } + ]) +}) + +it('still hides terminal-only names however well the provider describes them', () => { + const catalog = new ClaudeSlashCommandCatalog(init()) + expect( + catalog.observe({ + type: 'system', + subtype: 'commands_changed', + commands: [ + { name: 'doctor', description: 'Diagnose the CLI install' }, + { name: 'ref-oss', description: 'A skill' } + ] + }) + ).toBe(true) + expect(catalog.commands).toEqual([{ name: 'ref-oss', kind: 'skill', description: 'A skill' }]) +}) diff --git a/src/main/claude/claude-slash-command-catalog.ts b/src/main/claude/claude-slash-command-catalog.ts index b1f65d93d50..7c8591293ce 100644 --- a/src/main/claude/claude-slash-command-catalog.ts +++ b/src/main/claude/claude-slash-command-catalog.ts @@ -3,6 +3,11 @@ import type { AgentSessionSlashCommand } from '../../shared/agent-session-wire' // Stream init carries name arrays; control initialization and reloads carry descriptors. const MAX_COMMANDS = 512 const MAX_NAME_LENGTH = 200 +const MAX_DESCRIPTION_LENGTH = 200 +const MAX_ARGUMENT_HINT_LENGTH = 100 + +/** The provider's own row text for one command, absent when it reported none. */ +type CommandDetail = Pick function names(value: unknown): string[] { if (!Array.isArray(value)) { @@ -29,6 +34,40 @@ function descriptorNames(value: unknown): string[] { ) } +/** A single picker row's worth of provider text: unusable values are dropped, not truncated. */ +function rowText(value: unknown, maxLength: number): string | undefined { + if (typeof value !== 'string') { + return undefined + } + const collapsed = value.replace(/\s+/gu, ' ').trim() + return collapsed.length > 0 && collapsed.length <= maxLength ? collapsed : undefined +} + +function descriptorDetails(value: unknown): Map { + const details = new Map() + if (!Array.isArray(value)) { + return details + } + for (const entry of value) { + if (details.size >= MAX_COMMANDS) { + break + } + if (entry === null || typeof entry !== 'object' || typeof entry.name !== 'string') { + continue + } + const description = rowText(entry.description, MAX_DESCRIPTION_LENGTH) + const argumentHint = rowText(entry.argumentHint, MAX_ARGUMENT_HINT_LENGTH) + if (description === undefined && argumentHint === undefined) { + continue + } + details.set(entry.name.trim(), { + ...(description === undefined ? {} : { description }), + ...(argumentHint === undefined ? {} : { argumentHint }) + }) + } + return details +} + function carriesCommandCatalog(message: Record): boolean { return ( message.type === 'system' && @@ -56,6 +95,7 @@ export class ClaudeSlashCommandCatalog { private hasSkillClassification = false private hidden = new Set() private commandNames = new Set() + private details = new Map() constructor(initMessage?: Record, initialization?: unknown) { // SessionStart can prove acquisition before the first stream init exists. @@ -65,11 +105,14 @@ export class ClaudeSlashCommandCatalog { 'commands' in initialization && Array.isArray(initialization.commands) ) { - this.entries = descriptorNames(initialization.commands).map((name) => ({ - name, - kind: 'command', - kindUnspecified: true - })) + this.details = descriptorDetails(initialization.commands) + this.entries = this.describe( + descriptorNames(initialization.commands).map((name) => ({ + name, + kind: 'command', + kindUnspecified: true + })) + ) } if (initMessage) { this.observe(initMessage) @@ -80,13 +123,18 @@ export class ClaudeSlashCommandCatalog { return this.entries } + /** Provider row text, carried across the name-only frames that never restate it. */ + private describe(entries: AgentSessionSlashCommand[]): AgentSessionSlashCommand[] { + return entries.map((entry) => ({ ...entry, ...this.details.get(entry.name) })) + } + /** True when this frame replaced the catalog with a different one. */ observe(message: Record): boolean { let next: AgentSessionSlashCommand[] if (carriesCommandCatalog(message)) { this.hasSkillClassification = true this.hidden = new Set(names(message.terminal_slash_commands)) - next = readClaudeSlashCommands(message) + next = this.describe(readClaudeSlashCommands(message)) this.commandNames = new Set( next.filter((entry) => entry.kind === 'command').map((entry) => entry.name) ) @@ -95,13 +143,16 @@ export class ClaudeSlashCommandCatalog { message.subtype === 'commands_changed' && Array.isArray(message.commands) ) { - next = descriptorNames(message.commands) - .filter((name) => !this.hidden.has(name)) - .map((name) => - this.hasSkillClassification - ? { name, kind: this.commandNames.has(name) ? 'command' : 'skill' } - : { name, kind: 'command', kindUnspecified: true } - ) + this.details = descriptorDetails(message.commands) + next = this.describe( + descriptorNames(message.commands) + .filter((name) => !this.hidden.has(name)) + .map((name) => + this.hasSkillClassification + ? { name, kind: this.commandNames.has(name) ? 'command' : 'skill' } + : { name, kind: 'command', kindUnspecified: true } + ) + ) } else { return false } @@ -112,7 +163,9 @@ export class ClaudeSlashCommandCatalog { (entry, index) => entry.name === this.entries?.[index]?.name && entry.kind === this.entries?.[index]?.kind && - entry.kindUnspecified === this.entries?.[index]?.kindUnspecified + entry.kindUnspecified === this.entries?.[index]?.kindUnspecified && + entry.description === this.entries?.[index]?.description && + entry.argumentHint === this.entries?.[index]?.argumentHint ) ) { return false diff --git a/src/main/claude/claude-structured-session-commands.test.ts b/src/main/claude/claude-structured-session-commands.test.ts index 1b2fb6bde31..29f2bcf4aa7 100644 --- a/src/main/claude/claude-structured-session-commands.test.ts +++ b/src/main/claude/claude-structured-session-commands.test.ts @@ -55,8 +55,14 @@ it.each([ const adapter = adapterFor(claude) try { await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) + const described = commands[0]?.description expect(adapter.readCommands('session-1')).toEqual( - commands.map(({ name }) => ({ name, kind: 'command', kindUnspecified: true })) + commands.map(({ name, description }) => ({ + name, + kind: 'command', + kindUnspecified: true, + ...(description ? { description } : {}) + })) ) expect(claude.connections[0].sent).toEqual([]) expect(claude.connections[0].calls.map(({ subtype }) => subtype)).toEqual([ @@ -70,7 +76,10 @@ it.each([ slash_commands: ['project:check'], skills: ['project:check'] }) - expect(adapter.readCommands('session-1')).toEqual([{ name: 'project:check', kind: 'skill' }]) + // The stream init classifies the name; the control seed's text survives it. + expect(adapter.readCommands('session-1')).toEqual([ + { name: 'project:check', kind: 'skill', ...(described ? { description: described } : {}) } + ]) } finally { await adapter.closeSession('session-1') } diff --git a/src/shared/agent-session-wire.ts b/src/shared/agent-session-wire.ts index 9b7b4f7e76f..1d8da50d9ee 100644 --- a/src/shared/agent-session-wire.ts +++ b/src/shared/agent-session-wire.ts @@ -343,6 +343,10 @@ export type AgentSessionSlashCommand = { kind: 'command' | 'skill' /** Membership is authoritative, but this provider report did not classify the name. */ kindUnspecified?: true + /** Provider-authored row text; absent when the report carried names only. */ + description?: string + /** Provider-authored argument sketch, e.g. ``. */ + argumentHint?: string } /** The provider's own command surface, read per session. Additive read-only diff --git a/src/shared/native-chat-slash-commands.test.ts b/src/shared/native-chat-slash-commands.test.ts index 32d3d2d8876..5294b27a467 100644 --- a/src/shared/native-chat-slash-commands.test.ts +++ b/src/shared/native-chat-slash-commands.test.ts @@ -89,4 +89,31 @@ describe('a session that reports its own command surface', () => { it('splits skills out for the picker to group on its own', () => { expect(sessionReportedSkillNames(reported)).toEqual(['ref-oss']) }) + + it('prefers the description the session reported over the curated one', () => { + expect( + sessionSlashCommandSuggestions('claude', [ + { name: 'clear', kind: 'command', description: 'Wipe the transcript' }, + { name: 'goal', kind: 'command', description: 'Set or view the goal' }, + { name: 'compact', kind: 'command' } + ]) + ).toEqual([ + { name: 'clear', description: 'Wipe the transcript' }, + { name: 'goal', description: 'Set or view the goal' }, + { name: 'compact', description: 'Summarize and compact the conversation' } + ]) + }) + + it('keeps a reported description on a command the curated catalog never claims', () => { + expect( + sessionSlashCommandSuggestions('codex', [ + { + name: 'opsx:apply', + kind: 'command', + description: 'Apply the plan', + kindUnspecified: true + } + ]) + ).toEqual([{ name: 'opsx:apply', description: 'Apply the plan', kindUnspecified: true }]) + }) }) diff --git a/src/shared/native-chat-slash-commands.ts b/src/shared/native-chat-slash-commands.ts index 9337e9c78f7..95dbaba5ed5 100644 --- a/src/shared/native-chat-slash-commands.ts +++ b/src/shared/native-chat-slash-commands.ts @@ -93,9 +93,10 @@ export function getAgentSlashCommands(agent: AgentType): readonly SlashCommandSu } /** The command rows for a session that reports its own `/` surface. The report - * is the authority on WHICH commands exist; the curated catalog above is kept - * only as the description source for the names both know about. Skills are - * excluded — they render in the picker's own skills group. */ + * is the authority on WHICH commands exist and, when it carries one, on how a + * command is described; the curated catalog above only covers the names whose + * report is text-free. Skills are excluded — they render in the picker's own + * skills group. */ export function sessionSlashCommandSuggestions( agent: AgentType, reported: readonly AgentSessionSlashCommand[] @@ -106,7 +107,7 @@ export function sessionSlashCommandSuggestions( return reported .filter((entry) => entry.kind === 'command') .map((entry) => { - const description = described.get(entry.name) + const description = entry.description ?? described.get(entry.name) return { name: entry.name, ...(description ? { description } : {}),