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.
//! Tab-completion for the interactive `chat` REPL (UX-19).
//!
//! rustyline only offers completion through a `Helper` implementing
//! `Completer` (`Hinter`/`Highlighter`/`Validator` come along for free via
//! [`rustyline::Helper`]'s supertrait bound). [`ReplHelper`] is that helper;
//! `chat()` in `main.rs` is the only place it's constructed and wired into
//! the `Editor`, so this module has zero effect outside the interactive
//! REPL — non-interactive `run`/`resume`/`--quiet`/machine-output paths
//! never build an `Editor` at all, let alone this helper.

use rustyline::completion::{Completer, FilenameCompleter, Pair};
use rustyline::highlight::Highlighter;
use rustyline::hint::Hinter;
use rustyline::validate::Validator;
use rustyline::{Context, Result};

use crate::slash::{slash_command_names, slash_command_takes_path};

/// The `chat` REPL's rustyline helper. Tab completion has exactly two
/// modes, chosen by what's already typed:
///
/// 1. Line starts with `/` and the command name isn't finished (no
///    whitespace yet) → complete against [`slash::slash_command_names`],
///    the same table `handle_slash` dispatches from (UX-19 dev/01).
/// 2. Line starts with `/<known-path-taking-command> ` (`/add`, `/save`)
///    → delegate to rustyline's built-in [`FilenameCompleter`] for the
///    argument.
///
/// Anything else — including all free-form chat text that doesn't start
/// with `/` — yields zero candidates, so completion is inert for normal
/// prompt input (UX-19 dev/03) and can't interfere with history, Ctrl-C, or
/// any other `Editor` behavior `chat()` relies on.
pub struct ReplHelper {
    filename_completer: FilenameCompleter,
}

impl ReplHelper {
    pub fn new() -> Self {
        Self {
            filename_completer: FilenameCompleter::new(),
        }
    }
}

impl Default for ReplHelper {
    fn default() -> Self {
        Self::new()
    }
}

impl Completer for ReplHelper {
    type Candidate = Pair;

    fn complete(&self, line: &str, pos: usize, ctx: &Context<'_>) -> Result<(usize, Vec<Pair>)> {
        complete_repl_line(&self.filename_completer, line, pos, ctx)
    }
}

/// The actual candidate-generation logic, factored out of the `Completer`
/// impl so it's directly unit-testable without going through rustyline's
/// terminal machinery.
fn complete_repl_line(
    filename_completer: &FilenameCompleter,
    line: &str,
    pos: usize,
    ctx: &Context<'_>,
) -> Result<(usize, Vec<Pair>)> {
    let prefix = &line[..pos];

    // BP-5 (catalog D2 "@-file mentions / attachments", cc§2 "`@` in the
    // prompt triggers file-path autocomplete and injects file context"):
    // the WORD being typed decides, so a mention completes anywhere in the
    // line — mid-sentence is where one is usually written. The injection
    // half is `Agent::expand_file_mentions`; this is the composer half, and
    // it delegates to the same `FilenameCompleter` the path-taking slash
    // commands already use rather than growing a second path walker.
    if let Some(at) = mention_start(prefix) {
        let (_, pairs) = filename_completer.complete(line, pos, ctx)?;
        return Ok((at + 1, pairs));
    }

    // Otherwise only ever activate at the start of the line — free-form chat
    // text (anything not beginning with `/`) gets zero candidates.
    let Some(rest) = prefix.strip_prefix('/') else {
        return Ok((pos, Vec::new()));
    };

    if !rest.contains(char::is_whitespace) {
        // Still typing the command name itself: complete `/xyz` against
        // every accepted spelling (canonical + aliases) that shares this
        // prefix.
        let mut candidates: Vec<Pair> = slash_command_names()
            .filter(|name| name.starts_with(prefix))
            .map(|name| Pair {
                display: name.to_string(),
                replacement: name.to_string(),
            })
            .collect();
        candidates.sort_by(|a, b| a.display.cmp(&b.display));
        return Ok((0, candidates));
    }

    // Command name is finished; completing its argument. Only commands
    // whose argument is a path (`/add`, `/save` — see `slash.rs`) get
    // filename completion; everything else stays inert.
    let cmd_name = prefix.split_whitespace().next().unwrap_or("");
    if slash_command_takes_path(cmd_name) {
        return filename_completer.complete(line, pos, ctx);
    }
    Ok((pos, Vec::new()))
}

/// BP-5: the byte offset of the `@` opening the mention the cursor is
/// inside, or `None` when the cursor is not in one. A mention starts at a
/// word boundary (so an email address's `@` never opens one) and ends at the
/// first whitespace.
fn mention_start(prefix: &str) -> Option<usize> {
    let at = prefix.rfind('@')?;
    if prefix[at..].contains(char::is_whitespace) {
        return None;
    }
    let starts_word = at == 0
        || prefix[..at]
            .chars()
            .next_back()
            .is_some_and(char::is_whitespace);
    starts_word.then_some(at)
}

// Hinter/Highlighter/Validator: no-op implementations, written by hand
// (rather than via the `rustyline-derive` proc-macro's `#[derive(...)]`,
// which would pull in an extra crate) since each trait already provides a
// default method body — the only obligation is `Hinter::Hint`'s associated
// type, which has no default.
impl Hinter for ReplHelper {
    type Hint = String;
}

impl Highlighter for ReplHelper {}

impl Validator for ReplHelper {}

impl rustyline::Helper for ReplHelper {}

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

    fn candidates(line: &str) -> Vec<String> {
        let history = DefaultHistory::new();
        let ctx = Context::new(&history);
        let fc = FilenameCompleter::new();
        let pos = line.len();
        let (_start, pairs) = complete_repl_line(&fc, line, pos, &ctx).unwrap();
        pairs.into_iter().map(|p| p.replacement).collect()
    }

    /// BP-5: an `@` opening a word is a file mention and completes as a
    /// path; an `@` inside one (an email address) is ordinary text.
    #[test]
    fn a_mention_completes_as_a_path_and_an_email_address_does_not() {
        assert_eq!(mention_start("look at @src/li"), Some(8));
        assert_eq!(mention_start("@"), Some(0));
        assert_eq!(mention_start("mail me@example.com"), None);
        assert_eq!(mention_start("@done and now"), None);
        assert_eq!(mention_start("no mention here"), None);
        // A mention is completed by the same filename completer `/add` uses,
        // so a real path under the current directory yields candidates.
        assert!(!candidates("read @Car").is_empty());
    }

    #[test]
    fn slash_alone_lists_every_accepted_spelling() {
        let got = candidates("/");
        let expected: Vec<String> = {
            let mut v: Vec<String> = slash_command_names().map(String::from).collect();
            v.sort();
            v
        };
        assert_eq!(got, expected);
        assert!(got.contains(&"/help".to_string()));
        assert!(got.contains(&"/reduce".to_string()));
    }

    #[test]
    fn partial_command_narrows_to_matching_prefix() {
        let got = candidates("/re");
        // "/reduce" and "/reset" (an alias of /clear) both start with "/re".
        assert!(got.contains(&"/reduce".to_string()), "{got:?}");
        assert!(got.contains(&"/reset".to_string()), "{got:?}");
        assert!(!got.contains(&"/help".to_string()), "{got:?}");
        for c in &got {
            assert!(c.starts_with("/re"), "{c} does not start with /re");
        }
    }

    #[test]
    fn unambiguous_prefix_yields_single_candidate() {
        let got = candidates("/tok");
        assert_eq!(got, vec!["/tokens".to_string()]);
    }

    #[test]
    fn unknown_prefix_yields_no_candidates() {
        let got = candidates("/zzz");
        assert!(got.is_empty(), "{got:?}");
    }

    #[test]
    fn plain_text_is_inert_no_candidates() {
        // Free-form chat input never starting with `/` must never produce
        // slash-command candidates (UX-19 dev/03).
        assert!(candidates("hello there").is_empty());
        assert!(candidates("what is /help anyway").is_empty());
        assert!(candidates("").is_empty());
    }

    #[test]
    fn path_taking_command_delegates_to_filename_completer() {
        // `/add` takes a path argument — completion after the space must
        // not just be the empty "inert" result; it must go through
        // `FilenameCompleter` (verified indirectly: it doesn't error and
        // returns some result set for the cwd, which always has entries
        // when run from a real checkout).
        let history = DefaultHistory::new();
        let ctx = Context::new(&history);
        let fc = FilenameCompleter::new();
        let line = "/add ";
        let (start, _pairs) = complete_repl_line(&fc, line, line.len(), &ctx).unwrap();
        // FilenameCompleter's start index is wherever it judges the path
        // token begins (here, right after the space) — NOT the slash-name
        // branch's fixed 0, proving delegation actually happened.
        assert_eq!(start, "/add ".len());
    }

    #[test]
    fn non_path_command_argument_is_inert() {
        // `/model` takes no argument at all — typing a space after it must
        // not trigger filename completion.
        let got = candidates("/model ");
        assert!(got.is_empty(), "{got:?}");
    }
}