ralph-coder 0.2.1

An agentic code generation CLI powered by multiple LLM backends
Documentation
use colored::Colorize;
use std::io::Write;

#[derive(Debug, Clone)]
pub struct OutputConfig {
    pub format: OutputFormat,
    pub verbose: bool,
    pub session_id: Option<String>,
}

#[derive(Debug, Clone, PartialEq)]
pub enum OutputFormat {
    Terminal,
    Json,
}

impl OutputConfig {
    pub fn new(format: OutputFormat, verbose: bool) -> Self {
        Self {
            format,
            verbose,
            session_id: None,
        }
    }
}

/// Phase tag printed before each message line.
#[derive(Debug, Clone, Copy)]
pub enum Phase {
    Observe,
    Plan,
    Tool,
    Verify,
    Compact,
    Checkpoint,
    Ralph,
    Done,
    Failed,
    Session,
}

impl Phase {
    fn label(&self) -> &'static str {
        match self {
            Phase::Observe => "observe",
            Phase::Plan => "plan",
            Phase::Tool => "tool",
            Phase::Verify => "verify",
            Phase::Compact => "compact",
            Phase::Checkpoint => "checkpoint",
            Phase::Ralph => "ralph",
            Phase::Done => "done",
            Phase::Failed => "failed",
            Phase::Session => "session",
        }
    }

    fn colorize(&self, s: &str) -> String {
        match self {
            Phase::Observe => s.cyan().to_string(),
            Phase::Plan => s.yellow().bold().to_string(),
            Phase::Tool => s.blue().bold().to_string(),
            Phase::Verify => s.green().to_string(),
            Phase::Compact => s.magenta().bold().to_string(),
            Phase::Checkpoint => s.blue().bold().to_string(),
            Phase::Ralph => s.white().bold().to_string(),
            Phase::Done => s.green().bold().to_string(),
            Phase::Failed => s.red().bold().to_string(),
            Phase::Session => s.cyan().to_string(),
        }
    }
}

#[derive(Clone)]
pub struct Printer {
    pub config: OutputConfig,
}

impl Printer {
    pub fn new(config: OutputConfig) -> Self {
        Self { config }
    }

    pub fn with_session(mut self, session_id: String) -> Self {
        self.config.session_id = Some(session_id);
        self
    }

    pub fn clone_with_session(&self, session_id: String) -> Self {
        let mut c = self.clone();
        c.config.session_id = Some(session_id);
        c
    }

    pub fn print(&self, phase: Phase, message: &str) {
        match self.config.format {
            OutputFormat::Terminal => {
                let tag = format!("[{}]", phase.label());
                let colored_tag = phase.colorize(&tag);
                println!("{} {}", colored_tag, message);
            }
            OutputFormat::Json => {
                let obj = serde_json::json!({
                    "phase": phase.label(),
                    "message": message,
                    "session_id": self.config.session_id,
                    "timestamp": chrono::Utc::now().to_rfc3339(),
                });
                println!("{}", obj);
            }
        }
    }

    /// `display` is a pre-formatted human-readable description of the call
    /// (not raw JSON), produced by `describe_tool_call` in loop_runner.
    pub fn print_tool_call(&self, tool: &str, display: &str, result: &str) {
        match self.config.format {
            OutputFormat::Terminal => {
                let colored_tag = Phase::Tool.colorize("[tool]");
                let tool_name = tool.cyan().to_string();
                println!("{} {} {}", colored_tag, tool_name, display);
                // Only print the result line when verbose or it is short/important
                let skip = !self.config.verbose
                    && (result == "(executing...)"
                        || result.starts_with("DONE:")
                        || result.starts_with("FAILED:"));
                if !skip {
                    if result.len() <= 300 {
                        println!("       → {}", result.trim().dimmed());
                    } else {
                        let preview: String = result.chars().take(300).collect();
                        println!("       → {}…", preview.trim().dimmed());
                    }
                }
            }
            OutputFormat::Json => {
                let obj = serde_json::json!({
                    "phase": "execute",
                    "tool": tool,
                    "display": display,
                    "result": result,
                    "session_id": self.config.session_id,
                    "timestamp": chrono::Utc::now().to_rfc3339(),
                });
                println!("{}", obj);
            }
        }
    }

    pub fn print_error(&self, message: &str) {
        match self.config.format {
            OutputFormat::Terminal => {
                eprintln!("{} {}", "[error]".red().bold(), message.red());
            }
            OutputFormat::Json => {
                let obj = serde_json::json!({
                    "phase": "error",
                    "message": message,
                    "session_id": self.config.session_id,
                    "timestamp": chrono::Utc::now().to_rfc3339(),
                });
                eprintln!("{}", obj);
            }
        }
    }

    /// Print the `[plan]` prefix line for a streaming response.
    pub fn print_streaming_start(&self) {
        if matches!(self.config.format, OutputFormat::Terminal) {
            let tag = Phase::Plan.colorize("[plan]");
            print!("{} ", tag);
            std::io::stdout().flush().ok();
        }
    }

    /// Print a single streaming token inline (no newline).
    pub fn print_streaming_token(&self, token: &str) {
        if matches!(self.config.format, OutputFormat::Terminal) {
            print!("{}", token);
            std::io::stdout().flush().ok();
        }
    }

    /// Finish a streaming response with a trailing newline.
    pub fn print_streaming_end(&self) {
        if matches!(self.config.format, OutputFormat::Terminal) {
            println!();
        }
    }

    pub fn print_diff(&self, label: &str, old: &str, new: &str) {
        use similar::{ChangeTag, TextDiff};
        println!("{}", format!("--- {}", label).dimmed());
        let diff = TextDiff::from_lines(old, new);
        for change in diff.iter_all_changes() {
            let line = change.to_string_lossy();
            match change.tag() {
                ChangeTag::Delete => print!("{}", format!("-{}", line).red()),
                ChangeTag::Insert => print!("{}", format!("+{}", line).green()),
                ChangeTag::Equal => print!("{}", format!(" {}", line).dimmed()),
            }
        }
    }

    /// Prompt the user for a [y/N/d/c] confirmation.
    /// Returns: (proceed, want_checkpoint, show_diff_requested)
    pub fn confirm(
        &self,
        message: &str,
        show_diff_option: bool,
        show_checkpoint_option: bool,
    ) -> ConfirmResult {
        let options = build_options(show_diff_option, show_checkpoint_option);
        loop {
            print!("{} {} [{}] ", "[ralph]".white().bold(), message, options);
            std::io::stdout().flush().ok();
            let mut input = String::new();
            if std::io::stdin().read_line(&mut input).is_err() {
                return ConfirmResult::No;
            }
            match input.trim().to_lowercase().as_str() {
                "y" | "yes" => return ConfirmResult::Yes,
                "n" | "no" | "" => return ConfirmResult::No,
                "d" if show_diff_option => return ConfirmResult::ShowDiff,
                "c" if show_checkpoint_option => return ConfirmResult::CheckpointAndProceed,
                _ => {
                    println!("  Please enter one of: {}", options);
                }
            }
        }
    }
}

fn build_options(diff: bool, checkpoint: bool) -> String {
    let mut opts = vec!["y", "N"];
    if diff {
        opts.push("d(iff)");
    }
    if checkpoint {
        opts.push("c(heckpoint+proceed)");
    }
    opts.join("/")
}

#[derive(Debug, Clone, PartialEq)]
pub enum ConfirmResult {
    Yes,
    No,
    ShowDiff,
    CheckpointAndProceed,
}