/** Slash-command palette/autocomplete for the interactive REPL (TUI M3). */
import chalk from "chalk";
import { editDistance } from "../../ai/model-catalog-compat";
/** Order-preserving subsequence test: every char of `needle` appears in `hay`
* left-to-right (gjc-style fuzzy match, e.g. "expt" ⊑ "export"). */
function subsequence(needle: string, hay: string): boolean {
if (needle === "") return true;
let i = 0;
for (let j = 0; j < hay.length && i < needle.length; j++) {
if (hay[j] === needle[i]) i++;
}
return i === needle.length;
}
export interface SlashCommandInfo {
command: string;
usage: string;
description: string;
group: "session" | "models" | "subagents" | "code" | "skills" | "system";
}
export const SLASH_COMMAND_DETAILS: readonly SlashCommandInfo[] = [
{ command: "/help", usage: "/help", description: "Show this help message", group: "system" },
{ command: "/clear", usage: "/clear", description: "Clear conversation history (keeps system prompt)", group: "session" },
{ command: "/new", usage: "/new", description: "Start a new session (fresh history + new session id)", group: "session" },
{ command: "/drop", usage: "/drop", description: "Delete the current session and start a new one", group: "session" },
{ command: "/session", usage: "/session [info|delete]", description: "Show current session info, or delete it", group: "session" },
{ command: "/rename", usage: "/rename
", description: "Rename the current session", group: "session" },
{ command: "/resume", usage: "/resume [id|gajae:[#]] [--any-cwd]", description: "Resume a saved session or import a read-only GJC v5 session", group: "session" },
{ command: "/changelog", usage: "/changelog [--full]", description: "Show recent release notes (use --full for the complete changelog)", group: "system" },
{ command: "/retry", usage: "/retry", description: "Retry the last request", group: "session" },
{ command: "/undo", usage: "/undo", description: "Undo the last jeo auto-commit and restore the working tree", group: "session" },
{ command: "/history", usage: "/history [n|all]", description: "Re-print the worked turn history (prompts, tool steps, replies) into scrollback", group: "session" },
{ command: "/export", usage: "/export [path] [json]", description: "Export the session transcript to a file", group: "session" },
{ command: "/dump", usage: "/dump", description: "Copy the session transcript to the clipboard", group: "session" },
{ command: "/btw", usage: "/btw ", description: "Ask an ephemeral side question (history untouched)", group: "session" },
{ command: "/compact", usage: "/compact", description: "Summarize older turns to free context", group: "session" },
{ command: "/handoff", usage: "/handoff [focus]", description: "Generate a bounded handoff summary without mutating session history", group: "session" },
{ command: "/goal", usage: "/goal ", description: "Set a natural language stop condition for the session", group: "session" },
{ command: "/model", usage: "/model [id|#N|save|thinking |subagent ]", description: "Show/switch model; picker can apply to default or any subagent role and set thinking", group: "models" },
{ command: "/fast", usage: "/fast [on|off|status]", description: "Toggle fast thinking mode when the active model supports it", group: "models" },
{ command: "/provider", usage: "/provider [login [name] | key [name] [key] | add --base-url [--model ]]", description: "Provider onboarding: `login [name]` starts OAuth; `key [name]` stores an API key (groq, deepseek, …); `add --base-url ` registers an OpenAI-compatible endpoint. Switch the active model/provider with /model", group: "models" },
{ command: "/login", usage: "/login [provider]", description: "OAuth login — opens a provider picker showing live login status (account · expiry) for each provider (alias of /provider login)", group: "models" },
{ command: "/logout", usage: "/logout ", description: "Remove the stored OAuth token for a provider", group: "models" },
{ command: "/roles", usage: "/roles [tier model]", description: "Show or set model role tiers (smol/slow/plan)", group: "models" },
{ command: "/thinking", usage: "/thinking [level]", description: "Show or set thinking budget (low/medium/high/xhigh)", group: "models" },
{ command: "/route", usage: "/route [on|off|why|history [n]|save|on save|off save]", description: "Show/toggle prompt-based model routing for this session, explain the last routing decision, or list recent routing history (add save to persist on/off)", group: "models" },
{ command: "/agents", usage: "/agents [edit|role] [model|#N|thinking L|maxSteps N|reset]", description: "List subagent roles; use /agents edit for the interactive picker or pin role model/settings", group: "subagents" },
{ command: "/subagent", usage: "/subagent [role]", description: "Show the current subagent composition (per-role model · thinking · steps); alias of /agents", group: "subagents" },
{ command: "/view", usage: "/view [a-b]", description: "Render a file with line numbers + light highlight", group: "code" },
{ command: "/diff", usage: "/diff [file]", description: "Render `git diff` with +/- coloring", group: "code" },
{ command: "/find", usage: "/find ", description: "List files matching a glob", group: "code" },
{ command: "/search", usage: "/search [glob]", description: "Search the repo for a pattern", group: "code" },
{ command: "/sessions", usage: "/sessions", description: "List saved sessions", group: "session" },
{ command: "/jobs", usage: "/jobs [list|tail|await|cancel]", description: "List, inspect, await, or cancel background jobs in this session", group: "system" },
{ command: "/usage", usage: "/usage", description: "Show cumulative token usage for this session", group: "system" },
{ command: "/context", usage: "/context", description: "Show context token usage breakdown", group: "system" },
{ command: "/tools", usage: "/tools", description: "Show the tools currently visible to the agent", group: "system" },
{ command: "/computer", usage: "/computer [on|off]", description: "Show/toggle the desktop-automation 'computer' tool (screenshot/click/type/etc) for this session", group: "system" },
{ command: "/hotkeys", usage: "/hotkeys", description: "Show keyboard shortcuts", group: "system" },
{ command: "/theme", usage: "/theme [name]", description: "Show or set the TUI theme (cosmic/matrix/solar/mono)", group: "system" },
{ command: "/wiki", usage: "/wiki [path|off]", description: "Show or set the global llm-wiki vault root (shared across all sessions)", group: "system" },
{ command: "/settings", usage: "/settings", description: "Show effective runtime configuration (alias of /config)", group: "system" },
{ command: "/evolve", usage: "/evolve", description: "Simulate and view the agent's evolutionary gallery", group: "system" },
{ command: "/config", usage: "/config", description: "Show the effective runtime configuration", group: "system" },
{ command: "/exit", usage: "/exit", description: "Exit the agent", group: "system" },
{ command: "/quit", usage: "/quit", description: "Exit the agent", group: "system" },
];
export const SLASH_COMMANDS = SLASH_COMMAND_DETAILS.map(c => c.command);
/** Bundled command name → lowercased description (gjc §2.1 description matching). */
export const SLASH_COMMAND_DESCRIPTIONS = new Map(
SLASH_COMMAND_DETAILS.map(c => [c.command, c.description.toLowerCase()]),
);
export function mergeSlashCommandDetails(extra: readonly SlashCommandInfo[] = []): SlashCommandInfo[] {
const byCommand = new Map();
for (const d of [...SLASH_COMMAND_DETAILS, ...extra]) byCommand.set(d.command, d);
return [...byCommand.values()];
}
/** Return the slash commands that match `input` (case-insensitive). Prefix matches
* come first; a fuzzy subsequence fallback (gjc-style, e.g. `/expt` → `/export`)
* follows so typos / abbreviations still surface a command. When NOTHING matches
* the name, fall back to a description substring match (gjc §2.1) so intent-style
* queries with no literal name match still resolve — e.g. `/oauth` → `/login`,
* `/clipboard` → `/dump`. A bare `/` lists every command. Empty for non-slash input. */
export function matchSlash(input: string, commands: string[] = SLASH_COMMANDS): string[] {
if (!input.startsWith("/")) return [];
const q = input.toLowerCase();
const body = q.slice(1);
if (body === "") return [...commands];
const starts = commands.filter(c => c.toLowerCase().startsWith(q));
const fuzzy = commands.filter(c => !starts.includes(c) && subsequence(body, c.slice(1).toLowerCase()));
const named = [...starts, ...fuzzy];
if (named.length > 0) return named;
// Description fallback: only when no name matches, and only for queries of ≥2
// chars with a real substring hit (not a loose subsequence), to avoid flooding.
if (body.length < 2) return [];
return commands.filter(c => SLASH_COMMAND_DESCRIPTIONS.get(c)?.includes(body) ?? false);
}
/** True when `input` looks like a slash command (starts with "/" and has no space). */
export function isSlashAttempt(input: string): boolean {
return input.startsWith("/") && !input.slice(1).includes(" ");
}
/** Near-miss slash commands for a true typo — edit distance ≤ 2 on the command body,
* excluding the prefix/fuzzy hits `matchSlash` already surfaces. gjc parity for the
* `/provicer` → `/provider` correction. Ranked closest-first and capped. */
export function suggestSlashCommands(input: string, commands: string[] = SLASH_COMMANDS, limit = 3): string[] {
if (!input.startsWith("/")) return [];
const body = input.slice(1).toLowerCase();
if (body === "") return [];
const already = new Set(matchSlash(input, commands));
return commands
.filter(c => !already.has(c))
.map(c => ({ c, d: editDistance(body, c.slice(1).toLowerCase()) }))
.filter(s => s.d <= 2)
.sort((a, b) => a.d - b.d || a.c.localeCompare(b.c))
.slice(0, limit)
.map(s => s.c);
}
const GROUP_LABELS: Record = {
session: "Session",
models: "Models / Providers",
subagents: "Subagents",
code: "Code tools",
skills: "Skills",
system: "System",
};
const GROUP_ORDER: readonly SlashCommandInfo["group"][] = ["models", "subagents", "code", "skills", "session", "system"];
/** Format a visible command palette for `/`, `/help`, or a partial slash prefix. */
export function formatSlashCommandList(input = "/", extra: readonly SlashCommandInfo[] = []): string[] {
const details = mergeSlashCommandDetails(extra);
const commands = details.map(c => c.command);
const query = input === "/?" ? "/" : input;
const matches = matchSlash(query, commands);
if (matches.length === 0) {
const near = suggestSlashCommands(query, commands);
const tail = near.length ? `Did you mean ${near.join(", ")}?` : "Try /help.";
return [`Unknown command '${input}'. ${tail}`];
}
const wanted = new Set(matches);
const rows = details.filter(c => wanted.has(c.command));
const usageWidth = Math.max(...rows.map(c => c.usage.length), 6);
const title = query === "/" || query === "/help"
? "Slash Commands:"
: `Slash Commands matching '${query}':`;
const lines = [title];
for (const group of GROUP_ORDER) {
const groupRows = rows.filter(c => c.group === group);
if (groupRows.length === 0) continue;
lines.push(` ${GROUP_LABELS[group]}:`);
for (const c of groupRows) lines.push(` ${c.usage.padEnd(usageWidth)} - ${c.description}`);
}
lines.push("Tip: type a slash prefix like '/mod' and press Tab to autocomplete.");
return lines;
}
export interface SkillPreviewItem {
name: string;
summary?: string;
}
function renderPreviewRows(
rows: { usage: string; description: string }[],
max: number,
selected: number,
): string[] {
const fmt = (r: { usage: string; description: string }, on: boolean): string => {
const head = on ? chalk.cyan(`▸ ${r.usage}`) : ` ${r.usage}`;
return `${head} ${chalk.dim(r.description)}`;
};
const n = rows.length;
if (n === 0) return [];
// HARD row-budget contract: the returned lines NEVER exceed `max` — the caller
// reserves exactly that many footer rows, and one extra line shifts the input
// box / caret math (the "broken input box" corruption). When the list overflows,
// up to two slots are spent on ↑/↓ "more" markers INSIDE the budget.
const overflowing = n > max;
const slots = overflowing ? Math.max(1, max - 2) : n;
const sel = selected < 0 ? 0 : Math.min(selected, n - 1);
const start = Math.max(0, Math.min(sel - Math.floor(slots / 2), n - slots));
const lines: string[] = [];
// Position counter (1-based) of the selected row within the full match list,
// shown on a "more" marker so an overflowing list reads like gjc's `(3/33)`.
// When nothing is selected, the window starts at the top → `(1/total)`.
const counter = `(${sel + 1}/${n})`;
const below = n - (start + slots);
if (start > 0) lines.push(below > 0 ? ` ↑ ${start} more` : ` ↑ ${start} more ${counter}`);
for (let i = start; i < start + slots && i < n; i++) lines.push(fmt(rows[i]!, i === selected));
if (below > 0) lines.push(` ↓ ${below} more ${counter}`);
return lines;
}
/** Skills matching a `$keyword` probe (no space yet). Prefix matches come first,
* then a fuzzy subsequence fallback (gjc-style, e.g. `$dintv` → `$deep-interview`).
* A bare `$` lists every skill. */
function dollarMatches(trimmed: string, skills: readonly SkillPreviewItem[]): SkillPreviewItem[] {
const prefix = trimmed.slice(1).toLowerCase();
if (prefix === "") return [...skills];
const starts = skills.filter(s => s.name.toLowerCase().startsWith(prefix));
const fuzzy = skills.filter(s => !starts.includes(s) && subsequence(prefix, s.name.toLowerCase()));
return [...starts, ...fuzzy];
}
/** The `/command` or `$skill` token actively being typed, ANYWHERE in the line. */
export interface ActiveTrigger {
kind: "/" | "$";
/** The trigger token itself (`/mo`, `$te`, bare `/`, bare `$`, …). */
token: string;
/** Index of the token's first character in `line` — for in-place replacement. */
start: number;
}
/**
* Find the trigger token the user is typing, regardless of where it sits in the
* line (mention-style): the LAST whitespace-delimited word, when it starts with
* `/` or `$` and the caret is still on it (the word ends the line — no space
* after it yet). `"fix the bug /mo"` → `/mo`, `"explain $te"` → `$te`,
* `"/model"` → `/model`. No trigger once a space follows (`"/model gpt"` →
* the active word is `gpt`), and never for `/`/`$` glued inside a word
* (`"src/cli"`, `"FOO$BAR"` — paths/vars stay popup-free).
*/
export function activeTriggerToken(line: string): ActiveTrigger | undefined {
const m = /(^|\s)([/$]\S*)$/.exec(line);
if (!m) return undefined;
const token = m[2]!;
return { kind: token[0] as "/" | "$", token, start: (m.index ?? 0) + m[1]!.length };
}
/**
* The LEADING `/command` or `$skill` keyword once it has been committed with a
* trailing space — `"/model gpt-4"` → `/model`, `"$test the bug"` → `$test`.
* Unlike {@link activeTriggerToken} (which only matches the word the caret still
* sits on) this keeps the invoked keyword recognizable while arguments are typed,
* so the trigger highlight persists after the space instead of vanishing. Only
* the leading word counts — a command is invoked at the start of the line — and a
* still-being-typed keyword (no space yet) returns undefined so the active-token
* path owns it. Returns the same shape as {@link activeTriggerToken}.
*/
export function committedTriggerToken(line: string): ActiveTrigger | undefined {
const m = /^(\s*)([/$]\S+)\s/.exec(line);
if (!m) return undefined;
const token = m[2]!;
return { kind: token[0] as "/" | "$", token, start: Array.from(m[1]!).length };
}
/**
* EVERY `/command` or `$skill` trigger token in the line (mention-style), in
* left-to-right order — `"/model x then $test y"` → [`/model`, `$test`]. Each
* is a whitespace-delimited word whose first char is `/`·`$` (paths like
* `src/cli` and vars like `FOO$BAR` stay excluded, just like the single-token
* helpers). `start` is the token's first-character index in `line`. Used to
* highlight all invocations at once, independent of caret position. Pure.
*/
export function allTriggerTokens(line: string): ActiveTrigger[] {
const out: ActiveTrigger[] = [];
const re = /(^|\s)([/$]\S*)/g;
let m: RegExpExecArray | null;
while ((m = re.exec(line))) {
const token = m[2]!;
out.push({ kind: token[0] as "/" | "$", token, start: m.index + m[1]!.length });
}
return out;
}
/**
* Compact live preview shown beneath the input box while a `/command` or
* `$skill` keyword is being typed — at any position in the line (mention-style,
* gjc/Codex parity): `"do X then /mo"` previews commands, `"…then $te"`
* previews skills. Returns matching usages + descriptions, capped, or [] when
* no trigger token is active (finished words stay popup-free). Match ORDER is
* preserved from matchSlash/dollarMatches so prefix hits sit above fuzzy hits.
*/
export function formatSlashPreview(
line: string,
max = 6,
selected = -1,
extra: readonly SlashCommandInfo[] = [],
skills: readonly SkillPreviewItem[] = [],
): string[] {
const trigger = activeTriggerToken(line);
if (!trigger) return [];
if (trigger.kind === "$") {
const rows = dollarMatches(trigger.token, skills).map(s => ({
usage: `$${s.name} [intent]`,
description: s.summary?.trim() || "run this skill directly",
}));
return renderPreviewRows(rows, max, selected);
}
const details = mergeSlashCommandDetails(extra);
const byCommand = new Map(details.map(c => [c.command, c] as const));
const rows = matchSlash(trigger.token, details.map(c => c.command))
.map(cmd => byCommand.get(cmd))
.filter((c): c is SlashCommandInfo => Boolean(c));
if (rows.length === 0) return [];
return renderPreviewRows(rows, max, selected);
}
/** The matching command names for an active `/cmd` or `$name` trigger token
* (anywhere in the line), in display order (prefix-first, then fuzzy). */
export function slashPreviewMatches(
line: string,
extra: readonly SlashCommandInfo[] = [],
skills: readonly SkillPreviewItem[] = [],
): string[] {
const trigger = activeTriggerToken(line);
if (!trigger) return [];
if (trigger.kind === "$") return dollarMatches(trigger.token, skills).map(s => `$${s.name}`);
const details = mergeSlashCommandDetails(extra);
return matchSlash(trigger.token, details.map(c => c.command));
}
/**
* Tab-completion target for the live `/`·`$` popup: the highlighted row when the
* user arrowed to one, else the TOP match (prefix hits sort first). Returns the
* completed line WITH a trailing space — arguments follow, and the space closes
* the keyword popup (a space means a real invocation is being typed). Pure.
*/
export function tabCompleteSelection(line: string, matches: readonly string[], selected: number): string | undefined {
if (matches.length === 0) return undefined;
const trigger = activeTriggerToken(line);
if (!trigger) return undefined;
const pick = matches[selected >= 0 && selected < matches.length ? selected : 0]!;
return line.slice(0, trigger.start) + `${pick} `;
}