Skip to main content

orch/cli/
ndjson.rs

1//! Renders agent NDJSON as a Claude Code style transcript for `orch stream`, `orch task watch`
2//! and session export. Each agent's runner turns its own lines into [`StreamEvent`]s
3
4use crate::engine::runner::agents::{
5    get_runner, json_error_message, json_str, AgentRunner, StreamEvent,
6};
7use regex::Regex;
8use serde_json::Value;
9use std::collections::{BTreeSet, HashMap};
10use std::sync::LazyLock;
11
12/// Prefix of a tool result line, printed right under its tool call
13pub const RESULT_PREFIX: &str = "  ⎿  ";
14
15static ANSI: LazyLock<Regex> = LazyLock::new(|| {
16    Regex::new(r"\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b\][^\x07]*\x07|\r").expect("ansi regex is valid")
17});
18static WORKTREE: LazyLock<Regex> = LazyLock::new(|| {
19    Regex::new(r"\S*/\.orch/worktrees/[^/\s]+/[^/\s]+/").expect("worktree regex is valid")
20});
21
22const BOLD: &str = "1";
23const DIM: &str = "2";
24const THINK: &str = "2;3";
25const RED: &str = "31";
26const GREEN: &str = "32";
27
28/// Turns one agent's NDJSON lines into transcript entries, picking the runner from the first line
29#[derive(Default)]
30pub struct Formatter {
31    color: bool,
32    runner: Option<Box<dyn AgentRunner>>,
33    tools: HashMap<String, String>,
34    changed: BTreeSet<String>,
35}
36
37impl Formatter {
38    pub fn new(color: bool) -> Self {
39        Self {
40            color,
41            ..Self::default()
42        }
43    }
44
45    /// Entries for one output line. A step starts with a marker, a result with [`RESULT_PREFIX`]
46    pub fn format(&mut self, line: &str) -> Vec<String> {
47        let line = ANSI.replace_all(line, "");
48        let line = line.trim();
49        if line.is_empty() {
50            return vec![];
51        }
52
53        let Ok(v) = serde_json::from_str::<Value>(line) else {
54            return vec![self.paint(DIM, line)];
55        };
56
57        if self.runner.is_none() {
58            self.runner = detect_agent(&v).map(get_runner);
59        }
60
61        let events = match &self.runner {
62            Some(runner) => runner.stream_events(&v),
63            None if json_str(&v, "/type") == "error" => {
64                vec![StreamEvent::Error(json_error_message(&v))]
65            }
66            None => vec![],
67        };
68        events.into_iter().flat_map(|e| self.render(e)).collect()
69    }
70
71    /// Files the agent wrote or edited so far, as the agent named them
72    pub fn changed_files(&self) -> Vec<String> {
73        self.changed.iter().cloned().collect()
74    }
75
76    fn render(&mut self, event: StreamEvent) -> Vec<String> {
77        match event {
78            StreamEvent::Started { model } => {
79                vec![self.paint(DIM, &format!("⏺ Session started · {model}"))]
80            }
81            StreamEvent::Text(text) => {
82                let text = text.trim();
83                if text.is_empty() {
84                    return vec![];
85                }
86
87                let text = pretty_json(text).unwrap_or_else(|| text.to_string());
88                vec![format!("⏺ {}", text.replace('\n', "\n  "))]
89            }
90            StreamEvent::Thinking(text) => {
91                let Some(first) = text.lines().map(str::trim).find(|l| !l.is_empty()) else {
92                    return vec![];
93                };
94                let first = truncate(&first.replace("**", ""), 120);
95                vec![self.paint(THINK, &format!("✻ {first}"))]
96            }
97            StreamEvent::ToolCall { id, name, input } => {
98                let name = display_tool(&name).to_string();
99                self.record_change(&name, &input);
100                let call = self.tool_call(&name, &input);
101                self.tools.insert(id, name);
102                vec![call]
103            }
104            StreamEvent::ToolResult {
105                id,
106                name,
107                output,
108                is_error,
109            } => {
110                let called = self.tools.remove(&id).unwrap_or_default();
111                let name = if name.is_empty() { called } else { name };
112                vec![self.result(&name, &output, is_error)]
113            }
114            StreamEvent::Done(detail) => self.done(&detail),
115            StreamEvent::Error(text) => {
116                let text = text.trim();
117                let text = if text.is_empty() {
118                    "unknown error"
119                } else {
120                    text
121                };
122                vec![self.paint(RED, &format!("✗ {}", truncate(text, 300)))]
123            }
124        }
125    }
126
127    fn record_change(&mut self, name: &str, input: &Value) {
128        let edits = matches!(
129            name.to_ascii_lowercase().as_str(),
130            "write" | "edit" | "multiedit" | "notebookedit"
131        );
132        if !edits {
133            return;
134        }
135
136        let path = ["file_path", "filePath", "notebook_path", "path"]
137            .iter()
138            .find_map(|k| input[k].as_str().filter(|s| !s.is_empty()));
139        if let Some(path) = path {
140            self.changed.insert(path.to_string());
141        }
142    }
143
144    fn tool_call(&self, name: &str, input: &Value) -> String {
145        let name = self.paint(BOLD, &capitalize(name));
146        let summary = summarize_input(input);
147        let call = if summary.is_empty() {
148            name
149        } else {
150            format!("{name}({summary})")
151        };
152        format!("{} {call}", self.paint(GREEN, "⏺"))
153    }
154
155    fn result(&self, tool: &str, output: &str, is_error: bool) -> String {
156        let lines: Vec<&str> = output.lines().filter(|l| !l.trim().is_empty()).collect();
157        let first = lines
158            .first()
159            .map(|l| truncate(&shorten(l.trim()), 100))
160            .unwrap_or_default();
161        if is_error {
162            let first = if first.is_empty() {
163                "Error".to_string()
164            } else {
165                first
166            };
167            return format!("{RESULT_PREFIX}{}", self.paint(RED, &first));
168        }
169
170        let body = if lines.is_empty() {
171            "(no output)".to_string()
172        } else if tool.eq_ignore_ascii_case("read") {
173            format!("Read {} lines", lines.len())
174        } else if lines.len() > 1 {
175            format!("{first} … +{} lines", lines.len() - 1)
176        } else {
177            first
178        };
179        format!("{RESULT_PREFIX}{}", self.paint(DIM, &body))
180    }
181
182    fn done(&self, detail: &str) -> Vec<String> {
183        let text = if detail.is_empty() {
184            "✓ Done".to_string()
185        } else {
186            format!("✓ Done · {detail}")
187        };
188        let mut out = vec![self.paint(GREEN, &text)];
189        if !self.changed.is_empty() {
190            let files: Vec<String> = self.changed.iter().map(|f| shorten(f)).collect();
191            let noun = if files.len() == 1 { "file" } else { "files" };
192            let body = format!("Changed {} {noun}: {}", files.len(), files.join(", "));
193            out.push(format!("{RESULT_PREFIX}{}", self.paint(DIM, &body)));
194        }
195        out
196    }
197
198    fn paint(&self, code: &str, text: &str) -> String {
199        if self.color {
200            format!("\x1b[{code}m{text}\x1b[0m")
201        } else {
202            text.to_string()
203        }
204    }
205}
206
207/// Runner name for the output format a line belongs to. kimi and minimax emit Claude's format
208fn detect_agent(v: &Value) -> Option<&'static str> {
209    let kind = json_str(v, "/type");
210    if matches!(kind, "system" | "assistant" | "user" | "result") {
211        Some("claude")
212    } else if ["thread.", "turn.", "item."]
213        .iter()
214        .any(|p| kind.starts_with(p))
215    {
216        Some("codex")
217    } else if v.get("part").is_some() || v.get("sessionID").is_some() {
218        Some("opencode")
219    } else {
220        None
221    }
222}
223
224/// Print entries with a blank line before each step, keeping results right under their call
225pub fn print(entries: &[String]) {
226    for entry in entries {
227        if entry.starts_with(RESULT_PREFIX) {
228            println!("{entry}");
229        } else {
230            println!("\n{entry}");
231        }
232    }
233}
234
235/// Files written or edited in raw NDJSON output, the fallback when the agent reports none
236pub fn extract_files_changed(content: &str) -> Vec<String> {
237    let mut formatter = Formatter::new(false);
238    for line in content.lines() {
239        formatter.format(line);
240    }
241    formatter.changed_files()
242}
243
244/// Indented JSON when a message is a JSON object or array, bare or in a ```json fence
245fn pretty_json(text: &str) -> Option<String> {
246    let body = text
247        .strip_prefix("```json")
248        .or_else(|| text.strip_prefix("```"))
249        .and_then(|t| t.strip_suffix("```"))
250        .unwrap_or(text)
251        .trim();
252    if !body.starts_with(['{', '[']) {
253        return None;
254    }
255
256    let value: Value = serde_json::from_str(body).ok()?;
257    serde_json::to_string_pretty(&value).ok()
258}
259
260/// One line for a tool call's input: the command, path, pattern or query it acts on
261fn summarize_input(input: &Value) -> String {
262    const KEYS: [&str; 9] = [
263        "command",
264        "file_path",
265        "filePath",
266        "pattern",
267        "path",
268        "query",
269        "url",
270        "description",
271        "prompt",
272    ];
273    let value = KEYS
274        .iter()
275        .find_map(|k| input[k].as_str().filter(|s| !s.is_empty()))
276        .or_else(|| {
277            input
278                .as_object()?
279                .values()
280                .find_map(|v| v.as_str().filter(|s| !s.is_empty()))
281        })
282        .unwrap_or("");
283    let mut lines = value.lines();
284    let first = shorten(lines.next().unwrap_or(""));
285    let more = if lines.next().is_some() { " …" } else { "" };
286    truncate(&format!("{first}{more}"), 100)
287}
288
289/// Drop the worktree root from paths and abbreviate the home dir
290fn shorten(text: &str) -> String {
291    let text = WORKTREE.replace_all(text, "");
292    match std::env::var("HOME") {
293        Ok(home) if !home.is_empty() => text.replace(&home, "~"),
294        _ => text.into_owned(),
295    }
296}
297
298/// Grok tool ids shown with the same names as the other agents
299fn display_tool(name: &str) -> &str {
300    match name {
301        "read_file" | "hashline_read" => "Read",
302        "search_replace" | "hashline_edit" | "strreplace" => "Edit",
303        "run_terminal_cmd" | "run_terminal_command" => "Bash",
304        "list_dir" => "List",
305        "grep" | "hashline_grep" => "Grep",
306        "web_search" => "WebSearch",
307        "web_fetch" => "WebFetch",
308        "write" => "Write",
309        other => other,
310    }
311}
312
313fn capitalize(name: &str) -> String {
314    let mut chars = name.chars();
315    match chars.next() {
316        Some(c) => c.to_uppercase().chain(chars).collect(),
317        None => "Tool".to_string(),
318    }
319}
320
321/// Truncate a string to at most `max` chars, appending `…` if cut
322fn truncate(s: &str, max: usize) -> String {
323    for (char_count, (byte_idx, _ch)) in s.char_indices().enumerate() {
324        if char_count >= max {
325            return format!("{}…", &s[..byte_idx]);
326        }
327    }
328    s.to_string()
329}
330
331#[cfg(test)]
332mod tests {
333    use super::*;
334
335    fn fmt(lines: &[&str]) -> Vec<String> {
336        let mut f = Formatter::new(false);
337        lines.iter().flat_map(|l| f.format(l)).collect()
338    }
339
340    #[test]
341    fn claude_transcript() {
342        let lines = [
343            r#"{"type":"system","subtype":"init","model":"claude-sonnet-5"}"#,
344            r#"{"type":"system","subtype":"thinking_tokens","tokens":12}"#,
345            r#"{"type":"assistant","message":{"content":[{"type":"thinking","thinking":"","signature":"x"}]}}"#,
346            r#"{"type":"assistant","message":{"content":[{"type":"text","text":"Let me read it.\nThen fix."},{"type":"tool_use","id":"t1","name":"Read","input":{"file_path":"/x/.orch/worktrees/orch/br/src/main.rs"}}]}}"#,
347            r#"{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"t1","content":"1\tfn a() {}\n2\tfn b() {}\n"}]}}"#,
348            r#"{"type":"assistant","message":{"content":[{"type":"tool_use","id":"t2","name":"Edit","input":{"file_path":"/x/.orch/worktrees/orch/br/src/main.rs","old_string":"a","new_string":"b"}}]}}"#,
349            r#"{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"t2","content":"Exit code 1\nboom","is_error":true}]}}"#,
350            r#"{"type":"result","subtype":"success","is_error":false,"num_turns":3,"duration_ms":42000}"#,
351        ];
352        assert_eq!(
353            fmt(&lines),
354            vec![
355                "⏺ Session started · claude-sonnet-5",
356                "⏺ Let me read it.\n  Then fix.",
357                "⏺ Read(src/main.rs)",
358                "  ⎿  Read 2 lines",
359                "⏺ Edit(src/main.rs)",
360                "  ⎿  Exit code 1",
361                "✓ Done · 3 turns · 42s",
362                "  ⎿  Changed 1 file: src/main.rs",
363            ]
364        );
365    }
366
367    #[test]
368    fn codex_transcript() {
369        let lines = [
370            r#"{"type":"thread.started","thread_id":"t"}"#,
371            r#"{"type":"error","message":"Reconnecting... 1/5"}"#,
372            r#"{"type":"item.completed","item":{"id":"i0","type":"reasoning","text":"**Checking tests**\n\nbody"}}"#,
373            r#"{"type":"item.completed","item":{"id":"i1","type":"agent_message","text":"I will fix the bug."}}"#,
374            r#"{"type":"item.started","item":{"id":"i2","type":"command_execution","command":"ls"}}"#,
375            r#"{"type":"item.completed","item":{"id":"i2","type":"command_execution","command":"/bin/zsh -lc 'cargo test'","aggregated_output":"running 3 tests\nok\nok\n","exit_code":0}}"#,
376            r#"{"type":"item.completed","item":{"id":"i3","type":"file_change","changes":[{"path":"src/x.rs","kind":"update"}]}}"#,
377            r#"{"type":"turn.completed","usage":{}}"#,
378        ];
379        assert_eq!(
380            fmt(&lines),
381            vec![
382                "✗ Reconnecting... 1/5",
383                "✻ Checking tests",
384                "⏺ I will fix the bug.",
385                "⏺ Bash(cargo test)",
386                "  ⎿  running 3 tests … +2 lines",
387                "⏺ Edit(src/x.rs)",
388                "✓ Done",
389                "  ⎿  Changed 1 file: src/x.rs",
390            ]
391        );
392    }
393
394    #[test]
395    fn opencode_transcript() {
396        let lines = [
397            r#"{"type":"step_start","sessionID":"s","part":{"type":"step-start"}}"#,
398            r#"{"type":"text","sessionID":"s","part":{"type":"text","text":"Reviewing PR."}}"#,
399            r#"{"type":"tool_use","sessionID":"s","part":{"tool":"read","callID":"c1","state":{"status":"completed","input":{"filePath":"src/lib.rs"},"output":"a\nb\nc"}}}"#,
400            r#"{"type":"tool_use","sessionID":"s","part":{"tool":"bash","callID":"c2","state":{"status":"completed","input":{"command":"git status"},"output":""}}}"#,
401            r#"{"type":"step_finish","sessionID":"s","part":{"reason":"tool-calls"}}"#,
402            r#"{"type":"step_finish","sessionID":"s","part":{"reason":"stop"}}"#,
403        ];
404        assert_eq!(
405            fmt(&lines),
406            vec![
407                "⏺ Reviewing PR.",
408                "⏺ Read(src/lib.rs)",
409                "  ⎿  Read 3 lines",
410                "⏺ Bash(git status)",
411                "  ⎿  (no output)",
412                "✓ Done",
413            ]
414        );
415    }
416
417    #[test]
418    fn detects_each_format() {
419        let detect = |line: &str| detect_agent(&serde_json::from_str(line).unwrap());
420        assert_eq!(
421            detect(r#"{"type":"assistant","message":{}}"#),
422            Some("claude")
423        );
424        assert_eq!(detect(r#"{"type":"thread.started"}"#), Some("codex"));
425        assert_eq!(detect(r#"{"type":"text","part":{}}"#), Some("opencode"));
426        assert_eq!(detect(r#"{"type":"error","message":"x"}"#), None);
427    }
428
429    #[test]
430    fn error_before_detection_is_shown() {
431        assert_eq!(
432            fmt(&[r#"{"type":"error","message":"usage limit"}"#]),
433            vec!["✗ usage limit"]
434        );
435    }
436
437    #[test]
438    fn json_message_is_pretty_printed() {
439        let msg = r#"{"type":"item.completed","item":{"type":"agent_message","text":"{\"decision\":\"approve\",\"issues\":[]}"}}"#;
440        let fenced = r#"{"type":"item.completed","item":{"type":"agent_message","text":"```json\n{\"decision\":\"approve\"}\n```"}}"#;
441        let plain =
442            r#"{"type":"item.completed","item":{"type":"agent_message","text":"{not json} here"}}"#;
443        let start = r#"{"type":"thread.started"}"#;
444        assert_eq!(
445            fmt(&[start, msg, fenced, plain]),
446            vec![
447                "⏺ {\n    \"decision\": \"approve\",\n    \"issues\": []\n  }",
448                "⏺ {\n    \"decision\": \"approve\"\n  }",
449                "⏺ {not json} here",
450            ]
451        );
452    }
453
454    #[test]
455    fn long_command_is_cut_to_one_line() {
456        let line = r#"{"type":"assistant","message":{"content":[{"type":"tool_use","id":"t","name":"Bash","input":{"command":"cargo test\necho ok"}}]}}"#;
457        assert_eq!(fmt(&[line]), vec!["⏺ Bash(cargo test …)"]);
458    }
459
460    #[test]
461    fn non_json_passed_through_without_escapes() {
462        assert_eq!(
463            fmt(&["\x1b[?25hfatal: not a git repository\r"]),
464            vec!["fatal: not a git repository"]
465        );
466        assert!(fmt(&["\x1b[?25h"]).is_empty());
467        assert!(fmt(&["   "]).is_empty());
468    }
469
470    #[test]
471    fn color_wraps_markers() {
472        let mut f = Formatter::new(true);
473        f.format(r#"{"type":"thread.started"}"#);
474        assert_eq!(
475            f.format(r#"{"type":"turn.completed"}"#),
476            vec!["\x1b[32m✓ Done\x1b[0m"]
477        );
478    }
479
480    #[test]
481    fn truncate_adds_ellipsis() {
482        assert_eq!(truncate("hello", 10), "hello");
483        assert_eq!(truncate("abcdefghij", 5), "abcde…");
484    }
485
486    #[test]
487    fn extract_files_changed_from_claude_and_opencode() {
488        let claude = concat!(
489            r#"{"type":"assistant","message":{"content":[{"type":"tool_use","id":"a","name":"Write","input":{"file_path":"src/b.rs"}}]}}"#,
490            "\n",
491            r#"{"type":"assistant","message":{"content":[{"type":"tool_use","id":"b","name":"Edit","input":{"file_path":"src/a.rs"}}]}}"#,
492            "\n",
493            r#"{"type":"assistant","message":{"content":[{"type":"tool_use","id":"c","name":"Read","input":{"file_path":"src/c.rs"}}]}}"#,
494        );
495        assert_eq!(extract_files_changed(claude), vec!["src/a.rs", "src/b.rs"]);
496        let opencode = r#"{"type":"tool_use","part":{"tool":"edit","callID":"c","state":{"status":"completed","input":{"filePath":"src/bar.rs"}}}}"#;
497        assert_eq!(extract_files_changed(opencode), vec!["src/bar.rs"]);
498        let none = r#"{"type":"result","subtype":"success","result":"done"}"#;
499        assert!(extract_files_changed(none).is_empty());
500    }
501}