diff --git a/apps/desktop/src/app/chat/composer/hooks/use-slash-completions.ts b/apps/desktop/src/app/chat/composer/hooks/use-slash-completions.ts index f3344158097b..733f0cc84a16 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-slash-completions.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-slash-completions.ts @@ -67,10 +67,30 @@ export function useSlashCompletions(options: { gateway: HermesGateway | null }): return { items, query } } - const result = await gateway.request<{ items?: CompletionEntry[] }>('complete.slash', { text }) + const result = await gateway.request<{ items?: CompletionEntry[]; replace_from?: number }>( + 'complete.slash', + { text } + ) + + // Arg-completion items (replace_from > 1) carry just the arg stub — + // e.g. complete.slash returns `{text: "alice"}` for `/personality alic` + // with replace_from = 14. Rewrite those entries so the popover inserts + // the full `/personality alice` token instead of stranding `/alice`. + const replaceFrom = typeof result.replace_from === 'number' ? result.replace_from : 1 + const isArgCompletion = replaceFrom > 1 + const prefix = isArgCompletion ? text.slice(0, replaceFrom) : '' const items = (result.items ?? []) - .filter(item => isDesktopSlashSuggestion(item.text)) + .map(item => { + if (!isArgCompletion) { + return item + } + + const argText = typeof item.text === 'string' ? item.text : '' + + return { ...item, text: `${prefix}${argText}` } + }) + .filter(item => isArgCompletion || isDesktopSlashSuggestion(item.text)) .map(item => ({ ...item, meta: desktopSlashDescription(item.text, textValue(item.meta)) diff --git a/apps/desktop/src/app/chat/composer/text-utils.test.ts b/apps/desktop/src/app/chat/composer/text-utils.test.ts index 5ef677f4d0fe..f80e6db4385d 100644 --- a/apps/desktop/src/app/chat/composer/text-utils.test.ts +++ b/apps/desktop/src/app/chat/composer/text-utils.test.ts @@ -22,6 +22,33 @@ describe('detectTrigger', () => { it('returns null for plain text', () => { expect(detectTrigger('hello there')).toBeNull() }) + + it('keeps the slash trigger live while typing args', () => { + expect(detectTrigger('/personality ')).toEqual({ + kind: '/', + query: 'personality ', + tokenLength: 13 + }) + expect(detectTrigger('/personality alic')).toEqual({ + kind: '/', + query: 'personality alic', + tokenLength: 17 + }) + expect(detectTrigger('/tools enable foo')).toEqual({ + kind: '/', + query: 'tools enable foo', + tokenLength: 17 + }) + }) + + it('does not treat file-style paths as slash triggers', () => { + expect(detectTrigger('src/foo/bar')).toBeNull() + expect(detectTrigger('/path/to/file')).toBeNull() + }) + + it('still anchors at-mention triggers strictly at the token edge', () => { + expect(detectTrigger('@file:path with space')).toBeNull() + }) }) describe('extractClipboardImageBlobs', () => { diff --git a/apps/desktop/src/app/chat/composer/text-utils.ts b/apps/desktop/src/app/chat/composer/text-utils.ts index e9a8fb6aaee7..4535d6963c3a 100644 --- a/apps/desktop/src/app/chat/composer/text-utils.ts +++ b/apps/desktop/src/app/chat/composer/text-utils.ts @@ -6,7 +6,13 @@ export interface TriggerState { tokenLength: number } -const TRIGGER_RE = /(?:^|[\s])([@/])([^\s@/]*)$/ +// `@` triggers stop at the first whitespace — `@file:path` and `@diff` are +// single tokens. `/` triggers keep going so the popover stays live while the +// user types args (`/personality alic` → arg completer suggests `alice`). +// Restricting the slash command name to `[a-zA-Z][\w-]*` avoids matching file +// paths like `src/foo/bar`. +const AT_TRIGGER_RE = /(?:^|[\s])(@)([^\s@/]*)$/ +const SLASH_TRIGGER_RE = /(?:^|[\s])(\/)((?:[a-zA-Z][\w-]*(?:\s+\S*)*)?)$/ /** Stable key for paste dedupe — `items` and `files` often mirror the same image as different objects. */ export function blobDedupeKey(blob: Blob): string { @@ -97,11 +103,17 @@ export function textBeforeCaret(editor: HTMLDivElement): string | null { } export function detectTrigger(textBefore: string): TriggerState | null { - const match = TRIGGER_RE.exec(textBefore) + const slash = SLASH_TRIGGER_RE.exec(textBefore) - if (!match) { - return null + if (slash) { + return { kind: '/', query: slash[2], tokenLength: 1 + slash[2].length } } - return { kind: match[1] as '@' | '/', query: match[2], tokenLength: 1 + match[2].length } + const at = AT_TRIGGER_RE.exec(textBefore) + + if (at) { + return { kind: '@', query: at[2], tokenLength: 1 + at[2].length } + } + + return null }