rho-coding-agent 0.4.0

A lightweight agent harness inspired by Pi
mod agent;
mod cli;
mod commands;
mod config;
mod model;
mod prompt;
mod session;
mod tool;
mod tools;
mod transcript;
mod tui;

use std::io::{self, IsTerminal, Read};

use clap::Parser;

use agent::Agent;
use cli::{Cli, Command};
use config::Config;
use model::{build_provider, reasoning_config_value};
use session::Session;
use tool::ToolContext;
use tui::TuiInfo;

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    let cli = Cli::parse();
    validate_cli(&cli)?;
    let config_path = cli.config.clone();
    let mut cfg = Config::load(config_path.clone())?;
    let mut save_config = false;
    if let Some(provider) = cli.provider {
        cfg.provider = provider;
        save_config = true;
    }
    if let Some(model) = cli.model {
        cfg.model = model;
        save_config = true;
    }
    if let Some(auth) = cli.auth {
        cfg.auth = auth;
        save_config = true;
    }
    if save_config {
        cfg.save(config_path.clone())?;
    }

    if cli.command.is_none() && (!io::stdin().is_terminal() || !io::stdout().is_terminal()) {
        anyhow::bail!(
            "rho's default mode is the interactive TUI; use `rho run` for non-interactive automation"
        );
    }
    let run_prompt = match &cli.command {
        Some(Command::Run { prompt, stdin }) => Some(automation_prompt(prompt.clone(), *stdin)?),
        None => None,
    };

    let provider = build_provider(
        &cfg.provider,
        &cfg.model,
        reasoning_config_value(&cfg.reasoning_effort),
        reasoning_config_value(&cfg.reasoning_summary),
    )?;
    let registry = tools::registry();
    let cwd = std::env::current_dir()?;
    let ctx = ToolContext {
        cwd: cwd.clone(),
        max_output_bytes: cfg.max_output_bytes,
    };
    let mut agent = Agent::new(provider, registry, ctx);

    match run_prompt {
        Some(prompt) => {
            let answer = agent.run(prompt).await?;
            println!("{answer}");
        }
        None => {
            let session_id = if let Some(id) = &cli.resume {
                let (session, history) = Session::open_by_id(&cwd, id)?;
                let session_id = Some(session.id().to_string());
                agent = agent.with_history(history);
                agent.set_message_sink(move |message| session.append_message(message));
                session_id
            } else {
                None
            };
            let tui_result = tui::run(
                &mut agent,
                TuiInfo {
                    cwd,
                    provider: cfg.provider,
                    model: cfg.model,
                    reasoning_effort: cfg.reasoning_effort,
                    reasoning_summary: cfg.reasoning_summary,
                    auth: cfg.auth,
                    session_id,
                    config_path,
                },
            )
            .await?;
            if let Some(session_id) = tui_result.resume_session_id {
                println!("\nResume this session:\n  rho --resume {session_id}\n");
            }
        }
    }
    Ok(())
}

fn validate_cli(cli: &Cli) -> anyhow::Result<()> {
    if cli.resume.is_some() && matches!(&cli.command, Some(Command::Run { .. })) {
        anyhow::bail!("--resume is only supported for interactive sessions");
    }
    Ok(())
}

fn automation_prompt(parts: Vec<String>, read_stdin: bool) -> anyhow::Result<String> {
    automation_prompt_with_stdin(parts, read_stdin, &mut io::stdin())
}

fn automation_prompt_with_stdin(
    parts: Vec<String>,
    read_stdin: bool,
    stdin: &mut impl Read,
) -> anyhow::Result<String> {
    let mut chunks = Vec::new();
    let inline = parts.join(" ").trim().to_string();
    if !inline.is_empty() {
        chunks.push(inline);
    }
    if read_stdin {
        let mut buffer = String::new();
        stdin.read_to_string(&mut buffer)?;
        let buffer = buffer.trim().to_string();
        if !buffer.is_empty() {
            chunks.push(buffer);
        }
    }

    let prompt = chunks.join("\n\n");
    if prompt.is_empty() {
        anyhow::bail!("rho run requires a prompt argument or --stdin");
    }
    Ok(prompt)
}

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

    #[test]
    fn validate_cli_rejects_resume_with_run_before_prompt_reading() {
        let cli = Cli {
            provider: None,
            model: None,
            config: None,
            auth: None,
            resume: Some("session-id".into()),
            command: Some(Command::Run {
                stdin: true,
                prompt: Vec::new(),
            }),
        };

        let err = validate_cli(&cli).unwrap_err();

        assert!(err.to_string().contains("--resume is only supported"));
    }

    #[test]
    fn automation_prompt_joins_inline_parts() {
        let mut stdin = io::empty();
        let prompt =
            automation_prompt_with_stdin(vec!["review".into(), "this".into()], false, &mut stdin)
                .unwrap();

        assert_eq!(prompt, "review this");
    }

    #[test]
    fn automation_prompt_combines_inline_and_stdin() {
        let mut stdin = "diff contents".as_bytes();
        let prompt = automation_prompt_with_stdin(vec!["review".into()], true, &mut stdin).unwrap();

        assert_eq!(prompt, "review\n\ndiff contents");
    }

    #[test]
    fn automation_prompt_requires_input() {
        let mut stdin = io::empty();
        let err = automation_prompt_with_stdin(Vec::new(), false, &mut stdin).unwrap_err();

        assert!(err.to_string().contains("requires a prompt"));
    }
}