char-fork/llama.cpp

git clone https://git.t4t.associates/char-fork/llama.cpp

Aleksander Grygierui: Constants refactor (#26908)e21152dc9

char/aliceai-t5-moe
10.2 KiB378 linesraw
1import { PROMPT_TRIGGER_PREFIX } from '$lib/constants';
2import { ChatFormCommandAction, KeyboardKey } from '$lib/enums';
3import type { ChatFormCommand } from '$lib/types';
4import { getChatCommands } from '$lib/utils';
5import {
6	type CommandDismissSnapshot,
7	findCommandToken,
8	findMentionToken,
9	type MentionDismissSnapshot,
10	takeCommandDismissSnapshot,
11	takeMentionDismissSnapshot
12} from '$lib/utils';
13
14/** Dependencies injected as getters so the hook stays free of store circular imports. */
15export interface UseChatFormPickersOptions {
16	getValue: () => string;
17	/** Also fires the form's onChange. */
18	setValue: (value: string) => void;
19	/** Undefined when unmounted. */
20	getCaretOffset: () => number | undefined;
21	setCaretOffset: (offset: number) => void;
22	focusInput: () => void;
23	/** Gates `/model`. */
24	getShowModelSelector: () => boolean;
25	/** Gates `/prompt`. */
26	hasPrompts: () => boolean;
27	/** Gates `/cwd`. */
28	hasCwdTools: () => boolean;
29	getCwd: () => string | null;
30	/** Mention search fallback scope. */
31	getServerHome: () => string | null;
32	openModelSelector: () => void;
33	/** Delegate a keydown to the mounted pickers component, if any. */
34	getPickersRef: () => { handleKeydown(event: KeyboardEvent): boolean } | undefined;
35}
36
37/**
38 * Chat-form picker state and the `/`+`@` routing that drives them.
39 * Owns open/query state, dismiss snapshots and slash-command dispatch;
40 * textarea/caret/attachment handling stays in the chat form.
41 */
42export function useChatFormPickers(opts: UseChatFormPickersOptions) {
43	let isCommandPickerOpen = $state(false);
44	let commandQuery = $state('');
45	let isPromptPickerOpen = $state(false);
46	let promptSearchQuery = $state('');
47	let isMentionPickerOpen = $state(false);
48	let mentionQuery = $state('');
49	let isWorkingDirectoryPickerOpen = $state(false);
50	let workingDirectoryQuery = $state('');
51	// Last dismissed `@`-mention token; while intact, the picker does not
52	// reopen, so an escaped `@<query>` stays literal until edited.
53	let mentionDismissedSnapshot: MentionDismissSnapshot | null = null;
54	// Same dismissal contract for the `/`-command token.
55	let commandDismissedSnapshot: CommandDismissSnapshot | null = null;
56
57	// Fall back to the server home so the picker still finds matches
58	// before a cwd is set.
59	const mentionScopePath = $derived(opts.getCwd() ?? opts.getServerHome() ?? null);
60	const availableCommands = $derived(
61		getChatCommands({
62			hasCwdTools: opts.hasCwdTools,
63			hasPrompts: opts.hasPrompts,
64			showModelSelector: opts.getShowModelSelector()
65		})
66	);
67
68	// Dispatch a slash command picked from the list: consume the token and
69	// open the target picker, seeding its search with `args`. Runs only on
70	// explicit selection (Enter/click), so the buffer is never cleared
71	// mid-typing.
72	function dispatchCommand(command: ChatFormCommand, args: string) {
73		isCommandPickerOpen = false;
74		commandQuery = '';
75
76		switch (command.action) {
77			case ChatFormCommandAction.PROMPT:
78				isWorkingDirectoryPickerOpen = false;
79				opts.setValue('');
80				isPromptPickerOpen = true;
81				promptSearchQuery = args.trim();
82
83				break;
84			case ChatFormCommandAction.CWD: {
85				// Keep `/cwd <args>` in the input so the search field and the
86				// token stay two-way bound; normalize partial tokens (`/cw foo`).
87				const trimmed = args.trim();
88				const newValue = `/cwd ${trimmed}`;
89
90				if (opts.getValue() !== newValue) {
91					opts.setValue(newValue);
92					queueMicrotask(() => opts.setCaretOffset(newValue.length));
93				}
94
95				workingDirectoryQuery = trimmed;
96				isWorkingDirectoryPickerOpen = true;
97
98				break;
99			}
100			case ChatFormCommandAction.MODEL:
101				isWorkingDirectoryPickerOpen = false;
102				opts.setValue('');
103				opts.openModelSelector();
104
105				break;
106		}
107	}
108
109	function handleInput() {
110		const value = opts.getValue();
111		const cursor = opts.getCaretOffset() ?? value.length;
112
113		if (value.startsWith(PROMPT_TRIGGER_PREFIX)) {
114			isMentionPickerOpen = false;
115			mentionQuery = '';
116			isPromptPickerOpen = false;
117			promptSearchQuery = '';
118
119			const token = findCommandToken(value);
120
121			if (!token) {
122				isCommandPickerOpen = false;
123				commandQuery = '';
124
125				return;
126			}
127
128			// While the `/cwd` picker is open the token doubles as its search
129			// field: keep the two in sync instead of re-dispatching.
130			if (isWorkingDirectoryPickerOpen) {
131				isCommandPickerOpen = false;
132				commandQuery = '';
133
134				if (token.name === 'cwd') {
135					workingDirectoryQuery = token.args.trim();
136				} else {
137					isWorkingDirectoryPickerOpen = false;
138					workingDirectoryQuery = '';
139				}
140
141				return;
142			}
143
144			// Dismissed token stays literal until it changes.
145			const isDismissedSticky =
146				commandDismissedSnapshot !== null &&
147				commandDismissedSnapshot.name === token.name &&
148				commandDismissedSnapshot.args === token.args;
149
150			if (isDismissedSticky) {
151				isCommandPickerOpen = false;
152				commandQuery = '';
153
154				return;
155			}
156
157			// Commands dispatch only on explicit selection (Enter/click),
158			// never mid-typing: `/model is broken` is prose until the user
159			// picks the command from the list.
160			if (availableCommands.length > 0) {
161				isCommandPickerOpen = true;
162				commandQuery = token.name;
163			} else {
164				isCommandPickerOpen = false;
165				commandQuery = '';
166			}
167
168			return;
169		}
170
171		isCommandPickerOpen = false;
172		commandQuery = '';
173
174		if (commandDismissedSnapshot !== null) {
175			commandDismissedSnapshot = null;
176		}
177
178		if (isWorkingDirectoryPickerOpen) {
179			isWorkingDirectoryPickerOpen = false;
180		}
181
182		const token = findMentionToken(value, cursor);
183
184		if (token) {
185			// Dismissed token stays literal: don't reopen until it changes.
186			const isDismissedSticky =
187				mentionDismissedSnapshot !== null &&
188				mentionDismissedSnapshot.start === token.start &&
189				mentionDismissedSnapshot.query === token.query;
190
191			if (!isDismissedSticky) {
192				// Only search once a char follows `@`; a bare `@` is a no-op
193				// (otherwise the picker flashes an empty hint on re-type).
194				if (token.query.length > 0) {
195					mentionDismissedSnapshot = null;
196					isMentionPickerOpen = true;
197					mentionQuery = token.query;
198					isPromptPickerOpen = false;
199					promptSearchQuery = '';
200
201					return;
202				}
203			}
204		}
205
206		isPromptPickerOpen = false;
207		promptSearchQuery = '';
208		isMentionPickerOpen = false;
209		mentionQuery = '';
210
211		// Token gone or changed: reset the snapshot so a fresh `@` reopens.
212		if (mentionDismissedSnapshot !== null && !token) {
213			mentionDismissedSnapshot = null;
214		}
215	}
216
217	function handleKeydown(event: KeyboardEvent): boolean {
218		if (opts.getPickersRef()?.handleKeydown(event)) {
219			return true;
220		}
221
222		if (event.key === KeyboardKey.ESCAPE && isPromptPickerOpen) {
223			isPromptPickerOpen = false;
224			promptSearchQuery = '';
225
226			return true;
227		}
228
229		return false;
230	}
231
232	function handleCommandSelect(command: ChatFormCommand) {
233		// Dispatch on the live token so typed args seed the target picker.
234		const token = findCommandToken(opts.getValue());
235
236		dispatchCommand(command, token?.args ?? '');
237	}
238
239	// Picker dismissed: snapshot the live token so it stays literal until
240	// deleted or retyped.
241	function handleCommandPickerClose() {
242		if (isCommandPickerOpen) {
243			commandDismissedSnapshot = takeCommandDismissSnapshot(opts.getValue());
244		}
245
246		isCommandPickerOpen = false;
247		commandQuery = '';
248
249		// Target picker manages its own focus: don't yank it back to the input.
250		if (!isPromptPickerOpen && !isMentionPickerOpen && !isWorkingDirectoryPickerOpen) {
251			opts.focusInput();
252		}
253	}
254
255	// Same dismissal snapshot for the mention token.
256	function handleMentionPickerClose() {
257		if (isMentionPickerOpen) {
258			const cursor = opts.getCaretOffset() ?? opts.getValue().length;
259
260			mentionDismissedSnapshot = takeMentionDismissSnapshot(opts.getValue(), cursor);
261		}
262
263		isMentionPickerOpen = false;
264		mentionQuery = '';
265		opts.focusInput();
266	}
267
268	function handlePromptPickerClose() {
269		isPromptPickerOpen = false;
270		promptSearchQuery = '';
271		opts.focusInput();
272	}
273
274	function handleWorkingDirectoryOpen() {
275		workingDirectoryQuery = opts.getCwd() ?? '';
276		isWorkingDirectoryPickerOpen = true;
277	}
278
279	function handleWorkingDirectoryClose() {
280		isWorkingDirectoryPickerOpen = false;
281		workingDirectoryQuery = '';
282		opts.focusInput();
283	}
284
285	// Two-way bind the text after `/cwd ` and the picker search input; the
286	// reverse direction is handled by handleInput.
287	$effect(() => {
288		if (!isWorkingDirectoryPickerOpen) return;
289
290		const value = opts.getValue();
291		const token = findCommandToken(value);
292
293		if (!token || token.name !== 'cwd') return;
294
295		const newValue = `/cwd ${workingDirectoryQuery}`;
296
297		if (newValue === value) return;
298
299		opts.setValue(newValue);
300		queueMicrotask(() => opts.setCaretOffset(newValue.length));
301	});
302
303	return {
304		get availableCommands() {
305			return availableCommands;
306		},
307		closePromptPicker() {
308			isPromptPickerOpen = false;
309			promptSearchQuery = '';
310		},
311		get commandQuery() {
312			return commandQuery;
313		},
314		set commandQuery(v: string) {
315			commandQuery = v;
316		},
317		dispatchCommand,
318		handleCommandPickerClose,
319		handleCommandSelect,
320		handleInput,
321		// True when a picker consumed the event, so the form skips submit.
322		handleKeydown,
323		handleMentionPickerClose,
324		handlePromptPickerClose,
325		handleWorkingDirectoryClose,
326		handleWorkingDirectoryOpen,
327		get isCommandPickerOpen() {
328			return isCommandPickerOpen;
329		},
330		set isCommandPickerOpen(v: boolean) {
331			isCommandPickerOpen = v;
332		},
333		get isMentionPickerOpen() {
334			return isMentionPickerOpen;
335		},
336		set isMentionPickerOpen(v: boolean) {
337			isMentionPickerOpen = v;
338		},
339		get isPromptPickerOpen() {
340			return isPromptPickerOpen;
341		},
342		set isPromptPickerOpen(v: boolean) {
343			isPromptPickerOpen = v;
344		},
345		get isWorkingDirectoryPickerOpen() {
346			return isWorkingDirectoryPickerOpen;
347		},
348		set isWorkingDirectoryPickerOpen(v: boolean) {
349			isWorkingDirectoryPickerOpen = v;
350		},
351		get mentionQuery() {
352			return mentionQuery;
353		},
354		set mentionQuery(v: string) {
355			mentionQuery = v;
356		},
357		get mentionScopePath() {
358			return mentionScopePath;
359		},
360		openPromptPicker() {
361			isPromptPickerOpen = true;
362		},
363		get promptSearchQuery() {
364			return promptSearchQuery;
365		},
366		set promptSearchQuery(v: string) {
367			promptSearchQuery = v;
368		},
369		get workingDirectoryQuery() {
370			return workingDirectoryQuery;
371		},
372		set workingDirectoryQuery(v: string) {
373			workingDirectoryQuery = v;
374		}
375	};
376}
377
378export type UseChatFormPickersReturn = ReturnType<typeof useChatFormPickers>;