1use 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
12pub 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#[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 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 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
207fn 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
224pub 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
235pub 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
244fn 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
260fn 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
289fn 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
298fn 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
321fn 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}