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.
This commit is contained in:
Brennan Benson
2026-10-16 12:56:51 -07:00
committed by GitHub
parent bb7e74d236
commit 37beafdd6d
3 changed files with 21 additions and 21 deletions
@@ -14,10 +14,11 @@ function init(overrides: Record<string, unknown> = {}): Record<string, unknown>
}
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)
+11 -16
View File
@@ -88,17 +88,16 @@ function carriesCommandCatalog(message: Record<string, unknown>): 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<string, unknown>
): 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<string>()
private commandNames = new Set<string>()
private details = new Map<string, CommandDetail>()
@@ -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
@@ -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()