Skip to main content

agent_status/agents/
claude_code.rs

1use crate::agents::Agent;
2
3/// Claude Code (`claude.ai/code`).
4///
5/// Reads `session_id` from the hook event payload that Claude Code pipes to stdin.
6pub 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        // Prefer an explicit `message` field (Notification payloads) — it's
27        // the agent's user-facing text and always more informative than a
28        // derived activity description.
29        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        // Fall back to PreToolUse tool fields: synthesize an activity
36        // string. `tool_input` is allowed to be missing / null / wrong-
37        // typed — `format_pre_tool_use_activity` defends against that.
38        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
50/// Build a one-line, human-readable description of a Claude Code
51/// `PreToolUse` payload's tool call. Used as the entry `message` for
52/// `working` entries so the switcher can show *what* the agent is doing.
53///
54/// `tool_input` is the raw `tool_input` field from the hook payload — a JSON
55/// object whose shape depends on `tool_name`. We probe defensively: a
56/// missing or wrong-typed field falls back to a generic `"Using <tool>"`
57/// string rather than panicking, since the hook payload is external input.
58///
59/// Always returns a non-empty string. Length capping is the UI's job
60/// (`crates/agent-switcher/src/ui.rs` truncates to `MESSAGE_CAP`).
61fn 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            // URLs are already self-descriptive, so we use a space separator
83            // rather than the "verb: value" form the other field helpers use.
84            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
102/// Format a "<verb> <short-path>" activity for tools whose `tool_input`
103/// carries a single `file_path`. Returns the verb plus a short display
104/// form of the path; falls back to `"<verb> <fallback>"` when the field
105/// is missing or empty.
106fn 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
122/// Format a "<verb>: <field-value>" activity for tools whose `tool_input`
123/// carries a single string field. Falls back to `<empty_fallback>` (no
124/// colon) when the field is missing or empty.
125fn 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
142/// Return a short display form of `path`: the basename, except for
143/// generic basenames (`main.rs`, `mod.rs`, `lib.rs`, `index.*`) where the
144/// parent directory is prepended so the result still identifies the file.
145/// The parent is only prepended when the path has at least three
146/// components (so `/x/lib.rs` stays `lib.rs` — the lone parent dir adds
147/// no useful context).
148///
149/// `path` is treated as a POSIX-style path (`/`) — Claude Code's hooks
150/// always pass forward slashes.
151fn 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        // /repo/src/lib.rs: 3 components, generic basename → "src/lib.rs"
269        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        // If both are present (defensive — shouldn't happen in practice), the
278        // explicit message wins. Notification payloads sometimes carry extra
279        // fields and we don't want them to override the user-facing message.
280        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        // UserPromptSubmit, Stop, SessionStart, SessionEnd payloads don't have
295        // either field — we must keep returning None so the entry stores no
296        // message (the spinner alone communicates "working" in that case).
297        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        // Multi-line commands collapse to the first non-empty line so the
320        // snippet stays on one row of the table.
321        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        // `tool_input` is sometimes null, never an object — defend against it.
447        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}