1use std::io::{self, Write};
7
8#[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
17pub trait StreamHandler: Send {
22 fn on_text(&mut self, text: &str);
24
25 fn on_tool_call(&mut self, name: &str, id: &str, input: &serde_json::Value);
32
33 fn on_tool_result(&mut self, id: &str, output: &str);
35
36 fn on_error(&mut self, error: &str);
38
39 fn on_complete(&mut self, result: &SessionResult);
41}
42
43pub struct ConsoleStreamHandler {
48 verbose: bool,
49 stdout: io::Stdout,
50 stderr: io::Stderr,
51}
52
53impl ConsoleStreamHandler {
54 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 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
108pub 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
119fn 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
148fn truncate(s: &str, max_len: usize) -> String {
153 if s.chars().count() <= max_len {
154 s.to_string()
155 } else {
156 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 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 handler.on_text("Hello");
195 handler.on_tool_call("Read", "tool_1", &read_input);
196 handler.on_tool_result("tool_1", "output"); handler.on_complete(&SessionResult {
198 duration_ms: 1000,
199 total_cost_usd: 0.01,
200 num_turns: 1,
201 is_error: false,
202 }); }
204
205 #[test]
206 fn test_quiet_handler_is_silent() {
207 let mut handler = QuietStreamHandler;
208 let empty_input = json!({});
209
210 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 let with_arrows = "→→→→→→→→→→";
233 assert_eq!(truncate(with_arrows, 5), "→→→→→...");
235
236 let mixed = "a→b→c→d→e";
238 assert_eq!(truncate(mixed, 5), "a→b→c...");
239
240 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); }
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 assert_eq!(
299 format_tool_summary("Read", &json!({"wrong_field": "value"})),
300 None
301 );
302 assert_eq!(format_tool_summary("Bash", &json!({})), None);
304 }
305}