diff --git a/ui-tui/src/__tests__/useCompletion.test.ts b/ui-tui/src/__tests__/useCompletion.test.ts new file mode 100644 index 0000000000..67a9fcfea8 --- /dev/null +++ b/ui-tui/src/__tests__/useCompletion.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest' + +import { completionRequestForInput } from '../hooks/useCompletion.js' + +describe('completionRequestForInput', () => { + it('routes real slash commands to slash completion', () => { + expect(completionRequestForInput('/help')).toMatchObject({ + method: 'complete.slash', + params: { text: '/help' }, + replaceFrom: 1 + }) + }) + + it('does not route absolute paths through slash completion', () => { + expect( + completionRequestForInput('/home/d/Desktop/agenda/CrimsonRed/.hermes/plans/2026-05-04-HANDOFF-NEXT.md') + ).toMatchObject({ + method: 'complete.path', + params: { word: '/home/d/Desktop/agenda/CrimsonRed/.hermes/plans/2026-05-04-HANDOFF-NEXT.md' }, + replaceFrom: 0 + }) + }) + + it('keeps path completion for trailing absolute path tokens', () => { + expect(completionRequestForInput('read /home/d/Desktop/file.md')).toMatchObject({ + method: 'complete.path', + params: { word: '/home/d/Desktop/file.md' }, + replaceFrom: 5 + }) + }) + + it('leaves plain text alone', () => { + expect(completionRequestForInput('hello there')).toBeNull() + }) +}) diff --git a/ui-tui/src/hooks/useCompletion.ts b/ui-tui/src/hooks/useCompletion.ts index 08bd4945d7..6bafc35843 100644 --- a/ui-tui/src/hooks/useCompletion.ts +++ b/ui-tui/src/hooks/useCompletion.ts @@ -1,12 +1,43 @@ import { useEffect, useRef, useState } from 'react' import type { CompletionItem } from '../app/interfaces.js' +import { looksLikeSlashCommand } from '../domain/slash.js' import type { GatewayClient } from '../gatewayClient.js' import type { CompletionResponse } from '../gatewayTypes.js' import { asRpcResult } from '../lib/rpc.js' const TAB_PATH_RE = /((?:["']?(?:[A-Za-z]:[\\/]|\.{1,2}\/|~\/|\/|@|[^"'`\s]+\/))[^\s]*)$/ +export function completionRequestForInput( + input: string +): + | { method: 'complete.path'; params: { word: string }; replaceFrom: number } + | { method: 'complete.slash'; params: { text: string }; replaceFrom: number } + | null { + const isSlashCommand = looksLikeSlashCommand(input) + const pathWord = isSlashCommand ? null : (input.match(TAB_PATH_RE)?.[1] ?? null) + + if (!isSlashCommand && !pathWord) { + return null + } + + // `/model` / `/provider` use the two-step ModelPicker (real curated IDs). + // Slash completion here only showed short aliases + vendor/family meta. + if (isSlashCommand && /^\/(?:model|provider)(?:\s|$)/.test(input)) { + return null + } + + if (isSlashCommand) { + return { method: 'complete.slash', params: { text: input }, replaceFrom: 1 } + } + + return { + method: 'complete.path', + params: { word: pathWord! }, + replaceFrom: input.length - pathWord!.length + } +} + export function useCompletion(input: string, blocked: boolean, gw: GatewayClient) { const [completions, setCompletions] = useState([]) const [compIdx, setCompIdx] = useState(0) @@ -33,35 +64,19 @@ export function useCompletion(input: string, blocked: boolean, gw: GatewayClient ref.current = input - const isSlash = input.startsWith('/') - const pathWord = isSlash ? null : (input.match(TAB_PATH_RE)?.[1] ?? null) - - if (!isSlash && !pathWord) { + const request = completionRequestForInput(input) + if (!request) { clear() return } - // `/model` / `/provider` use the two-step ModelPicker (real curated IDs). - // Slash completion here only showed short aliases + vendor/family meta. - if (isSlash && /^\/(?:model|provider)(?:\s|$)/.test(input)) { - clear() - - return - } - - const pathReplace = input.length - (pathWord?.length ?? 0) - const t = setTimeout(() => { if (ref.current !== input) { return } - const req = isSlash - ? gw.request('complete.slash', { text: input }) - : gw.request('complete.path', { word: pathWord }) - - req + gw.request(request.method, request.params) .then(raw => { if (ref.current !== input) { return @@ -71,7 +86,7 @@ export function useCompletion(input: string, blocked: boolean, gw: GatewayClient setCompletions(r?.items ?? []) setCompIdx(0) - setCompReplace(isSlash ? (r?.replace_from ?? 1) : pathReplace) + setCompReplace(request.method === 'complete.slash' ? (r?.replace_from ?? 1) : request.replaceFrom) }) .catch((e: unknown) => { if (ref.current !== input) { @@ -86,7 +101,7 @@ export function useCompletion(input: string, blocked: boolean, gw: GatewayClient } ]) setCompIdx(0) - setCompReplace(isSlash ? 1 : pathReplace) + setCompReplace(request.replaceFrom) }) }, 60)