diff --git a/apps/desktop/src/app/chat/composer/composer-utils.ts b/apps/desktop/src/app/chat/composer/composer-utils.ts index 7939be35b6b..547a210f06f 100644 --- a/apps/desktop/src/app/chat/composer/composer-utils.ts +++ b/apps/desktop/src/app/chat/composer/composer-utils.ts @@ -50,6 +50,9 @@ export function slashChipKindForItem(item: Unstable_TriggerItem): SlashChipKind return 'command' } +/** True for a skill completion — the only kind offered mid-message. */ +export const isSkillItem = (item: Unstable_TriggerItem) => slashChipKindForItem(item) === 'skill' + /** A `/` query is at its arg stage once it's past the command name. */ export const slashArgStage = (query: string) => query.includes(' ') diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-trigger.test.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-trigger.test.ts index 62a194da20d..c356fae3af8 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-composer-trigger.test.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-trigger.test.ts @@ -25,11 +25,11 @@ function mountEditor(text: string) { return editor } -const item = (command: string): Unstable_TriggerItem => ({ +const item = (command: string, group = 'Skills'): Unstable_TriggerItem => ({ id: command, type: 'slash', label: command.slice(1), - metadata: { command, display: command, meta: '', group: 'Skills', action: '', rawText: command } + metadata: { command, display: command, meta: '', group, action: '', rawText: command } }) function mountTrigger(editor: HTMLDivElement, items: Unstable_TriggerItem[]) { @@ -83,6 +83,25 @@ describe('useComposerTrigger — slash anywhere in the prompt', () => { expect(composerPlainText(editor)).toBe('please run /clean ') }) + it('offers only skills mid-message, not app commands', () => { + // `/model` and `/new` act on the app — meaningless as a reference in prose. + const editor = mountEditor('please run /') + const { hook } = mountTrigger(editor, [item('/clean'), item('/model', 'Commands'), item('/new', 'Commands')]) + + act(() => hook.result.current.refreshTrigger()) + + expect(hook.result.current.triggerItems.map(i => i.label)).toEqual(['clean']) + }) + + it('still offers the full command set at the start of the prompt', () => { + const editor = mountEditor('/') + const { hook } = mountTrigger(editor, [item('/clean'), item('/model', 'Commands')]) + + act(() => hook.result.current.refreshTrigger()) + + expect(hook.result.current.triggerItems.map(i => i.label)).toEqual(['clean', 'model']) + }) + it('still opens the list for a slash at the start of the prompt', () => { const editor = mountEditor('/cle') const { hook } = mountTrigger(editor, [item('/clean')]) diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-trigger.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-trigger.ts index e4ac85bd770..3c22fddb634 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-composer-trigger.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-trigger.ts @@ -4,7 +4,13 @@ import { type MutableRefObject, type RefObject, useCallback, useEffect, useRef, import { hermesDirectiveFormatter } from '@/components/assistant-ui/directive-text' import { desktopSlashCommandTakesArgs } from '@/lib/desktop-slash-commands' -import { COMPLETION_ACTIONS, slashArgStage, slashChipKindForItem, slashCommandToken } from '../composer-utils' +import { + COMPLETION_ACTIONS, + isSkillItem, + slashArgStage, + slashChipKindForItem, + slashCommandToken +} from '../composer-utils' import { composerPlainText, placeCaretEnd, @@ -112,7 +118,13 @@ export function useComposerTrigger({ return } - setTriggerItems(triggerAdapter.search(trigger.query)) + const items = triggerAdapter.search(trigger.query) + + // Mid-message only offers SKILLS. A built-in like `/model` or `/new` acts + // on the app, so it's meaningless as a reference inside prose — only a + // skill reads as "handle this part with X". Filtering here rather than in + // the fetcher keeps one completion source for both shapes. + setTriggerItems(trigger.inline ? items.filter(isSkillItem) : items) }, [trigger, triggerAdapter]) const triggerLoading = trigger?.kind === '@' ? at.loading : trigger?.kind === '/' ? slash.loading : false diff --git a/apps/desktop/src/components/assistant-ui/directive-text.test.ts b/apps/desktop/src/components/assistant-ui/directive-text.test.ts index 36ab54d743e..d5f7074e5d1 100644 --- a/apps/desktop/src/components/assistant-ui/directive-text.test.ts +++ b/apps/desktop/src/components/assistant-ui/directive-text.test.ts @@ -47,3 +47,42 @@ describe('hermesDirectiveFormatter.parse', () => { ]) }) }) + +describe('inline skill references', () => { + const skills = (text: string) => + [...hermesDirectiveFormatter.parse(text)] + .filter(segment => segment.kind === 'mention' && segment.type === 'skill') + .map(segment => (segment.kind === 'mention' ? segment.id : '')) + + it('keeps a picked skill a chip in the sent message instead of flattening it', () => { + expect(skills('please run /clean on this')).toEqual(['/clean']) + }) + + it('keeps the surrounding prose as text around the chip', () => { + const segments = hermesDirectiveFormatter.parse('tidy this with /clean thanks') + + expect(segments).toEqual([ + { kind: 'text', text: 'tidy this with ' }, + { kind: 'mention', type: 'skill', label: 'clean', id: '/clean' }, + { kind: 'text', text: ' thanks' } + ]) + }) + + it('leaves file paths and fractions alone', () => { + expect(skills('check src/foo/bar')).toEqual([]) + expect(skills('look at /usr/local/bin')).toEqual([]) + expect(skills('roughly 3 /4 of it')).toEqual([]) + }) + + it('does not chip a leading slash — that is a command invocation, not prose', () => { + expect(skills('/clean')).toEqual([]) + }) + + it('parses a skill chip alongside an @ reference', () => { + const mentions = [...hermesDirectiveFormatter.parse('run /clean on @file:`src/a.ts`')].filter( + segment => segment.kind === 'mention' + ) + + expect(mentions.map(segment => (segment.kind === 'mention' ? segment.type : ''))).toEqual(['skill', 'file']) + }) +}) diff --git a/apps/desktop/src/components/assistant-ui/directive-text.tsx b/apps/desktop/src/components/assistant-ui/directive-text.tsx index 6ea2580f16b..c9a45f2d3f7 100644 --- a/apps/desktop/src/components/assistant-ui/directive-text.tsx +++ b/apps/desktop/src/components/assistant-ui/directive-text.tsx @@ -171,6 +171,17 @@ const HERMES_DIRECTIVE_RE = new RegExp( 'g' ) +// A skill referenced mid-prose (`clean this up with /clean`). The composer +// inserts it as a pill, so the sent message renders it as one too rather than +// flattening back to raw text. Only matches after whitespace — a leading `/` +// is a command invocation, which never reaches a rendered message as text. +// +// Unlike the composer's caret-anchored trigger, this scans finished text, so +// it must reject a token that continues into a path: `/usr/local/bin` would +// otherwise chip as `/usr`. `(?![\w-]*\/)` requires the token to end at +// something other than another slash. +const SLASH_SKILL_RE = /(?<=\s)\/([a-zA-Z][\w-]*)(?![\w-]*\/)/g + const TRAILING_PUNCTUATION_RE = /[,.;!?]+$/ function unwrapRefValue(raw: string): string { @@ -269,7 +280,14 @@ function parseDirectiveText(text: string): Unstable_DirectiveSegment[] { label: shortLabel(match[1] as HermesRefType, id), id } - }) + }), + ...Array.from(text.matchAll(SLASH_SKILL_RE)).map(match => ({ + start: match.index ?? 0, + end: (match.index ?? 0) + match[0].length, + type: 'skill', + label: match[1], + id: `/${match[1]}` + })) ] .filter(match => match.id) .sort((a, b) => a.start - b.start) @@ -369,6 +387,8 @@ export function DirectiveContent({ text }: { text: string }) { {segment.text} ) : segment.type === 'image' ? null : segment.type === 'session' ? ( + ) : segment.type === 'skill' ? ( + ) : ( ) @@ -505,6 +525,28 @@ export const SessionRefLink: FC<{ ) } +/** A skill referenced inside a sent message — the rendered twin of the + * composer's slash pill, so a picked skill stays a chip after send. */ +const SlashChip: FC<{ kind: SlashChipKind; label: string; value: string }> = ({ kind, label, value }) => ( + + + {SLASH_ICON_PATHS[kind].map(d => ( + + ))} + + {label} + +) + /** Inert by default; `onClick` promotes the chip to a real button (session * refs, which open the session they name). */ const DirectiveChip: FC<{