/** * useCompletion drives the composer's "@" and "/" popups: it derives the * active trigger from the text and caret, fetches file hits or skills, * filters them, and exposes the keyboard/selection state. * * @example * const completion = useCompletion({ text, caret, commands }); * if (completion.open) { … render completion.items[completion.active] … } */ import { useEffect, useMemo, useState } from "react"; import { listSkills, searchFiles, type Skill } from "./api"; import { type CompletionItem, type Trigger, detectTrigger, fileItem, matchesQuery, skillItems, } from "./mentions"; import type { AcpAvailableCommand } from "./protocol"; /** Delay before a file search runs, so fast typing sends one request. */ const SEARCH_DEBOUNCE_MS = 120; const MAX_FILE_HITS = 30; interface CompletionOptions { text: string; caret: number; commands: AcpAvailableCommand[]; } export interface Completion { /** True when a popup with at least one item should show. */ open: boolean; trigger: Trigger | null; items: CompletionItem[]; /** Index of the highlighted item. */ active: number; setActive: (index: number) => void; moveActive: (delta: number) => void; /** Closes the popup until the trigger changes. */ dismiss: () => void; } export function useCompletion({ text, caret, commands, }: CompletionOptions): Completion { const trigger = useMemo(() => detectTrigger(text, caret), [text, caret]); const triggerKey = trigger ? `${trigger.kind}:${trigger.start}` : ""; const fileHits = useFileSearch( trigger?.kind === "file" ? trigger.query : null, ); const skills = useSkills(trigger?.kind === "skill"); const items = useMemo(() => { if (!trigger) { return []; } if (trigger.kind === "file") { return fileHits.map(fileItem); } return skillItems(skills, commands).filter((item) => matchesQuery(item, trigger.query), ); }, [trigger, fileHits, skills, commands]); // The highlighted index is remembered per item list: a new trigger or a // changed list starts again from the first item. const listKey = `${triggerKey}:${items.length}`; const [highlight, setHighlight] = useState({ key: "", index: 0 }); const [dismissedKey, setDismissedKey] = useState(""); const active = highlight.key === listKey ? highlight.index : 0; const setActive = (index: number) => setHighlight({ key: listKey, index }); const open = trigger !== null && items.length > 0 && dismissedKey !== triggerKey; return { open, trigger, items, active, setActive, moveActive: (delta) => { if (items.length > 0) { setActive((active + delta + items.length) % items.length); } }, dismiss: () => setDismissedKey(triggerKey), }; } /** * useFileSearch queries the backend for `query` (debounced) and ignores * stale answers; a null query clears the hits. */ function useFileSearch(query: string | null) { const [hits, setHits] = useState<{ path: string; relPath: string }[]>([]); useEffect(() => { if (query === null) { setHits([]); return; } let stale = false; const timer = setTimeout(() => { searchFiles(query, MAX_FILE_HITS) .then((result) => { if (!stale) { setHits(result.files); } }) .catch(() => { if (!stale) { setHits([]); } }); }, SEARCH_DEBOUNCE_MS); return () => { stale = true; clearTimeout(timer); }; }, [query]); return hits; } /** useSkills loads the skill list once, the first time the "/" popup opens. */ function useSkills(enabled: boolean) { const [skills, setSkills] = useState(null); useEffect(() => { if (!enabled || skills !== null) { return; } let cancelled = false; listSkills() .then((result) => { if (!cancelled) { setSkills(result.skills); } }) .catch(() => { if (!cancelled) { setSkills([]); } }); return () => { cancelled = true; }; }, [enabled, skills]); return skills ?? []; }