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.
This commit is contained in:
Merge Sim
2026-09-06 14:01:42 -07:00
parent 15d0f8aedf
commit d769b82958
24 changed files with 454 additions and 35 deletions
@@ -0,0 +1,72 @@
import { describe, expect, it } from 'vitest'
import { ClaudeSlashCommandCatalog, readClaudeSlashCommands } from './claude-slash-command-catalog'
function init(overrides: Record<string, unknown> = {}): Record<string, unknown> {
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' }])
})
})
@@ -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<string>()
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<string, unknown>): 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<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) }))
}
/** 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<string, unknown>) {
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<string, unknown>): 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
}
}
@@ -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(),
@@ -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(),
@@ -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<StructuredAgentSessionAdapter['readCommands']> = (sessionId) =>
this.sessions.get(sessionId)?.commands.commands
answerPrompt: StructuredAgentSessionAdapter['answerPrompt'] = (input) =>
answerClaudePrompt(this.session(input.sessionId), input)
setOption: StructuredAgentSessionAdapter['setOption'] = (input) =>
@@ -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),
@@ -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. */
@@ -60,6 +60,9 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi
sessionId
) => this.owners.get(sessionId)?.backgroundTaskState?.(sessionId)
readCommands: NonNullable<StructuredAgentSessionAdapter['readCommands']> = (sessionId) =>
this.owners.get(sessionId)?.readCommands?.(sessionId)
answerPrompt: StructuredAgentSessionAdapter['answerPrompt'] = (input) =>
this.owner(input.sessionId).answerPrompt(input)
@@ -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: {
@@ -317,6 +317,12 @@ export class StructuredAgentSessionHost {
readOptions = (sessionId: string): Promise<SessionWire.AgentSessionOptionsResult> =>
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<SessionWire.AgentSessionHandoffStatus> {
this.requireSession(sessionId)
return this.serialize(sessionId, () =>
@@ -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,
@@ -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',
@@ -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<NativeChatComposerHandle, NativeChatCo
dictationState === 'listening' ||
dictationState === 'stopping'
const agentCommands = useMemo(
() =>
structuredTransport ? structuredSlashCommands(agent) : getVerifiedNativeChatCommands(agent),
[agent, structuredTransport]
const { agentCommands, sessionSkillNames } = useNativeChatComposerCatalog(
agent,
structuredTransport
)
const picker = useNativeChatPickerState({
agent,
@@ -121,6 +119,7 @@ const NativeChatComposerPane = forwardRef<NativeChatComposerHandle, NativeChatCo
draft,
caret,
agentCommands,
sessionSkillNames,
textareaRef,
setDraft,
setCaret,
@@ -140,6 +140,7 @@ export function NativeChatStructuredSession(
optionsSurface: controller.optionSurface,
optionSnapshot: controller.optionSnapshot,
optionPickerRequest,
sessionCommands: controller.sessionCommands,
worktreeId: fileLinkContext?.worktreeId,
onError: setComposerError,
runtime: (props.target.kind === 'local' ? 'local' : 'remote') as 'local' | 'remote'
@@ -236,6 +236,45 @@ describe('native skill and command picker', () => {
}
})
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(
[],
@@ -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',
@@ -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'
@@ -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<NativeChatPickerItem, { kind: 'skill' }>[] {
const exactPaths = new Map<string, DiscoveredSkill>()
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<NativeChatPickerItem, { kind: 'skill' }> {
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<T extends NativeChatPickerItem>(
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<NativeChatPickerItem, { kind: 'skill' }>): number {
const sourceKind = item.sources[0]?.sourceKind
return sourceKind === undefined ? UNLOCATED_SCOPE_PRIORITY : SCOPE_PRIORITY[sourceKind]
}
function comparePickerSkills(
a: Extract<NativeChatPickerItem, { kind: 'skill' }>,
b: Extract<NativeChatPickerItem, { kind: 'skill' }>
): number {
return (
SCOPE_PRIORITY[a.sources[0].sourceKind] - SCOPE_PRIORITY[b.sources[0].sourceKind] ||
skillScopePriority(a) - skillScopePriority(b) ||
compareBaseSensitivityLocaleText(a.name, b.name)
)
}
@@ -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 }
}
@@ -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<HTMLTextAreaElement | null>
setDraft: (value: string) => void
setCaret: Dispatch<SetStateAction<number>>
@@ -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(
@@ -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<readonly AgentSessionSlashCommand[]>([])
useEffect(() => {
if (!isVisible) {
return
}
let stale = false
void callStructuredAgentSession<AgentSessionCommandsResult>(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
}
}
+15
View File
@@ -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 = {
@@ -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'])
})
})
+27
View File
@@ -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. */