1use 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
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 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 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 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
217fn 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
234pub 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
245pub 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
254fn 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
270fn 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
299fn 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
308fn 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
331pub(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}