Skip to main content

ralph/
output.rs

1use colored::Colorize;
2use std::io::Write;
3
4#[derive(Debug, Clone)]
5pub struct OutputConfig {
6    pub format: OutputFormat,
7    pub verbose: bool,
8    pub session_id: Option<String>,
9}
10
11#[derive(Debug, Clone, PartialEq)]
12pub enum OutputFormat {
13    Terminal,
14    Json,
15}
16
17impl OutputConfig {
18    pub fn new(format: OutputFormat, verbose: bool) -> Self {
19        Self {
20            format,
21            verbose,
22            session_id: None,
23        }
24    }
25}
26
27/// Phase tag printed before each message line.
28#[derive(Debug, Clone, Copy)]
29pub enum Phase {
30    Observe,
31    Plan,
32    Tool,
33    Verify,
34    Compact,
35    Checkpoint,
36    Ralph,
37    Done,
38    Failed,
39    Session,
40}
41
42impl Phase {
43    fn label(&self) -> &'static str {
44        match self {
45            Phase::Observe => "observe",
46            Phase::Plan => "plan",
47            Phase::Tool => "tool",
48            Phase::Verify => "verify",
49            Phase::Compact => "compact",
50            Phase::Checkpoint => "checkpoint",
51            Phase::Ralph => "ralph",
52            Phase::Done => "done",
53            Phase::Failed => "failed",
54            Phase::Session => "session",
55        }
56    }
57
58    fn colorize(&self, s: &str) -> String {
59        match self {
60            Phase::Observe => s.cyan().to_string(),
61            Phase::Plan => s.yellow().bold().to_string(),
62            Phase::Tool => s.blue().bold().to_string(),
63            Phase::Verify => s.green().to_string(),
64            Phase::Compact => s.magenta().bold().to_string(),
65            Phase::Checkpoint => s.blue().bold().to_string(),
66            Phase::Ralph => s.white().bold().to_string(),
67            Phase::Done => s.green().bold().to_string(),
68            Phase::Failed => s.red().bold().to_string(),
69            Phase::Session => s.cyan().to_string(),
70        }
71    }
72}
73
74#[derive(Clone)]
75pub struct Printer {
76    pub config: OutputConfig,
77}
78
79impl Printer {
80    pub fn new(config: OutputConfig) -> Self {
81        Self { config }
82    }
83
84    pub fn with_session(mut self, session_id: String) -> Self {
85        self.config.session_id = Some(session_id);
86        self
87    }
88
89    pub fn clone_with_session(&self, session_id: String) -> Self {
90        let mut c = self.clone();
91        c.config.session_id = Some(session_id);
92        c
93    }
94
95    pub fn print(&self, phase: Phase, message: &str) {
96        match self.config.format {
97            OutputFormat::Terminal => {
98                let tag = format!("[{}]", phase.label());
99                let colored_tag = phase.colorize(&tag);
100                println!("{} {}", colored_tag, message);
101            }
102            OutputFormat::Json => {
103                let obj = serde_json::json!({
104                    "phase": phase.label(),
105                    "message": message,
106                    "session_id": self.config.session_id,
107                    "timestamp": chrono::Utc::now().to_rfc3339(),
108                });
109                println!("{}", obj);
110            }
111        }
112    }
113
114    /// `display` is a pre-formatted human-readable description of the call
115    /// (not raw JSON), produced by `describe_tool_call` in loop_runner.
116    pub fn print_tool_call(&self, tool: &str, display: &str, result: &str) {
117        match self.config.format {
118            OutputFormat::Terminal => {
119                let colored_tag = Phase::Tool.colorize("[tool]");
120                let tool_name = tool.cyan().to_string();
121                println!("{} {} {}", colored_tag, tool_name, display);
122                // Only print the result line when verbose or it is short/important
123                let skip = !self.config.verbose
124                    && (result == "(executing...)"
125                        || result.starts_with("DONE:")
126                        || result.starts_with("FAILED:"));
127                if !skip {
128                    if result.len() <= 300 {
129                        println!("       → {}", result.trim().dimmed());
130                    } else {
131                        let preview: String = result.chars().take(300).collect();
132                        println!("       → {}…", preview.trim().dimmed());
133                    }
134                }
135            }
136            OutputFormat::Json => {
137                let obj = serde_json::json!({
138                    "phase": "execute",
139                    "tool": tool,
140                    "display": display,
141                    "result": result,
142                    "session_id": self.config.session_id,
143                    "timestamp": chrono::Utc::now().to_rfc3339(),
144                });
145                println!("{}", obj);
146            }
147        }
148    }
149
150    pub fn print_error(&self, message: &str) {
151        match self.config.format {
152            OutputFormat::Terminal => {
153                eprintln!("{} {}", "[error]".red().bold(), message.red());
154            }
155            OutputFormat::Json => {
156                let obj = serde_json::json!({
157                    "phase": "error",
158                    "message": message,
159                    "session_id": self.config.session_id,
160                    "timestamp": chrono::Utc::now().to_rfc3339(),
161                });
162                eprintln!("{}", obj);
163            }
164        }
165    }
166
167    /// Print the `[plan]` prefix line for a streaming response.
168    pub fn print_streaming_start(&self) {
169        if matches!(self.config.format, OutputFormat::Terminal) {
170            let tag = Phase::Plan.colorize("[plan]");
171            print!("{} ", tag);
172            std::io::stdout().flush().ok();
173        }
174    }
175
176    /// Print a single streaming token inline (no newline).
177    pub fn print_streaming_token(&self, token: &str) {
178        if matches!(self.config.format, OutputFormat::Terminal) {
179            print!("{}", token);
180            std::io::stdout().flush().ok();
181        }
182    }
183
184    /// Finish a streaming response with a trailing newline.
185    pub fn print_streaming_end(&self) {
186        if matches!(self.config.format, OutputFormat::Terminal) {
187            println!();
188        }
189    }
190
191    pub fn print_diff(&self, label: &str, old: &str, new: &str) {
192        use similar::{ChangeTag, TextDiff};
193        println!("{}", format!("--- {}", label).dimmed());
194        let diff = TextDiff::from_lines(old, new);
195        for change in diff.iter_all_changes() {
196            let line = change.to_string_lossy();
197            match change.tag() {
198                ChangeTag::Delete => print!("{}", format!("-{}", line).red()),
199                ChangeTag::Insert => print!("{}", format!("+{}", line).green()),
200                ChangeTag::Equal => print!("{}", format!(" {}", line).dimmed()),
201            }
202        }
203    }
204
205    /// Prompt the user for a [y/N/d/c] confirmation.
206    /// Returns: (proceed, want_checkpoint, show_diff_requested)
207    pub fn confirm(
208        &self,
209        message: &str,
210        show_diff_option: bool,
211        show_checkpoint_option: bool,
212    ) -> ConfirmResult {
213        let options = build_options(show_diff_option, show_checkpoint_option);
214        loop {
215            print!("{} {} [{}] ", "[ralph]".white().bold(), message, options);
216            std::io::stdout().flush().ok();
217            let mut input = String::new();
218            if std::io::stdin().read_line(&mut input).is_err() {
219                return ConfirmResult::No;
220            }
221            match input.trim().to_lowercase().as_str() {
222                "y" | "yes" => return ConfirmResult::Yes,
223                "n" | "no" | "" => return ConfirmResult::No,
224                "d" if show_diff_option => return ConfirmResult::ShowDiff,
225                "c" if show_checkpoint_option => return ConfirmResult::CheckpointAndProceed,
226                _ => {
227                    println!("  Please enter one of: {}", options);
228                }
229            }
230        }
231    }
232}
233
234fn build_options(diff: bool, checkpoint: bool) -> String {
235    let mut opts = vec!["y", "N"];
236    if diff {
237        opts.push("d(iff)");
238    }
239    if checkpoint {
240        opts.push("c(heckpoint+proceed)");
241    }
242    opts.join("/")
243}
244
245#[derive(Debug, Clone, PartialEq)]
246pub enum ConfirmResult {
247    Yes,
248    No,
249    ShowDiff,
250    CheckpointAndProceed,
251}