1use agcodex_common::elapsed::format_duration;
2use agcodex_common::elapsed::format_elapsed;
3use agcodex_core::config::Config;
4use agcodex_core::plan_tool::UpdatePlanArgs;
5use agcodex_core::protocol::AgentMessageDeltaEvent;
6use agcodex_core::protocol::AgentMessageEvent;
7use agcodex_core::protocol::AgentReasoningDeltaEvent;
8use agcodex_core::protocol::AgentReasoningRawContentDeltaEvent;
9use agcodex_core::protocol::AgentReasoningRawContentEvent;
10use agcodex_core::protocol::BackgroundEventEvent;
11use agcodex_core::protocol::ErrorEvent;
12use agcodex_core::protocol::Event;
13use agcodex_core::protocol::EventMsg;
14use agcodex_core::protocol::ExecCommandBeginEvent;
15use agcodex_core::protocol::ExecCommandEndEvent;
16use agcodex_core::protocol::FileChange;
17use agcodex_core::protocol::McpInvocation;
18use agcodex_core::protocol::McpToolCallBeginEvent;
19use agcodex_core::protocol::McpToolCallEndEvent;
20use agcodex_core::protocol::PatchApplyBeginEvent;
21use agcodex_core::protocol::PatchApplyEndEvent;
22use agcodex_core::protocol::SessionConfiguredEvent;
23use agcodex_core::protocol::TaskCompleteEvent;
24use agcodex_core::protocol::TurnAbortReason;
25use agcodex_core::protocol::TurnDiffEvent;
26use owo_colors::OwoColorize;
27use owo_colors::Style;
28use shlex::try_join;
29use std::collections::HashMap;
30use std::io::Write;
31use std::path::PathBuf;
32use std::time::Instant;
33
34use crate::event_processor::CodexStatus;
35use crate::event_processor::EventProcessor;
36use crate::event_processor::handle_last_message;
37use agcodex_common::create_config_summary_entries;
38
39const MAX_OUTPUT_LINES_FOR_EXEC_TOOL_CALL: usize = 20;
42pub(crate) struct EventProcessorWithHumanOutput {
43 call_id_to_command: HashMap<String, ExecCommandBegin>,
44 call_id_to_patch: HashMap<String, PatchApplyBegin>,
45
46 bold: Style,
50 italic: Style,
51 dimmed: Style,
52
53 magenta: Style,
54 red: Style,
55 green: Style,
56 cyan: Style,
57
58 show_agent_reasoning: bool,
60 show_raw_agent_reasoning: bool,
61 answer_started: bool,
62 reasoning_started: bool,
63 raw_reasoning_started: bool,
64 last_message_path: Option<PathBuf>,
65}
66
67impl EventProcessorWithHumanOutput {
68 pub(crate) fn create_with_ansi(
69 with_ansi: bool,
70 config: &Config,
71 last_message_path: Option<PathBuf>,
72 ) -> Self {
73 let call_id_to_command = HashMap::new();
74 let call_id_to_patch = HashMap::new();
75
76 if with_ansi {
77 Self {
78 call_id_to_command,
79 call_id_to_patch,
80 bold: Style::new().bold(),
81 italic: Style::new().italic(),
82 dimmed: Style::new().dimmed(),
83 magenta: Style::new().magenta(),
84 red: Style::new().red(),
85 green: Style::new().green(),
86 cyan: Style::new().cyan(),
87 show_agent_reasoning: !config.hide_agent_reasoning,
88 show_raw_agent_reasoning: config.show_raw_agent_reasoning,
89 answer_started: false,
90 reasoning_started: false,
91 raw_reasoning_started: false,
92 last_message_path,
93 }
94 } else {
95 Self {
96 call_id_to_command,
97 call_id_to_patch,
98 bold: Style::new(),
99 italic: Style::new(),
100 dimmed: Style::new(),
101 magenta: Style::new(),
102 red: Style::new(),
103 green: Style::new(),
104 cyan: Style::new(),
105 show_agent_reasoning: !config.hide_agent_reasoning,
106 show_raw_agent_reasoning: config.show_raw_agent_reasoning,
107 answer_started: false,
108 reasoning_started: false,
109 raw_reasoning_started: false,
110 last_message_path,
111 }
112 }
113 }
114}
115
116struct ExecCommandBegin {
117 command: Vec<String>,
118}
119
120struct PatchApplyBegin {
121 start_time: Instant,
122 auto_approved: bool,
123}
124
125#[macro_export]
127macro_rules! ts_println {
128 ($self:ident, $($arg:tt)*) => {{
129 let now = chrono::Utc::now();
130 let formatted = now.format("[%Y-%m-%dT%H:%M:%S]");
131 print!("{} ", formatted.style($self.dimmed));
132 println!($($arg)*);
133 }};
134}
135
136impl EventProcessor for EventProcessorWithHumanOutput {
137 fn print_config_summary(&mut self, config: &Config, prompt: &str) {
141 const VERSION: &str = env!("CARGO_PKG_VERSION");
142 ts_println!(
143 self,
144 "OpenAI Codex v{} (research preview)\n--------",
145 VERSION
146 );
147
148 let entries = create_config_summary_entries(config);
149
150 for (key, value) in entries {
151 println!("{} {}", format!("{key}:").style(self.bold), value);
152 }
153
154 println!("--------");
155
156 ts_println!(
160 self,
161 "{}\n{}",
162 "User instructions:".style(self.bold).style(self.cyan),
163 prompt
164 );
165 }
166
167 fn process_event(&mut self, event: Event) -> CodexStatus {
168 let Event { id: _, msg } = event;
169 match msg {
170 EventMsg::Error(ErrorEvent { message }) => {
171 let prefix = "ERROR:".style(self.red);
172 ts_println!(self, "{prefix} {message}");
173 }
174 EventMsg::BackgroundEvent(BackgroundEventEvent { message }) => {
175 ts_println!(self, "{}", message.style(self.dimmed));
176 }
177 EventMsg::TaskStarted => {
178 }
180 EventMsg::TaskComplete(TaskCompleteEvent { last_agent_message }) => {
181 if let Some(output_file) = self.last_message_path.as_deref() {
182 handle_last_message(last_agent_message.as_deref(), output_file);
183 }
184 return CodexStatus::InitiateShutdown;
185 }
186 EventMsg::TokenCount(token_usage) => {
187 ts_println!(self, "tokens used: {}", token_usage.blended_total());
188 }
189 EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { delta }) => {
190 if !self.answer_started {
191 ts_println!(
192 self,
193 "{}\n",
194 "agcodex".style(self.italic).style(self.magenta)
195 );
196 self.answer_started = true;
197 }
198 print!("{delta}");
199 #[expect(clippy::expect_used)]
200 std::io::stdout().flush().expect("could not flush stdout");
201 }
202 EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent { delta }) => {
203 if !self.show_agent_reasoning {
204 return CodexStatus::Running;
205 }
206 if !self.reasoning_started {
207 ts_println!(
208 self,
209 "{}\n",
210 "thinking".style(self.italic).style(self.magenta),
211 );
212 self.reasoning_started = true;
213 }
214 print!("{delta}");
215 #[expect(clippy::expect_used)]
216 std::io::stdout().flush().expect("could not flush stdout");
217 }
218 EventMsg::AgentReasoningSectionBreak(_) => {
219 if !self.show_agent_reasoning {
220 return CodexStatus::Running;
221 }
222 println!();
223 #[expect(clippy::expect_used)]
224 std::io::stdout().flush().expect("could not flush stdout");
225 }
226 EventMsg::AgentReasoningRawContent(AgentReasoningRawContentEvent { text }) => {
227 if !self.show_raw_agent_reasoning {
228 return CodexStatus::Running;
229 }
230 if !self.raw_reasoning_started {
231 print!("{text}");
232 #[expect(clippy::expect_used)]
233 std::io::stdout().flush().expect("could not flush stdout");
234 } else {
235 println!();
236 self.raw_reasoning_started = false;
237 }
238 }
239 EventMsg::AgentReasoningRawContentDelta(AgentReasoningRawContentDeltaEvent {
240 delta,
241 }) => {
242 if !self.show_raw_agent_reasoning {
243 return CodexStatus::Running;
244 }
245 if !self.raw_reasoning_started {
246 self.raw_reasoning_started = true;
247 }
248 print!("{delta}");
249 #[expect(clippy::expect_used)]
250 std::io::stdout().flush().expect("could not flush stdout");
251 }
252 EventMsg::AgentMessage(AgentMessageEvent { message }) => {
253 if !self.answer_started {
256 ts_println!(
257 self,
258 "{}\n{}",
259 "agcodex".style(self.italic).style(self.magenta),
260 message,
261 );
262 } else {
263 println!();
264 self.answer_started = false;
265 }
266 }
267 EventMsg::ExecCommandBegin(ExecCommandBeginEvent {
268 call_id,
269 command,
270 cwd,
271 parsed_cmd: _,
272 }) => {
273 self.call_id_to_command.insert(
274 call_id.clone(),
275 ExecCommandBegin {
276 command: command.clone(),
277 },
278 );
279 ts_println!(
280 self,
281 "{} {} in {}",
282 "exec".style(self.magenta),
283 escape_command(&command).style(self.bold),
284 cwd.to_string_lossy(),
285 );
286 }
287 EventMsg::ExecCommandOutputDelta(_) => {}
288 EventMsg::ExecCommandEnd(ExecCommandEndEvent {
289 call_id,
290 stdout,
291 stderr,
292 duration,
293 exit_code,
294 }) => {
295 let exec_command = self.call_id_to_command.remove(&call_id);
296 let (duration, call) = if let Some(ExecCommandBegin { command, .. }) = exec_command
297 {
298 (
299 format!(" in {}", format_duration(duration)),
300 format!("{}", escape_command(&command).style(self.bold)),
301 )
302 } else {
303 ("".to_string(), format!("exec('{call_id}')"))
304 };
305
306 let output = if exit_code == 0 { stdout } else { stderr };
307 let truncated_output = output
308 .lines()
309 .take(MAX_OUTPUT_LINES_FOR_EXEC_TOOL_CALL)
310 .collect::<Vec<_>>()
311 .join("\n");
312 match exit_code {
313 0 => {
314 let title = format!("{call} succeeded{duration}:");
315 ts_println!(self, "{}", title.style(self.green));
316 }
317 _ => {
318 let title = format!("{call} exited {exit_code}{duration}:");
319 ts_println!(self, "{}", title.style(self.red));
320 }
321 }
322 println!("{}", truncated_output.style(self.dimmed));
323 }
324 EventMsg::McpToolCallBegin(McpToolCallBeginEvent {
325 call_id: _,
326 invocation,
327 }) => {
328 ts_println!(
329 self,
330 "{} {}",
331 "tool".style(self.magenta),
332 format_mcp_invocation(&invocation).style(self.bold),
333 );
334 }
335 EventMsg::McpToolCallEnd(tool_call_end_event) => {
336 let is_success = tool_call_end_event.is_success();
337 let McpToolCallEndEvent {
338 call_id: _,
339 result,
340 invocation,
341 duration,
342 } = tool_call_end_event;
343
344 let duration = format!(" in {}", format_duration(duration));
345
346 let status_str = if is_success { "success" } else { "failed" };
347 let title_style = if is_success { self.green } else { self.red };
348 let title = format!(
349 "{} {status_str}{duration}:",
350 format_mcp_invocation(&invocation)
351 );
352
353 ts_println!(self, "{}", title.style(title_style));
354
355 if let Ok(res) = result {
356 let val: serde_json::Value = res.into();
357 let pretty =
358 serde_json::to_string_pretty(&val).unwrap_or_else(|_| val.to_string());
359
360 for line in pretty.lines().take(MAX_OUTPUT_LINES_FOR_EXEC_TOOL_CALL) {
361 println!("{}", line.style(self.dimmed));
362 }
363 }
364 }
365 EventMsg::PatchApplyBegin(PatchApplyBeginEvent {
366 call_id,
367 auto_approved,
368 changes,
369 }) => {
370 self.call_id_to_patch.insert(
373 call_id.clone(),
374 PatchApplyBegin {
375 start_time: Instant::now(),
376 auto_approved,
377 },
378 );
379
380 ts_println!(
381 self,
382 "{} auto_approved={}:",
383 "apply_patch".style(self.magenta),
384 auto_approved,
385 );
386
387 for (path, change) in changes.iter() {
390 match change {
391 FileChange::Add { content } => {
392 let header = format!(
393 "{} {}",
394 format_file_change(change),
395 path.to_string_lossy()
396 );
397 println!("{}", header.style(self.magenta));
398 for line in content.lines() {
399 println!("{}", line.style(self.green));
400 }
401 }
402 FileChange::Delete => {
403 let header = format!(
404 "{} {}",
405 format_file_change(change),
406 path.to_string_lossy()
407 );
408 println!("{}", header.style(self.magenta));
409 }
410 FileChange::Update {
411 unified_diff,
412 move_path,
413 } => {
414 let header = if let Some(dest) = move_path {
415 format!(
416 "{} {} -> {}",
417 format_file_change(change),
418 path.to_string_lossy(),
419 dest.to_string_lossy()
420 )
421 } else {
422 format!("{} {}", format_file_change(change), path.to_string_lossy())
423 };
424 println!("{}", header.style(self.magenta));
425
426 for diff_line in unified_diff.lines() {
430 if diff_line.starts_with('+') && !diff_line.starts_with("+++") {
431 println!("{}", diff_line.style(self.green));
432 } else if diff_line.starts_with('-')
433 && !diff_line.starts_with("---")
434 {
435 println!("{}", diff_line.style(self.red));
436 } else {
437 println!("{diff_line}");
438 }
439 }
440 }
441 }
442 }
443 }
444 EventMsg::PatchApplyEnd(PatchApplyEndEvent {
445 call_id,
446 stdout,
447 stderr,
448 success,
449 ..
450 }) => {
451 let patch_begin = self.call_id_to_patch.remove(&call_id);
452
453 let (duration, label) = if let Some(PatchApplyBegin {
455 start_time,
456 auto_approved,
457 }) = patch_begin
458 {
459 (
460 format!(" in {}", format_elapsed(start_time)),
461 format!("apply_patch(auto_approved={auto_approved})"),
462 )
463 } else {
464 (String::new(), format!("apply_patch('{call_id}')"))
465 };
466
467 let (exit_code, output, title_style) = if success {
468 (0, stdout, self.green)
469 } else {
470 (1, stderr, self.red)
471 };
472
473 let title = format!("{label} exited {exit_code}{duration}:");
474 ts_println!(self, "{}", title.style(title_style));
475 for line in output.lines() {
476 println!("{}", line.style(self.dimmed));
477 }
478 }
479 EventMsg::TurnDiff(TurnDiffEvent { unified_diff }) => {
480 ts_println!(self, "{}", "turn diff:".style(self.magenta));
481 println!("{unified_diff}");
482 }
483 EventMsg::ExecApprovalRequest(_) => {
484 }
486 EventMsg::ApplyPatchApprovalRequest(_) => {
487 }
489 EventMsg::AgentReasoning(agent_reasoning_event) => {
490 if self.show_agent_reasoning {
491 if !self.reasoning_started {
492 ts_println!(
493 self,
494 "{}\n{}",
495 "agcodex".style(self.italic).style(self.magenta),
496 agent_reasoning_event.text,
497 );
498 } else {
499 println!();
500 self.reasoning_started = false;
501 }
502 }
503 }
504 EventMsg::SessionConfigured(session_configured_event) => {
505 let SessionConfiguredEvent {
506 session_id,
507 model,
508 history_log_id: _,
509 history_entry_count: _,
510 } = session_configured_event;
511
512 ts_println!(
513 self,
514 "{} {}",
515 "codex session".style(self.magenta).style(self.bold),
516 session_id.to_string().style(self.dimmed)
517 );
518
519 ts_println!(self, "model: {}", model);
520 println!();
521 }
522 EventMsg::PlanUpdate(plan_update_event) => {
523 let UpdatePlanArgs { explanation, plan } = plan_update_event;
524 ts_println!(self, "explanation: {explanation:?}");
525 ts_println!(self, "plan: {plan:?}");
526 }
527 EventMsg::GetHistoryEntryResponse(_) => {
528 }
530 EventMsg::McpListToolsResponse(_) => {
531 }
533 EventMsg::TurnAborted(abort_reason) => match abort_reason.reason {
534 TurnAbortReason::Interrupted => {
535 ts_println!(self, "task interrupted");
536 }
537 TurnAbortReason::Replaced => {
538 ts_println!(self, "task aborted: replaced by a new task");
539 }
540 },
541 EventMsg::ShutdownComplete => return CodexStatus::Shutdown,
542 }
543 CodexStatus::Running
544 }
545}
546
547fn escape_command(command: &[String]) -> String {
548 try_join(command.iter().map(|s| s.as_str())).unwrap_or_else(|_| command.join(" "))
549}
550
551const fn format_file_change(change: &FileChange) -> &'static str {
552 match change {
553 FileChange::Add { .. } => "A",
554 FileChange::Delete => "D",
555 FileChange::Update {
556 move_path: Some(_), ..
557 } => "R",
558 FileChange::Update {
559 move_path: None, ..
560 } => "M",
561 }
562}
563
564fn format_mcp_invocation(invocation: &McpInvocation) -> String {
565 let fq_tool_name = format!("{}.{}", invocation.server, invocation.tool);
567
568 let args_str = invocation
570 .arguments
571 .as_ref()
572 .map(|v: &serde_json::Value| serde_json::to_string(v).unwrap_or_else(|_| v.to_string()))
573 .unwrap_or_default();
574
575 if args_str.is_empty() {
576 format!("{fq_tool_name}()")
577 } else {
578 format!("{fq_tool_name}({args_str})")
579 }
580}