ralph_adapters/
stream_handler.rs

1//! Stream handler trait and implementations for processing Claude stream events.
2//!
3//! The `StreamHandler` trait abstracts over how stream events are displayed,
4//! allowing for different output strategies (console, quiet, TUI, etc.).
5
6use std::io::{self, Write};
7
8/// Session completion result data.
9#[derive(Debug, Clone)]
10pub struct SessionResult {
11    pub duration_ms: u64,
12    pub total_cost_usd: f64,
13    pub num_turns: u32,
14    pub is_error: bool,
15}
16
17/// Handler for streaming output events from Claude.
18///
19/// Implementors receive events as Claude processes and can format/display
20/// them in various ways (console output, TUI updates, logging, etc.).
21pub trait StreamHandler: Send {
22    /// Called when Claude emits text.
23    fn on_text(&mut self, text: &str);
24
25    /// Called when Claude invokes a tool.
26    ///
27    /// # Arguments
28    /// * `name` - Tool name (e.g., "Read", "Bash", "Grep")
29    /// * `id` - Unique tool invocation ID
30    /// * `input` - Tool input parameters as JSON (file paths, commands, patterns, etc.)
31    fn on_tool_call(&mut self, name: &str, id: &str, input: &serde_json::Value);
32
33    /// Called when a tool returns results (verbose only).
34    fn on_tool_result(&mut self, id: &str, output: &str);
35
36    /// Called when an error occurs.
37    fn on_error(&mut self, error: &str);
38
39    /// Called when session completes (verbose only).
40    fn on_complete(&mut self, result: &SessionResult);
41}
42
43/// Writes streaming output to stdout/stderr.
44///
45/// In normal mode, displays assistant text and tool invocations.
46/// In verbose mode, also displays tool results and session summary.
47pub struct ConsoleStreamHandler {
48    verbose: bool,
49    stdout: io::Stdout,
50    stderr: io::Stderr,
51}
52
53impl ConsoleStreamHandler {
54    /// Creates a new console handler.
55    ///
56    /// # Arguments
57    /// * `verbose` - If true, shows tool results and session summary.
58    pub fn new(verbose: bool) -> Self {
59        Self {
60            verbose,
61            stdout: io::stdout(),
62            stderr: io::stderr(),
63        }
64    }
65}
66
67impl StreamHandler for ConsoleStreamHandler {
68    fn on_text(&mut self, text: &str) {
69        let _ = writeln!(self.stdout, "Claude: {}", text);
70    }
71
72    fn on_tool_call(&mut self, name: &str, _id: &str, input: &serde_json::Value) {
73        match format_tool_summary(name, input) {
74            Some(summary) => {
75                let _ = writeln!(self.stdout, "[Tool] {}: {}", name, summary);
76            }
77            None => {
78                let _ = writeln!(self.stdout, "[Tool] {}", name);
79            }
80        }
81    }
82
83    fn on_tool_result(&mut self, _id: &str, output: &str) {
84        if self.verbose {
85            let _ = writeln!(self.stdout, "[Result] {}", truncate(output, 200));
86        }
87    }
88
89    fn on_error(&mut self, error: &str) {
90        // Write to both stdout (inline) and stderr (for separation)
91        let _ = writeln!(self.stdout, "[Error] {}", error);
92        let _ = writeln!(self.stderr, "[Error] {}", error);
93    }
94
95    fn on_complete(&mut self, result: &SessionResult) {
96        if self.verbose {
97            let _ = writeln!(
98                self.stdout,
99                "\n--- Session Complete ---\nDuration: {}ms | Cost: ${:.4} | Turns: {}",
100                result.duration_ms,
101                result.total_cost_usd,
102                result.num_turns
103            );
104        }
105    }
106}
107
108/// Suppresses all streaming output (for CI/silent mode).
109pub struct QuietStreamHandler;
110
111impl StreamHandler for QuietStreamHandler {
112    fn on_text(&mut self, _: &str) {}
113    fn on_tool_call(&mut self, _: &str, _: &str, _: &serde_json::Value) {}
114    fn on_tool_result(&mut self, _: &str, _: &str) {}
115    fn on_error(&mut self, _: &str) {}
116    fn on_complete(&mut self, _: &SessionResult) {}
117}
118
119/// Extracts the most relevant field from tool input for display.
120///
121/// Returns a human-readable summary (file path, command, pattern, etc.) based on the tool type.
122/// Returns `None` for unknown tools or if the expected field is missing.
123fn format_tool_summary(name: &str, input: &serde_json::Value) -> Option<String> {
124    match name {
125        "Read" | "Edit" | "Write" => {
126            input.get("file_path")?.as_str().map(|s| s.to_string())
127        }
128        "Bash" => {
129            let cmd = input.get("command")?.as_str()?;
130            Some(truncate(cmd, 60))
131        }
132        "Grep" => input.get("pattern")?.as_str().map(|s| s.to_string()),
133        "Glob" => input.get("pattern")?.as_str().map(|s| s.to_string()),
134        "Task" => input.get("description")?.as_str().map(|s| s.to_string()),
135        "WebFetch" => input.get("url")?.as_str().map(|s| s.to_string()),
136        "WebSearch" => input.get("query")?.as_str().map(|s| s.to_string()),
137        "LSP" => {
138            let op = input.get("operation")?.as_str()?;
139            let file = input.get("filePath")?.as_str()?;
140            Some(format!("{} @ {}", op, file))
141        }
142        "NotebookEdit" => input.get("notebook_path")?.as_str().map(|s| s.to_string()),
143        "TodoWrite" => Some("updating todo list".to_string()),
144        _ => None,
145    }
146}
147
148/// Truncates a string to approximately `max_len` characters, adding "..." if truncated.
149///
150/// Uses `char_indices` to find a valid UTF-8 boundary, ensuring we never slice
151/// in the middle of a multi-byte character.
152fn truncate(s: &str, max_len: usize) -> String {
153    if s.chars().count() <= max_len {
154        s.to_string()
155    } else {
156        // Find the byte index of the max_len-th character
157        let byte_idx = s
158            .char_indices()
159            .nth(max_len)
160            .map(|(idx, _)| idx)
161            .unwrap_or(s.len());
162        format!("{}...", &s[..byte_idx])
163    }
164}
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169    use serde_json::json;
170
171    #[test]
172    fn test_console_handler_verbose_shows_results() {
173        let mut handler = ConsoleStreamHandler::new(true);
174        let bash_input = json!({"command": "ls -la"});
175
176        // These calls should not panic
177        handler.on_text("Hello");
178        handler.on_tool_call("Bash", "tool_1", &bash_input);
179        handler.on_tool_result("tool_1", "output");
180        handler.on_complete(&SessionResult {
181            duration_ms: 1000,
182            total_cost_usd: 0.01,
183            num_turns: 1,
184            is_error: false,
185        });
186    }
187
188    #[test]
189    fn test_console_handler_normal_skips_results() {
190        let mut handler = ConsoleStreamHandler::new(false);
191        let read_input = json!({"file_path": "src/main.rs"});
192
193        // These should not show tool results
194        handler.on_text("Hello");
195        handler.on_tool_call("Read", "tool_1", &read_input);
196        handler.on_tool_result("tool_1", "output"); // Should be silent
197        handler.on_complete(&SessionResult {
198            duration_ms: 1000,
199            total_cost_usd: 0.01,
200            num_turns: 1,
201            is_error: false,
202        }); // Should be silent
203    }
204
205    #[test]
206    fn test_quiet_handler_is_silent() {
207        let mut handler = QuietStreamHandler;
208        let empty_input = json!({});
209
210        // All of these should be no-ops
211        handler.on_text("Hello");
212        handler.on_tool_call("Read", "tool_1", &empty_input);
213        handler.on_tool_result("tool_1", "output");
214        handler.on_error("Something went wrong");
215        handler.on_complete(&SessionResult {
216            duration_ms: 1000,
217            total_cost_usd: 0.01,
218            num_turns: 1,
219            is_error: false,
220        });
221    }
222
223    #[test]
224    fn test_truncate_helper() {
225        assert_eq!(truncate("short", 10), "short");
226        assert_eq!(truncate("this is a long string", 10), "this is a ...");
227    }
228
229    #[test]
230    fn test_truncate_utf8_boundaries() {
231        // Arrow → is 3 bytes (U+2192: E2 86 92)
232        let with_arrows = "→→→→→→→→→→";
233        // Should truncate at character boundary, not byte boundary
234        assert_eq!(truncate(with_arrows, 5), "→→→→→...");
235
236        // Mixed ASCII and multi-byte
237        let mixed = "a→b→c→d→e";
238        assert_eq!(truncate(mixed, 5), "a→b→c...");
239
240        // Emoji (4-byte characters)
241        let emoji = "🎉🎊🎁🎈🎄";
242        assert_eq!(truncate(emoji, 3), "🎉🎊🎁...");
243    }
244
245    #[test]
246    fn test_format_tool_summary_file_tools() {
247        assert_eq!(
248            format_tool_summary("Read", &json!({"file_path": "src/main.rs"})),
249            Some("src/main.rs".to_string())
250        );
251        assert_eq!(
252            format_tool_summary("Edit", &json!({"file_path": "/path/to/file.txt"})),
253            Some("/path/to/file.txt".to_string())
254        );
255        assert_eq!(
256            format_tool_summary("Write", &json!({"file_path": "output.json"})),
257            Some("output.json".to_string())
258        );
259    }
260
261    #[test]
262    fn test_format_tool_summary_bash_truncates() {
263        let short_cmd = json!({"command": "ls -la"});
264        assert_eq!(
265            format_tool_summary("Bash", &short_cmd),
266            Some("ls -la".to_string())
267        );
268
269        let long_cmd = json!({"command": "this is a very long command that should be truncated because it exceeds sixty characters"});
270        let result = format_tool_summary("Bash", &long_cmd).unwrap();
271        assert!(result.ends_with("..."));
272        assert!(result.len() <= 70); // 60 chars + "..."
273    }
274
275    #[test]
276    fn test_format_tool_summary_search_tools() {
277        assert_eq!(
278            format_tool_summary("Grep", &json!({"pattern": "TODO"})),
279            Some("TODO".to_string())
280        );
281        assert_eq!(
282            format_tool_summary("Glob", &json!({"pattern": "**/*.rs"})),
283            Some("**/*.rs".to_string())
284        );
285    }
286
287    #[test]
288    fn test_format_tool_summary_unknown_tool_returns_none() {
289        assert_eq!(
290            format_tool_summary("UnknownTool", &json!({"some_field": "value"})),
291            None
292        );
293    }
294
295    #[test]
296    fn test_format_tool_summary_missing_field_returns_none() {
297        // Read without file_path
298        assert_eq!(
299            format_tool_summary("Read", &json!({"wrong_field": "value"})),
300            None
301        );
302        // Bash without command
303        assert_eq!(format_tool_summary("Bash", &json!({})), None);
304    }
305}