rho-coding-agent 0.6.0

A lightweight agent harness inspired by Pi
use thiserror::Error;

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CommandId {
    Login,
    Logout,
    Model,
    TitleModel,
    Resume,
    Config,
    Skills,
    Exit,
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct CommandSpec {
    pub id: CommandId,
    pub name: &'static str,
    pub usage: &'static str,
    pub description: &'static str,
}

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CommandInvocation {
    pub id: CommandId,
    pub name: String,
    pub raw_args: String,
    pub args: String,
}

#[derive(Clone, Debug, Error, PartialEq, Eq)]
pub enum CommandParseError {
    #[error("unknown command '/{0}'")]
    Unknown(String),
}

pub static COMMANDS: &[CommandSpec] = &[
    CommandSpec {
        id: CommandId::Login,
        name: "login",
        usage: "/login [provider]",
        description: "log in to a provider",
    },
    CommandSpec {
        id: CommandId::Logout,
        name: "logout",
        usage: "/logout [provider]",
        description: "delete provider credentials",
    },
    CommandSpec {
        id: CommandId::Model,
        name: "model",
        usage: "/model [model]",
        description: "show or switch model",
    },
    CommandSpec {
        id: CommandId::TitleModel,
        name: "title-model",
        usage: "/title-model [model]",
        description: "show or switch session title model",
    },
    CommandSpec {
        id: CommandId::Resume,
        name: "resume",
        usage: "/resume [id]",
        description: "resume a saved session",
    },
    CommandSpec {
        id: CommandId::Config,
        name: "config",
        usage: "/config",
        description: "open configuration picker",
    },
    CommandSpec {
        id: CommandId::Skills,
        name: "skills",
        usage: "/skills",
        description: "show loaded skills and descriptions",
    },
    CommandSpec {
        id: CommandId::Exit,
        name: "exit",
        usage: "/exit",
        description: "quit rho",
    },
];

pub fn command_prefix(input: &str) -> Option<&str> {
    let token_end = input
        .char_indices()
        .find_map(|(index, ch)| ch.is_whitespace().then_some(index))
        .unwrap_or(input.len());
    let prefix = input[..token_end].strip_prefix('/')?;
    if prefix.starts_with('/') {
        None
    } else {
        Some(prefix)
    }
}

pub fn matching_commands(prefix: &str) -> Vec<&'static CommandSpec> {
    let prefix = prefix
        .strip_prefix('/')
        .unwrap_or(prefix)
        .to_ascii_lowercase();
    COMMANDS
        .iter()
        .filter(|command| command.name.starts_with(&prefix))
        .collect()
}

pub fn parse_command(input: &str) -> Result<Option<CommandInvocation>, CommandParseError> {
    let input = input.trim_end();
    let Some(rest) = input.strip_prefix('/') else {
        return Ok(None);
    };
    if rest.starts_with('/') {
        return Ok(None);
    }

    let name_end = rest
        .char_indices()
        .find_map(|(index, ch)| ch.is_whitespace().then_some(index))
        .unwrap_or(rest.len());
    let name = &rest[..name_end];
    let raw_args = rest[name_end..].to_string();
    let args = raw_args.trim().to_string();

    let spec = COMMANDS
        .iter()
        .find(|command| command.name.eq_ignore_ascii_case(name))
        .ok_or_else(|| CommandParseError::Unknown(name.to_string()))?;

    Ok(Some(CommandInvocation {
        id: spec.id,
        name: spec.name.to_string(),
        raw_args,
        args,
    }))
}

pub fn complete_command(input: &str, cursor: usize, spec: &CommandSpec) -> (String, usize) {
    let token_end = first_token_end_byte(input);
    let token_len = input[..token_end].chars().count();
    let args = input[token_end..].trim_start();
    let completed_prefix = format!("/{} ", spec.name);
    let completed_prefix_len = completed_prefix.chars().count();
    let completed = if args.is_empty() {
        completed_prefix
    } else {
        format!("{completed_prefix}{args}")
    };

    let new_cursor = if cursor <= token_len {
        completed_prefix_len
    } else {
        completed
            .chars()
            .count()
            .min(completed_prefix_len.saturating_add(cursor.saturating_sub(token_len)))
    };

    (completed, new_cursor)
}

fn first_token_end_byte(input: &str) -> usize {
    input
        .char_indices()
        .find_map(|(index, ch)| ch.is_whitespace().then_some(index))
        .unwrap_or(input.len())
}

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

    #[test]
    fn matches_all_commands_for_empty_slash_prefix() {
        let matches = matching_commands(command_prefix("/").unwrap());

        assert_eq!(matches.len(), COMMANDS.len());
        assert!(matches.iter().any(|command| command.name == "model"));
    }

    #[test]
    fn additional_leading_slashes_are_literal_text() {
        assert_eq!(command_prefix("//"), None);
        assert_eq!(parse_command("//literal").unwrap(), None);
    }

    #[test]
    fn slash_must_be_first_character_to_parse_as_command() {
        assert_eq!(command_prefix(" /model"), None);
        assert_eq!(parse_command(" /model").unwrap(), None);
    }

    #[test]
    fn matches_commands_by_case_insensitive_prefix() {
        let matches = matching_commands(command_prefix("/M").unwrap());

        assert_eq!(matches.len(), 1);
        assert_eq!(matches[0].name, "model");
    }

    #[test]
    fn matches_full_command_name() {
        let matches = matching_commands(command_prefix("/model").unwrap());

        assert_eq!(matches.len(), 1);
        assert_eq!(matches[0].id, CommandId::Model);
    }

    #[test]
    fn matching_unknown_command_returns_no_matches() {
        let matches = matching_commands(command_prefix("/nope").unwrap());

        assert!(matches.is_empty());
    }

    #[test]
    fn parses_skills_command() {
        let invocation = parse_command("/skills").unwrap().unwrap();

        assert_eq!(invocation.id, CommandId::Skills);
        assert_eq!(invocation.name, "skills");
    }

    #[test]
    fn parses_model_command_with_arguments() {
        let invocation = parse_command("/model gpt-5.5").unwrap().unwrap();

        assert_eq!(invocation.id, CommandId::Model);
        assert_eq!(invocation.name, "model");
        assert_eq!(invocation.raw_args, " gpt-5.5");
        assert_eq!(invocation.args, "gpt-5.5");
    }

    #[test]
    fn parses_non_command_as_none() {
        assert_eq!(parse_command("hello /model").unwrap(), None);
    }

    #[test]
    fn rejects_unknown_command() {
        let err = parse_command("/nope").unwrap_err();

        assert_eq!(err, CommandParseError::Unknown("nope".into()));
    }

    #[test]
    fn parses_logout_command_with_provider_argument() {
        let invocation = parse_command("/logout openai-codex").unwrap().unwrap();

        assert_eq!(invocation.id, CommandId::Logout);
        assert_eq!(invocation.args, "openai-codex");
    }

    #[test]
    fn login_usage_accepts_provider_argument() {
        let login = COMMANDS
            .iter()
            .find(|command| command.name == "login")
            .unwrap();

        assert_eq!(login.usage, "/login [provider]");
    }

    #[test]
    fn completes_command_and_preserves_args() {
        let spec = COMMANDS
            .iter()
            .find(|command| command.name == "model")
            .unwrap();
        let (input, cursor) = complete_command("/m gpt-5.5", 2, spec);

        assert_eq!(input, "/model gpt-5.5");
        assert_eq!(cursor, 7);
    }
}