fix(native-chat): one / picker for every agent, anywhere in the prompt (#19832)

* 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 <sim@local>
This commit is contained in:
Brennan Benson
2026-09-09 23:21:57 -07:00
committed by GitHub
co-authored by Merge Sim
parent 34790ce084
commit dda103d2cf
14 changed files with 342 additions and 137 deletions
@@ -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' }]
}
@@ -12,7 +12,7 @@ export const NativeChatPickerMenu = memo(function NativeChatPickerMenu({
onChoose,
onRetry
}: {
autocomplete: Extract<ComposerAutocomplete, { mode: 'slash' | 'skill' }>
autocomplete: Extract<ComposerAutocomplete, { mode: 'slash' }>
activeIndex: number
listboxId: string
onChoose: (item: NativeChatPickerItem) => void
@@ -54,7 +54,6 @@ export const NativeChatPickerMenu = memo(function NativeChatPickerMenu({
<PickerOption
key={item.id}
item={item}
prefix={autocomplete.prefix}
index={index}
activeIndex={activeIndex}
listboxId={listboxId}
@@ -102,7 +101,6 @@ export const NativeChatPickerMenu = memo(function NativeChatPickerMenu({
<PickerOption
key={item.id}
item={item}
prefix={autocomplete.prefix}
index={index}
activeIndex={activeIndex}
listboxId={listboxId}
@@ -140,9 +138,9 @@ export const NativeChatPickerMenu = memo(function NativeChatPickerMenu({
})
function getPickerEmptyText(
autocomplete: Extract<ComposerAutocomplete, { mode: 'slash' | 'skill' }>
autocomplete: Extract<ComposerAutocomplete, { mode: 'slash' }>
): 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({
<Package className="mt-0.5 size-3.5 shrink-0 text-muted-foreground" />
) : null}
<span className="min-w-0 flex-1">
<span className="block truncate font-mono font-medium">{prefix + item.name}</span>
<span className="block truncate font-mono font-medium">{item.token}</span>
{item.description ? (
<span className="block truncate text-xs text-muted-foreground">{item.description}</span>
) : null}
@@ -166,7 +166,7 @@ export function NativeChatComposerField({
{/* Extra bottom padding keeps the input box off the window rim. */}
<div className="px-3 pt-2 pb-4 sm:px-4">
<div className="relative mx-auto w-full max-w-4xl">
{autocomplete.mode === 'slash' || autocomplete.mode === 'skill' ? (
{autocomplete.mode === 'slash' ? (
<NativeChatPickerMenu
autocomplete={autocomplete}
activeIndex={activeSuggestion}
@@ -239,15 +239,10 @@ export function NativeChatComposerField({
}}
onPasteCapture={onPaste}
onSelect={onTextareaSelect}
aria-expanded={autocomplete.mode === 'slash' || autocomplete.mode === 'skill'}
aria-controls={
autocomplete.mode === 'slash' || autocomplete.mode === 'skill'
? pickerListboxId
: undefined
}
aria-expanded={autocomplete.mode === 'slash'}
aria-controls={autocomplete.mode === 'slash' ? pickerListboxId : undefined}
aria-activedescendant={
(autocomplete.mode === 'slash' || autocomplete.mode === 'skill') &&
autocomplete.items.length > 0
autocomplete.mode === 'slash' && autocomplete.items.length > 0
? `${pickerListboxId}-option-${Math.min(activeSuggestion, autocomplete.items.length - 1)}`
: undefined
}
@@ -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')
})
@@ -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 } : {})
}
}
@@ -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<string>
unclassifiedNames: ReadonlySet<string>,
skillSigil: '/' | '$'
): Extract<NativeChatPickerItem, { kind: 'skill' }>[] {
const exactPaths = new Map<string, DiscoveredSkill>()
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<NativeChatPickerItem, { kind: 'skill' }> {
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 }
}
@@ -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,
@@ -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<ComposerAutocomplete, { mode: 'slash
items,
triggerKey: '/:0',
prefix: '/',
dispatchable: true,
grouped: false,
commandsEnabled: true,
skillsEnabled: false,
@@ -27,7 +35,7 @@ function picker(items = [COMMAND]): Extract<ComposerAutocomplete, { mode: 'slash
}
}
function setup(autocomplete: ComposerAutocomplete = picker(), composing = false) {
function setup(autocomplete: ComposerAutocomplete = picker(), composing = false, draft = '/') {
const callbacks = {
completePickerItem: vi.fn(),
dispatchPickerCommand: vi.fn(),
@@ -43,7 +51,7 @@ function setup(autocomplete: ComposerAutocomplete = picker(), composing = false)
useNativeChatComposerKeyDown({
autocomplete,
activeSuggestion: 0,
draft: '/',
draft,
history: EMPTY_HISTORY,
isComposing: () => 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)
@@ -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)
@@ -23,6 +23,7 @@ const COMMAND = {
kind: 'command' as const,
id: 'command:status',
name: 'status',
token: '/status',
description: 'Show status',
skillCollision: false
}
@@ -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)
}
},
@@ -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 <Probe enabled={isSkillPickerTriggered(draft, getNativeChatAgentProfile('codex'))} />
}
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(<DraftProbe draft="Explain" />)
expect(mocks.callRuntimeRpc).not.toHaveBeenCalled()
view.rerender(<DraftProbe draft="Explain /" />)
await waitFor(() => expect(mocks.snapshots.at(-1)?.status).toBe('ready'))
for (const draft of ['Explain /b', 'Explain /br', 'Explain /bro']) {
view.rerender(<DraftProbe draft={draft} />)
expect(mocks.snapshots.at(-1)?.skills.map((skill) => skill.name)).toEqual(['browser'])
}
view.rerender(<DraftProbe draft="Explain $browser " />)
expect(mocks.snapshots.at(-1)?.status).toBe('idle')
view.rerender(<DraftProbe draft="/" />)
await waitFor(() => expect(mocks.snapshots.at(-1)?.status).toBe('ready'))
view.rerender(<DraftProbe draft="/bro" />)
expect(mocks.callRuntimeRpc).toHaveBeenCalledTimes(1)
})
it('surfaces discovery failure instead of remaining loading', async () => {
mocks.callRuntimeRpc.mockRejectedValueOnce(new Error('scan failed'))
render(<Probe enabled />)
@@ -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'
})
})
-5
View File
@@ -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<Record<AgentType, NativeChatAgentProfile>> = {
codex: {
skillPrefix: '$',
groupedSlash: false,
skillSourceOwner: 'codex'
},
claude: {
skillPrefix: '/',
groupedSlash: true,
skillSourceOwner: 'claude'
},
openclaude: {
skillPrefix: '/',
groupedSlash: true,
skillSourceOwner: 'claude'
},
grok: {
skillPrefix: '/',
groupedSlash: true,
skillSourceOwner: 'grok'
}
}