From d769b8295839a677264dbbddb3083ef54351ea9b Mon Sep 17 00:00:00 2001 From: Merge Sim Date: Sun, 6 Sep 2026 14:01:42 -0700 Subject: [PATCH] fix(native-chat): list the slash commands and skills a structured Claude session actually loaded The chat composer's `/` menu was built from a curated five-command catalog plus a host disk scan of skill roots. Neither is what the running session can do: the session reports its own `/` surface, which carries this repo's `.claude/commands`, the skills that only reach it through plugin roots, and a hide-list of commands that mean nothing outside a terminal UI. On one local session the menu offered 6 commands and 17 skills where the session reported 62 commands and 33 skills. Read that surface per session and let it drive the picker: - A per-session catalog seeded from the frame that proves the session and kept current by every later report, exposed over a new `agentSession.commands` read. - The report is the authority on WHICH skills exist; the disk scan stays the source of scope and description for the names both know about, so a skill the session never loaded is no longer offered and one it loaded from a root the scan cannot see now is. - A host that predates the read answers `method_not_found` and the composer keeps its curated catalog, so mixed versions and the PTY lane are unchanged. --- .../claude-slash-command-catalog.test.ts | 72 +++++++++++++++++ .../claude/claude-slash-command-catalog.ts | 78 +++++++++++++++++++ .../claude/claude-structured-dispatch.test.ts | 2 + .../claude/claude-structured-options.test.ts | 2 + .../claude-structured-session-adapter.ts | 5 ++ .../claude-structured-session-publication.ts | 2 + .../claude/claude-structured-session-state.ts | 4 + ...structured-agent-session-adapter-router.ts | 3 + .../structured-agent-session-adapter.ts | 4 + .../structured-agent-session-host.ts | 6 ++ .../rpc/methods/structured-agent-session.ts | 5 ++ .../runtime-rpc-mobile-method-allowlist.ts | 1 + .../native-chat/NativeChatComposer.tsx | 13 ++-- .../NativeChatStructuredSession.tsx | 1 + .../native-chat-composer-state.test.ts | 39 ++++++++++ .../native-chat/native-chat-composer-state.ts | 20 +++-- .../native-chat/native-chat-composer-types.ts | 4 + .../native-chat/native-chat-picker-items.ts | 63 ++++++++++----- .../use-native-chat-composer-catalog.ts | 45 +++++++++++ .../use-native-chat-picker-state.ts | 25 +++++- .../use-structured-agent-session.ts | 27 ++++++- src/shared/agent-session-wire.ts | 15 ++++ src/shared/native-chat-slash-commands.test.ts | 26 +++++++ src/shared/native-chat-slash-commands.ts | 27 +++++++ 24 files changed, 454 insertions(+), 35 deletions(-) create mode 100644 src/main/claude/claude-slash-command-catalog.test.ts create mode 100644 src/main/claude/claude-slash-command-catalog.ts create mode 100644 src/renderer/src/components/native-chat/use-native-chat-composer-catalog.ts diff --git a/src/main/claude/claude-slash-command-catalog.test.ts b/src/main/claude/claude-slash-command-catalog.test.ts new file mode 100644 index 00000000000..ff767b94ac9 --- /dev/null +++ b/src/main/claude/claude-slash-command-catalog.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest' +import { ClaudeSlashCommandCatalog, readClaudeSlashCommands } from './claude-slash-command-catalog' + +function init(overrides: Record = {}): Record { + return { + type: 'system', + subtype: 'init', + session_id: 'provider-1', + slash_commands: ['clear', 'ref-oss', 'doctor', 'opsx:apply'], + terminal_slash_commands: ['doctor'], + skills: ['ref-oss', 'doctor'], + ...overrides + } +} + +describe('claude slash command catalog', () => { + it('tags reported skills and drops the commands reserved for a terminal UI', () => { + expect(readClaudeSlashCommands(init())).toEqual([ + { name: 'clear', kind: 'command' }, + { name: 'ref-oss', kind: 'skill' }, + { name: 'opsx:apply', kind: 'command' } + ]) + }) + + it('rejects blank, whitespace-carrying and duplicate names', () => { + expect( + readClaudeSlashCommands( + init({ slash_commands: ['clear', ' ', 'two words', 'clear'], skills: [] }) + ) + ).toEqual([{ name: 'clear', kind: 'command' }]) + }) + + it('seeds from the init frame that proved the session', () => { + expect(new ClaudeSlashCommandCatalog(init()).commands).toHaveLength(3) + expect(new ClaudeSlashCommandCatalog().commands).toEqual([]) + // A frame of the right subtype but without the array is not a catalog. + expect(new ClaudeSlashCommandCatalog({ type: 'system', subtype: 'init' }).commands).toEqual([]) + }) + + it('replaces the catalog on commands_changed and reports only real changes', () => { + const catalog = new ClaudeSlashCommandCatalog(init()) + expect(catalog.observe(init())).toBe(false) + expect(catalog.observe({ type: 'assistant', slash_commands: ['other'] })).toBe(false) + expect( + catalog.observe({ + type: 'system', + subtype: 'commands_changed', + slash_commands: ['clear', 'brand-new'], + skills: ['brand-new'] + }) + ).toBe(true) + expect(catalog.commands).toEqual([ + { name: 'clear', kind: 'command' }, + { name: 'brand-new', kind: 'skill' } + ]) + }) + + it('notices a name that only changed kind', () => { + const catalog = new ClaudeSlashCommandCatalog( + init({ slash_commands: ['review'], skills: [], terminal_slash_commands: [] }) + ) + expect( + catalog.observe({ + type: 'system', + subtype: 'commands_changed', + slash_commands: ['review'], + skills: ['review'] + }) + ).toBe(true) + expect(catalog.commands).toEqual([{ name: 'review', kind: 'skill' }]) + }) +}) diff --git a/src/main/claude/claude-slash-command-catalog.ts b/src/main/claude/claude-slash-command-catalog.ts new file mode 100644 index 00000000000..980fe42f48f --- /dev/null +++ b/src/main/claude/claude-slash-command-catalog.ts @@ -0,0 +1,78 @@ +import type { AgentSessionSlashCommand } from '../../shared/agent-session-wire' + +// The CLI reports its whole `/` surface on `system/init` and republishes it on +// `system/commands_changed`; both frames carry the same three arrays. +const MAX_COMMANDS = 512 +const MAX_NAME_LENGTH = 200 + +function names(value: unknown): string[] { + if (!Array.isArray(value)) { + return [] + } + const seen = new Set() + for (const entry of value) { + if (seen.size >= MAX_COMMANDS) { + break + } + const name = typeof entry === 'string' ? entry.trim() : '' + if (name.length > 0 && name.length <= MAX_NAME_LENGTH && !/\s/u.test(name)) { + seen.add(name) + } + } + return [...seen] +} + +function carriesCommandCatalog(message: Record): boolean { + return ( + message.type === 'system' && + (message.subtype === 'init' || message.subtype === 'commands_changed') && + Array.isArray(message.slash_commands) + ) +} + +/** What the session reports it can run, minus what it reserves for a terminal UI. */ +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) })) +} + +/** Per-session `/` catalog, seeded from the init frame that proved the session + * and refreshed by every later init or `commands_changed` frame. */ +export class ClaudeSlashCommandCatalog { + private entries: AgentSessionSlashCommand[] + + constructor(initMessage?: Record) { + this.entries = + initMessage && carriesCommandCatalog(initMessage) ? readClaudeSlashCommands(initMessage) : [] + } + + get commands(): AgentSessionSlashCommand[] { + return this.entries + } + + /** True when this frame replaced the catalog with a different one. */ + observe(message: Record): boolean { + if (!carriesCommandCatalog(message)) { + return false + } + const next = readClaudeSlashCommands(message) + if ( + next.length === this.entries.length && + next.every( + (entry, index) => + entry.name === this.entries[index]?.name && entry.kind === this.entries[index]?.kind + ) + ) { + return false + } + this.entries = next + return true + } +} diff --git a/src/main/claude/claude-structured-dispatch.test.ts b/src/main/claude/claude-structured-dispatch.test.ts index d66a64f82eb..b4093ce55ca 100644 --- a/src/main/claude/claude-structured-dispatch.test.ts +++ b/src/main/claude/claude-structured-dispatch.test.ts @@ -7,6 +7,7 @@ import { dispatchClaudeTurn, resolveClaudeReplayWaiter } from './claude-structur import { readClaudeImage } from './claude-structured-dispatch-content' import type { ClaudeSession } from './claude-structured-session-state' import { ClaudeBackgroundTaskTracker } from './claude-background-task-tracker' +import { ClaudeSlashCommandCatalog } from './claude-slash-command-catalog' function sessionFor(send = vi.fn().mockResolvedValue(undefined)): ClaudeSession { return { @@ -21,6 +22,7 @@ function sessionFor(send = vi.fn().mockResolvedValue(undefined)): ClaudeSession retiredDispatchWaiters: [], replayContentFallbackBlocked: false, backgroundTasks: new ClaudeBackgroundTaskTracker(), + commands: new ClaudeSlashCommandCatalog(), dispatchSequence: 0, optionMutationSequence: 0, options: new Map(), diff --git a/src/main/claude/claude-structured-options.test.ts b/src/main/claude/claude-structured-options.test.ts index 0738095f45d..2375df12d93 100644 --- a/src/main/claude/claude-structured-options.test.ts +++ b/src/main/claude/claude-structured-options.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest' import { setClaudeStructuredOption } from './claude-structured-options' import type { ClaudeSession } from './claude-structured-session-state' import { ClaudeBackgroundTaskTracker } from './claude-background-task-tracker' +import { ClaudeSlashCommandCatalog } from './claude-slash-command-catalog' function sessionFor(setModel: ClaudeSession['connection']['setModel']): ClaudeSession { return { @@ -16,6 +17,7 @@ function sessionFor(setModel: ClaudeSession['connection']['setModel']): ClaudeSe retiredDispatchWaiters: [], replayContentFallbackBlocked: false, backgroundTasks: new ClaudeBackgroundTaskTracker(), + commands: new ClaudeSlashCommandCatalog(), dispatchSequence: 0, optionMutationSequence: 0, options: new Map(), diff --git a/src/main/claude/claude-structured-session-adapter.ts b/src/main/claude/claude-structured-session-adapter.ts index c00a588e891..0bdbfeaf9d7 100644 --- a/src/main/claude/claude-structured-session-adapter.ts +++ b/src/main/claude/claude-structured-session-adapter.ts @@ -195,6 +195,9 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda : event.type === 'message' ? (session?.backgroundTasks.observe(event.message, event.startsTurn === true) ?? false) : false + if (event.type === 'message') { + session?.commands.observe(event.message) + } session?.translator?.handle(event) this.deps.onEvent?.(event) if (backgroundTasksChanged) { @@ -261,6 +264,8 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda const session = this.sessions.get(sessionId) return session ? backgroundTaskState(session) : undefined } + readCommands: NonNullable = (sessionId) => + this.sessions.get(sessionId)?.commands.commands answerPrompt: StructuredAgentSessionAdapter['answerPrompt'] = (input) => answerClaudePrompt(this.session(input.sessionId), input) setOption: StructuredAgentSessionAdapter['setOption'] = (input) => diff --git a/src/main/claude/claude-structured-session-publication.ts b/src/main/claude/claude-structured-session-publication.ts index 7f8cc4b5692..8a987c9c2e1 100644 --- a/src/main/claude/claude-structured-session-publication.ts +++ b/src/main/claude/claude-structured-session-publication.ts @@ -5,6 +5,7 @@ import type { ClaudePromptRegistry } from './claude-structured-prompt-replies' import type { ClaudeJournalTranslator } from './claude-structured-journal-translation' import type { ClaudeSession } from './claude-structured-session-state' import { ClaudeBackgroundTaskTracker } from './claude-background-task-tracker' +import { ClaudeSlashCommandCatalog } from './claude-slash-command-catalog' export function createClaudeSessionPublication(input: { connection: ClaudeSession['connection'] @@ -52,6 +53,7 @@ export function createClaudeSessionPublication(input: { retiredDispatchWaiters: [], replayContentFallbackBlocked: false, backgroundTasks: new ClaudeBackgroundTaskTracker(), + commands: new ClaudeSlashCommandCatalog(input.init.message), dispatchSequence: 0, optionMutationSequence: 0, options: new Map(input.options), diff --git a/src/main/claude/claude-structured-session-state.ts b/src/main/claude/claude-structured-session-state.ts index 1c0b1862913..bd7bb9d9661 100644 --- a/src/main/claude/claude-structured-session-state.ts +++ b/src/main/claude/claude-structured-session-state.ts @@ -11,6 +11,7 @@ import { cancelProcessAcquisition } from '../../shared/child-process/cancel-proc import { randomUUID } from 'node:crypto' import type { AgentSessionBackgroundTaskState } from '../../shared/agent-session-wire' import type { ClaudeBackgroundTaskTracker } from './claude-background-task-tracker' +import type { ClaudeSlashCommandCatalog } from './claude-slash-command-catalog' export type ClaudeAuthDiagnostic = { apiKeySourceConfigured: boolean @@ -126,6 +127,9 @@ export type ClaudeSession = { /** Provider uuid of the most recently admitted turn, if one is active. */ activeTurnId?: string backgroundTasks: ClaudeBackgroundTaskTracker + /** The `/` surface the CLI reports for itself; seeded from init, kept current + * by later init and `commands_changed` frames. */ + commands: ClaudeSlashCommandCatalog /** Monotonic fence advanced when a dispatch starts, including unresolved dispatches. */ dispatchSequence: number /** Dispatch sequence that admitted activeTurnId. */ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts index 226b9c1aab5..2ac8a5570f0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts @@ -60,6 +60,9 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi sessionId ) => this.owners.get(sessionId)?.backgroundTaskState?.(sessionId) + readCommands: NonNullable = (sessionId) => + this.owners.get(sessionId)?.readCommands?.(sessionId) + answerPrompt: StructuredAgentSessionAdapter['answerPrompt'] = (input) => this.owner(input.sessionId).answerPrompt(input) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts index e6f8e478695..4872426d009 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts @@ -19,6 +19,7 @@ import type { import type { AgentSessionBackgroundTaskState, AgentSessionOptionsResult, + AgentSessionSlashCommand, AgentSessionWireRefusalCode } from '../../../shared/agent-session-wire' import type { StructuredAgentSessionEventSink } from './structured-agent-session-event-sink' @@ -143,6 +144,9 @@ export type StructuredAgentSessionAdapter = { taskId?: string }): Promise<{ cancelled: boolean }> backgroundTaskState?(sessionId: string): AgentSessionBackgroundTaskState | null | undefined + /** The `/` surface the running provider reports for itself. Undefined when the + * provider never reports one, which is what keeps the client on its catalog. */ + readCommands?(sessionId: string): AgentSessionSlashCommand[] | undefined /** Fires the provider callback for an approval or a question. The wire calls * this only after the durable compare-and-set won, so it runs exactly once. */ answerPrompt(input: { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index 13b7d7b441e..60ef98c1900 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -317,6 +317,12 @@ export class StructuredAgentSessionHost { readOptions = (sessionId: string): Promise => readStructuredAgentSessionOptions(this.mutationContext(), sessionId) + /** Empty when the provider reports no catalog, which the client reads as + * "keep the curated list" rather than "this session has no commands". */ + readCommands = (sessionId: string): SessionWire.AgentSessionCommandsResult => ({ + commands: this.deps.adapter.readCommands?.(sessionId) ?? [] + }) + async handoffStatus(sessionId: string): Promise { this.requireSession(sessionId) return this.serialize(sessionId, () => diff --git a/src/main/runtime/rpc/methods/structured-agent-session.ts b/src/main/runtime/rpc/methods/structured-agent-session.ts index 9ca3c632a83..e30ce8b2a8c 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.ts @@ -224,6 +224,11 @@ export const STRUCTURED_AGENT_SESSION_METHODS: RpcAnyMethod[] = [ params: OptionsParams, handler: async (params, ctx) => requireHost(ctx).readOptions(params.sessionId) }), + defineMethod({ + name: 'agentSession.commands', + params: OptionsParams, + handler: async (params, ctx) => requireHost(ctx).readCommands(params.sessionId) + }), defineMethod({ name: 'agentSession.history', params: HistoryParams, diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-allowlist.ts b/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-allowlist.ts index 05666add0bc..322664f32cf 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-allowlist.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-allowlist.ts @@ -214,6 +214,7 @@ export const MOBILE_RPC_METHOD_ALLOWLIST = new Set([ 'agentSession.setOption', 'agentSession.handoffStatus', 'agentSession.options', + 'agentSession.commands', 'agentSession.history', 'agentSession.subscribe', 'agentSession.unsubscribe', diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.tsx index 06ae0ec5c8c..e675739c9a3 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.tsx @@ -1,9 +1,7 @@ -import { forwardRef, useCallback, useImperativeHandle, useMemo, useState } from 'react' +import { forwardRef, useCallback, useImperativeHandle, useState } from 'react' import { useAppStore } from '../../store' import { sendRuntimePtyInput } from '@/runtime/runtime-terminal-inspection' import { getSettingsForAgentTabRuntimeOwner } from '@/lib/agent-paste-draft' -import { getVerifiedNativeChatCommands } from '../../../../shared/native-chat-agent-profiles' -import { structuredSlashCommands } from '../../../../shared/structured-agent-session-composer' import { applyMentionSuggestion, EMPTY_HISTORY, @@ -22,6 +20,7 @@ import { useNativeChatSessionOptions } from './use-native-chat-session-options' import { useNativeChatFileAttachmentActions } from './use-native-chat-file-attachment-actions' import { useNativeChatDictationActions } from './use-native-chat-dictation-actions' import { useNativeChatSessionOptionCommand } from './use-native-chat-session-option-command' +import { useNativeChatComposerCatalog } from './use-native-chat-composer-catalog' import { useNativeChatPickerState } from './use-native-chat-picker-state' import { useNativeChatPickerCommandDispatch } from './use-native-chat-picker-command-dispatch' import { useNativeChatTypedInsertion } from './use-native-chat-typed-insertion' @@ -109,10 +108,9 @@ const NativeChatComposerPane = forwardRef - structuredTransport ? structuredSlashCommands(agent) : getVerifiedNativeChatCommands(agent), - [agent, structuredTransport] + const { agentCommands, sessionSkillNames } = useNativeChatComposerCatalog( + agent, + structuredTransport ) const picker = useNativeChatPickerState({ agent, @@ -121,6 +119,7 @@ const NativeChatComposerPane = forwardRef { } }) + it('lets a session report replace the disk scan and enrich the names it knows', () => { + const items = buildNativeChatPickerItems( + [], + [ + skill({ + name: 'ref-oss', + description: 'On disk', + skillFilePath: '/home/ref-oss/SKILL.md', + sourceKind: 'home' + }), + skill({ name: 'stale-on-disk', skillFilePath: '/home/stale/SKILL.md', sourceKind: 'home' }) + ], + '', + '/', + ['dataviz', 'ref-oss'] + ) + // The scanned-but-unreported skill is gone; the reported-but-unscanned one is + // offered without a scope, and sorts after the one the scan located. + expect(items.map((item) => item.name)).toEqual(['ref-oss', 'dataviz']) + expect(items[0]).toMatchObject({ kind: 'skill', description: 'On disk' }) + expect(items[1]).toMatchObject({ kind: 'skill', description: null, sources: [] }) + }) + + it('keeps the disk scan when the session reports nothing', () => { + const items = buildNativeChatPickerItems( + [], + [skill({ name: 'ref-oss', skillFilePath: '/home/ref-oss/SKILL.md' })], + '', + '/', + [] + ) + expect(items.map((item) => item.name)).toEqual(['ref-oss']) + }) + + it('rejects a session-reported name that is not a safe insertion token', () => { + const items = buildNativeChatPickerItems([], [], '', '/', ['ok', 'two words', 'cle\u200bar']) + expect(items.map((item) => item.name)).toEqual(['ok']) + }) + it('ranks exact, prefix, fuzzy, then description matches within a group', () => { const items = buildNativeChatPickerItems( [], diff --git a/src/renderer/src/components/native-chat/native-chat-composer-state.ts b/src/renderer/src/components/native-chat/native-chat-composer-state.ts index a14bed089f6..6a10978a6c6 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-state.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-state.ts @@ -51,11 +51,19 @@ export function deriveComposerAutocomplete( skills: readonly DiscoveredSkill[] = [], profile: NativeChatAgentProfile | null = null, discovery: NativeChatSkillDiscoverySnapshot = { ...EMPTY_DISCOVERY, skills }, - dismissedTriggerKey: string | null = null + dismissedTriggerKey: string | null = null, + sessionSkillNames: readonly string[] = [] ): ComposerAutocomplete { const before = draft.slice(0, caret) if (before.startsWith('/') && !/\s/.test(before)) { - return deriveSlashAutocomplete(before, agentCommands, profile, discovery, dismissedTriggerKey) + return deriveSlashAutocomplete( + before, + agentCommands, + profile, + discovery, + dismissedTriggerKey, + sessionSkillNames + ) } const mentionMatch = before.match(/(?:^|\s)@(\S*)$/) if (mentionMatch) { @@ -81,7 +89,7 @@ export function deriveComposerAutocomplete( grouped: false, commandsEnabled: false, skillsEnabled: true, - items: buildNativeChatPickerItems([], discovery.skills, query, '$'), + items: buildNativeChatPickerItems([], discovery.skills, query, '$', sessionSkillNames), skillStatus: discovery.status === 'idle' ? 'loading' : discovery.status, ...(discovery.errorKind ? { skillErrorKind: discovery.errorKind } : {}) } @@ -92,7 +100,8 @@ function deriveSlashAutocomplete( agentCommands: readonly SlashCommandSuggestion[], profile: NativeChatAgentProfile | null, discovery: NativeChatSkillDiscoverySnapshot, - dismissedTriggerKey: string | null + dismissedTriggerKey: string | null, + sessionSkillNames: readonly string[] ): ComposerAutocomplete { const triggerKey = '/:0' if (dismissedTriggerKey === triggerKey) { @@ -106,7 +115,8 @@ function deriveSlashAutocomplete( agentCommands, hasSlashSkills ? discovery.skills : [], query, - '/' + '/', + hasSlashSkills ? sessionSkillNames : [] ) return { mode: 'slash', diff --git a/src/renderer/src/components/native-chat/native-chat-composer-types.ts b/src/renderer/src/components/native-chat/native-chat-composer-types.ts index 9c314f90264..9a9af79e536 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-types.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-types.ts @@ -1,3 +1,4 @@ +import type { AgentSessionSlashCommand } from '../../../../shared/agent-session-wire' import type { AgentType } from '../../../../shared/agent-status-types' import type { StructuredAgentSessionCommandOutcome } from '../../../../shared/structured-agent-session-composer' import type { @@ -18,6 +19,9 @@ export type NativeChatStructuredComposerTransport = { optionsSurface: SessionOptionsSurface optionSnapshot: SessionOptionDescriptor[] optionPickerRequest?: NativeChatOptionPickerRequest | null + /** The `/` surface the running session reports. Empty keeps the curated + * per-agent catalog, which is what an older host leaves the client with. */ + sessionCommands?: readonly AgentSessionSlashCommand[] worktreeId?: string onError: (message: string | null) => void runtime: 'local' | 'remote' diff --git a/src/renderer/src/components/native-chat/native-chat-picker-items.ts b/src/renderer/src/components/native-chat/native-chat-picker-items.ts index 6938c2078a4..7a3f8083332 100644 --- a/src/renderer/src/components/native-chat/native-chat-picker-items.ts +++ b/src/renderer/src/components/native-chat/native-chat-picker-items.ts @@ -47,9 +47,10 @@ export function buildNativeChatPickerItems( commands: readonly SlashCommandSuggestion[], skills: readonly DiscoveredSkill[], query: string, - prefix: '/' | '$' + prefix: '/' | '$', + sessionSkillNames?: readonly string[] ): NativeChatPickerItem[] { - const mergedSkills = mergeNativeChatSkills(skills) + const mergedSkills = mergeNativeChatSkills(skills, sessionSkillNames) const skillNames = new Set(mergedSkills.map((skill) => skill.name)) const commandNames = new Set(commands.map((command) => command.name)) const commandItems = rankItems( @@ -80,7 +81,8 @@ export function buildNativeChatPickerItems( } function mergeNativeChatSkills( - skills: readonly DiscoveredSkill[] + skills: readonly DiscoveredSkill[], + sessionSkillNames?: readonly string[] ): Extract[] { const exactPaths = new Map() for (const skill of skills) { @@ -96,23 +98,39 @@ function mergeNativeChatSkills( } byName.set(safeName, [...(byName.get(safeName) ?? []), { ...skill, name: safeName }]) } - return [...byName.entries()] - .map(([name, namedSkills]) => { - const sorted = [...namedSkills].sort(compareDiscoveredSkills) - return { - kind: 'skill' as const, - id: `skill:${name}`, - name, - description: sorted[0]?.description ? sanitizePickerText(sorted[0].description, 240) : null, - sources: sorted.map((skill) => ({ - sourceKind: skill.sourceKind, - skillFilePath: skill.skillFilePath - })) - } - }) + const discovered = new Map( + [...byName.entries()].map(([name, namedSkills]) => [name, pickerSkill(name, namedSkills)]) + ) + // Why: when the running session reports its own skills, that report is the + // authority on which ones exist — a disk scan cannot see what the session + // actually loaded (plugin roots, setting-source filters), and a scanned root + // the session ignored must not be offered. The scan stays the source of + // description and scope for the names both know about. + const names = sessionSkillNames?.length + ? sessionSkillNames.filter(isTokenSafe) + : [...discovered.keys()] + return [...new Set(names)] + .map((name) => discovered.get(name) ?? pickerSkill(name, [])) .sort(comparePickerSkills) } +function pickerSkill( + name: string, + namedSkills: readonly DiscoveredSkill[] +): Extract { + const sorted = [...namedSkills].sort(compareDiscoveredSkills) + return { + kind: 'skill' as const, + id: `skill:${name}`, + name, + description: sorted[0]?.description ? sanitizePickerText(sorted[0].description, 240) : null, + sources: sorted.map((skill) => ({ + sourceKind: skill.sourceKind, + skillFilePath: skill.skillFilePath + })) + } +} + function rankItems( entries: { item: T; stableOrder: number }[], query: string @@ -197,12 +215,21 @@ function compareDiscoveredSkills(a: DiscoveredSkill, b: DiscoveredSkill): number ) } +// A session-reported skill this host could not locate on disk sorts last: it is +// real and invocable, but carries no scope or description to rank on. +const UNLOCATED_SCOPE_PRIORITY = Object.keys(SCOPE_PRIORITY).length + +function skillScopePriority(item: Extract): number { + const sourceKind = item.sources[0]?.sourceKind + return sourceKind === undefined ? UNLOCATED_SCOPE_PRIORITY : SCOPE_PRIORITY[sourceKind] +} + function comparePickerSkills( a: Extract, b: Extract ): number { return ( - SCOPE_PRIORITY[a.sources[0].sourceKind] - SCOPE_PRIORITY[b.sources[0].sourceKind] || + skillScopePriority(a) - skillScopePriority(b) || compareBaseSensitivityLocaleText(a.name, b.name) ) } diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-catalog.ts b/src/renderer/src/components/native-chat/use-native-chat-composer-catalog.ts new file mode 100644 index 00000000000..2e70e542aa3 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-catalog.ts @@ -0,0 +1,45 @@ +import { useMemo } from 'react' +import type { AgentType } from '../../../../shared/agent-status-types' +import { getVerifiedNativeChatCommands } from '../../../../shared/native-chat-agent-profiles' +import { + sessionReportedSkillNames, + sessionSlashCommandSuggestions, + type SlashCommandSuggestion +} from '../../../../shared/native-chat-slash-commands' +import { structuredSlashCommands } from '../../../../shared/structured-agent-session-composer' +import type { NativeChatStructuredComposerTransport } from './native-chat-composer-types' + +const EMPTY_SKILL_NAMES: readonly string[] = [] + +export type NativeChatComposerCatalog = { + agentCommands: readonly SlashCommandSuggestion[] + sessionSkillNames: readonly string[] +} + +/** + * What the `/` menu offers. A structured session reports the surface it actually + * loaded — the only list that includes this repo's own commands and the skills + * that reach the session through plugin roots — so it wins whenever it is + * present. The curated per-agent catalog remains the answer for the PTY lane and + * for a host that predates the report. + */ +export function useNativeChatComposerCatalog( + agent: AgentType, + structuredTransport?: NativeChatStructuredComposerTransport +): NativeChatComposerCatalog { + const reported = structuredTransport?.sessionCommands + const agentCommands = useMemo( + () => + !structuredTransport + ? getVerifiedNativeChatCommands(agent) + : reported?.length + ? sessionSlashCommandSuggestions(agent, reported) + : structuredSlashCommands(agent), + [agent, reported, structuredTransport] + ) + const sessionSkillNames = useMemo( + () => (reported?.length ? sessionReportedSkillNames(reported) : EMPTY_SKILL_NAMES), + [reported] + ) + return { agentCommands, sessionSkillNames } +} diff --git a/src/renderer/src/components/native-chat/use-native-chat-picker-state.ts b/src/renderer/src/components/native-chat/use-native-chat-picker-state.ts index d66228356a2..1e735900259 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-picker-state.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-picker-state.ts @@ -28,6 +28,8 @@ import { emitNativeChatSendClassified } from '@/lib/native-chat-telemetry' +const EMPTY_SESSION_SKILL_NAMES: readonly string[] = [] + export type NativeChatPickerState = { autocomplete: ComposerAutocomplete listboxId: string @@ -46,6 +48,8 @@ export function useNativeChatPickerState(args: { draft: string caret: number agentCommands: readonly SlashCommandSuggestion[] + /** Skill names the running session reports; empty keeps the host disk scan. */ + sessionSkillNames?: readonly string[] textareaRef: RefObject setDraft: (value: string) => void setCaret: Dispatch> @@ -58,6 +62,7 @@ export function useNativeChatPickerState(args: { draft, caret, agentCommands, + sessionSkillNames = EMPTY_SESSION_SKILL_NAMES, textareaRef, setDraft, setCaret, @@ -86,9 +91,19 @@ export function useNativeChatPickerState(args: { discovery.skills, profile, discovery, - dismissed?.context === dismissalContext ? dismissed.triggerKey : null + dismissed?.context === dismissalContext ? dismissed.triggerKey : null, + sessionSkillNames ), - [agentCommands, caret, dismissalContext, dismissed, discovery, draft, profile] + [ + agentCommands, + caret, + dismissalContext, + dismissed, + discovery, + draft, + profile, + sessionSkillNames + ] ) useEffect(() => { @@ -152,7 +167,9 @@ export function useNativeChatPickerState(args: { agentCommands, discovery.skills, profile, - discovery + discovery, + null, + sessionSkillNames ) if ( (next.mode !== 'slash' && next.mode !== 'skill') || @@ -161,7 +178,7 @@ export function useNativeChatPickerState(args: { setDismissed(null) } }, - [agentCommands, dismissalContext, dismissed, discovery, draft, profile] + [agentCommands, dismissalContext, dismissed, discovery, draft, profile, sessionSkillNames] ) const classifySend = useCallback( diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.ts b/src/renderer/src/components/native-chat/use-structured-agent-session.ts index b0bab73669c..0b3e7051f71 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session.ts @@ -2,10 +2,12 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' import type { AgentType } from '../../../../shared/agent-status-types' import type { + AgentSessionCommandsResult, AgentSessionMutationResult, AgentSessionOptionResult, AgentSessionOptionsResult, - AgentSessionPromptResult + AgentSessionPromptResult, + AgentSessionSlashCommand } from '../../../../shared/agent-session-wire' import { getAgentSessionOptionCatalog } from '../../../../shared/agent-session-option-catalog' import type { SessionOptionsSurface } from '../../../../shared/native-chat-session-options' @@ -171,6 +173,28 @@ export function useStructuredAgentSession(args: { } }, [isVisible, optionCatalog, sessionId, state.fence, target, turnId]) + const [sessionCommands, setSessionCommands] = useState([]) + useEffect(() => { + if (!isVisible) { + return + } + let stale = false + void callStructuredAgentSession(target, 'agentSession.commands', { + sessionId + }) + .then((result) => { + if (!stale) { + setSessionCommands(result.commands ?? []) + } + }) + // Why: a host that predates this method answers method_not_found, which is + // the same as "no catalog" — the composer keeps its curated list. + .catch(() => {}) + return () => { + stale = true + } + }, [isVisible, sessionId, state.fence, target, turnId]) + const optionSnapshot = useMemo( () => structuredAgentSessionOptionSnapshot(optionState), [optionState] @@ -270,6 +294,7 @@ export function useStructuredAgentSession(args: { ), optionSnapshot, optionSurface, + sessionCommands, setStructuredOption } } diff --git a/src/shared/agent-session-wire.ts b/src/shared/agent-session-wire.ts index b7360bd0e62..f52bc24b850 100644 --- a/src/shared/agent-session-wire.ts +++ b/src/shared/agent-session-wire.ts @@ -305,6 +305,21 @@ export type AgentSessionModelOption = { efforts: AgentSessionOptionChoice[] } +/** One entry of the `/` menu the running provider reports for itself. `skill` + * marks a name the session loaded as a skill rather than a built-in command; + * commands the provider reserves for a terminal UI are already removed. */ +export type AgentSessionSlashCommand = { + name: string + kind: 'command' | 'skill' +} + +/** The provider's own command surface, read per session. Additive read-only + * surface: a host that predates it answers `method_not_found`, and the client + * keeps rendering its curated catalog. */ +export type AgentSessionCommandsResult = { + commands: AgentSessionSlashCommand[] +} + /** Provider-reported choices and effective next-turn values. Additive read-only * surface so older hosts can reject it without changing structured v1 writes. */ export type AgentSessionOptionsResult = { diff --git a/src/shared/native-chat-slash-commands.test.ts b/src/shared/native-chat-slash-commands.test.ts index bd9f7984551..32d3d2d8876 100644 --- a/src/shared/native-chat-slash-commands.test.ts +++ b/src/shared/native-chat-slash-commands.test.ts @@ -4,6 +4,8 @@ import { filterSlashCommands, getAgentSlashCommands, isSlashCommandDraft, + sessionReportedSkillNames, + sessionSlashCommandSuggestions, slashCommandDispatchText } from './native-chat-slash-commands' @@ -64,3 +66,27 @@ describe('dispatch vs completion text', () => { expect(applySlashSuggestion({ name: 'model' })).toBe('/model ') }) }) + +describe('a session that reports its own command surface', () => { + const reported = [ + { name: 'clear', kind: 'command' as const }, + { name: 'opsx:apply', kind: 'command' as const }, + { name: 'ref-oss', kind: 'skill' as const } + ] + + it('offers exactly the reported commands, described from the curated catalog', () => { + expect(sessionSlashCommandSuggestions('claude', reported)).toEqual([ + { name: 'clear', description: 'Clear conversation history' }, + { name: 'opsx:apply' } + ]) + }) + + it('does not resurrect a curated command the session never reported', () => { + const names = sessionSlashCommandSuggestions('claude', reported).map((c) => c.name) + expect(names).not.toContain('compact') + }) + + it('splits skills out for the picker to group on its own', () => { + expect(sessionReportedSkillNames(reported)).toEqual(['ref-oss']) + }) +}) diff --git a/src/shared/native-chat-slash-commands.ts b/src/shared/native-chat-slash-commands.ts index 99c0152577f..1c712b85359 100644 --- a/src/shared/native-chat-slash-commands.ts +++ b/src/shared/native-chat-slash-commands.ts @@ -4,6 +4,7 @@ // mirrored copy to drift, unlike the agent-specific parsers in src/shared that // Metro forces us to duplicate. +import type { AgentSessionSlashCommand } from './agent-session-wire' import type { AgentType } from './agent-status-types' export type SlashCommandSuggestion = { @@ -90,6 +91,32 @@ export function getAgentSlashCommands(agent: AgentType): readonly SlashCommandSu return COMMANDS_BY_AGENT[agent] ?? COMMON_COMMANDS } +/** 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. */ +export function sessionSlashCommandSuggestions( + agent: AgentType, + reported: readonly AgentSessionSlashCommand[] +): readonly SlashCommandSuggestion[] { + const described = new Map( + getAgentSlashCommands(agent).map((command) => [command.name, command.description]) + ) + return reported + .filter((entry) => entry.kind === 'command') + .map((entry) => { + const description = described.get(entry.name) + return { name: entry.name, ...(description ? { description } : {}) } + }) +} + +/** Names the session reported as skills, in the order it reported them. */ +export function sessionReportedSkillNames( + reported: readonly AgentSessionSlashCommand[] +): readonly string[] { + return reported.filter((entry) => entry.kind === 'skill').map((entry) => entry.name) +} + /** Whether the draft is a slash command (leading `/`, ignoring leading space). * Slash drafts dispatch to the agent's own TUI and must NOT render an optimistic * user bubble — they are control actions, not chat turns. */