//! A tool call's input, read rather than dumped -- the port of //! `ToolInput.kt`'s `parseToolInput`, which is what both the collapsed //! card's one-line summary and the expanded card's key/value list are //! derived from. //! //! Every tool's input arrives as JSON, and showing it raw makes the reader //! parse `{"command":"…","timeout":120000}` themselves to find the one //! line they care about. So the fields that carry the meaning are pulled //! out, and anything left over is still shown, because dropping a field //! would be claiming the tool has no other input when it might. //! //! Pure, and here rather than in the widget crate, for the reason the rest //! of this crate exists: the derivation is the same on a phone and on a //! desktop, and it is testable without a renderer. use crate::durations::format_millis_text; use crate::highlight::Language; use serde_json::{Map, Value}; /// A tool call's input, split into the parts a card draws separately. #[derive(Debug, Clone, PartialEq, Eq, Default)] pub struct ToolInput { /// The thing that will actually be run or read, if this tool has one. pub subject: Option, /// The language [`ToolInput::subject`] is written in, for /// highlighting. pub language: Option, /// The tool's own one-line summary, when it wrote one. pub description: Option, /// How long the call may take, in the largest units it fits. Shown /// apart because it is a limit on the call rather than part of what /// the call does. pub timeout: Option, /// Everything else, as `name: value` lines. Never dropped. pub rest: Vec, } impl ToolInput { /// The one line to show when there is only room for one: what this /// call is for. pub fn title(&self) -> Option<&str> { self.description .as_deref() .or(self.subject.as_deref()) // A subject that is only whitespace would draw as an empty // summary line, which reads as a tool with nothing to say // rather than as one whose subject was blank. .filter(|t| !t.trim().is_empty()) } } /// Which field of which tool is the subject. /// /// A table rather than a chain of `if`s: adding a tool is a row, and the /// shape stops any of them from being the special case that gets its own /// code path. Unknown tools fall through to "no subject, everything is /// rest". const SUBJECTS: &[(&str, &str, Option)] = &[ ("Bash", "command", Some(Language::Shell)), ("Read", "file_path", None), ("Write", "file_path", None), ("Edit", "file_path", None), ("Glob", "pattern", None), ("Grep", "pattern", None), ("WebFetch", "url", None), ]; /// Fields that are the tool's own prose about itself rather than input to /// it. const DESCRIPTIONS: &[&str] = &["description", "prompt"]; /// One JSON value as the Kotlin's `JSONObject.optString`/`get` wrote it: a /// string is its own characters, anything else is its JSON form. /// /// One function rather than two, because the same coercion decides both /// what a subject reads as and what a leftover field's value reads as, and /// two copies would eventually disagree about a number. fn as_text(value: &Value) -> String { match value { Value::String(s) => s.clone(), other => other.to_string(), } } fn non_blank(value: Option<&Value>) -> Option { let text = as_text(value?); (!text.trim().is_empty()).then_some(text) } /// Split `input` (a tool call's JSON) into the parts a card draws. /// /// Input that is not a JSON object -- older transcripts and some tools /// send a bare string -- is still the input, so it is still shown, as the /// whole of `rest`. pub fn parse_tool_input(tool: &str, input: &str) -> ToolInput { let Ok(Value::Object(json)) = serde_json::from_str::(input) else { return ToolInput { rest: match input.trim().is_empty() { true => Vec::new(), false => vec![input.to_string()], }, ..ToolInput::default() }; }; parse_object(tool, &json) } fn parse_object(tool: &str, json: &Map) -> ToolInput { let (subject_key, language) = SUBJECTS .iter() .find(|(name, ..)| *name == tool) .map(|(_, key, language)| (Some(*key), *language)) .unwrap_or((None, None)); let subject = subject_key.and_then(|key| non_blank(json.get(key))); let description = DESCRIPTIONS .iter() .find_map(|key| non_blank(json.get(*key))); let timeout = non_blank(json.get("timeout")).map(|t| format_millis_text(&t)); // Sorted, so the leftovers are in the same order every time this call // is drawn rather than in whatever order the JSON happened to arrive // in. A field is left out only when it is already drawn somewhere // else on the card. let mut keys: Vec<&String> = json .keys() .filter(|k| Some(k.as_str()) != subject_key || subject.is_none()) .filter(|k| !DESCRIPTIONS.contains(&k.as_str()) || description.is_none()) .filter(|k| k.as_str() != "timeout" || timeout.is_none()) .collect(); keys.sort(); let rest = keys .into_iter() .map(|key| format!("{key}: {}", as_text(&json[key]))) .collect(); ToolInput { subject, language, description, timeout, rest, } } #[cfg(test)] mod tests { use super::*; #[test] fn each_tool_in_the_table_has_its_own_subject() { // One assertion per row of `SUBJECTS`, because the table is the // whole of the rule and a row lost in an edit would otherwise // only show up as a card with no summary line. let cases = [ ("Bash", r#"{"command":"ls -la"}"#, "ls -la"), ("Read", r#"{"file_path":"/tmp/x.rs"}"#, "/tmp/x.rs"), ("Write", r#"{"file_path":"/tmp/y.rs"}"#, "/tmp/y.rs"), ("Edit", r#"{"file_path":"/tmp/z.rs"}"#, "/tmp/z.rs"), ("Glob", r#"{"pattern":"**/*.rs"}"#, "**/*.rs"), ("Grep", r#"{"pattern":"fn main"}"#, "fn main"), ("WebFetch", r#"{"url":"https://x/y"}"#, "https://x/y"), ]; for (tool, input, expected) in cases { let parsed = parse_tool_input(tool, input); assert_eq!(parsed.subject.as_deref(), Some(expected), "{tool}"); assert_eq!(parsed.title(), Some(expected), "{tool}"); assert!(parsed.rest.is_empty(), "{tool}: {:?}", parsed.rest); } assert_eq!( parse_tool_input("Bash", r#"{"command":"ls"}"#).language, Some(Language::Shell), "a Bash command is shell, and is the one row that names a language" ); } #[test] fn a_tools_own_description_is_what_the_one_line_says() { // The description wins over the subject: it is the tool's own // prose about what this call is for, which is what a reader // scanning a collapsed run is looking for. let parsed = parse_tool_input( "Bash", r#"{"command":"cargo test -p iris","description":"Run the iris tests"}"#, ); assert_eq!(parsed.title(), Some("Run the iris tests")); assert_eq!(parsed.subject.as_deref(), Some("cargo test -p iris")); assert!(parsed.rest.is_empty(), "{:?}", parsed.rest); } #[test] fn a_timeout_is_read_as_a_span_and_kept_apart_from_the_rest() { let parsed = parse_tool_input("Bash", r#"{"command":"sleep 500","timeout":480000}"#); assert_eq!(parsed.timeout.as_deref(), Some("8m")); assert!(parsed.rest.is_empty(), "{:?}", parsed.rest); } #[test] fn every_field_not_drawn_elsewhere_is_still_shown() { // The half the "never dropped" promise is about: a tool this // build has never heard of has no subject, so *everything* is // rest -- and a known tool's extra fields are too. let parsed = parse_tool_input( "Edit", r#"{"file_path":"/a.rs","old_string":"x","new_string":"y","replace_all":true}"#, ); assert_eq!( parsed.rest, vec![ "new_string: y".to_string(), "old_string: x".to_string(), "replace_all: true".to_string(), ], "sorted, and a non-string value written as JSON" ); let unknown = parse_tool_input("SomeNewTool", r#"{"b":2,"a":"one"}"#); assert_eq!(unknown.subject, None); assert_eq!(unknown.rest, vec!["a: one".to_string(), "b: 2".to_string()]); } #[test] fn input_that_is_not_an_object_is_still_the_input() { // Older transcripts and some tools send a bare string; a card // that dropped it would claim the call had no input at all. assert_eq!( parse_tool_input("Bash", "just a string").rest, vec!["just a string".to_string()] ); assert_eq!(parse_tool_input("Bash", " ").rest, Vec::::new()); assert_eq!(parse_tool_input("Bash", "").title(), None); } #[test] fn a_blank_subject_is_no_subject_rather_than_an_empty_summary_line() { let parsed = parse_tool_input("Bash", r#"{"command":" ","other":1}"#); assert_eq!(parsed.subject, None); assert_eq!(parsed.title(), None); // Not dropped just because it was blank -- it is still a field // the call carried. assert_eq!( parsed.rest, vec!["command: ".to_string(), "other: 1".to_string()] ); } }