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
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
/**
 * 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<Skill[] | null>(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 ?? [];
}