From dda103d2cf69dbdd730d0a7179bb3021b6467501 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 9 Sep 2026 23:21:57 -0700 Subject: [PATCH] fix(native-chat): one `/` picker for every agent, anywhere in the prompt (#19832) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(native-chat): one `/` picker for every agent, anywhere in the prompt The composer only opened its picker when `/` was the first character of the draft, so a skill named mid-sentence ("validate it with /electron") offered nothing. Codex was worse: its `/` menu listed commands only, and skills lived on a separate `$` trigger, so the prompt box behaved differently per agent. `/` is now the whole composer grammar. It opens one grouped commands+skills menu for every agent with a known grammar, both at the start of the draft and mid-prompt after whitespace. The `$` trigger is gone. Per-agent invocation is preserved where it belongs — in what a pick writes. Each row carries its own token, so choosing a skill in Codex inserts `$electron` while Claude inserts `/electron`, and the text that reaches the agent stays the text that agent actually invokes. Only a draft-leading command is dispatchable; picking one mid-sentence completes the token instead of sending the command on its own and discarding the draft. Name collisions now key on whether both kinds share a sigil, so a Codex `/review` command and a `$review` skill stay separate rows. * test(native-chat): model dismissal inputs on the live `/` grammar The trigger-key swap cases still used `$:4` keys. editReplacesTriggerToken is sigil-agnostic so they passed, but they modelled an input the composer can no longer produce. * test(native-chat): pin inline picker dispatch and discovery reuse --------- Co-authored-by: Merge Sim --- .../NativeChatAutocompleteMenus.test.tsx | 3 + .../NativeChatAutocompleteMenus.tsx | 12 +- .../native-chat/NativeChatComposerField.tsx | 13 +- .../native-chat-composer-state.test.ts | 201 ++++++++++++++---- .../native-chat/native-chat-composer-state.ts | 86 ++++---- .../native-chat/native-chat-picker-items.ts | 54 +++-- .../use-native-chat-composer-catalog.test.tsx | 1 + .../use-native-chat-composer-keydown.test.tsx | 44 +++- .../use-native-chat-composer-keydown.ts | 6 +- ...tive-chat-picker-command-dispatch.test.tsx | 1 + .../use-native-chat-picker-state.ts | 20 +- .../use-native-chat-skills.react.test.tsx | 24 +++ src/shared/native-chat-agent-profiles.test.ts | 9 +- src/shared/native-chat-agent-profiles.ts | 5 - 14 files changed, 342 insertions(+), 137 deletions(-) diff --git a/src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.test.tsx b/src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.test.tsx index d5dac1efab5..88506c51838 100644 --- a/src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.test.tsx @@ -13,6 +13,7 @@ function autocomplete( query: '', triggerKey: '/:0', prefix: '/', + dispatchable: true, grouped: true, commandsEnabled: true, skillsEnabled: true, @@ -21,6 +22,7 @@ function autocomplete( kind: 'command', id: 'command:clear', name: 'clear', + token: '/clear', description: 'Clear history', skillCollision: false }, @@ -28,6 +30,7 @@ function autocomplete( kind: 'skill', id: 'skill:browser', name: 'browser', + token: '/browser', description: 'Use a browser', sources: [{ sourceKind: 'repo', skillFilePath: '/repo/browser/SKILL.md' }] } diff --git a/src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.tsx b/src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.tsx index 8357931fb49..7f1cd943c0f 100644 --- a/src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.tsx +++ b/src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.tsx @@ -12,7 +12,7 @@ export const NativeChatPickerMenu = memo(function NativeChatPickerMenu({ onChoose, onRetry }: { - autocomplete: Extract + autocomplete: Extract activeIndex: number listboxId: string onChoose: (item: NativeChatPickerItem) => void @@ -54,7 +54,6 @@ export const NativeChatPickerMenu = memo(function NativeChatPickerMenu({ + autocomplete: Extract ): string { - if (autocomplete.mode === 'skill' || !autocomplete.commandsEnabled) { + if (!autocomplete.commandsEnabled) { return translate('components.native-chat.composer.noSkills', 'No matching skills') } if (autocomplete.skillsEnabled) { @@ -174,7 +172,6 @@ function PickerStatus({ children }: { children: React.ReactNode }): React.JSX.El function PickerOption({ item, - prefix, index, activeIndex, listboxId, @@ -182,7 +179,6 @@ function PickerOption({ onChoose }: { item: NativeChatPickerItem - prefix: '/' | '$' index: number activeIndex: number listboxId: string @@ -213,7 +209,7 @@ function PickerOption({ ) : null} - {prefix + item.name} + {item.token} {item.description ? ( {item.description} ) : null} diff --git a/src/renderer/src/components/native-chat/NativeChatComposerField.tsx b/src/renderer/src/components/native-chat/NativeChatComposerField.tsx index 6b474a2f267..f51bf4c5400 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposerField.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposerField.tsx @@ -166,7 +166,7 @@ export function NativeChatComposerField({ {/* Extra bottom padding keeps the input box off the window rim. */}
- {autocomplete.mode === 'slash' || autocomplete.mode === 'skill' ? ( + {autocomplete.mode === 'slash' ? ( 0 + autocomplete.mode === 'slash' && autocomplete.items.length > 0 ? `${pickerListboxId}-option-${Math.min(activeSuggestion, autocomplete.items.length - 1)}` : undefined } diff --git a/src/renderer/src/components/native-chat/native-chat-composer-state.test.ts b/src/renderer/src/components/native-chat/native-chat-composer-state.test.ts index 483d29f1ff2..9d116d373fb 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-state.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-state.test.ts @@ -9,6 +9,7 @@ import { editReplacesTriggerToken, EMPTY_HISTORY, filterSlashCommands, + isSkillPickerTriggered, isSlashCommandDraft, pushHistory, recallNext, @@ -96,28 +97,30 @@ describe('deriveComposerAutocomplete — mention', () => { }) }) -describe('deriveComposerAutocomplete — skill', () => { +describe('deriveComposerAutocomplete — one grammar for every agent', () => { const skills = [ skill({ name: 'typescript' }), skill({ name: 'react-useeffect', directoryPath: '/repo/.agents/skills/react-useeffect' }) ] + const codex = getNativeChatAgentProfile('codex') - it('enters skill mode with the query after `$`', () => { - const result = deriveComposerAutocomplete('use $type', 9, COMMANDS, skills) - expect(result.mode).toBe('skill') - if (result.mode !== 'skill') { + it('offers Codex skills under `/`, tokenised as the form Codex invokes', () => { + const result = deriveComposerAutocomplete('use /type', 9, COMMANDS, skills, codex) + expect(result.mode).toBe('slash') + if (result.mode !== 'slash') { return } expect(result.query).toBe('type') - expect(result.items.map((entry) => entry.name)).toEqual(['typescript']) + expect(result.items.map((entry) => entry.token)).toEqual(['$typescript']) }) - it('fires at the start of input too', () => { - expect(deriveComposerAutocomplete('$react', 6, COMMANDS, skills).mode).toBe('skill') + it('no longer treats `$` as a composer trigger', () => { + expect(deriveComposerAutocomplete('use $type', 9, COMMANDS, skills, codex).mode).toBe('none') + expect(deriveComposerAutocomplete('$react', 6, COMMANDS, skills, codex).mode).toBe('none') }) it('does not fire inside shell-style text', () => { - expect(deriveComposerAutocomplete('price$tag', 9, COMMANDS, skills).mode).toBe('none') + expect(deriveComposerAutocomplete('price$tag', 9, COMMANDS, skills, codex).mode).toBe('none') }) }) @@ -194,31 +197,151 @@ describe('apply suggestions', () => { expect(result.caret).toBe('open @src/app.ts '.length) }) - it('applyPickerSuggestion replaces the active $token at the caret', () => { - const result = applyPickerSuggestion( - 'use $typ now', - 8, - { kind: 'skill', id: 'skill:typescript', name: 'typescript', description: null, sources: [] }, - '$' - ) + it('applyPickerSuggestion swaps the typed /token for the agent-native token', () => { + const result = applyPickerSuggestion('use /typ now', 8, { + kind: 'skill', + id: 'skill:typescript', + name: 'typescript', + token: '$typescript', + description: null, + sources: [] + }) expect(result.draft).toBe('use $typescript now') expect(result.caret).toBe('use $typescript '.length) + expect(result.insertedToken).toBe('$typescript') }) }) describe('native skill and command picker', () => { - it('keeps Codex commands under slash and skills under dollar', () => { - const profile = getNativeChatAgentProfile('codex') - const slash = deriveComposerAutocomplete('/', 1, COMMANDS, [skill({})], profile) + it('puts Codex commands and skills in one `/` menu, each with its own token', () => { + const slash = deriveComposerAutocomplete( + '/', + 1, + COMMANDS, + [skill({ name: 'browser' })], + getNativeChatAgentProfile('codex') + ) expect(slash.mode).toBe('slash') - if (slash.mode === 'slash') { - expect(slash.items.every((item) => item.kind === 'command')).toBe(true) + if (slash.mode !== 'slash') { + return } - const dollar = deriveComposerAutocomplete('$', 1, COMMANDS, [skill({})], profile) - expect(dollar.mode).toBe('skill') - if (dollar.mode === 'skill') { - expect(dollar.items.every((item) => item.kind === 'skill')).toBe(true) + expect(slash.grouped).toBe(true) + expect(slash.items.filter((item) => item.kind === 'command').map((item) => item.token)).toEqual( + ['/clear', '/compact', '/help'] + ) + expect(slash.items.filter((item) => item.kind === 'skill').map((item) => item.token)).toEqual([ + '$browser' + ]) + }) + + it('keeps a Codex command and a same-named skill as separate rows', () => { + const result = deriveComposerAutocomplete( + '/clear', + 6, + COMMANDS, + [skill({ name: 'clear' })], + getNativeChatAgentProfile('codex') + ) + expect(result.mode).toBe('slash') + if (result.mode !== 'slash') { + return } + expect(result.items.map((item) => item.token)).toEqual(['/clear', '$clear']) + expect(result.items.find((item) => item.kind === 'command')?.skillCollision).toBe(false) + }) + + it('offers the same commands and skills for a `/` typed mid-prompt as for a leading one', () => { + const args = [ + COMMANDS, + [skill({ name: 'electron' })], + getNativeChatAgentProfile('claude') + ] as const + const leading = deriveComposerAutocomplete('/', 1, ...args) + const midPrompt = deriveComposerAutocomplete('validate it with /', 18, ...args) + expect(midPrompt.mode).toBe('slash') + if (midPrompt.mode !== 'slash' || leading.mode !== 'slash') { + return + } + expect(midPrompt.items).toEqual(leading.items) + expect(midPrompt.items.map((item) => item.kind)).toContain('command') + expect(midPrompt.items.map((item) => item.kind)).toContain('skill') + expect(midPrompt.grouped).toBe(leading.grouped) + }) + + it('filters the mid-prompt `/` menu by the typed token', () => { + const result = deriveComposerAutocomplete( + 'validate it with /elec', + 22, + COMMANDS, + [skill({ name: 'electron' })], + getNativeChatAgentProfile('claude') + ) + expect(result.mode).toBe('slash') + if (result.mode === 'slash') { + expect(result.prefix).toBe('/') + expect(result.items.map((item) => item.name)).toEqual(['electron']) + } + }) + + it('marks only a draft-leading `/command` dispatchable', () => { + const profile = getNativeChatAgentProfile('claude') + const leading = deriveComposerAutocomplete('/comp', 5, COMMANDS, [], profile) + const midPrompt = deriveComposerAutocomplete('then /comp', 10, COMMANDS, [], profile) + expect(leading.mode === 'slash' && leading.dispatchable).toBe(true) + expect(midPrompt.mode === 'slash' && midPrompt.dispatchable).toBe(false) + }) + + it('leaves a mid-prompt path alone', () => { + expect( + deriveComposerAutocomplete( + 'open /Users/me/notes', + 20, + COMMANDS, + [skill({ name: 'electron' })], + getNativeChatAgentProfile('claude') + ).mode + ).toBe('none') + }) + + it('opens the mid-prompt `/` menu for Codex too, tokenised for Codex', () => { + const result = deriveComposerAutocomplete( + 'validate it with /elec', + 22, + COMMANDS, + [skill({ name: 'electron' })], + getNativeChatAgentProfile('codex') + ) + expect(result.mode).toBe('slash') + if (result.mode !== 'slash') { + return + } + expect(result.dispatchable).toBe(false) + expect(result.items.map((item) => item.token)).toEqual(['$electron']) + }) + + it.each(['claude', 'codex'] as const)( + 'loads the skill catalog for both `/` trigger positions on %s', + (agent) => { + const profile = getNativeChatAgentProfile(agent) + expect(isSkillPickerTriggered('/elec', profile)).toBe(true) + expect(isSkillPickerTriggered('validate it with /elec', profile)).toBe(true) + expect(isSkillPickerTriggered('open /Users/me', profile)).toBe(false) + // Without a catalog fetch the menu would sit on a permanent loading row. + expect(isSkillPickerTriggered('use $elec', profile)).toBe(false) + } + ) + + it('applyPickerSuggestion replaces a mid-prompt /token at the caret', () => { + const result = applyPickerSuggestion('validate it with /elec now', 22, { + kind: 'skill', + id: 'skill:electron', + name: 'electron', + token: '/electron', + description: null, + sources: [] + }) + expect(result.draft).toBe('validate it with /electron now') + expect(result.caret).toBe('validate it with /electron '.length) }) it('groups Claude commands and skills under slash', () => { @@ -327,7 +450,7 @@ describe('native skill and command picker', () => { '$' ) expect(items.map((item) => item.name)).toEqual([longName]) - const applied = applyPickerSuggestion('$sk', 3, items[0], '$') + const applied = applyPickerSuggestion('/sk', 3, items[0]) expect(applied.draft).toBe(`$${longName} `) }) @@ -364,12 +487,14 @@ describe('native skill and command picker', () => { }) it('replaces only the active slash token and preserves text after the caret', () => { - const result = applyPickerSuggestion( - '/bro trailing', - 4, - { kind: 'skill', id: 'skill:browser', name: 'browser', description: null, sources: [] }, - '/' - ) + const result = applyPickerSuggestion('/bro trailing', 4, { + kind: 'skill', + id: 'skill:browser', + name: 'browser', + token: '/browser', + description: null, + sources: [] + }) expect(result.draft).toBe('/browser trailing') expect(result.caret).toBe('/browser '.length) }) @@ -396,29 +521,29 @@ describe('native skill and command picker', () => { it('treats a one-edit token swap as a new trigger occurrence', () => { expect(editReplacesTriggerToken('/foo', '/bar', '/:0')).toBe(true) - expect(editReplacesTriggerToken('use $foo', 'use $bar', '$:4')).toBe(true) + expect(editReplacesTriggerToken('use /foo', 'use /bar', '/:4')).toBe(true) }) it('keeps suppression while typing or deleting inside the dismissed token', () => { expect(editReplacesTriggerToken('/foo', '/food', '/:0')).toBe(false) expect(editReplacesTriggerToken('/food', '/foo', '/:0')).toBe(false) - expect(editReplacesTriggerToken('use $foo now', 'ran $foo now', '$:4')).toBe(false) + expect(editReplacesTriggerToken('use /foo now', 'ran /foo now', '/:4')).toBe(false) }) it('suppresses only the dismissed trigger occurrence', () => { const profile = getNativeChatAgentProfile('codex') - expect(deriveComposerAutocomplete('use $bro', 8, COMMANDS, [skill({})], profile).mode).toBe( - 'skill' + expect(deriveComposerAutocomplete('use /bro', 8, COMMANDS, [skill({})], profile).mode).toBe( + 'slash' ) expect( deriveComposerAutocomplete( - 'use $bro', + 'use /bro', 8, COMMANDS, [skill({})], profile, { status: 'ready', skills: [skill({})] }, - '$:4' + '/:4' ).mode ).toBe('none') }) 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 3535a90c04b..b76622ed255 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 @@ -9,6 +9,8 @@ import { } from '../../../../shared/native-chat-slash-commands' import { buildNativeChatPickerItems, + LEADING_SLASH_TRIGGER, + MID_PROMPT_SLASH_TRIGGER, type NativeChatPickerItem, type NativeChatSkillDiscoverySnapshot } from './native-chat-picker-items' @@ -28,7 +30,9 @@ type PickerAutocomplete = { query: string items: NativeChatPickerItem[] triggerKey: string - prefix: '/' | '$' + prefix: '/' + /** Only a draft-leading `/command` reaches the agent as a command. */ + dispatchable: boolean grouped: boolean commandsEnabled: boolean skillsEnabled: boolean @@ -40,10 +44,20 @@ export type ComposerAutocomplete = | { mode: 'none' } | ({ mode: 'slash' } & PickerAutocomplete) | { mode: 'mention'; query: string } - | ({ mode: 'skill' } & PickerAutocomplete) const EMPTY_DISCOVERY: NativeChatSkillDiscoverySnapshot = { status: 'ready', skills: [] } +/** Whether the caret sits in a token that needs the skill catalog loaded. */ +export function isSkillPickerTriggered( + before: string, + profile: NativeChatAgentProfile | null +): boolean { + if (!profile) { + return false + } + return LEADING_SLASH_TRIGGER.test(before) || MID_PROMPT_SLASH_TRIGGER.test(before) +} + export function deriveComposerAutocomplete( draft: string, caret: number, @@ -55,9 +69,11 @@ export function deriveComposerAutocomplete( sessionSkillNames?: readonly string[] ): ComposerAutocomplete { const before = draft.slice(0, caret) - if (before.startsWith('/') && !/\s/.test(before)) { + const leadingMatch = before.match(LEADING_SLASH_TRIGGER) + if (leadingMatch) { return deriveSlashAutocomplete( - before, + leadingMatch[1], + 0, agentCommands, profile, discovery, @@ -69,70 +85,64 @@ export function deriveComposerAutocomplete( if (mentionMatch) { return { mode: 'mention', query: mentionMatch[1] } } - const skillMatch = - profile?.skillPrefix === '$' || (!profile && skills.length > 0) - ? before.match(/(?:^|\s)\$(\S*)$/) - : null - if (!skillMatch) { + // Why: `/` is the whole composer grammar, so a mid-prompt token opens the same + // menu a leading one does — it just cannot dispatch. + const midPromptMatch = profile ? before.match(MID_PROMPT_SLASH_TRIGGER) : null + if (!midPromptMatch) { return { mode: 'none' } } - const triggerKey = `$:${before.length - skillMatch[1].length - 1}` - if (dismissedTriggerKey === triggerKey) { - return { mode: 'none' } - } - const query = skillMatch[1] - return { - mode: 'skill', + const query = midPromptMatch[1] + return deriveSlashAutocomplete( query, - triggerKey, - prefix: '$', - grouped: false, - commandsEnabled: false, - skillsEnabled: true, - items: buildNativeChatPickerItems([], discovery.skills, query, '$', sessionSkillNames), - skillStatus: discovery.status === 'idle' ? 'loading' : discovery.status, - ...(discovery.errorKind ? { skillErrorKind: discovery.errorKind } : {}) - } + before.length - query.length - 1, + agentCommands, + profile, + discovery, + dismissedTriggerKey, + sessionSkillNames + ) } function deriveSlashAutocomplete( - before: string, + query: string, + triggerPosition: number, agentCommands: readonly SlashCommandSuggestion[], profile: NativeChatAgentProfile | null, discovery: NativeChatSkillDiscoverySnapshot, dismissedTriggerKey: string | null, sessionSkillNames: readonly string[] | undefined ): ComposerAutocomplete { - const triggerKey = '/:0' + const triggerKey = `/:${triggerPosition}` if (dismissedTriggerKey === triggerKey) { return { mode: 'none' } } - const query = before.slice(1) - const hasSlashSkills = profile?.skillPrefix === '/' - // Why: the caller owns catalog policy (e.g. Grok ships skills-only until a - // verified catalog lands); this derivation must not re-gate per agent. + // Every agent with a known grammar offers skills here; only the token a pick + // inserts differs. The caller owns catalog policy (e.g. Grok ships skills-only + // until a verified catalog lands), so this derivation must not re-gate per agent. + const skillsEnabled = profile !== null const items = buildNativeChatPickerItems( agentCommands, - hasSlashSkills ? discovery.skills : [], + skillsEnabled ? discovery.skills : [], query, - '/', - hasSlashSkills ? sessionSkillNames : [] + profile?.skillPrefix ?? '/', + skillsEnabled ? sessionSkillNames : [] ) return { mode: 'slash', query, triggerKey, prefix: '/', - grouped: profile?.groupedSlash === true, + dispatchable: triggerPosition === 0, + grouped: skillsEnabled, commandsEnabled: agentCommands.length > 0, - skillsEnabled: hasSlashSkills, + skillsEnabled, items, - skillStatus: hasSlashSkills + skillStatus: skillsEnabled ? discovery.status === 'idle' ? 'loading' : discovery.status : 'ready', - ...(hasSlashSkills && discovery.errorKind ? { skillErrorKind: discovery.errorKind } : {}) + ...(skillsEnabled && discovery.errorKind ? { skillErrorKind: discovery.errorKind } : {}) } } 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 427e3b74497..1a21a2461c6 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 @@ -18,6 +18,8 @@ export type NativeChatPickerItem = kind: 'command' id: string name: string + /** Exactly what a pick inserts — the form the agent invokes. */ + token: string description?: string skillCollision: boolean } @@ -25,6 +27,7 @@ export type NativeChatPickerItem = kind: 'skill' id: string name: string + token: string description: string | null sources: { sourceKind: SkillSourceKind; skillFilePath: string }[] } @@ -47,16 +50,24 @@ export function buildNativeChatPickerItems( commands: readonly SlashCommandSuggestion[], skills: readonly DiscoveredSkill[], query: string, - prefix: '/' | '$', + skillSigil: '/' | '$', sessionSkillNames?: readonly string[] ): NativeChatPickerItem[] { + // A name can only collide when both kinds invoke through the same sigil; + // where skills carry their own, `/review` and `$review` are distinct entries. + const sharedSigil = skillSigil === '/' const unclassifiedNames = new Set( commands.filter((command) => command.kindUnspecified).map((command) => command.name) ) - const mergedSkills = mergeNativeChatSkills(skills, sessionSkillNames, unclassifiedNames) + const mergedSkills = mergeNativeChatSkills( + skills, + sessionSkillNames, + unclassifiedNames, + skillSigil + ) const skillNames = new Set(mergedSkills.map((skill) => skill.name)) const resolvedCommands = commands.filter( - (command) => !(command.kindUnspecified && skillNames.has(command.name)) + (command) => !(sharedSigil && command.kindUnspecified && skillNames.has(command.name)) ) const commandNames = new Set(resolvedCommands.map((command) => command.name)) const commandItems = rankItems( @@ -67,8 +78,9 @@ export function buildNativeChatPickerItems( // it is inserted verbatim; only untrusted skill text gets sanitized. id: `command:${command.name}`, name: command.name, + token: `/${command.name}`, description: command.description ? sanitizePickerText(command.description, 240) : undefined, - skillCollision: prefix === '/' && skillNames.has(command.name) + skillCollision: sharedSigil && skillNames.has(command.name) }, stableOrder: index })), @@ -76,7 +88,7 @@ export function buildNativeChatPickerItems( ) const skillItems = rankItems( mergedSkills - .filter((skill) => !(prefix === '/' && commandNames.has(skill.name))) + .filter((skill) => !(sharedSigil && commandNames.has(skill.name))) .map((item, index) => ({ item, stableOrder: index })), query ) @@ -89,7 +101,8 @@ export function buildNativeChatPickerItems( function mergeNativeChatSkills( skills: readonly DiscoveredSkill[], sessionSkillNames: readonly string[] | undefined, - unclassifiedNames: ReadonlySet + unclassifiedNames: ReadonlySet, + skillSigil: '/' | '$' ): Extract[] { const exactPaths = new Map() for (const skill of skills) { @@ -106,7 +119,10 @@ function mergeNativeChatSkills( byName.set(safeName, [...(byName.get(safeName) ?? []), { ...skill, name: safeName }]) } const discovered = new Map( - [...byName.entries()].map(([name, namedSkills]) => [name, pickerSkill(name, namedSkills)]) + [...byName.entries()].map(([name, namedSkills]) => [ + name, + pickerSkill(name, namedSkills, skillSigil) + ]) ) // 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 @@ -121,19 +137,21 @@ function mergeNativeChatSkills( ] : [...discovered.keys()] return [...new Set(names)] - .map((name) => discovered.get(name) ?? pickerSkill(name, [])) + .map((name) => discovered.get(name) ?? pickerSkill(name, [], skillSigil)) .sort(comparePickerSkills) } function pickerSkill( name: string, - namedSkills: readonly DiscoveredSkill[] + namedSkills: readonly DiscoveredSkill[], + skillSigil: '/' | '$' ): Extract { const sorted = [...namedSkills].sort(compareDiscoveredSkills) return { kind: 'skill' as const, id: `skill:${name}`, name, + token: `${skillSigil}${name}`, description: sorted[0]?.description ? sanitizePickerText(sorted[0].description, 240) : null, sources: sorted.map((skill) => ({ sourceKind: skill.sourceKind, @@ -245,21 +263,27 @@ function comparePickerSkills( ) } +// `/` is the composer's only trigger, for every agent. A draft-leading slash is +// the one that can dispatch; elsewhere the token starts after whitespace and its +// query stops at the next `/` so file paths stay prose. +export const LEADING_SLASH_TRIGGER = /^\/(\S*)$/ +export const MID_PROMPT_SLASH_TRIGGER = /\s\/([^\s/]*)$/ + +/** Replaces the typed `/token` with the item's own token, which for a skill is + * the agent-native form even though every agent is typed the same way. */ export function applyPickerSuggestion( draft: string, caret: number, - item: NativeChatPickerItem, - prefix: '/' | '$' + item: NativeChatPickerItem ): { draft: string; caret: number; insertedToken: string } { const before = draft.slice(0, caret) const after = draft.slice(caret) - const match = prefix === '/' ? before.match(/^\/(\S*)$/) : before.match(/(^|\s)\$(\S*)$/) + const match = before.match(LEADING_SLASH_TRIGGER) ?? before.match(MID_PROMPT_SLASH_TRIGGER) if (!match) { return { draft, caret, insertedToken: '' } } const query = match.at(-1) ?? '' const tokenStart = before.length - query.length - 1 - const insertedToken = `${prefix}${item.name}` - const nextBefore = `${before.slice(0, tokenStart)}${insertedToken} ` - return { draft: nextBefore + after, caret: nextBefore.length, insertedToken } + const nextBefore = `${before.slice(0, tokenStart)}${item.token} ` + return { draft: nextBefore + after, caret: nextBefore.length, insertedToken: item.token } } diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-catalog.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-composer-catalog.test.tsx index 1c68ac91ffa..9e7b3abc5b2 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-catalog.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-catalog.test.tsx @@ -103,6 +103,7 @@ it('Enter completes a known pre-init skill while still dispatching a built-in co items, triggerKey: '/', prefix: '/', + dispatchable: true, grouped: true, commandsEnabled: true, skillsEnabled: true, diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-keydown.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-composer-keydown.test.tsx index ed3814e8476..bf39562169e 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-keydown.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-keydown.test.tsx @@ -2,13 +2,20 @@ import { renderHook } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' -import { EMPTY_HISTORY, type ComposerAutocomplete } from './native-chat-composer-state' +import { + applyPickerSuggestion, + deriveComposerAutocomplete, + EMPTY_HISTORY, + type ComposerAutocomplete +} from './native-chat-composer-state' +import { getNativeChatAgentProfile } from '../../../../shared/native-chat-agent-profiles' import { useNativeChatComposerKeyDown } from './use-native-chat-composer-keydown' const COMMAND = { kind: 'command' as const, id: 'command:clear', name: 'clear', + token: '/clear', description: 'Clear history', skillCollision: false } @@ -20,6 +27,7 @@ function picker(items = [COMMAND]): Extract composing, ...callbacks @@ -81,6 +89,36 @@ describe('useNativeChatComposerKeyDown', () => { expect(callbacks.send).toHaveBeenCalledOnce() }) + it.each(['claude', 'openclaude', 'codex', 'grok'] as const)( + 'completes mid-prompt command Enter without dispatching or losing prose for %s', + (agent) => { + const draft = 'Explain /cle before continuing' + const caret = 'Explain /cle'.length + const autocomplete = deriveComposerAutocomplete( + draft, + caret, + [COMMAND], + [], + getNativeChatAgentProfile(agent) + ) + expect(autocomplete.mode).toBe('slash') + const { handler, callbacks } = setup(autocomplete, false, draft) + const event = keyEvent('Enter') + handler(event as never) + + expect(event.preventDefault).toHaveBeenCalledOnce() + expect(callbacks.dispatchPickerCommand).not.toHaveBeenCalled() + expect(callbacks.send).not.toHaveBeenCalled() + expect(callbacks.completePickerItem).toHaveBeenCalledOnce() + const [item] = callbacks.completePickerItem.mock.calls[0] + expect(applyPickerSuggestion(draft, caret, item)).toEqual({ + draft: 'Explain /clear before continuing', + caret: 'Explain /clear '.length, + insertedToken: '/clear' + }) + } + ) + it('dismisses Escape without interrupting the agent', () => { const { handler, callbacks } = setup() handler(keyEvent('Escape') as never) diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-keydown.ts b/src/renderer/src/components/native-chat/use-native-chat-composer-keydown.ts index 2fb712f9825..c85ad949aad 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-keydown.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-keydown.ts @@ -51,7 +51,7 @@ export function useNativeChatComposerKeyDown({ return } - if (autocomplete.mode === 'slash' || autocomplete.mode === 'skill') { + if (autocomplete.mode === 'slash') { const items = autocomplete.items if (event.key === 'ArrowDown' && items.length > 0) { event.preventDefault() @@ -66,7 +66,9 @@ export function useNativeChatComposerKeyDown({ if ((event.key === 'Enter' || event.key === 'Tab') && items.length > 0) { event.preventDefault() const item = items[activeSuggestion] ?? items[0] - if (event.key === 'Enter' && item.kind === 'command') { + // A mid-prompt command is part of the sentence being written, so Enter + // completes the token instead of sending the command on its own. + if (event.key === 'Enter' && item.kind === 'command' && autocomplete.dispatchable) { dispatchPickerCommand(item) } else { completePickerItem(item) diff --git a/src/renderer/src/components/native-chat/use-native-chat-picker-command-dispatch.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-picker-command-dispatch.test.tsx index 90c495a2bc7..7584dcc15a4 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-picker-command-dispatch.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-picker-command-dispatch.test.tsx @@ -23,6 +23,7 @@ const COMMAND = { kind: 'command' as const, id: 'command:status', name: 'status', + token: '/status', description: 'Show status', skillCollision: false } 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 d96060342e5..9d4054b433e 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 @@ -18,6 +18,7 @@ import { classifyNativeChatSend, deriveComposerAutocomplete, editReplacesTriggerToken, + isSkillPickerTriggered, type ComposerAutocomplete, type NativeChatPickerItem, type NativeChatSendClassification @@ -68,13 +69,7 @@ export function useNativeChatPickerState(args: { setActiveSuggestion } = args const profile = useMemo(() => getNativeChatAgentProfile(agent), [agent]) - const beforeCaret = draft.slice(0, caret) - const skillPickerTriggered = - profile?.skillPrefix === '$' - ? /(?:^|\s)\$\S*$/.test(beforeCaret) - : profile?.skillPrefix === '/' - ? beforeCaret.startsWith('/') && !/\s/.test(beforeCaret) - : false + const skillPickerTriggered = isSkillPickerTriggered(draft.slice(0, caret), profile) const discovery = useNativeChatSkills(agent, terminalTabId, skillPickerTriggered) const listboxId = `native-chat-picker-${useId().replaceAll(':', '')}` const dismissalContext = `${draftScopeKey}:${agent}` @@ -114,7 +109,7 @@ export function useNativeChatPickerState(args: { }, [dismissalContext]) useEffect(() => { - if (autocomplete.mode !== 'slash' && autocomplete.mode !== 'skill') { + if (autocomplete.mode !== 'slash') { lastOpenKeyRef.current = null return } @@ -127,10 +122,10 @@ export function useNativeChatPickerState(args: { const completeItem = useCallback( (item: NativeChatPickerItem) => { - if (autocomplete.mode !== 'slash' && autocomplete.mode !== 'skill') { + if (autocomplete.mode !== 'slash') { return } - const result = applyPickerSuggestion(draft, caret, item, autocomplete.prefix) + const result = applyPickerSuggestion(draft, caret, item) if (item.kind === 'skill' && textareaRef.current?.insertSkill) { const from = result.caret - result.insertedToken.length - 1 textareaRef.current.insertSkill(from, caret, result.insertedToken) @@ -174,10 +169,7 @@ export function useNativeChatPickerState(args: { null, sessionSkillNames ) - if ( - (next.mode !== 'slash' && next.mode !== 'skill') || - next.triggerKey !== dismissed.triggerKey - ) { + if (next.mode !== 'slash' || next.triggerKey !== dismissed.triggerKey) { setDismissed(null) } }, diff --git a/src/renderer/src/components/native-chat/use-native-chat-skills.react.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-skills.react.test.tsx index f360394cf37..e05cbb7a870 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-skills.react.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-skills.react.test.tsx @@ -3,6 +3,8 @@ import { act, cleanup, render, waitFor } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { NativeChatSkillDiscovery } from './use-native-chat-skills' +import { getNativeChatAgentProfile } from '../../../../shared/native-chat-agent-profiles' +import { isSkillPickerTriggered } from './native-chat-composer-state' const mocks = vi.hoisted(() => ({ callRuntimeRpc: vi.fn(), @@ -56,6 +58,10 @@ function Probe({ enabled }: { enabled: boolean }): null { return null } +function DraftProbe({ draft }: { draft: string }): React.JSX.Element { + return +} + describe('useNativeChatSkills', () => { beforeEach(() => { mocks.state = stateForHost('local') @@ -132,6 +138,24 @@ describe('useNativeChatSkills', () => { ) }) + it('reuses one discovery while typing and reopening leading and mid-prompt slash tokens', async () => { + const view = render() + expect(mocks.callRuntimeRpc).not.toHaveBeenCalled() + + view.rerender() + await waitFor(() => expect(mocks.snapshots.at(-1)?.status).toBe('ready')) + for (const draft of ['Explain /b', 'Explain /br', 'Explain /bro']) { + view.rerender() + expect(mocks.snapshots.at(-1)?.skills.map((skill) => skill.name)).toEqual(['browser']) + } + view.rerender() + expect(mocks.snapshots.at(-1)?.status).toBe('idle') + view.rerender() + await waitFor(() => expect(mocks.snapshots.at(-1)?.status).toBe('ready')) + view.rerender() + expect(mocks.callRuntimeRpc).toHaveBeenCalledTimes(1) + }) + it('surfaces discovery failure instead of remaining loading', async () => { mocks.callRuntimeRpc.mockRejectedValueOnce(new Error('scan failed')) render() diff --git a/src/shared/native-chat-agent-profiles.test.ts b/src/shared/native-chat-agent-profiles.test.ts index 27b7e07d33b..4d64e283c01 100644 --- a/src/shared/native-chat-agent-profiles.test.ts +++ b/src/shared/native-chat-agent-profiles.test.ts @@ -2,24 +2,23 @@ import { describe, expect, it } from 'vitest' import { getNativeChatAgentProfile } from './native-chat-agent-profiles' describe('native chat agent picker profiles', () => { - it('keeps Codex dollar skills separate from slash commands', () => { + // The composer types the same `/` for every agent; skillPrefix is only the + // form a picked skill is written as. + it('keeps Codex skills invocable as dollar tokens', () => { expect(getNativeChatAgentProfile('codex')).toMatchObject({ skillPrefix: '$', - groupedSlash: false, skillSourceOwner: 'codex' }) }) - it('groups Claude-family and Grok skills under slash', () => { + it('writes Claude-family and Grok skills as slash tokens', () => { expect(getNativeChatAgentProfile('claude')).toMatchObject({ skillPrefix: '/', - groupedSlash: true, skillSourceOwner: 'claude' }) expect(getNativeChatAgentProfile('openclaude')).toMatchObject({ skillSourceOwner: 'claude' }) expect(getNativeChatAgentProfile('grok')).toMatchObject({ skillPrefix: '/', - groupedSlash: true, skillSourceOwner: 'grok' }) }) diff --git a/src/shared/native-chat-agent-profiles.ts b/src/shared/native-chat-agent-profiles.ts index ae52b48efef..6f86313d17d 100644 --- a/src/shared/native-chat-agent-profiles.ts +++ b/src/shared/native-chat-agent-profiles.ts @@ -3,7 +3,6 @@ import { getAgentSlashCommands, type SlashCommandSuggestion } from './native-cha export type NativeChatAgentProfile = { skillPrefix: '$' | '/' - groupedSlash: boolean /** OpenClaude reads Claude-owned roots, so this can differ from the agent. */ skillSourceOwner: AgentType } @@ -11,22 +10,18 @@ export type NativeChatAgentProfile = { const NATIVE_CHAT_AGENT_PROFILES: Partial> = { codex: { skillPrefix: '$', - groupedSlash: false, skillSourceOwner: 'codex' }, claude: { skillPrefix: '/', - groupedSlash: true, skillSourceOwner: 'claude' }, openclaude: { skillPrefix: '/', - groupedSlash: true, skillSourceOwner: 'claude' }, grok: { skillPrefix: '/', - groupedSlash: true, skillSourceOwner: 'grok' } }