mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
fix(desktop): scope mid-message references to skills and keep them as chips
Two corrections to the inline slash reference. Only skills are offered mid-message now. A built-in like `/model` or `/new` acts on the app, so it reads as nothing useful in the middle of a sentence — whereas a skill is exactly the thing you want to point at while describing work. A leading `/` is unchanged and still offers the full command set. A picked skill also stays a pill in the sent message. The composer already inserts one, but the message renderer knew nothing about slash references and flattened it back to raw text on send. It now parses a mid-prose `/skill` into a chip segment and renders it with the same styling the composer uses. The submitted text is untouched — the chip still round-trips to the literal `/clean` the backend expects — so this is presentation only. Path-like tokens (`/usr/local/bin`) are excluded: unlike the caret-anchored composer trigger, this scans finished text, so it also has to reject a token that runs on into a path.
This commit is contained in:
parent
bfc8e3b00d
commit
412a535433
5 changed files with 120 additions and 5 deletions
|
|
@ -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(' ')
|
||||
|
||||
|
|
|
|||
|
|
@ -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')])
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 }) {
|
|||
<Fragment key={`t-${index}`}>{segment.text}</Fragment>
|
||||
) : segment.type === 'image' ? null : segment.type === 'session' ? (
|
||||
<SessionRefChip key={`m-${index}-${segment.id}`} label={segment.label} value={segment.id} />
|
||||
) : segment.type === 'skill' ? (
|
||||
<SlashChip key={`m-${index}-${segment.id}`} kind="skill" label={segment.label} value={segment.id} />
|
||||
) : (
|
||||
<DirectiveChip id={segment.id} key={`m-${index}-${segment.id}`} label={segment.label} type={segment.type} />
|
||||
)
|
||||
|
|
@ -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 }) => (
|
||||
<span className={slashChipClass(kind)} data-slot="aui_slash-chip" title={value}>
|
||||
<svg
|
||||
className="size-3 shrink-0 opacity-80"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
{SLASH_ICON_PATHS[kind].map(d => (
|
||||
<path d={d} key={d} />
|
||||
))}
|
||||
</svg>
|
||||
<span className="truncate">{label}</span>
|
||||
</span>
|
||||
)
|
||||
|
||||
/** Inert by default; `onClick` promotes the chip to a real button (session
|
||||
* refs, which open the session they name). */
|
||||
const DirectiveChip: FC<{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue