'use client'; /** * `useSlashCommands` — React glue over the pure slash machine. * * Given the live editor value, exposes: * - the current `SlashState`, * - the filtered verb list while composing, * - a `highlight` index + keyboard handler for the menu, * - `pick(cmd)` which either executes the verb (`autoExecute: true`) * or inserts `" "` so the user can type arguments (default), * - `clear()` which strips the leading slash to close the menu. * * The hook owns no editor state — the host passes `value` in and * applies the strings `pick` / `onKeyDown` hand back to its editor's * setter. This keeps it usable from the chat composer (driven by * `useChatComposer`) and from any plain `useState` driven input. */ import { useCallback, useMemo, useState, type KeyboardEvent } from 'react'; import { filterCommands, isSubmittableSlash, parseSlashState, resolveCommandAction, } from './state'; import type { SlashCommand, SlashState } from './types'; export interface UseSlashCommandsOptions { /** Live editor value (the full draft). */ value: string; /** Verb set surfaced to the user. */ commands: readonly SlashCommand[]; /** * Apply a new editor value. Called by `pick` (after an `insert` * action), `clear` (Escape), and the auto-execute branch (clears * the buffer after running the command). */ onApply: (next: string) => void; } export interface UseSlashCommandsReturn { /** Current machine state — `none | composing | command`. */ state: SlashState; /** True while the dropdown should be visible (including empty-match state). */ isOpen: boolean; /** Filtered verbs (empty list is possible when `isOpen` is true). */ matches: SlashCommand[]; /** Current filter string (the partial verb after the leading slash). */ query: string; /** Index of the highlighted row in `matches`. */ highlight: number; /** Set the highlight index (pointer-move sync). */ setHighlight: (i: number) => void; /** The resolved verb once `state.kind === "command"`, else null. */ active: SlashCommand | null; /** * Whether the current buffer may be submitted as a chat message. * * Mirrors `isSubmittableSlash(value, commands)`. The composer ANDs * this with `composer.canSubmit` to disable Send and turn Enter into * a no-op while a slash command is missing its required argument or * is an `autoExecute` action (which must be picked from the menu, * not dispatched as a text message). * * When `commands` is empty (slash effectively disabled) this is * always `true` — the gate is a no-op. */ canSubmit: boolean; /** * Apply a verb. Commands with `autoExecute: true` invoke * `command.onExecute?.('')` and clear the buffer. All other commands * (the default) replace the leading `/partial` with `" "` so * the caret lands on the argument, and the user submits normally. */ pick: (command: SlashCommand) => void; /** Close the menu by dropping the leading slash. */ clear: () => void; /** * Keydown handler for the host editor. Consumes ↑/↓/Enter/Tab/Esc * while the menu is open (calling `e.preventDefault()`); otherwise * the event passes through untouched. */ onKeyDown: (e: KeyboardEvent) => void; } export function useSlashCommands({ value, commands, onApply, }: UseSlashCommandsOptions): UseSlashCommandsReturn { const [highlight, setHighlightState] = useState(0); const state = useMemo( () => parseSlashState(value, commands), [value, commands], ); const matches = useMemo( () => state.kind === 'composing' ? filterCommands(commands, state.filter) : [], [state, commands], ); // Menu stays open for the whole `composing` state — even when the // filter narrows to nothing, so we can render a "No commands match" // line instead of silently closing on the user. const isOpen = state.kind === 'composing'; const query = state.kind === 'composing' ? state.filter : ''; const active = state.kind === 'command' ? state.command : null; // Empty `commands` means slash is effectively off — never block submit // (the composer instantiates the hook unconditionally, see Composer.tsx). const canSubmit = useMemo( () => (commands.length === 0 ? true : isSubmittableSlash(value, commands)), [value, commands], ); // Clamp the highlight whenever the match list shrinks. const safeHighlight = matches.length > 0 ? Math.min(highlight, matches.length - 1) : 0; const setHighlight = useCallback((i: number) => { setHighlightState(i); }, []); const pick = useCallback( (command: SlashCommand) => { const action = resolveCommandAction(value, command); if (action.kind === 'execute') { // Fire-and-forget; the host owns error handling. Clear the // buffer so the dropdown closes and the next draft starts fresh. try { void command.onExecute?.(''); } finally { onApply(''); } } else { onApply(action.text); } setHighlightState(0); }, [value, onApply], ); const clear = useCallback(() => { onApply(value.replace(/^\//, '')); setHighlightState(0); }, [value, onApply]); const onKeyDown = useCallback( (e: KeyboardEvent) => { if (!isOpen) return; switch (e.key) { case 'ArrowDown': if (matches.length === 0) return; e.preventDefault(); setHighlightState((h) => (h + 1) % matches.length); break; case 'ArrowUp': if (matches.length === 0) return; e.preventDefault(); setHighlightState((h) => (h - 1 + matches.length) % matches.length); break; case 'Enter': case 'Tab': { // When there are no matches, swallow Enter so the composer // doesn't submit a `/xyzzy` literal — but leave Tab to the // browser so focus management still works. if (matches.length === 0) { if (e.key === 'Enter') e.preventDefault(); return; } e.preventDefault(); const sel = matches[safeHighlight]; if (sel) pick(sel); break; } case 'Escape': e.preventDefault(); clear(); break; default: break; } }, [isOpen, matches, safeHighlight, pick, clear], ); return { state, isOpen, matches, query, highlight: safeHighlight, setHighlight, active, canSubmit, pick, clear, onKeyDown, }; }