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, result.total_cost_usd, result.num_turns
101            );
102        }
103    }
104}
105
106/// Suppresses all streaming output (for CI/silent mode).
107pub struct QuietStreamHandler;
108
109impl StreamHandler for QuietStreamHandler {
110    fn on_text(&mut self, _: &str) {}
111    fn on_tool_call(&mut self, _: &str, _: &str, _: &serde_json::Value) {}
112    fn on_tool_result(&mut self, _: &str, _: &str) {}
113    fn on_error(&mut self, _: &str) {}
114    fn on_complete(&mut self, _: &SessionResult) {}
115}
116
117/// Extracts the most relevant field from tool input for display.
118///
119/// Returns a human-readable summary (file path, command, pattern, etc.) based on the tool type.
120/// Returns `None` for unknown tools or if the expected field is missing.
121fn format_tool_summary(name: &str, input: &serde_json::Value) -> Option<String> {
122    match name {
123        "Read" | "Edit" | "Write" => input.get("file_path")?.as_str().map(|s| s.to_string()),
124        "Bash" => {
125            let cmd = input.get("command")?.as_str()?;
126            Some(truncate(cmd, 60))
127        }
128        "Grep" => input.get("pattern")?.as_str().map(|s| s.to_string()),
129        "Glob" => input.get("pattern")?.as_str().map(|s| s.to_string()),
130        "Task" => input.get("description")?.as_str().map(|s| s.to_string()),
131        "WebFetch" => input.get("url")?.as_str().map(|s| s.to_string()),
132        "WebSearch" => input.get("query")?.as_str().map(|s| s.to_string()),
133        "LSP" => {
134            let op = input.get("operation")?.as_str()?;
135            let file = input.get("filePath")?.as_str()?;
136            Some(format!("{} @ {}", op, file))
137        }
138        "NotebookEdit" => input.get("notebook_path")?.as_str().map(|s| s.to_string()),
139        "TodoWrite" => Some("updating todo list".to_string()),
140        _ => None,
141    }
142}
143
144/// Truncates a string to approximately `max_len` characters, adding "..." if truncated.
145///
146/// Uses `char_indices` to find a valid UTF-8 boundary, ensuring we never slice
147/// in the middle of a multi-byte character.
148fn truncate(s: &str, max_len: usize) -> String {
149    if s.chars().count() <= max_len {
150        s.to_string()
151    } else {
152        // Find the byte index of the max_len-th character
153        let byte_idx = s
154            .char_indices()
155            .nth(max_len)
156            .map(|(idx, _)| idx)
157            .unwrap_or(s.len());
158        format!("{}...", &s[..byte_idx])
159    }
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165    use serde_json::json;
166
167    #[test]
168    fn test_console_handler_verbose_shows_results() {
169        let mut handler = ConsoleStreamHandler::new(true);
170        let bash_input = json!({"command": "ls -la"});
171
172        // These calls should not panic
173        handler.on_text("Hello");
174        handler.on_tool_call("Bash", "tool_1", &bash_input);
175        handler.on_tool_result("tool_1", "output");
176        handler.on_complete(&SessionResult {
177            duration_ms: 1000,
178            total_cost_usd: 0.01,
179            num_turns: 1,
180            is_error: false,
181        });
182    }
183
184    #[test]
185    fn test_console_handler_normal_skips_results() {
186        let mut handler = ConsoleStreamHandler::new(false);
187        let read_input = json!({"file_path": "src/main.rs"});
188
189        // These should not show tool results
190        handler.on_text("Hello");
191        handler.on_tool_call("Read", "tool_1", &read_input);
192        handler.on_tool_result("tool_1", "output"); // Should be silent
193        handler.on_complete(&SessionResult {
194            duration_ms: 1000,
195            total_cost_usd: 0.01,
196            num_turns: 1,
197            is_error: false,
198        }); // Should be silent
199    }
200
201    #[test]
202    fn test_quiet_handler_is_silent() {
203        let mut handler = QuietStreamHandler;
204        let empty_input = json!({});
205
206        // All of these should be no-ops
207        handler.on_text("Hello");
208        handler.on_tool_call("Read", "tool_1", &empty_input);
209        handler.on_tool_result("tool_1", "output");
210        handler.on_error("Something went wrong");
211        handler.on_complete(&SessionResult {
212            duration_ms: 1000,
213            total_cost_usd: 0.01,
214            num_turns: 1,
215            is_error: false,
216        });
217    }
218
219    #[test]
220    fn test_truncate_helper() {
221        assert_eq!(truncate("short", 10), "short");
222        assert_eq!(truncate("this is a long string", 10), "this is a ...");
223    }
224
225    #[test]
226    fn test_truncate_utf8_boundaries() {
227        // Arrow → is 3 bytes (U+2192: E2 86 92)
228        let with_arrows = "→→→→→→→→→→";
229        // Should truncate at character boundary, not byte boundary
230        assert_eq!(truncate(with_arrows, 5), "→→→→→...");
231
232        // Mixed ASCII and multi-byte
233        let mixed = "a→b→c→d→e";
234        assert_eq!(truncate(mixed, 5), "a→b→c...");
235
236        // Emoji (4-byte characters)
237        let emoji = "🎉🎊🎁🎈🎄";
238        assert_eq!(truncate(emoji, 3), "🎉🎊🎁...");
239    }
240
241    #[test]
242    fn test_format_tool_summary_file_tools() {
243        assert_eq!(
244            format_tool_summary("Read", &json!({"file_path": "src/main.rs"})),
245            Some("src/main.rs".to_string())
246        );
247        assert_eq!(
248            format_tool_summary("Edit", &json!({"file_path": "/path/to/file.txt"})),
249            Some("/path/to/file.txt".to_string())
250        );
251        assert_eq!(
252            format_tool_summary("Write", &json!({"file_path": "output.json"})),
253            Some("output.json".to_string())
254        );
255    }
256
257    #[test]
258    fn test_format_tool_summary_bash_truncates() {
259        let short_cmd = json!({"command": "ls -la"});
260        assert_eq!(
261            format_tool_summary("Bash", &short_cmd),
262            Some("ls -la".to_string())
263        );
264
265        let long_cmd = json!({"command": "this is a very long command that should be truncated because it exceeds sixty characters"});
266        let result = format_tool_summary("Bash", &long_cmd).unwrap();
267        assert!(result.ends_with("..."));
268        assert!(result.len() <= 70); // 60 chars + "..."
269    }
270
271    #[test]
272    fn test_format_tool_summary_search_tools() {
273        assert_eq!(
274            format_tool_summary("Grep", &json!({"pattern": "TODO"})),
275            Some("TODO".to_string())
276        );
277        assert_eq!(
278            format_tool_summary("Glob", &json!({"pattern": "**/*.rs"})),
279            Some("**/*.rs".to_string())
280        );
281    }
282
283    #[test]
284    fn test_format_tool_summary_unknown_tool_returns_none() {
285        assert_eq!(
286            format_tool_summary("UnknownTool", &json!({"some_field": "value"})),
287            None
288        );
289    }
290
291    #[test]
292    fn test_format_tool_summary_missing_field_returns_none() {
293        // Read without file_path
294        assert_eq!(
295            format_tool_summary("Read", &json!({"wrong_field": "value"})),
296            None
297        );
298        // Bash without command
299        assert_eq!(format_tool_summary("Bash", &json!({})), None);
300    }
301}