mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
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:
co-authored by
Merge Sim
parent
34790ce084
commit
dda103d2cf
@@ -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)
|
||||
|
||||
+1
@@ -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'
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user