Skip to main content

recursive/tui/app/
render.rs

1//! Standalone helper functions for rendering / display.
2//!
3//! These are pure functions (no `&mut self`) used by event_loop and the
4//! terminal UI to format tool arguments, parse diffs, etc.
5
6use serde_json::Value;
7
8use super::{DiffHunk, DiffLine, DiffLineKind};
9
10// ── Argument preview ─────────────────────────────────────────────────
11
12/// Produce a short preview of a JSON-encoded arguments string.
13///
14/// Picks up to two top-level fields, formats them as `k=v`, and clamps
15/// to ~60 characters with an ellipsis.
16pub fn preview_args(arguments: &str) -> String {
17    let parsed: Result<Value, _> = serde_json::from_str(arguments);
18    let Ok(Value::Object(map)) = parsed else {
19        // Not JSON-y; just clamp the raw string.
20        return clamp(arguments, 60);
21    };
22
23    let mut parts = Vec::new();
24    for (k, v) in map.iter().take(2) {
25        let v_str = match v {
26            Value::String(s) => format!("\"{}\"", clamp(s, 30)),
27            other => clamp(&other.to_string(), 30),
28        };
29        parts.push(format!("{k}={v_str}"));
30    }
31    clamp(&parts.join(" "), 60)
32}
33
34fn clamp(s: &str, max: usize) -> String {
35    if s.chars().count() <= max {
36        s.to_string()
37    } else {
38        let head: String = s.chars().take(max.saturating_sub(1)).collect();
39        format!("{head}…")
40    }
41}
42
43// ── Spinner verb ─────────────────────────────────────────────────────
44
45/// Pick a spinner verb based on the tool name.
46pub fn verb_for_tool(name: &str) -> &'static str {
47    match name {
48        "read_file" | "list_dir" | "search_files" => "Reading",
49        "apply_patch" | "write_file" => "Editing",
50        "run_shell" => "Running",
51        _ => "Calling tool",
52    }
53}
54
55// ── V4A patch parser ─────────────────────────────────────────────────
56
57/// Parse a V4A patch envelope from an `apply_patch` arguments JSON.
58///
59/// Returns `(path, hunks)` for the first `*** Update File:` /
60/// `*** Add File:` block found, or `None` if the input is not parseable
61/// as a V4A patch.
62pub fn parse_apply_patch_input(arguments: &str) -> Option<(String, Vec<DiffHunk>)> {
63    let v: Value = serde_json::from_str(arguments).ok()?;
64    let input = v.get("input")?.as_str()?;
65    parse_v4a_patch(input)
66}
67
68/// Pure parser for a V4A patch string.
69pub fn parse_v4a_patch(input: &str) -> Option<(String, Vec<DiffHunk>)> {
70    let mut path: Option<String> = None;
71    let mut current = Vec::new();
72    let mut hunks: Vec<DiffHunk> = Vec::new();
73
74    for line in input.lines() {
75        if let Some(rest) = line
76            .strip_prefix("*** Update File: ")
77            .or_else(|| line.strip_prefix("*** Add File: "))
78        {
79            if path.is_some() {
80                // Multiple update sections — only the first is used,
81                // per goal scope.
82                break;
83            }
84            path = Some(rest.trim().to_string());
85            continue;
86        }
87        if line.starts_with("*** Begin Patch")
88            || line.starts_with("*** End Patch")
89            || line.starts_with("*** End of File")
90        {
91            continue;
92        }
93        if path.is_none() {
94            continue;
95        }
96        // @@ anchor lines start a new hunk.
97        if let Some(stripped) = line.strip_prefix("@@") {
98            if !current.is_empty() {
99                hunks.push(DiffHunk {
100                    lines: std::mem::take(&mut current),
101                });
102            }
103            let text = stripped.trim_start().to_string();
104            if !text.is_empty() {
105                current.push(DiffLine {
106                    kind: DiffLineKind::Context,
107                    text,
108                });
109            }
110            continue;
111        }
112        if let Some(rest) = line.strip_prefix('+') {
113            current.push(DiffLine {
114                kind: DiffLineKind::Add,
115                text: rest.to_string(),
116            });
117        } else if let Some(rest) = line.strip_prefix('-') {
118            current.push(DiffLine {
119                kind: DiffLineKind::Remove,
120                text: rest.to_string(),
121            });
122        } else if let Some(rest) = line.strip_prefix(' ') {
123            current.push(DiffLine {
124                kind: DiffLineKind::Context,
125                text: rest.to_string(),
126            });
127        }
128    }
129    if !current.is_empty() {
130        hunks.push(DiffHunk { lines: current });
131    }
132
133    let path = path?;
134    if hunks.is_empty() {
135        return None;
136    }
137    Some((path, hunks))
138}
139
140// ── write_file path extractor ────────────────────────────────────────
141
142/// Best-effort path extraction from a write_file ToolResult output.
143///
144/// The `WriteFile` tool emits something like
145/// `"Wrote 42 bytes to crates/foo/bar.rs"`. We parse that pattern and
146/// fall back to `None` if it doesn't match.
147pub(crate) fn extract_write_file_path_from_result(output: &str) -> Option<String> {
148    let trimmed = output.trim();
149    if let Some(idx) = trimmed.rfind(" to ") {
150        let candidate = &trimmed[idx + 4..];
151        if !candidate.is_empty() {
152            return Some(candidate.to_string());
153        }
154    }
155    None
156}
157
158// ──────────────────────────────────────────────────────────────────────
159// Tests
160// ──────────────────────────────────────────────────────────────────────
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165    use crate::tui::app::DiffLineKind;
166
167    #[test]
168    fn tool_call_args_preview_extracts_path() {
169        let preview = preview_args(r#"{"path":"src/agent.rs"}"#);
170        assert!(preview.contains("path"));
171        assert!(preview.contains("src/agent.rs"));
172    }
173
174    #[test]
175    fn verb_for_tool_categorises_tools() {
176        assert_eq!(verb_for_tool("read_file"), "Reading");
177        assert_eq!(verb_for_tool("apply_patch"), "Editing");
178        assert_eq!(verb_for_tool("run_shell"), "Running");
179        assert_eq!(verb_for_tool("custom_xyz"), "Calling tool");
180    }
181
182    #[test]
183    fn parse_v4a_patch_extracts_path_and_pm_lines() {
184        let patch = "*** Begin Patch\n*** Update File: src/foo.rs\n@@ pub fn bar()\n pub fn bar() {\n-    let x = 1;\n+    let x = 2;\n }\n*** End Patch";
185        let (path, hunks) = parse_v4a_patch(patch).unwrap();
186        assert_eq!(path, "src/foo.rs");
187        assert!(!hunks.is_empty());
188        let kinds: Vec<_> = hunks
189            .iter()
190            .flat_map(|h| h.lines.iter().map(|l| l.kind.clone()))
191            .collect();
192        assert!(kinds.contains(&DiffLineKind::Add));
193        assert!(kinds.contains(&DiffLineKind::Remove));
194    }
195}