1use crate::agents::Agent;
2
3pub struct ClaudeCodeAgent;
7
8impl Agent for ClaudeCodeAgent {
9 fn name(&self) -> &'static str {
10 "claude-code"
11 }
12
13 fn extract_session_id(&self, stdin_json: &str) -> Option<String> {
14 let v: serde_json::Value = serde_json::from_str(stdin_json).ok()?;
15 let id = v.get("session_id")?.as_str()?;
16 if id.is_empty() {
17 None
18 } else {
19 Some(id.to_string())
20 }
21 }
22
23 fn extract_message(&self, stdin_json: &str) -> Option<String> {
24 let v: serde_json::Value = serde_json::from_str(stdin_json).ok()?;
25
26 if let Some(m) = v.get("message").and_then(serde_json::Value::as_str) {
30 if !m.is_empty() {
31 return Some(m.to_string());
32 }
33 }
34
35 let tool_name = v.get("tool_name").and_then(serde_json::Value::as_str)?;
39 if tool_name.is_empty() {
40 return None;
41 }
42 let tool_input = v
43 .get("tool_input")
44 .cloned()
45 .unwrap_or(serde_json::Value::Null);
46 Some(format_pre_tool_use_activity(tool_name, &tool_input))
47 }
48}
49
50fn format_pre_tool_use_activity(tool_name: &str, tool_input: &serde_json::Value) -> String {
62 match tool_name {
63 "Bash" => {
64 let cmd = tool_input
65 .get("command")
66 .and_then(serde_json::Value::as_str)
67 .unwrap_or("");
68 let first = cmd.lines().find(|l| !l.trim().is_empty()).unwrap_or("");
69 if first.is_empty() {
70 "Running command".to_string()
71 } else {
72 format!("Running: {first}")
73 }
74 }
75 "Read" => format_file_path_activity(tool_input, "Reading", "file"),
76 "Edit" | "MultiEdit" => format_file_path_activity(tool_input, "Editing", "file"),
77 "Write" => format_file_path_activity(tool_input, "Writing", "file"),
78 "Grep" => format_field_activity(tool_input, "pattern", "Searching", "Searching"),
79 "Glob" => format_field_activity(tool_input, "pattern", "Globbing", "Globbing files"),
80 "Task" => format_field_activity(tool_input, "description", "Subagent", "Running subagent"),
81 "WebFetch" => {
82 let url = tool_input
85 .get("url")
86 .and_then(serde_json::Value::as_str)
87 .unwrap_or("");
88 if url.is_empty() {
89 "Fetching URL".to_string()
90 } else {
91 format!("Fetching {url}")
92 }
93 }
94 "WebSearch" => format_field_activity(tool_input, "query", "Searching web", "Searching web"),
95 "TodoWrite" => "Updating tasks".to_string(),
96 "NotebookEdit" => "Editing notebook".to_string(),
97 "ExitPlanMode" => "Exiting plan mode".to_string(),
98 other => format!("Using {other}"),
99 }
100}
101
102fn format_file_path_activity(
107 tool_input: &serde_json::Value,
108 verb: &str,
109 fallback_noun: &str,
110) -> String {
111 let path = tool_input
112 .get("file_path")
113 .and_then(serde_json::Value::as_str)
114 .unwrap_or("");
115 if path.is_empty() {
116 return format!("{verb} {fallback_noun}");
117 }
118 let short = short_path(path);
119 format!("{verb} {short}")
120}
121
122fn format_field_activity(
126 tool_input: &serde_json::Value,
127 field: &str,
128 verb: &str,
129 empty_fallback: &str,
130) -> String {
131 let value = tool_input
132 .get(field)
133 .and_then(serde_json::Value::as_str)
134 .unwrap_or("");
135 if value.is_empty() {
136 empty_fallback.to_string()
137 } else {
138 format!("{verb}: {value}")
139 }
140}
141
142fn short_path(path: &str) -> String {
152 let parts: Vec<&str> = path
153 .trim_end_matches('/')
154 .split('/')
155 .filter(|p| !p.is_empty())
156 .collect();
157 match parts.as_slice() {
158 [] => path.to_string(),
159 [only] => (*only).to_string(),
160 rest => {
161 let n = rest.len();
162 let base = rest[n - 1];
163 if n >= 3 && is_generic_basename(base) {
164 format!("{}/{}", rest[n - 2], base)
165 } else {
166 base.to_string()
167 }
168 }
169 }
170}
171
172fn is_generic_basename(name: &str) -> bool {
173 matches!(name, "main.rs" | "mod.rs" | "lib.rs") || name.starts_with("index.")
174}
175
176#[cfg(test)]
177mod tests {
178 use super::*;
179
180 #[test]
181 fn name_is_claude_code() {
182 assert_eq!(ClaudeCodeAgent.name(), "claude-code");
183 }
184
185 #[test]
186 fn extract_session_id_returns_id() {
187 let json = r#"{"session_id":"abc-123","other":"stuff"}"#;
188 assert_eq!(
189 ClaudeCodeAgent.extract_session_id(json).as_deref(),
190 Some("abc-123")
191 );
192 }
193
194 #[test]
195 fn extract_session_id_returns_none_for_missing_field() {
196 assert_eq!(
197 ClaudeCodeAgent.extract_session_id(r#"{"other":1}"#),
198 None
199 );
200 }
201
202 #[test]
203 fn extract_session_id_returns_none_for_empty_string() {
204 assert_eq!(
205 ClaudeCodeAgent.extract_session_id(r#"{"session_id":""}"#),
206 None
207 );
208 }
209
210 #[test]
211 fn extract_session_id_returns_none_for_invalid_json() {
212 assert_eq!(ClaudeCodeAgent.extract_session_id("not json"), None);
213 }
214
215 #[test]
216 fn extract_message_returns_string_when_present() {
217 let json = r#"{"session_id":"x","message":"Permission required"}"#;
218 assert_eq!(
219 ClaudeCodeAgent.extract_message(json).as_deref(),
220 Some("Permission required")
221 );
222 }
223
224 #[test]
225 fn extract_message_returns_none_when_field_missing() {
226 let json = r#"{"session_id":"x"}"#;
227 assert!(ClaudeCodeAgent.extract_message(json).is_none());
228 }
229
230 #[test]
231 fn extract_message_returns_none_when_empty() {
232 let json = r#"{"session_id":"x","message":""}"#;
233 assert!(ClaudeCodeAgent.extract_message(json).is_none());
234 }
235
236 #[test]
237 fn extract_message_returns_none_for_non_string_value() {
238 let json = r#"{"session_id":"x","message":42}"#;
239 assert!(ClaudeCodeAgent.extract_message(json).is_none());
240 }
241
242 #[test]
243 fn extract_message_returns_none_for_invalid_json() {
244 assert!(ClaudeCodeAgent.extract_message("not json").is_none());
245 }
246
247 #[test]
248 fn extract_message_returns_activity_for_pre_tool_use_payload() {
249 let json = r#"{
250 "session_id": "abc-123",
251 "transcript_path": "/x/y.jsonl",
252 "tool_name": "Bash",
253 "tool_input": {"command": "git status", "description": "Show status"}
254 }"#;
255 assert_eq!(
256 ClaudeCodeAgent.extract_message(json).as_deref(),
257 Some("Running: git status"),
258 );
259 }
260
261 #[test]
262 fn extract_message_returns_activity_for_read_pre_tool_use_payload() {
263 let json = r#"{
264 "session_id": "abc",
265 "tool_name": "Read",
266 "tool_input": {"file_path": "/repo/src/lib.rs"}
267 }"#;
268 assert_eq!(
270 ClaudeCodeAgent.extract_message(json).as_deref(),
271 Some("Reading src/lib.rs"),
272 );
273 }
274
275 #[test]
276 fn extract_message_prefers_message_field_over_tool_fields() {
277 let json = r#"{
281 "session_id": "abc",
282 "message": "Permission required",
283 "tool_name": "Bash",
284 "tool_input": {"command": "rm -rf /"}
285 }"#;
286 assert_eq!(
287 ClaudeCodeAgent.extract_message(json).as_deref(),
288 Some("Permission required"),
289 );
290 }
291
292 #[test]
293 fn extract_message_returns_none_when_neither_message_nor_tool_name_present() {
294 let json = r#"{"session_id":"abc","prompt":"hello"}"#;
298 assert!(ClaudeCodeAgent.extract_message(json).is_none());
299 }
300
301 #[test]
302 fn extract_message_returns_none_when_tool_name_is_empty() {
303 let json = r#"{"session_id":"abc","tool_name":"","tool_input":{}}"#;
304 assert!(ClaudeCodeAgent.extract_message(json).is_none());
305 }
306
307 #[test]
308 fn format_pre_tool_use_activity_bash_uses_command() {
309 let input = serde_json::json!({"command": "git status", "description": "Show status"});
310 assert_eq!(
311 format_pre_tool_use_activity("Bash", &input),
312 "Running: git status"
313 );
314 }
315
316 #[test]
317 fn format_pre_tool_use_activity_bash_collapses_multiline_command() {
318 let input = serde_json::json!({"command": "set -e\nmake build\nmake test"});
319 assert_eq!(
322 format_pre_tool_use_activity("Bash", &input),
323 "Running: set -e"
324 );
325 }
326
327 #[test]
328 fn format_pre_tool_use_activity_read_uses_basename() {
329 let input = serde_json::json!({"file_path": "/Users/me/work/repo/src/main.rs"});
330 assert_eq!(
331 format_pre_tool_use_activity("Read", &input),
332 "Reading src/main.rs"
333 );
334 }
335
336 #[test]
337 fn format_pre_tool_use_activity_edit_uses_basename() {
338 let input = serde_json::json!({"file_path": "/x/lib.rs", "old_string": "a", "new_string": "b"});
339 assert_eq!(
340 format_pre_tool_use_activity("Edit", &input),
341 "Editing lib.rs"
342 );
343 }
344
345 #[test]
346 fn format_pre_tool_use_activity_multiedit_uses_basename() {
347 let input = serde_json::json!({"file_path": "/x/a/b/c.rs"});
348 assert_eq!(
349 format_pre_tool_use_activity("MultiEdit", &input),
350 "Editing c.rs"
351 );
352 }
353
354 #[test]
355 fn format_pre_tool_use_activity_write_uses_basename() {
356 let input = serde_json::json!({"file_path": "/x/new.rs", "content": "fn main() {}"});
357 assert_eq!(
358 format_pre_tool_use_activity("Write", &input),
359 "Writing new.rs"
360 );
361 }
362
363 #[test]
364 fn format_pre_tool_use_activity_read_falls_back_when_path_missing() {
365 let input = serde_json::json!({});
366 assert_eq!(format_pre_tool_use_activity("Read", &input), "Reading file");
367 }
368
369 #[test]
370 fn format_pre_tool_use_activity_grep_uses_pattern() {
371 let input = serde_json::json!({"pattern": "fn main", "path": "src"});
372 assert_eq!(
373 format_pre_tool_use_activity("Grep", &input),
374 "Searching: fn main"
375 );
376 }
377
378 #[test]
379 fn format_pre_tool_use_activity_glob_uses_pattern() {
380 let input = serde_json::json!({"pattern": "**/*.rs"});
381 assert_eq!(
382 format_pre_tool_use_activity("Glob", &input),
383 "Globbing: **/*.rs"
384 );
385 }
386
387 #[test]
388 fn format_pre_tool_use_activity_task_uses_description() {
389 let input = serde_json::json!({
390 "description": "Audit auth middleware",
391 "subagent_type": "general-purpose",
392 });
393 assert_eq!(
394 format_pre_tool_use_activity("Task", &input),
395 "Subagent: Audit auth middleware"
396 );
397 }
398
399 #[test]
400 fn format_pre_tool_use_activity_task_falls_back_when_description_missing() {
401 let input = serde_json::json!({"subagent_type": "general-purpose"});
402 assert_eq!(
403 format_pre_tool_use_activity("Task", &input),
404 "Running subagent"
405 );
406 }
407
408 #[test]
409 fn format_pre_tool_use_activity_webfetch_uses_url() {
410 let input = serde_json::json!({"url": "https://example.com/docs", "prompt": "summarize"});
411 assert_eq!(
412 format_pre_tool_use_activity("WebFetch", &input),
413 "Fetching https://example.com/docs"
414 );
415 }
416
417 #[test]
418 fn format_pre_tool_use_activity_websearch_uses_query() {
419 let input = serde_json::json!({"query": "ratatui table widget"});
420 assert_eq!(
421 format_pre_tool_use_activity("WebSearch", &input),
422 "Searching web: ratatui table widget"
423 );
424 }
425
426 #[test]
427 fn format_pre_tool_use_activity_todowrite_is_generic() {
428 let input = serde_json::json!({"todos": []});
429 assert_eq!(
430 format_pre_tool_use_activity("TodoWrite", &input),
431 "Updating tasks"
432 );
433 }
434
435 #[test]
436 fn format_pre_tool_use_activity_unknown_tool_falls_back() {
437 let input = serde_json::json!({});
438 assert_eq!(
439 format_pre_tool_use_activity("Frobnicator", &input),
440 "Using Frobnicator"
441 );
442 }
443
444 #[test]
445 fn format_pre_tool_use_activity_handles_missing_input_object() {
446 let input = serde_json::Value::Null;
448 assert_eq!(format_pre_tool_use_activity("Bash", &input), "Running command");
449 assert_eq!(format_pre_tool_use_activity("Read", &input), "Reading file");
450 assert_eq!(format_pre_tool_use_activity("Grep", &input), "Searching");
451 }
452}