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