nandi/oripublic Fork 0
35061753be581c0ba47a3189520d64e7788c183d
Commits
Clone
git clone https://git.rickub.com/nandi/ori.git
git clone ssh://git@rickub.com/nandi/ori.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

forked from bots-garden/ori

useCompletion.ts · 154 lines · 3.8 KBTypeScript Blame HistoryRaw
✨ Workspace panel, selectors, previews, desktop app, sandbox template, resizable file tree, light/dark theme 76d62ac k33g yesterday1/**
2 * useCompletion drives the composer's "@" and "/" popups: it derives the
3 * active trigger from the text and caret, fetches file hits or skills,
4 * filters them, and exposes the keyboard/selection state.
5 *
6 * @example
7 * const completion = useCompletion({ text, caret, commands });
8 * if (completion.open) { … render completion.items[completion.active] … }
9 */
10
11import { useEffect, useMemo, useState } from "react";
12import { listSkills, searchFiles, type Skill } from "./api";
13import {
14 type CompletionItem,
15 type Trigger,
16 detectTrigger,
17 fileItem,
18 matchesQuery,
19 skillItems,
20} from "./mentions";
21import type { AcpAvailableCommand } from "./protocol";
22
23/** Delay before a file search runs, so fast typing sends one request. */
24const SEARCH_DEBOUNCE_MS = 120;
25const MAX_FILE_HITS = 30;
26
27interface CompletionOptions {
28 text: string;
29 caret: number;
30 commands: AcpAvailableCommand[];
31}
32
33export interface Completion {
34 /** True when a popup with at least one item should show. */
35 open: boolean;
36 trigger: Trigger | null;
37 items: CompletionItem[];
38 /** Index of the highlighted item. */
39 active: number;
40 setActive: (index: number) => void;
41 moveActive: (delta: number) => void;
42 /** Closes the popup until the trigger changes. */
43 dismiss: () => void;
44}
45
46export function useCompletion({
47 text,
48 caret,
49 commands,
50}: CompletionOptions): Completion {
51 const trigger = useMemo(() => detectTrigger(text, caret), [text, caret]);
52 const triggerKey = trigger ? `${trigger.kind}:${trigger.start}` : "";
53
54 const fileHits = useFileSearch(
55 trigger?.kind === "file" ? trigger.query : null,
56 );
57 const skills = useSkills(trigger?.kind === "skill");
58
59 const items = useMemo(() => {
60 if (!trigger) {
61 return [];
62 }
63 if (trigger.kind === "file") {
64 return fileHits.map(fileItem);
65 }
66 return skillItems(skills, commands).filter((item) =>
67 matchesQuery(item, trigger.query),
68 );
69 }, [trigger, fileHits, skills, commands]);
70
71 // The highlighted index is remembered per item list: a new trigger or a
72 // changed list starts again from the first item.
73 const listKey = `${triggerKey}:${items.length}`;
74 const [highlight, setHighlight] = useState({ key: "", index: 0 });
75 const [dismissedKey, setDismissedKey] = useState("");
76 const active = highlight.key === listKey ? highlight.index : 0;
77 const setActive = (index: number) => setHighlight({ key: listKey, index });
78
79 const open =
80 trigger !== null && items.length > 0 && dismissedKey !== triggerKey;
81
82 return {
83 open,
84 trigger,
85 items,
86 active,
87 setActive,
88 moveActive: (delta) => {
89 if (items.length > 0) {
90 setActive((active + delta + items.length) % items.length);
91 }
92 },
93 dismiss: () => setDismissedKey(triggerKey),
94 };
95}
96
97/**
98 * useFileSearch queries the backend for `query` (debounced) and ignores
99 * stale answers; a null query clears the hits.
100 */
101function useFileSearch(query: string | null) {
102 const [hits, setHits] = useState<{ path: string; relPath: string }[]>([]);
103 useEffect(() => {
104 if (query === null) {
105 setHits([]);
106 return;
107 }
108 let stale = false;
109 const timer = setTimeout(() => {
110 searchFiles(query, MAX_FILE_HITS)
111 .then((result) => {
112 if (!stale) {
113 setHits(result.files);
114 }
115 })
116 .catch(() => {
117 if (!stale) {
118 setHits([]);
119 }
120 });
121 }, SEARCH_DEBOUNCE_MS);
122 return () => {
123 stale = true;
124 clearTimeout(timer);
125 };
126 }, [query]);
127 return hits;
128}
129
130/** useSkills loads the skill list once, the first time the "/" popup opens. */
131function useSkills(enabled: boolean) {
132 const [skills, setSkills] = useState<Skill[] | null>(null);
133 useEffect(() => {
134 if (!enabled || skills !== null) {
135 return;
136 }
137 let cancelled = false;
138 listSkills()
139 .then((result) => {
140 if (!cancelled) {
141 setSkills(result.skills);
142 }
143 })
144 .catch(() => {
145 if (!cancelled) {
146 setSkills([]);
147 }
148 });
149 return () => {
150 cancelled = true;
151 };
152 }, [enabled, skills]);
153 return skills ?? [];
154}