/** * Interactive autocomplete engine for the REPL. * * Completes slash-command *names* and their *arguments*: * - `/mod` → `/model` * - `/model gpt` → live (logged-in) model ids + aliases + catalog ids * - `/provider an` → provider names; second arg → that provider's live models * - `/agents exec` → subagent role ids; second arg → live model ids * - `/thinking h` → low/medium/high/xhigh * * Pure + synchronous: the dynamic data (live models from the OAuth-authenticated * accounts, alias snapshot) is passed in via `CompletionContext`, so the readline * completer never blocks on the network. Static data (slash names, catalog ids, * provider names, role ids) is filled by `staticCompletionContext()`. */ import { SLASH_COMMANDS, SLASH_COMMAND_DESCRIPTIONS } from "./slash"; import { catalogIds } from "../../ai/model-catalog-compat"; import { PROVIDER_NAMES } from "../../ai/provider-status"; import { SUBAGENT_ROLES } from "../../agent/subagents"; import { skillNames } from "../../skills/catalog"; import { listThemes } from "./themes"; export interface CompletionContext { slashCommands: string[]; /** Flattened live model ids discovered from logged-in providers (cache). */ liveModels: string[]; /** Alias names (e.g. fast/sonnet/gpt). */ aliases: string[]; /** Curated catalog model ids. */ catalogModels: string[]; providers: string[]; roleIds: string[]; thinkingLevels: string[]; /** Resolved skill names (bundled + user/project). Falls back to bundled when omitted. */ skillNames?: string[]; /** Live model ids for a given provider (for `/provider

`). */ modelsForProvider: (provider: string) => string[]; /** Sync path suggestions for free-text `@path` mentions (relative to cwd). */ mentionPaths?: (prefix: string) => string[]; } export interface CompletionResult { /** Candidate completions for `token`, ranked, de-duplicated, capped. */ completions: string[]; /** The substring being completed (what readline should replace). */ token: string; /** What was completed: command | model | provider | role | thinking | subcommand | none. */ kind: string; } const PREVIEW_LABEL: Record = { command: "Commands", skill: "Skills", model: "Models", provider: "Providers", role: "Subagent roles", thinking: "Thinking levels", subcommand: "Subcommands", path: "Paths", }; const MAX_COMPLETIONS = 50; const THINKING_LEVELS = ["low", "medium", "high", "xhigh"]; /** Static half of a completion context (no network/config needed). */ export function staticCompletionContext(): Omit { return { slashCommands: [...SLASH_COMMANDS], catalogModels: catalogIds(), providers: [...PROVIDER_NAMES], roleIds: SUBAGENT_ROLES.map(r => r.id), thinkingLevels: [...THINKING_LEVELS], }; } /** Tokenize a line into words + whether it ends with whitespace (→ completing a new token). */ export function tokenize(line: string): { tokens: string[]; trailingSpace: boolean } { const trailingSpace = /\s$/.test(line); const tokens = line.split(/\s+/).filter(t => t.length > 0); return { tokens, trailingSpace }; } function prefixHits(pool: string[], token: string): string[] { const q = token.toLowerCase(); return pool.filter(c => c.toLowerCase().startsWith(q)); } /** * Fuzzy subsequence test (gjc parity): every char of `query` appears in * `target` in order (not necessarily adjacent). Empty query matches everything. */ export function fuzzyMatch(query: string, target: string): boolean { if (!query) return true; let qi = 0; for (let ti = 0; ti < target.length && qi < query.length; ti++) { if (target[ti] === query[qi]) qi++; } return qi === query.length; } /** * Score a match (gjc parity): exact=100 > startsWith=80 > includes=60 > * subsequence(40 − gaps×5, min 1). Returns 0 when `query` is not a subsequence. */ export function fuzzyScore(query: string, target: string): number { if (!query) return 80; if (query === target) return 100; if (target.startsWith(query)) return 80; if (target.includes(query)) return 60; let qi = 0; let gaps = 0; let lastMatch = -1; for (let ti = 0; ti < target.length && qi < query.length; ti++) { if (target[ti] === query[qi]) { if (lastMatch !== -1 && ti !== lastMatch + 1) gaps++; lastMatch = ti; qi++; } } if (qi < query.length) return 0; return Math.max(1, 40 - gaps * 5); } /** * Fuzzy command-name matches, ranked best-first (gjc §2.1): higher fuzzyScore * first, ties broken by registration order so the list stays stable. Prefix * matches always outrank looser subsequence hits, so `/mod`→`/model` is kept * while `/mdl`→`/model` now also completes. */ function fuzzyHits(pool: string[], token: string): string[] { const q = token.toLowerCase(); return pool .map((c, index) => ({ c, score: fuzzyScore(q, c.toLowerCase()), index })) .filter(x => x.score > 0) .sort((a, b) => b.score - a.score || a.index - b.index) .map(x => x.c); } /** * Command-name completion with a description fallback (gjc §2.1). Name matches * win outright (so `/mod`→`/model` stays precise); only when NOTHING matches the * name do we fall back to substring-or-better description hits, so intent-style * queries with no literal name match still resolve — e.g. `/oauth`→`/login`, * `/transcript`→`/dump`. The description fallback requires ≥2 query chars and a * real substring (not a loose subsequence) to avoid flooding the dropdown. */ function fuzzyCommandHits(pool: string[], token: string): string[] { const nameHits = fuzzyHits(pool, token); if (nameHits.length > 0) return nameHits; const q = token.replace(/^\//, "").toLowerCase(); if (q.length < 2) return []; return pool .map((c, index) => { const desc = SLASH_COMMAND_DESCRIPTIONS.get(c); const hit = desc?.includes(q) ?? false; return { c, score: hit ? fuzzyScore(q, desc!) * 0.5 : 0, index }; }) .filter(x => x.score > 0) .sort((a, b) => b.score - a.score || a.index - b.index) .map(x => x.c); } /** De-duplicate (case-insensitive, first wins), preserving order, capped. */ function dedupeCap(items: string[], cap = MAX_COMPLETIONS): string[] { const seen = new Set(); const out: string[] = []; for (const it of items) { const k = it.toLowerCase(); if (seen.has(k)) continue; seen.add(k); out.push(it); if (out.length >= cap) break; } return out; } /** Rank model candidates: live (logged-in) first, then aliases, then catalog. */ function rankedModelPool(ctx: CompletionContext): string[] { return dedupeCap([...ctx.liveModels, ...ctx.aliases, ...ctx.catalogModels], Number.MAX_SAFE_INTEGER); } /** * Compute completions for the current input line. Slash commands are completed as * before; free-text input stays untouched except for `@path` mentions, which can * surface local relative paths. */ /** * True when `pos` sits inside an unterminated (or paired) single-backtick span * opened earlier on the same line (gjc parity, #2619/#2629). A backslash * ALWAYS escapes the next character — an escaped backtick (`\``) never toggles * the span — so a composer example like "use `/model` to switch" or a literal * "type \` then a command" both classify correctly. Only line-local state: * spans never carry across lines (there is no multi-line buffer here). */ function insideBacktickSpan(line: string, pos: number): boolean { let open = false; let i = 0; while (i < pos && i < line.length) { const ch = line[i]; if (ch === "\\") { i += 2; continue; } if (ch === "`") open = !open; i++; } return open; } export function complete(line: string, ctx: CompletionContext): CompletionResult { const { tokens, trailingSpace } = tokenize(line); if (!line.startsWith("/")) { const token = trailingSpace ? "" : tokens[tokens.length - 1] ?? ""; if (token.startsWith("@")) { const prefix = token.slice(1); // `mentionPaths` already does the matching (recursive fuzzy for a bare // fragment, single-dir listing when the prefix has a slash), so the pool // is returned as-is rather than re-filtered by strict prefix. const pool = (ctx.mentionPaths?.(prefix) ?? []).map(p => (p.startsWith("@") ? p : `@${p}`)); return { completions: dedupeCap(pool), token, kind: "path" }; } // Literal backtick spans (`` `/model` ``, `` `$skill` ``) are protected text, // not live mentions — suppress command/skill matching (and the Tab/Enter // dispatch riding on it) below so a typed example never pops the palette. // `@path` mentions above stay live inside a span (gjc parity: path // completion is preserved in literals; only command/skill matching is // suppressed). The token's own start offset is `line.length - token.length` // (or `line.length` when trailing-space, which never matches $/ below). if (insideBacktickSpan(line, line.length - token.length)) { return { completions: [], token: line, kind: "none" }; } // `$skill` mention completion at ANY position in the line (mention-style; // a leading `$name` is additionally the direct-invocation entrypoint). if (token.startsWith("$")) { const names = ctx.skillNames ?? skillNames(); return { completions: dedupeCap(prefixHits(names.map(n => `$${n}`), token)), token, kind: "skill" }; } // `/command` mention completion mid-line (the leading-token case is the // dedicated command branch below, which also completes arguments). if (token.startsWith("/")) { return { completions: dedupeCap(fuzzyCommandHits(ctx.slashCommands, token)), token, kind: "command" }; } return { completions: [], token: line, kind: "none" }; } // Completing the command name itself (single token, still typing it). if (tokens.length <= 1 && !trailingSpace) { const token = tokens[0] ?? "/"; return { completions: dedupeCap(fuzzyCommandHits(ctx.slashCommands, token)), token, kind: "command" }; } const cmd = tokens[0]!.toLowerCase(); // Token currently being completed (empty when the line ends with a space). const token = trailingSpace ? "" : tokens[tokens.length - 1]!; // 0-based index of the argument being completed. const argIndex = (trailingSpace ? tokens.length : tokens.length - 1) - 1; const finish = (pool: string[], kind: string): CompletionResult => ({ completions: dedupeCap(prefixHits(pool, token)), token, kind, }); switch (cmd) { case "/model": { if (token.startsWith("#")) return { completions: [], token, kind: "none" }; // numbered pick if (argIndex === 0) return finish(["save", "subagent", "role", "thinking", ...rankedModelPool(ctx)], "model"); if (argIndex === 1 && (tokens[1]?.toLowerCase() === "save")) return finish(rankedModelPool(ctx), "model"); if (argIndex === 1 && (tokens[1]?.toLowerCase() === "thinking" || tokens[1]?.toLowerCase() === "think")) return finish(ctx.thinkingLevels, "thinking"); if (argIndex === 1 && (tokens[1]?.toLowerCase() === "subagent" || tokens[1]?.toLowerCase() === "role")) return finish(ctx.roleIds, "role"); if (argIndex === 2 && (tokens[1]?.toLowerCase() === "subagent" || tokens[1]?.toLowerCase() === "role")) return finish(["thinking", ...rankedModelPool(ctx)], "model"); if (argIndex === 3 && (tokens[1]?.toLowerCase() === "subagent" || tokens[1]?.toLowerCase() === "role") && (tokens[3]?.toLowerCase() === "thinking" || tokens[3]?.toLowerCase() === "think")) return finish(["inherit", ...ctx.thinkingLevels], "thinking"); return { completions: [], token, kind: "none" }; } case "/fast": return argIndex === 0 ? finish(["on", "off", "status"], "subcommand") : { completions: [], token, kind: "none" }; case "/provider": { // /provider is onboarding-only (gjc parity): login + add. Model/provider // switching completes under /model, not here. const cloud = ["anthropic", "openai", "gemini", "antigravity"]; if (argIndex === 0) return finish(["login", "add", "help"], "subcommand"); const sub = tokens[1]?.toLowerCase(); if (sub === "login" || sub === "auth") return argIndex === 1 ? finish(cloud, "provider") : { completions: [], token, kind: "none" }; if (sub === "add") return finish(["--base-url", "--model", "--compat", "clear"], "subcommand"); return { completions: [], token, kind: "none" }; } case "/logout": return argIndex === 0 ? finish(["anthropic", "openai", "gemini", "antigravity"], "provider") : { completions: [], token, kind: "none" }; case "/agents": { if (argIndex === 0) return finish(["edit", ...ctx.roleIds], "role"); if (argIndex === 1) return finish(["reset", "thinking", "maxSteps", ...rankedModelPool(ctx)], "model"); if (argIndex === 2 && (tokens[2]?.toLowerCase() === "thinking" || tokens[2]?.toLowerCase() === "think")) return finish(["inherit", ...ctx.thinkingLevels], "thinking"); if (argIndex === 2 && (tokens[2]?.toLowerCase() === "maxsteps" || tokens[2]?.toLowerCase() === "steps")) return { completions: [], token, kind: "none" }; return { completions: [], token, kind: "none" }; } case "/roles": { const tiers = ["smol", "slow", "plan"]; if (argIndex === 0) return finish(tiers, "role"); if (argIndex === 1 && tiers.includes(tokens[1]?.toLowerCase() ?? "")) return finish(rankedModelPool(ctx), "model"); return { completions: [], token, kind: "none" }; } case "/thinking": return argIndex === 0 ? finish(ctx.thinkingLevels, "thinking") : { completions: [], token, kind: "none" }; case "/session": return argIndex === 0 ? finish(["list", "info", "new", "drop", "delete", "rename", "resume"], "subcommand") : { completions: [], token, kind: "none" }; case "/theme": return argIndex === 0 ? finish(listThemes().map(t => t.name), "subcommand") : { completions: [], token, kind: "none" }; case "/login": return argIndex === 0 ? finish(["anthropic", "openai", "gemini", "antigravity"], "provider") : { completions: [], token, kind: "none" }; case "/export": return argIndex <= 1 ? finish(["json", "markdown"], "subcommand") : { completions: [], token, kind: "none" }; default: return { completions: [], token, kind: "none" }; } } /** Compact live preview for slash-command arguments (`/subagent `, `/provider login `, ...). */ export function formatCompletionPreview(line: string, ctx: CompletionContext, max = 6): string[] { if (max <= 0) return []; const result = complete(line, ctx); if (result.kind === "none" || result.kind === "command" || result.completions.length === 0) return []; const label = PREVIEW_LABEL[result.kind] ?? "Matches"; const budget = Math.max(1, max - 1); const shown = result.completions.slice(0, budget); const lines = [`${label}:`, ...shown.map(c => ` ${c}`)]; const hidden = result.completions.length - shown.length; if (hidden > 0) { if (lines.length >= max) lines[lines.length - 1] = ` …(+${hidden + 1} more)`; else lines.push(` …(+${hidden} more)`); } return lines; } /** Compact mid-turn command/skill preview. Like formatCompletionPreview but ALSO * surfaces command-name and $skill-name matches (the kinds the argument-only preview * skips), so a /command or $skill typed WHILE a turn runs visibly reacts. */ export function formatMidTurnHint(line: string, ctx: CompletionContext, max = 5): string[] { if (max <= 0) return []; const result = complete(line, ctx); if (result.completions.length === 0) return []; const label = PREVIEW_LABEL[result.kind] ?? "Matches"; const shown = result.completions.slice(0, max); const hidden = result.completions.length - shown.length; const lines = [`${label}:`, ...shown.map(c => ` ${c}`)]; if (hidden > 0) lines.push(` …(+${hidden} more)`); return lines; } /** Longest common prefix of a list (for tab "fill to ambiguity"). */ export function commonPrefix(items: string[]): string { if (items.length === 0) return ""; let prefix = items[0]!; for (const s of items.slice(1)) { let i = 0; while (i < prefix.length && i < s.length && prefix[i]!.toLowerCase() === s[i]!.toLowerCase()) i++; prefix = prefix.slice(0, i); if (!prefix) break; } return prefix; } /** * Adapter for Node/Bun `readline` completer contract: returns * `[completions, tokenBeingReplaced]`. When nothing matches, returns the empty * hit list with the whole line so readline leaves the input untouched. */ export function readlineCompleter(line: string, ctx: CompletionContext): [string[], string] { const r = complete(line, ctx); return [r.completions, r.completions.length ? r.token : line]; }