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