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, result.total_cost_usd, result.num_turns
101 );
102 }
103 }
104}
105
106pub 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
117fn 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
144fn truncate(s: &str, max_len: usize) -> String {
149 if s.chars().count() <= max_len {
150 s.to_string()
151 } else {
152 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 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 handler.on_text("Hello");
191 handler.on_tool_call("Read", "tool_1", &read_input);
192 handler.on_tool_result("tool_1", "output"); handler.on_complete(&SessionResult {
194 duration_ms: 1000,
195 total_cost_usd: 0.01,
196 num_turns: 1,
197 is_error: false,
198 }); }
200
201 #[test]
202 fn test_quiet_handler_is_silent() {
203 let mut handler = QuietStreamHandler;
204 let empty_input = json!({});
205
206 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 let with_arrows = "→→→→→→→→→→";
229 assert_eq!(truncate(with_arrows, 5), "→→→→→...");
231
232 let mixed = "a→b→c→d→e";
234 assert_eq!(truncate(mixed, 5), "a→b→c...");
235
236 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); }
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 assert_eq!(
295 format_tool_summary("Read", &json!({"wrong_field": "value"})),
296 None
297 );
298 assert_eq!(format_tool_summary("Bash", &json!({})), None);
300 }
301}