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 }) => (
+
+
+ {label}
+
+)
+
/** Inert by default; `onClick` promotes the chip to a real button (session
* refs, which open the session they name). */
const DirectiveChip: FC<{