supercode-cli 0.4.15

supercode — a lightweight, fully-customizable AI coding agent CLI in Rust. Any model via OpenRouter; natively continues Claude Code and Codex sessions.
//! Single source of truth for the REPL's `/command` set (UX-19).
//!
//! Three consumers read this table instead of hardcoding command spellings:
//! `handle_slash`'s dispatch (`main.rs`), the `/help` text, and the
//! rustyline tab-completer (`repl_completer.rs`). Add a command by adding a
//! [`SlashCmd`] variant and a matching [`SLASH_COMMANDS`] entry — `/help`
//! and completion pick it up automatically, and the compiler forces a
//! `handle_slash` match arm for it (the match in `main.rs` is exhaustive
//! over `SlashCmd`, so a variant with no arm fails to build).

/// Identifies a REPL slash-command's behavior. `handle_slash` matches on
/// this exhaustively, so adding a variant here without adding a match arm
/// there is a compile error — that's the drift guard.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum SlashCmd {
    Help,
    Model,
    /// BP-13 (catalog D9 "Fast mode / service tiers"): toggle the priority
    /// service tier for this session.
    Fast,
    Tokens,
    Add,
    Save,
    Clear,
    Undo,
    Rewind,
    Expand,
    Reduce,
    Experimental,
    Compact,
    Context,
    Handoff,
    Plan,
    Goal,
    Review,
    Side,
    Effort,
    Exit,
}

/// One row of the command table: spelling(s), argument shape, and help text.
pub struct SlashCommandSpec {
    pub cmd: SlashCmd,
    /// Canonical spelling, e.g. `"/help"`. Always leads with `/`.
    pub name: &'static str,
    /// Additional accepted spellings, e.g. `["/?"]`.
    pub aliases: &'static [&'static str],
    /// Argument usage hint shown in `/help` (empty if the command takes none).
    pub arg_hint: &'static str,
    /// One-line description shown in `/help`.
    pub desc: &'static str,
    /// Whether the argument is a filesystem path — drives rustyline
    /// `FilenameCompleter` delegation for this command's argument position.
    pub takes_path: bool,
}

/// The REPL's full slash-command set. See module docs: this is the single
/// source of truth — `/help`, the tab-completer, and the "did you mean"
/// suggester all derive from it.
pub const SLASH_COMMANDS: &[SlashCommandSpec] = &[
    SlashCommandSpec {
        cmd: SlashCmd::Help,
        name: "/help",
        aliases: &["/?"],
        arg_hint: "",
        desc: "this help",
        takes_path: false,
    },
    SlashCommandSpec {
        cmd: SlashCmd::Model,
        name: "/model",
        aliases: &[],
        arg_hint: "",
        desc: "show the active model, or pick a new one (interactive terminal)",
        takes_path: false,
    },
    SlashCommandSpec {
        cmd: SlashCmd::Fast,
        name: "/fast",
        aliases: &[],
        arg_hint: "[on|off]",
        desc: "toggle the priority (fast) service tier for this session",
        takes_path: false,
    },
    SlashCommandSpec {
        cmd: SlashCmd::Tokens,
        name: "/tokens",
        aliases: &[],
        arg_hint: "",
        desc: "output tokens + turns this session",
        takes_path: false,
    },
    SlashCommandSpec {
        cmd: SlashCmd::Add,
        name: "/add",
        aliases: &[],
        arg_hint: "<file>",
        desc: "stage a file as context for the next message",
        takes_path: true,
    },
    SlashCommandSpec {
        cmd: SlashCmd::Save,
        name: "/save",
        aliases: &[],
        arg_hint: "[file]",
        desc: "write the transcript (default transcript.jsonl)",
        takes_path: true,
    },
    SlashCommandSpec {
        cmd: SlashCmd::Clear,
        name: "/clear",
        aliases: &["/reset"],
        arg_hint: "",
        desc: "reset the conversation (keep the system prompt)",
        takes_path: false,
    },
    SlashCommandSpec {
        cmd: SlashCmd::Undo,
        name: "/undo",
        aliases: &[],
        arg_hint: "",
        desc: "drop the last exchange",
        takes_path: false,
    },
    SlashCommandSpec {
        cmd: SlashCmd::Rewind,
        name: "/rewind",
        aliases: &[],
        arg_hint: "[n|undo]",
        desc: "list rewind points, rewind the conversation to one, or undo the last rewind",
        takes_path: false,
    },
    SlashCommandSpec {
        cmd: SlashCmd::Expand,
        name: "/expand",
        aliases: &[],
        arg_hint: "<id|all|last>",
        desc: "rehydrate a stub (reduced sessions only)",
        takes_path: false,
    },
    SlashCommandSpec {
        cmd: SlashCmd::Reduce,
        name: "/reduce",
        aliases: &[],
        arg_hint: "",
        desc: "re-stub everything not excluded (reduced sessions only)",
        takes_path: false,
    },
    SlashCommandSpec {
        cmd: SlashCmd::Experimental,
        name: "/experimental",
        aliases: &["/features"],
        arg_hint: "",
        desc: "list the staged experimental flags and this session's values",
        takes_path: false,
    },
    SlashCommandSpec {
        cmd: SlashCmd::Compact,
        name: "/compact",
        aliases: &[],
        arg_hint: "[focus]",
        desc: "compact the conversation now; the optional focus steers what the summary keeps",
        takes_path: false,
    },
    SlashCommandSpec {
        cmd: SlashCmd::Context,
        name: "/context",
        aliases: &[],
        arg_hint: "",
        desc: "context-window usage: tokens, percentage, and what is consuming it",
        takes_path: false,
    },
    SlashCommandSpec {
        cmd: SlashCmd::Handoff,
        name: "/handoff",
        aliases: &[],
        arg_hint: "<objective>",
        desc: "start a fresh working context seeded with an objective and the recent tail",
        takes_path: false,
    },
    SlashCommandSpec {
        cmd: SlashCmd::Plan,
        name: "/plan",
        aliases: &[],
        arg_hint: "[on|off]",
        desc: "toggle plan mode: a read-only research phase (write/exec tools refused)",
        takes_path: false,
    },
    SlashCommandSpec {
        cmd: SlashCmd::Goal,
        name: "/goal",
        aliases: &[],
        arg_hint: "[objective|clear]",
        desc: "show, set, or clear the session's standing objective",
        takes_path: false,
    },
    SlashCommandSpec {
        cmd: SlashCmd::Review,
        name: "/review",
        aliases: &["/code-review"],
        arg_hint: "[focus]",
        desc: "run the dedicated code-review turn with this harness's report format",
        takes_path: false,
    },
    SlashCommandSpec {
        cmd: SlashCmd::Side,
        name: "/btw",
        aliases: &["/side"],
        arg_hint: "<question>",
        desc: "ask a tool-less question over the full context; the exchange never enters history",
        takes_path: false,
    },
    SlashCommandSpec {
        cmd: SlashCmd::Effort,
        name: "/effort",
        aliases: &[],
        arg_hint: "[low|medium|high|off]",
        desc: "show or change the reasoning effort for the rest of the session",
        takes_path: false,
    },
    SlashCommandSpec {
        cmd: SlashCmd::Exit,
        name: "/exit",
        aliases: &["/quit", "/q"],
        arg_hint: "",
        desc: "leave (also Ctrl-D)",
        takes_path: false,
    },
];

/// Resolve a typed token (canonical spelling or alias) to its [`SlashCmd`],
/// or `None` if it isn't a known command.
pub fn resolve_slash_command(input: &str) -> Option<SlashCmd> {
    SLASH_COMMANDS
        .iter()
        .find(|c| c.name == input || c.aliases.contains(&input))
        .map(|c| c.cmd)
}

/// Every accepted spelling (canonical names + aliases), in table order —
/// the candidate universe for tab-completion.
pub fn slash_command_names() -> impl Iterator<Item = &'static str> {
    SLASH_COMMANDS
        .iter()
        .flat_map(|c| std::iter::once(c.name).chain(c.aliases.iter().copied()))
}

/// Whether the given spelling's argument is a filesystem path (drives
/// rustyline `FilenameCompleter` delegation).
pub fn slash_command_takes_path(input: &str) -> bool {
    SLASH_COMMANDS
        .iter()
        .any(|c| c.takes_path && (c.name == input || c.aliases.contains(&input)))
}

/// Render the `/help` body from [`SLASH_COMMANDS`] (replaces a hand-written
/// string so help text can't drift from the table).
pub fn help_text() -> String {
    let labels: Vec<String> = SLASH_COMMANDS
        .iter()
        .map(|c| {
            if c.arg_hint.is_empty() {
                c.name.to_string()
            } else {
                format!("{} {}", c.name, c.arg_hint)
            }
        })
        .collect();
    let width = labels.iter().map(|l| l.len()).max().unwrap_or(0);
    let mut out = String::from("commands:");
    for (label, spec) in labels.iter().zip(SLASH_COMMANDS.iter()) {
        out.push_str(&format!("\n  {label:width$} {}", spec.desc));
    }
    out
}

/// Levenshtein (edit) distance between two strings, for typo-tolerant
/// "did you mean" matching. Plain O(len_a * len_b) DP, no external crate.
fn levenshtein(a: &str, b: &str) -> usize {
    let a: Vec<char> = a.chars().collect();
    let b: Vec<char> = b.chars().collect();
    let mut dp: Vec<usize> = (0..=b.len()).collect();
    for i in 1..=a.len() {
        let mut prev = dp[0];
        dp[0] = i;
        for j in 1..=b.len() {
            let temp = dp[j];
            dp[j] = if a[i - 1] == b[j - 1] {
                prev
            } else {
                1 + prev.min(dp[j]).min(dp[j - 1])
            };
            prev = temp;
        }
    }
    dp[b.len()]
}

/// Nearest known slash-command to an unrecognized `/token`, for the "did
/// you mean" suggestion (UX-19 AC dev/02). `None` when nothing is close
/// enough to plausibly be a typo of a real command (bounds both the
/// absolute edit distance and its size relative to the input, so a short
/// garbage token doesn't "match" an unrelated long command name).
pub fn suggest_slash_command(input: &str) -> Option<&'static str> {
    if !input.starts_with('/') || input.len() <= 1 {
        return None;
    }
    let mut best: Option<(&'static str, usize)> = None;
    for name in slash_command_names() {
        let d = levenshtein(input, name);
        if best.is_none_or(|(_, bd)| d < bd) {
            best = Some((name, d));
        }
    }
    best.filter(|(_, d)| *d <= 2 && *d < input.len())
        .map(|(name, _)| name)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn resolve_finds_canonical_and_aliases() {
        assert_eq!(resolve_slash_command("/help"), Some(SlashCmd::Help));
        assert_eq!(resolve_slash_command("/?"), Some(SlashCmd::Help));
        assert_eq!(resolve_slash_command("/reset"), Some(SlashCmd::Clear));
        assert_eq!(resolve_slash_command("/q"), Some(SlashCmd::Exit));
        assert_eq!(resolve_slash_command("/nope"), None);
    }

    #[test]
    fn every_table_entry_resolves_to_itself() {
        for spec in SLASH_COMMANDS {
            assert_eq!(resolve_slash_command(spec.name), Some(spec.cmd));
            for alias in spec.aliases {
                assert_eq!(resolve_slash_command(alias), Some(spec.cmd));
            }
        }
    }

    #[test]
    fn suggest_typo_did_you_mean() {
        assert_eq!(suggest_slash_command("/hlep"), Some("/help"));
        assert_eq!(suggest_slash_command("/toekns"), Some("/tokens"));
        assert_eq!(suggest_slash_command("/exti"), Some("/exit"));
        assert_eq!(suggest_slash_command("/reduc"), Some("/reduce"));
    }

    #[test]
    fn suggest_none_for_unrelated_or_empty() {
        assert_eq!(suggest_slash_command("/zzzzzzzzzzzz"), None);
        assert_eq!(suggest_slash_command("/"), None);
        assert_eq!(suggest_slash_command("not-a-slash"), None);
    }

    #[test]
    fn help_text_lists_every_command() {
        let text = help_text();
        for spec in SLASH_COMMANDS {
            assert!(text.contains(spec.name), "help text missing {}", spec.name);
            assert!(
                text.contains(spec.desc),
                "help text missing desc for {}",
                spec.name
            );
        }
    }

    #[test]
    fn takes_path_matches_table() {
        assert!(slash_command_takes_path("/add"));
        assert!(slash_command_takes_path("/save"));
        assert!(!slash_command_takes_path("/help"));
        assert!(!slash_command_takes_path("/tokens"));
    }
}