Files
llama.cpp/tools/ui/src/lib/hooks/use-chat-form-pickers.svelte.ts
T
Aleksander Grygier 6de1b63473 allozaur/feat/chat slash commands (#26716)
* base : slash-command/misc foundation - model icon and focus-selector constants

* feat : slash-command picker and command parsing helpers

* refactor : wire command and @-mention pickers into the chat form

* ui : improve model selector keyboard navigation and load/dismiss

* feat: Unify markdown/raw-text rendering under one setting with migration

* fix: Misc fixes - tool-call subtitle, assistant wrap, progress guards

* feat: Clamp and style numeric settings inputs from registry bounds
2026-08-07 20:20:01 +02:00

356 lines
10 KiB
TypeScript

import { getChatCommands, PROMPT_TRIGGER_PREFIX } from '$lib/constants';
import { ChatFormCommandAction, KeyboardKey } from '$lib/enums';
import type { ChatFormCommand } from '$lib/types';
import {
findCommandToken,
findMentionToken,
takeCommandDismissSnapshot,
takeMentionDismissSnapshot,
type CommandDismissSnapshot,
type MentionDismissSnapshot
} from '$lib/utils';
/** Dependencies injected as getters so the hook stays free of store circular imports. */
export interface UseChatFormPickersOptions {
getValue: () => string;
/** Also fires the form's onChange. */
setValue: (value: string) => void;
/** Undefined when unmounted. */
getCaretOffset: () => number | undefined;
setCaretOffset: (offset: number) => void;
focusInput: () => void;
/** Gates `/model`. */
getShowModelSelector: () => boolean;
/** Gates `/prompt`. */
hasPrompts: () => boolean;
/** Gates `/cwd`. */
hasBuiltinTools: () => boolean;
getCwd: () => string | null;
/** Mention search fallback scope. */
getServerHome: () => string | null;
openModelSelector: () => void;
/** Delegate a keydown to the mounted pickers component, if any. */
getPickersRef: () => { handleKeydown(event: KeyboardEvent): boolean } | undefined;
}
/**
* Chat-form picker state and the `/`+`@` routing that drives them.
* Owns open/query state, dismiss snapshots and slash-command dispatch;
* textarea/caret/attachment handling stays in the chat form.
*/
export function useChatFormPickers(opts: UseChatFormPickersOptions) {
let isCommandPickerOpen = $state(false);
let commandQuery = $state('');
let isPromptPickerOpen = $state(false);
let promptSearchQuery = $state('');
let isMentionPickerOpen = $state(false);
let mentionQuery = $state('');
let isWorkingDirectoryPickerOpen = $state(false);
let workingDirectoryQuery = $state('');
// Last dismissed `@`-mention token; while intact, the picker does not
// reopen, so an escaped `@<query>` stays literal until edited.
let mentionDismissedSnapshot: MentionDismissSnapshot | null = null;
// Same dismissal contract for the `/`-command token.
let commandDismissedSnapshot: CommandDismissSnapshot | null = null;
// Fall back to the server home so the picker still finds matches
// before a cwd is set.
const mentionScopePath = $derived(opts.getCwd() ?? opts.getServerHome() ?? null);
const availableCommands = $derived(
getChatCommands({
showModelSelector: opts.getShowModelSelector(),
hasPrompts: opts.hasPrompts,
hasBuiltinTools: opts.hasBuiltinTools
})
);
// Dispatch a slash command picked from the list: consume the token and
// open the target picker, seeding its search with `args`. Runs only on
// explicit selection (Enter/click), so the buffer is never cleared
// mid-typing.
function dispatchCommand(command: ChatFormCommand, args: string) {
isCommandPickerOpen = false;
commandQuery = '';
switch (command.action) {
case ChatFormCommandAction.PROMPT:
isWorkingDirectoryPickerOpen = false;
opts.setValue('');
isPromptPickerOpen = true;
promptSearchQuery = args.trim();
break;
case ChatFormCommandAction.CWD: {
// Keep `/cwd <args>` in the input so the search field and the
// token stay two-way bound; normalize partial tokens (`/cw foo`).
const trimmed = args.trim();
const newValue = `/cwd ${trimmed}`;
if (opts.getValue() !== newValue) {
opts.setValue(newValue);
queueMicrotask(() => opts.setCaretOffset(newValue.length));
}
workingDirectoryQuery = trimmed;
isWorkingDirectoryPickerOpen = true;
break;
}
case ChatFormCommandAction.MODEL:
isWorkingDirectoryPickerOpen = false;
opts.setValue('');
opts.openModelSelector();
break;
}
}
function handleInput() {
const value = opts.getValue();
const cursor = opts.getCaretOffset() ?? value.length;
if (value.startsWith(PROMPT_TRIGGER_PREFIX)) {
isMentionPickerOpen = false;
mentionQuery = '';
isPromptPickerOpen = false;
promptSearchQuery = '';
const token = findCommandToken(value);
if (!token) {
isCommandPickerOpen = false;
commandQuery = '';
return;
}
// While the `/cwd` picker is open the token doubles as its search
// field: keep the two in sync instead of re-dispatching.
if (isWorkingDirectoryPickerOpen) {
isCommandPickerOpen = false;
commandQuery = '';
if (token.name === 'cwd') {
workingDirectoryQuery = token.args.trim();
} else {
isWorkingDirectoryPickerOpen = false;
workingDirectoryQuery = '';
}
return;
}
// Dismissed token stays literal until it changes.
const isDismissedSticky =
commandDismissedSnapshot !== null &&
commandDismissedSnapshot.name === token.name &&
commandDismissedSnapshot.args === token.args;
if (isDismissedSticky) {
isCommandPickerOpen = false;
commandQuery = '';
return;
}
// Commands dispatch only on explicit selection (Enter/click),
// never mid-typing: `/model is broken` is prose until the user
// picks the command from the list.
if (availableCommands.length > 0) {
isCommandPickerOpen = true;
commandQuery = token.name;
} else {
isCommandPickerOpen = false;
commandQuery = '';
}
return;
}
isCommandPickerOpen = false;
commandQuery = '';
if (commandDismissedSnapshot !== null) {
commandDismissedSnapshot = null;
}
if (isWorkingDirectoryPickerOpen) {
isWorkingDirectoryPickerOpen = false;
}
const token = findMentionToken(value, cursor);
if (token) {
// Dismissed token stays literal: don't reopen until it changes.
const isDismissedSticky =
mentionDismissedSnapshot !== null &&
mentionDismissedSnapshot.start === token.start &&
mentionDismissedSnapshot.query === token.query;
if (!isDismissedSticky) {
// Only search once a char follows `@`; a bare `@` is a no-op
// (otherwise the picker flashes an empty hint on re-type).
if (token.query.length > 0) {
mentionDismissedSnapshot = null;
isMentionPickerOpen = true;
mentionQuery = token.query;
isPromptPickerOpen = false;
promptSearchQuery = '';
return;
}
}
}
isPromptPickerOpen = false;
promptSearchQuery = '';
isMentionPickerOpen = false;
mentionQuery = '';
// Token gone or changed: reset the snapshot so a fresh `@` reopens.
if (mentionDismissedSnapshot !== null && !token) {
mentionDismissedSnapshot = null;
}
}
function handleKeydown(event: KeyboardEvent): boolean {
if (opts.getPickersRef()?.handleKeydown(event)) {
return true;
}
if (event.key === KeyboardKey.ESCAPE && isPromptPickerOpen) {
isPromptPickerOpen = false;
promptSearchQuery = '';
return true;
}
return false;
}
function handleCommandSelect(command: ChatFormCommand) {
// Dispatch on the live token so typed args seed the target picker.
const token = findCommandToken(opts.getValue());
dispatchCommand(command, token?.args ?? '');
}
// Picker dismissed: snapshot the live token so it stays literal until
// deleted or retyped.
function handleCommandPickerClose() {
if (isCommandPickerOpen) {
commandDismissedSnapshot = takeCommandDismissSnapshot(opts.getValue());
}
isCommandPickerOpen = false;
commandQuery = '';
// Target picker manages its own focus: don't yank it back to the input.
if (!isPromptPickerOpen && !isMentionPickerOpen && !isWorkingDirectoryPickerOpen) {
opts.focusInput();
}
}
// Same dismissal snapshot for the mention token.
function handleMentionPickerClose() {
if (isMentionPickerOpen) {
const cursor = opts.getCaretOffset() ?? opts.getValue().length;
mentionDismissedSnapshot = takeMentionDismissSnapshot(opts.getValue(), cursor);
}
isMentionPickerOpen = false;
mentionQuery = '';
opts.focusInput();
}
function handlePromptPickerClose() {
isPromptPickerOpen = false;
promptSearchQuery = '';
opts.focusInput();
}
function handleWorkingDirectoryOpen() {
workingDirectoryQuery = opts.getCwd() ?? '';
isWorkingDirectoryPickerOpen = true;
}
function handleWorkingDirectoryClose() {
isWorkingDirectoryPickerOpen = false;
workingDirectoryQuery = '';
opts.focusInput();
}
// Two-way bind the text after `/cwd ` and the picker search input; the
// reverse direction is handled by handleInput.
$effect(() => {
if (!isWorkingDirectoryPickerOpen) return;
const value = opts.getValue();
const token = findCommandToken(value);
if (!token || token.name !== 'cwd') return;
const newValue = `/cwd ${workingDirectoryQuery}`;
if (newValue === value) return;
opts.setValue(newValue);
queueMicrotask(() => opts.setCaretOffset(newValue.length));
});
return {
get isCommandPickerOpen() {
return isCommandPickerOpen;
},
set isCommandPickerOpen(v: boolean) {
isCommandPickerOpen = v;
},
get commandQuery() {
return commandQuery;
},
set commandQuery(v: string) {
commandQuery = v;
},
get isPromptPickerOpen() {
return isPromptPickerOpen;
},
set isPromptPickerOpen(v: boolean) {
isPromptPickerOpen = v;
},
get promptSearchQuery() {
return promptSearchQuery;
},
set promptSearchQuery(v: string) {
promptSearchQuery = v;
},
get isMentionPickerOpen() {
return isMentionPickerOpen;
},
set isMentionPickerOpen(v: boolean) {
isMentionPickerOpen = v;
},
get mentionQuery() {
return mentionQuery;
},
set mentionQuery(v: string) {
mentionQuery = v;
},
get isWorkingDirectoryPickerOpen() {
return isWorkingDirectoryPickerOpen;
},
set isWorkingDirectoryPickerOpen(v: boolean) {
isWorkingDirectoryPickerOpen = v;
},
get workingDirectoryQuery() {
return workingDirectoryQuery;
},
set workingDirectoryQuery(v: string) {
workingDirectoryQuery = v;
},
get availableCommands() {
return availableCommands;
},
get mentionScopePath() {
return mentionScopePath;
},
handleInput,
// True when a picker consumed the event, so the form skips submit.
handleKeydown,
dispatchCommand,
handleCommandSelect,
handleCommandPickerClose,
handleMentionPickerClose,
handlePromptPickerClose,
handleWorkingDirectoryOpen,
handleWorkingDirectoryClose,
openPromptPicker() {
isPromptPickerOpen = true;
},
closePromptPicker() {
isPromptPickerOpen = false;
promptSearchQuery = '';
}
};
}
export type UseChatFormPickersReturn = ReturnType<typeof useChatFormPickers>;