From 37beafdd6da532d41ad2a1e13c84de9f309b2afd Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 16 Oct 2026 12:56:51 -0700 Subject: [PATCH] fix(native-chat): keep Claude's terminal-only commands in the chat / menu (#29784) A Claude chat's / menu listed /color, /doctor and /reload-plugins until the first message ran, then dropped them: the CLI names its terminal-only commands only in the init frame a turn emits, and the catalog treated that list as a hide-list. Chat runs all three (/color and /reload-plugins answer locally, /doctor is an ordinary turn), so the catalog now keeps every command Claude reports and the menu no longer changes on the first message. --- .../claude-slash-command-catalog.test.ts | 10 ++++--- .../claude/claude-slash-command-catalog.ts | 27 ++++++++----------- ...claude-structured-session-commands.test.ts | 5 +++- 3 files changed, 21 insertions(+), 21 deletions(-) diff --git a/src/main/claude/claude-slash-command-catalog.test.ts b/src/main/claude/claude-slash-command-catalog.test.ts index 885d9fc90af..262e14baaad 100644 --- a/src/main/claude/claude-slash-command-catalog.test.ts +++ b/src/main/claude/claude-slash-command-catalog.test.ts @@ -14,10 +14,11 @@ function init(overrides: Record = {}): Record } describe('claude slash command catalog', () => { - it('tags reported skills and drops the commands reserved for a terminal UI', () => { + it('tags reported skills and keeps the commands Claude marks terminal-only', () => { expect(readClaudeSlashCommands(init())).toEqual([ { name: 'clear', kind: 'command' }, { name: 'ref-oss', kind: 'skill' }, + { name: 'doctor', kind: 'skill' }, { name: 'opsx:apply', kind: 'command' } ]) }) @@ -31,7 +32,7 @@ describe('claude slash command catalog', () => { }) it('seeds from the init frame that proved the session', () => { - expect(new ClaudeSlashCommandCatalog(init()).commands).toHaveLength(3) + expect(new ClaudeSlashCommandCatalog(init()).commands).toHaveLength(4) expect(new ClaudeSlashCommandCatalog().commands).toBeUndefined() // A frame of the right subtype but without the array is not a catalog. expect( @@ -92,7 +93,7 @@ describe('claude slash command catalog', () => { }) }) -it('accepts descriptor reloads, removing old skills while retaining terminal filtering', () => { +it('accepts descriptor reloads, removing old skills and keeping terminal-only commands', () => { const catalog = new ClaudeSlashCommandCatalog(init()) const reload = { type: 'system', @@ -106,7 +107,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', description: 'Clear' }, - { name: 'new-skill', kind: 'skill', description: 'New' } + { name: 'new-skill', kind: 'skill', description: 'New' }, + { name: 'doctor', kind: 'skill', description: 'Terminal' } ]) expect(catalog.observe(reload)).toBe(false) expect(catalog.observe({ ...reload, commands: [] })).toBe(true) diff --git a/src/main/claude/claude-slash-command-catalog.ts b/src/main/claude/claude-slash-command-catalog.ts index 141b2847592..bbad2d1e550 100644 --- a/src/main/claude/claude-slash-command-catalog.ts +++ b/src/main/claude/claude-slash-command-catalog.ts @@ -88,17 +88,16 @@ function carriesCommandCatalog(message: Record): boolean { ) } -/** What the session reports it can run, minus what it reserves for a terminal UI. */ +/** What the session reports it can run, terminal-only commands included: chat runs them too, and + * that list arrives only with a turn, so hiding it made the menu change mid-chat. */ export function readClaudeSlashCommands( message: Record ): AgentSessionSlashCommand[] { - // Why: the hide-list exists so a non-terminal UI like chat does not offer a - // command that only means something inside the CLI's own TUI. - const hidden = new Set(names(message.terminal_slash_commands)) const skills = new Set(names(message.skills)) - return names(message.slash_commands) - .filter((name) => !hidden.has(name)) - .map((name) => ({ name, kind: skills.has(name) ? ('skill' as const) : ('command' as const) })) + return names(message.slash_commands).map((name) => ({ + name, + kind: skills.has(name) ? ('skill' as const) : ('command' as const) + })) } /** Per-session catalog seeded during acquisition and refreshed by provider frames. */ @@ -107,7 +106,6 @@ export class ClaudeSlashCommandCatalog { private hasSkillClassification = false /** The folder the first catalog frame reported: the one the session's own `/` list was loaded for. */ private launchCwd: string | undefined - private hidden = new Set() private commandNames = new Set() private details = new Map() @@ -165,7 +163,6 @@ export class ClaudeSlashCommandCatalog { let next: AgentSessionSlashCommand[] if (carriesCommandCatalog(message)) { this.hasSkillClassification = true - this.hidden = new Set(names(message.terminal_slash_commands)) next = this.describe(this.classify(message)) this.commandNames = new Set( next.filter((entry) => entry.kind === 'command').map((entry) => entry.name) @@ -178,13 +175,11 @@ export class ClaudeSlashCommandCatalog { const catalog = descriptorCatalog(message.commands) this.details = catalog.details next = this.describe( - catalog.names - .filter((name) => !this.hidden.has(name)) - .map((name) => - this.hasSkillClassification - ? { name, kind: this.commandNames.has(name) ? 'command' : 'skill' } - : { name, kind: 'command', kindUnspecified: true } - ) + catalog.names.map((name) => + this.hasSkillClassification + ? { name, kind: this.commandNames.has(name) ? 'command' : 'skill' } + : { name, kind: 'command', kindUnspecified: true } + ) ) } else { 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 29f2bcf4aa7..dbe751882dd 100644 --- a/src/main/claude/claude-structured-session-commands.test.ts +++ b/src/main/claude/claude-structured-session-commands.test.ts @@ -34,7 +34,10 @@ describe('session command updates', () => { } claude.connections[0].handlers.onMessage?.(frame) await tick() - expect(adapter.readCommands('session-1')).toEqual([{ name: 'plugin:check', kind: 'skill' }]) + expect(adapter.readCommands('session-1')).toEqual([ + { name: 'plugin:check', kind: 'skill' }, + { name: 'doctor', kind: 'command' } + ]) expect(changed).toHaveBeenCalledTimes(1) claude.connections[0].handlers.onMessage?.(frame) await tick()