/** * Slash-command state machine — pure functions, no React. * * A small model for the composer's `/` surface: derive `SlashState` from * the raw editor value, narrow the verb list by a filter, and produce * the next editor value when a verb is picked. * * Triggering rule: a slash verb only counts when it is the first token * of the buffer (`"/clear"` matches, `"hello /clear"` does not). The * caret is irrelevant — the machine is buffer-shape driven, matching * the Warp `slash_command_model.rs` design the cmdop port came from. * * Layering: this file imports types only — keep it React-free so it * stays trivially unit-testable. */ import type { SlashCommand, SlashState } from './types'; /** A `/verb` only counts when it is the very first token of the input. */ const SLASH_RE = /^\/([a-zA-Z][\w-]*)?(?:[ \n]([\s\S]*))?$/; /** * Derive the slash state from the raw editor value. * * - Buffer empty or not starting with `/` → `none`. * - `/`, `/c`, `/cle` (no space yet) → `composing` with `filter`. * - `/clear`, `/clear stuff` (verb followed by space) → `command` if * `verb` matches a known id, else `none`. */ export function parseSlashState( value: string, commands: readonly SlashCommand[], ): SlashState { if (!value.startsWith('/')) return { kind: 'none' }; const m = SLASH_RE.exec(value); if (!m) return { kind: 'none' }; const verb = (m[1] ?? '').toLowerCase(); const rest = m[2]; // The buffer has advanced past the verb (a space/newline follows). if (rest !== undefined) { const command = commands.find((c) => c.id === verb); if (!command) return { kind: 'none' }; return { kind: 'command', command, argument: rest }; } // Still typing the verb — `/` alone or `/cle`. return { kind: 'composing', filter: verb }; } /** * Narrow a verb list by the `composing` filter. * * Empty filter returns every command in input order. Otherwise the * filter is matched (case-insensitive) against `id`, `token`, `label` * and any `keywords`: prefix matches rank above substring matches. */ export function filterCommands( commands: readonly SlashCommand[], filter: string, ): SlashCommand[] { const q = filter.trim().toLowerCase(); if (q.length === 0) return [...commands]; const scored: { cmd: SlashCommand; score: number; idx: number }[] = []; commands.forEach((cmd, idx) => { const terms = [cmd.id, cmd.token, cmd.label, ...(cmd.keywords ?? [])]; let best = -1; for (const term of terms) { const t = term.toLowerCase(); if (t.startsWith(q) || t.startsWith(`/${q}`)) best = Math.max(best, 2); else if (t.includes(q)) best = Math.max(best, 1); } if (best >= 0) scored.push({ cmd, score: best, idx }); }); scored.sort((a, b) => (b.score - a.score) || (a.idx - b.idx)); return scored.map((s) => s.cmd); } /** * What should happen when the user picks a verb in the menu. * * - `execute` — the command explicitly opted in via `autoExecute: true`. * The host should call `command.onExecute?.()` and clear the editor * buffer. Use for action commands that produce no chat message. * - `insert` — default. The host should replace the leading `/partial` * with `text` (which is ` `) so the user can keep typing and * submit normally. */ export type SlashCommandAction = | { kind: 'execute'; command: SlashCommand } | { kind: 'insert'; command: SlashCommand; text: string }; /** * Decide whether a picked verb runs immediately or just inserts a token. * * Pure — does not touch the editor value. The React layer in * `useSlashCommands` consumes this to branch between `onExecute` * + clear-buffer and `applyCommand` + keep-buffer. * * Default is `insert`. Only `autoExecute === true` triggers `execute` * — `argHint` is display-only and does NOT influence this decision. */ export function resolveCommandAction( value: string, command: SlashCommand, ): SlashCommandAction { const shouldAutoExecute = command.autoExecute === true; if (shouldAutoExecute) return { kind: 'execute', command }; return { kind: 'insert', command, text: applyCommand(value, command) }; } /** * Find the `/verb` slice at the start of the buffer (if any). Returns * the matched token + its end offset so an overlay-mirror highlighter * can wrap the exact same characters the parser considers a slash verb. * * - `/`, `/cle` (still composing) → returns the partial slice. * - `/clear`, `/clear stuff` (resolved) → returns the bare `/verb`. * - `hello /clear` (slash not at start) → returns `null`. */ export function extractSlashToken( value: string, ): { token: string; end: number } | null { if (!value.startsWith('/')) return null; const m = SLASH_RE.exec(value); if (!m) return null; const verb = m[1] ?? ''; const token = `/${verb}`; return { token, end: token.length }; } /** * Whether the current buffer is a slash command that may be submitted. * * Returns `true` when the buffer is NOT a resolved slash command — the * host decides on its own emptiness gates for plain messages. Returns * `true` for resolved commands whose `argHint` is unset (the command is * self-contained) and whose `autoExecute` is not `true`. Returns * `false` when: * * - the resolved command has `autoExecute: true` (action commands are * picked from the menu, not submitted as a text message), * - or the resolved command declares an `argHint` and the trimmed * argument is empty. * * Pure — no React, no DOM. The composer uses this to gate the Send * button + Enter keypress so half-typed `/note` etc. cannot be * submitted as a message. */ export function isSubmittableSlash( value: string, commands: readonly SlashCommand[], ): boolean { const state = parseSlashState(value, commands); if (state.kind !== 'command') return true; if (state.command.autoExecute === true) return false; if (!state.command.argHint) return true; return state.argument.trim().length > 0; } /** * Apply a picked command to the editor value — replaces the leading * `/partial` with `" "` so the user can immediately type the * argument. Any existing argument text is preserved. */ export function applyCommand(value: string, command: SlashCommand): string { const m = SLASH_RE.exec(value); const rest = m?.[2]; const tail = rest !== undefined && rest.length > 0 ? rest : ''; return `${command.token} ${tail}`; }