From e16657ca4545100676c6dd5902dc8179067ed55b Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Thu, 17 Jul 2025 15:10:15 -0700 Subject: [PATCH 1/4] feat: add --json flag to `codex exec` (#1603) This is designed to facilitate programmatic use of Codex in a more lightweight way than using `codex mcp`. Passing `--json` to `codex exec` will print each event as a line of JSON to stdout. Note that it does not print the individual tokens as they are streamed, only full messages, as this is aimed at programmatic use rather than to power UI. image I changed the existing `EventProcessor` into a trait and moved the implementation to `EventProcessorWithHumanOutput`. Then I introduced an alternative implementation, `EventProcessorWithJsonOutput`. The `--json` flag determines which implementation to use. --- codex-rs/exec/src/cli.rs | 4 + codex-rs/exec/src/event_processor.rs | 552 +----------------- .../src/event_processor_with_human_output.rs | 520 +++++++++++++++++ .../src/event_processor_with_json_output.rs | 48 ++ codex-rs/exec/src/lib.rs | 18 +- 5 files changed, 613 insertions(+), 529 deletions(-) create mode 100644 codex-rs/exec/src/event_processor_with_human_output.rs create mode 100644 codex-rs/exec/src/event_processor_with_json_output.rs diff --git a/codex-rs/exec/src/cli.rs b/codex-rs/exec/src/cli.rs index 613fedf0a1..53af25c7e9 100644 --- a/codex-rs/exec/src/cli.rs +++ b/codex-rs/exec/src/cli.rs @@ -51,6 +51,10 @@ pub struct Cli { #[arg(long = "color", value_enum, default_value_t = Color::Auto)] pub color: Color, + /// Print events to stdout as JSONL. + #[arg(long = "json", default_value_t = false)] + pub json: bool, + /// Specifies file where the last message from the agent should be written. #[arg(long = "output-last-message")] pub last_message_file: Option, diff --git a/codex-rs/exec/src/event_processor.rs b/codex-rs/exec/src/event_processor.rs index 5ab09994b1..56db651a83 100644 --- a/codex-rs/exec/src/event_processor.rs +++ b/codex-rs/exec/src/event_processor.rs @@ -1,539 +1,37 @@ -use codex_common::elapsed::format_elapsed; use codex_common::summarize_sandbox_policy; use codex_core::WireApi; use codex_core::config::Config; use codex_core::model_supports_reasoning_summaries; -use codex_core::protocol::AgentMessageDeltaEvent; -use codex_core::protocol::AgentMessageEvent; -use codex_core::protocol::AgentReasoningDeltaEvent; -use codex_core::protocol::BackgroundEventEvent; -use codex_core::protocol::ErrorEvent; use codex_core::protocol::Event; -use codex_core::protocol::EventMsg; -use codex_core::protocol::ExecCommandBeginEvent; -use codex_core::protocol::ExecCommandEndEvent; -use codex_core::protocol::FileChange; -use codex_core::protocol::McpToolCallBeginEvent; -use codex_core::protocol::McpToolCallEndEvent; -use codex_core::protocol::PatchApplyBeginEvent; -use codex_core::protocol::PatchApplyEndEvent; -use codex_core::protocol::SessionConfiguredEvent; -use codex_core::protocol::TokenUsage; -use owo_colors::OwoColorize; -use owo_colors::Style; -use shlex::try_join; -use std::collections::HashMap; -use std::io::Write; -use std::time::Instant; -/// This should be configurable. When used in CI, users may not want to impose -/// a limit so they can see the full transcript. -const MAX_OUTPUT_LINES_FOR_EXEC_TOOL_CALL: usize = 20; +pub(crate) trait EventProcessor { + /// Print summary of effective configuration and user prompt. + fn print_config_summary(&mut self, config: &Config, prompt: &str); -pub(crate) struct EventProcessor { - call_id_to_command: HashMap, - call_id_to_patch: HashMap, - - /// Tracks in-flight MCP tool calls so we can calculate duration and print - /// a concise summary when the corresponding `McpToolCallEnd` event is - /// received. - call_id_to_tool_call: HashMap, - - // To ensure that --color=never is respected, ANSI escapes _must_ be added - // using .style() with one of these fields. If you need a new style, add a - // new field here. - bold: Style, - italic: Style, - dimmed: Style, - - magenta: Style, - red: Style, - green: Style, - cyan: Style, - - /// Whether to include `AgentReasoning` events in the output. - show_agent_reasoning: bool, - answer_started: bool, - reasoning_started: bool, + /// Handle a single event emitted by the agent. + fn process_event(&mut self, event: Event); } -impl EventProcessor { - pub(crate) fn create_with_ansi(with_ansi: bool, config: &Config) -> Self { - let call_id_to_command = HashMap::new(); - let call_id_to_patch = HashMap::new(); - let call_id_to_tool_call = HashMap::new(); - - if with_ansi { - Self { - call_id_to_command, - call_id_to_patch, - bold: Style::new().bold(), - italic: Style::new().italic(), - dimmed: Style::new().dimmed(), - magenta: Style::new().magenta(), - red: Style::new().red(), - green: Style::new().green(), - cyan: Style::new().cyan(), - call_id_to_tool_call, - show_agent_reasoning: !config.hide_agent_reasoning, - answer_started: false, - reasoning_started: false, - } - } else { - Self { - call_id_to_command, - call_id_to_patch, - bold: Style::new(), - italic: Style::new(), - dimmed: Style::new(), - magenta: Style::new(), - red: Style::new(), - green: Style::new(), - cyan: Style::new(), - call_id_to_tool_call, - show_agent_reasoning: !config.hide_agent_reasoning, - answer_started: false, - reasoning_started: false, - } - } - } -} - -struct ExecCommandBegin { - command: Vec, - start_time: Instant, -} - -/// Metadata captured when an `McpToolCallBegin` event is received. -struct McpToolCallBegin { - /// Formatted invocation string, e.g. `server.tool({"city":"sf"})`. - invocation: String, - /// Timestamp when the call started so we can compute duration later. - start_time: Instant, -} - -struct PatchApplyBegin { - start_time: Instant, - auto_approved: bool, -} - -// Timestamped println helper. The timestamp is styled with self.dimmed. -#[macro_export] -macro_rules! ts_println { - ($self:ident, $($arg:tt)*) => {{ - let now = chrono::Utc::now(); - let formatted = now.format("[%Y-%m-%dT%H:%M:%S]"); - print!("{} ", formatted.style($self.dimmed)); - println!($($arg)*); - }}; -} - -impl EventProcessor { - /// Print a concise summary of the effective configuration that will be used - /// for the session. This mirrors the information shown in the TUI welcome - /// screen. - pub(crate) fn print_config_summary(&mut self, config: &Config, prompt: &str) { - const VERSION: &str = env!("CARGO_PKG_VERSION"); - ts_println!( - self, - "OpenAI Codex v{} (research preview)\n--------", - VERSION - ); - - let mut entries = vec![ - ("workdir", config.cwd.display().to_string()), - ("model", config.model.clone()), - ("provider", config.model_provider_id.clone()), - ("approval", format!("{:?}", config.approval_policy)), - ("sandbox", summarize_sandbox_policy(&config.sandbox_policy)), - ]; - if config.model_provider.wire_api == WireApi::Responses - && model_supports_reasoning_summaries(config) - { - entries.push(( - "reasoning effort", - config.model_reasoning_effort.to_string(), - )); - entries.push(( - "reasoning summaries", - config.model_reasoning_summary.to_string(), - )); - } - - for (key, value) in entries { - println!("{} {}", format!("{key}:").style(self.bold), value); - } - - println!("--------"); - - // Echo the prompt that will be sent to the agent so it is visible in the - // transcript/logs before any events come in. Note the prompt may have been - // read from stdin, so it may not be visible in the terminal otherwise. - ts_println!( - self, - "{}\n{}", - "User instructions:".style(self.bold).style(self.cyan), - prompt - ); +pub(crate) fn create_config_summary_entries(config: &Config) -> Vec<(&'static str, String)> { + let mut entries = vec![ + ("workdir", config.cwd.display().to_string()), + ("model", config.model.clone()), + ("provider", config.model_provider_id.clone()), + ("approval", format!("{:?}", config.approval_policy)), + ("sandbox", summarize_sandbox_policy(&config.sandbox_policy)), + ]; + if config.model_provider.wire_api == WireApi::Responses + && model_supports_reasoning_summaries(config) + { + entries.push(( + "reasoning effort", + config.model_reasoning_effort.to_string(), + )); + entries.push(( + "reasoning summaries", + config.model_reasoning_summary.to_string(), + )); } - pub(crate) fn process_event(&mut self, event: Event) { - let Event { id: _, msg } = event; - match msg { - EventMsg::Error(ErrorEvent { message }) => { - let prefix = "ERROR:".style(self.red); - ts_println!(self, "{prefix} {message}"); - } - EventMsg::BackgroundEvent(BackgroundEventEvent { message }) => { - ts_println!(self, "{}", message.style(self.dimmed)); - } - EventMsg::TaskStarted | EventMsg::TaskComplete(_) => { - // Ignore. - } - EventMsg::TokenCount(TokenUsage { total_tokens, .. }) => { - ts_println!(self, "tokens used: {total_tokens}"); - } - EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { delta }) => { - if !self.answer_started { - ts_println!(self, "{}\n", "codex".style(self.italic).style(self.magenta)); - self.answer_started = true; - } - print!("{delta}"); - #[allow(clippy::expect_used)] - std::io::stdout().flush().expect("could not flush stdout"); - } - EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent { delta }) => { - if !self.show_agent_reasoning { - return; - } - if !self.reasoning_started { - ts_println!( - self, - "{}\n", - "thinking".style(self.italic).style(self.magenta), - ); - self.reasoning_started = true; - } - print!("{delta}"); - #[allow(clippy::expect_used)] - std::io::stdout().flush().expect("could not flush stdout"); - } - EventMsg::AgentMessage(AgentMessageEvent { message }) => { - // if answer_started is false, this means we haven't received any - // delta. Thus, we need to print the message as a new answer. - if !self.answer_started { - ts_println!( - self, - "{}\n{}", - "codex".style(self.italic).style(self.magenta), - message, - ); - } else { - println!(); - self.answer_started = false; - } - } - EventMsg::ExecCommandBegin(ExecCommandBeginEvent { - call_id, - command, - cwd, - }) => { - self.call_id_to_command.insert( - call_id.clone(), - ExecCommandBegin { - command: command.clone(), - start_time: Instant::now(), - }, - ); - ts_println!( - self, - "{} {} in {}", - "exec".style(self.magenta), - escape_command(&command).style(self.bold), - cwd.to_string_lossy(), - ); - } - EventMsg::ExecCommandEnd(ExecCommandEndEvent { - call_id, - stdout, - stderr, - exit_code, - }) => { - let exec_command = self.call_id_to_command.remove(&call_id); - let (duration, call) = if let Some(ExecCommandBegin { - command, - start_time, - }) = exec_command - { - ( - format!(" in {}", format_elapsed(start_time)), - format!("{}", escape_command(&command).style(self.bold)), - ) - } else { - ("".to_string(), format!("exec('{call_id}')")) - }; - - let output = if exit_code == 0 { stdout } else { stderr }; - let truncated_output = output - .lines() - .take(MAX_OUTPUT_LINES_FOR_EXEC_TOOL_CALL) - .collect::>() - .join("\n"); - match exit_code { - 0 => { - let title = format!("{call} succeeded{duration}:"); - ts_println!(self, "{}", title.style(self.green)); - } - _ => { - let title = format!("{call} exited {exit_code}{duration}:"); - ts_println!(self, "{}", title.style(self.red)); - } - } - println!("{}", truncated_output.style(self.dimmed)); - } - EventMsg::McpToolCallBegin(McpToolCallBeginEvent { - call_id, - server, - tool, - arguments, - }) => { - // Build fully-qualified tool name: server.tool - let fq_tool_name = format!("{server}.{tool}"); - - // Format arguments as compact JSON so they fit on one line. - let args_str = arguments - .as_ref() - .map(|v: &serde_json::Value| { - serde_json::to_string(v).unwrap_or_else(|_| v.to_string()) - }) - .unwrap_or_default(); - - let invocation = if args_str.is_empty() { - format!("{fq_tool_name}()") - } else { - format!("{fq_tool_name}({args_str})") - }; - - self.call_id_to_tool_call.insert( - call_id.clone(), - McpToolCallBegin { - invocation: invocation.clone(), - start_time: Instant::now(), - }, - ); - - ts_println!( - self, - "{} {}", - "tool".style(self.magenta), - invocation.style(self.bold), - ); - } - EventMsg::McpToolCallEnd(tool_call_end_event) => { - let is_success = tool_call_end_event.is_success(); - let McpToolCallEndEvent { call_id, result } = tool_call_end_event; - // Retrieve start time and invocation for duration calculation and labeling. - let info = self.call_id_to_tool_call.remove(&call_id); - - let (duration, invocation) = if let Some(McpToolCallBegin { - invocation, - start_time, - .. - }) = info - { - (format!(" in {}", format_elapsed(start_time)), invocation) - } else { - (String::new(), format!("tool('{call_id}')")) - }; - - let status_str = if is_success { "success" } else { "failed" }; - let title_style = if is_success { self.green } else { self.red }; - let title = format!("{invocation} {status_str}{duration}:"); - - ts_println!(self, "{}", title.style(title_style)); - - if let Ok(res) = result { - let val: serde_json::Value = res.into(); - let pretty = - serde_json::to_string_pretty(&val).unwrap_or_else(|_| val.to_string()); - - for line in pretty.lines().take(MAX_OUTPUT_LINES_FOR_EXEC_TOOL_CALL) { - println!("{}", line.style(self.dimmed)); - } - } - } - EventMsg::PatchApplyBegin(PatchApplyBeginEvent { - call_id, - auto_approved, - changes, - }) => { - // Store metadata so we can calculate duration later when we - // receive the corresponding PatchApplyEnd event. - self.call_id_to_patch.insert( - call_id.clone(), - PatchApplyBegin { - start_time: Instant::now(), - auto_approved, - }, - ); - - ts_println!( - self, - "{} auto_approved={}:", - "apply_patch".style(self.magenta), - auto_approved, - ); - - // Pretty-print the patch summary with colored diff markers so - // it's easy to scan in the terminal output. - for (path, change) in changes.iter() { - match change { - FileChange::Add { content } => { - let header = format!( - "{} {}", - format_file_change(change), - path.to_string_lossy() - ); - println!("{}", header.style(self.magenta)); - for line in content.lines() { - println!("{}", line.style(self.green)); - } - } - FileChange::Delete => { - let header = format!( - "{} {}", - format_file_change(change), - path.to_string_lossy() - ); - println!("{}", header.style(self.magenta)); - } - FileChange::Update { - unified_diff, - move_path, - } => { - let header = if let Some(dest) = move_path { - format!( - "{} {} -> {}", - format_file_change(change), - path.to_string_lossy(), - dest.to_string_lossy() - ) - } else { - format!("{} {}", format_file_change(change), path.to_string_lossy()) - }; - println!("{}", header.style(self.magenta)); - - // Colorize diff lines. We keep file header lines - // (--- / +++) without extra coloring so they are - // still readable. - for diff_line in unified_diff.lines() { - if diff_line.starts_with('+') && !diff_line.starts_with("+++") { - println!("{}", diff_line.style(self.green)); - } else if diff_line.starts_with('-') - && !diff_line.starts_with("---") - { - println!("{}", diff_line.style(self.red)); - } else { - println!("{diff_line}"); - } - } - } - } - } - } - EventMsg::PatchApplyEnd(PatchApplyEndEvent { - call_id, - stdout, - stderr, - success, - }) => { - let patch_begin = self.call_id_to_patch.remove(&call_id); - - // Compute duration and summary label similar to exec commands. - let (duration, label) = if let Some(PatchApplyBegin { - start_time, - auto_approved, - }) = patch_begin - { - ( - format!(" in {}", format_elapsed(start_time)), - format!("apply_patch(auto_approved={auto_approved})"), - ) - } else { - (String::new(), format!("apply_patch('{call_id}')")) - }; - - let (exit_code, output, title_style) = if success { - (0, stdout, self.green) - } else { - (1, stderr, self.red) - }; - - let title = format!("{label} exited {exit_code}{duration}:"); - ts_println!(self, "{}", title.style(title_style)); - for line in output.lines() { - println!("{}", line.style(self.dimmed)); - } - } - EventMsg::ExecApprovalRequest(_) => { - // Should we exit? - } - EventMsg::ApplyPatchApprovalRequest(_) => { - // Should we exit? - } - EventMsg::AgentReasoning(agent_reasoning_event) => { - if self.show_agent_reasoning { - if !self.reasoning_started { - ts_println!( - self, - "{}\n{}", - "codex".style(self.italic).style(self.magenta), - agent_reasoning_event.text, - ); - } else { - println!(); - self.reasoning_started = false; - } - } - } - EventMsg::SessionConfigured(session_configured_event) => { - let SessionConfiguredEvent { - session_id, - model, - history_log_id: _, - history_entry_count: _, - } = session_configured_event; - - ts_println!( - self, - "{} {}", - "codex session".style(self.magenta).style(self.bold), - session_id.to_string().style(self.dimmed) - ); - - ts_println!(self, "model: {}", model); - println!(); - } - EventMsg::GetHistoryEntryResponse(_) => { - // Currently ignored in exec output. - } - } - } -} - -fn escape_command(command: &[String]) -> String { - try_join(command.iter().map(|s| s.as_str())).unwrap_or_else(|_| command.join(" ")) -} - -fn format_file_change(change: &FileChange) -> &'static str { - match change { - FileChange::Add { .. } => "A", - FileChange::Delete => "D", - FileChange::Update { - move_path: Some(_), .. - } => "R", - FileChange::Update { - move_path: None, .. - } => "M", - } + entries } diff --git a/codex-rs/exec/src/event_processor_with_human_output.rs b/codex-rs/exec/src/event_processor_with_human_output.rs new file mode 100644 index 0000000000..7b39071116 --- /dev/null +++ b/codex-rs/exec/src/event_processor_with_human_output.rs @@ -0,0 +1,520 @@ +use codex_common::elapsed::format_elapsed; +use codex_core::config::Config; +use codex_core::protocol::AgentMessageDeltaEvent; +use codex_core::protocol::AgentMessageEvent; +use codex_core::protocol::AgentReasoningDeltaEvent; +use codex_core::protocol::BackgroundEventEvent; +use codex_core::protocol::ErrorEvent; +use codex_core::protocol::Event; +use codex_core::protocol::EventMsg; +use codex_core::protocol::ExecCommandBeginEvent; +use codex_core::protocol::ExecCommandEndEvent; +use codex_core::protocol::FileChange; +use codex_core::protocol::McpToolCallBeginEvent; +use codex_core::protocol::McpToolCallEndEvent; +use codex_core::protocol::PatchApplyBeginEvent; +use codex_core::protocol::PatchApplyEndEvent; +use codex_core::protocol::SessionConfiguredEvent; +use codex_core::protocol::TokenUsage; +use owo_colors::OwoColorize; +use owo_colors::Style; +use shlex::try_join; +use std::collections::HashMap; +use std::io::Write; +use std::time::Instant; + +use crate::event_processor::EventProcessor; +use crate::event_processor::create_config_summary_entries; + +/// This should be configurable. When used in CI, users may not want to impose +/// a limit so they can see the full transcript. +const MAX_OUTPUT_LINES_FOR_EXEC_TOOL_CALL: usize = 20; +pub(crate) struct EventProcessorWithHumanOutput { + call_id_to_command: HashMap, + call_id_to_patch: HashMap, + + /// Tracks in-flight MCP tool calls so we can calculate duration and print + /// a concise summary when the corresponding `McpToolCallEnd` event is + /// received. + call_id_to_tool_call: HashMap, + + // To ensure that --color=never is respected, ANSI escapes _must_ be added + // using .style() with one of these fields. If you need a new style, add a + // new field here. + bold: Style, + italic: Style, + dimmed: Style, + + magenta: Style, + red: Style, + green: Style, + cyan: Style, + + /// Whether to include `AgentReasoning` events in the output. + show_agent_reasoning: bool, + answer_started: bool, + reasoning_started: bool, +} + +impl EventProcessorWithHumanOutput { + pub(crate) fn create_with_ansi(with_ansi: bool, config: &Config) -> Self { + let call_id_to_command = HashMap::new(); + let call_id_to_patch = HashMap::new(); + let call_id_to_tool_call = HashMap::new(); + + if with_ansi { + Self { + call_id_to_command, + call_id_to_patch, + bold: Style::new().bold(), + italic: Style::new().italic(), + dimmed: Style::new().dimmed(), + magenta: Style::new().magenta(), + red: Style::new().red(), + green: Style::new().green(), + cyan: Style::new().cyan(), + call_id_to_tool_call, + show_agent_reasoning: !config.hide_agent_reasoning, + answer_started: false, + reasoning_started: false, + } + } else { + Self { + call_id_to_command, + call_id_to_patch, + bold: Style::new(), + italic: Style::new(), + dimmed: Style::new(), + magenta: Style::new(), + red: Style::new(), + green: Style::new(), + cyan: Style::new(), + call_id_to_tool_call, + show_agent_reasoning: !config.hide_agent_reasoning, + answer_started: false, + reasoning_started: false, + } + } + } +} + +struct ExecCommandBegin { + command: Vec, + start_time: Instant, +} + +/// Metadata captured when an `McpToolCallBegin` event is received. +struct McpToolCallBegin { + /// Formatted invocation string, e.g. `server.tool({"city":"sf"})`. + invocation: String, + /// Timestamp when the call started so we can compute duration later. + start_time: Instant, +} + +struct PatchApplyBegin { + start_time: Instant, + auto_approved: bool, +} + +// Timestamped println helper. The timestamp is styled with self.dimmed. +#[macro_export] +macro_rules! ts_println { + ($self:ident, $($arg:tt)*) => {{ + let now = chrono::Utc::now(); + let formatted = now.format("[%Y-%m-%dT%H:%M:%S]"); + print!("{} ", formatted.style($self.dimmed)); + println!($($arg)*); + }}; +} + +impl EventProcessor for EventProcessorWithHumanOutput { + /// Print a concise summary of the effective configuration that will be used + /// for the session. This mirrors the information shown in the TUI welcome + /// screen. + fn print_config_summary(&mut self, config: &Config, prompt: &str) { + const VERSION: &str = env!("CARGO_PKG_VERSION"); + ts_println!( + self, + "OpenAI Codex v{} (research preview)\n--------", + VERSION + ); + + let entries = create_config_summary_entries(config); + + for (key, value) in entries { + println!("{} {}", format!("{key}:").style(self.bold), value); + } + + println!("--------"); + + // Echo the prompt that will be sent to the agent so it is visible in the + // transcript/logs before any events come in. Note the prompt may have been + // read from stdin, so it may not be visible in the terminal otherwise. + ts_println!( + self, + "{}\n{}", + "User instructions:".style(self.bold).style(self.cyan), + prompt + ); + } + + fn process_event(&mut self, event: Event) { + let Event { id: _, msg } = event; + match msg { + EventMsg::Error(ErrorEvent { message }) => { + let prefix = "ERROR:".style(self.red); + ts_println!(self, "{prefix} {message}"); + } + EventMsg::BackgroundEvent(BackgroundEventEvent { message }) => { + ts_println!(self, "{}", message.style(self.dimmed)); + } + EventMsg::TaskStarted | EventMsg::TaskComplete(_) => { + // Ignore. + } + EventMsg::TokenCount(TokenUsage { total_tokens, .. }) => { + ts_println!(self, "tokens used: {total_tokens}"); + } + EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { delta }) => { + if !self.answer_started { + ts_println!(self, "{}\n", "codex".style(self.italic).style(self.magenta)); + self.answer_started = true; + } + print!("{delta}"); + #[allow(clippy::expect_used)] + std::io::stdout().flush().expect("could not flush stdout"); + } + EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent { delta }) => { + if !self.show_agent_reasoning { + return; + } + if !self.reasoning_started { + ts_println!( + self, + "{}\n", + "thinking".style(self.italic).style(self.magenta), + ); + self.reasoning_started = true; + } + print!("{delta}"); + #[allow(clippy::expect_used)] + std::io::stdout().flush().expect("could not flush stdout"); + } + EventMsg::AgentMessage(AgentMessageEvent { message }) => { + // if answer_started is false, this means we haven't received any + // delta. Thus, we need to print the message as a new answer. + if !self.answer_started { + ts_println!( + self, + "{}\n{}", + "codex".style(self.italic).style(self.magenta), + message, + ); + } else { + println!(); + self.answer_started = false; + } + } + EventMsg::ExecCommandBegin(ExecCommandBeginEvent { + call_id, + command, + cwd, + }) => { + self.call_id_to_command.insert( + call_id.clone(), + ExecCommandBegin { + command: command.clone(), + start_time: Instant::now(), + }, + ); + ts_println!( + self, + "{} {} in {}", + "exec".style(self.magenta), + escape_command(&command).style(self.bold), + cwd.to_string_lossy(), + ); + } + EventMsg::ExecCommandEnd(ExecCommandEndEvent { + call_id, + stdout, + stderr, + exit_code, + }) => { + let exec_command = self.call_id_to_command.remove(&call_id); + let (duration, call) = if let Some(ExecCommandBegin { + command, + start_time, + }) = exec_command + { + ( + format!(" in {}", format_elapsed(start_time)), + format!("{}", escape_command(&command).style(self.bold)), + ) + } else { + ("".to_string(), format!("exec('{call_id}')")) + }; + + let output = if exit_code == 0 { stdout } else { stderr }; + let truncated_output = output + .lines() + .take(MAX_OUTPUT_LINES_FOR_EXEC_TOOL_CALL) + .collect::>() + .join("\n"); + match exit_code { + 0 => { + let title = format!("{call} succeeded{duration}:"); + ts_println!(self, "{}", title.style(self.green)); + } + _ => { + let title = format!("{call} exited {exit_code}{duration}:"); + ts_println!(self, "{}", title.style(self.red)); + } + } + println!("{}", truncated_output.style(self.dimmed)); + } + EventMsg::McpToolCallBegin(McpToolCallBeginEvent { + call_id, + server, + tool, + arguments, + }) => { + // Build fully-qualified tool name: server.tool + let fq_tool_name = format!("{server}.{tool}"); + + // Format arguments as compact JSON so they fit on one line. + let args_str = arguments + .as_ref() + .map(|v: &serde_json::Value| { + serde_json::to_string(v).unwrap_or_else(|_| v.to_string()) + }) + .unwrap_or_default(); + + let invocation = if args_str.is_empty() { + format!("{fq_tool_name}()") + } else { + format!("{fq_tool_name}({args_str})") + }; + + self.call_id_to_tool_call.insert( + call_id.clone(), + McpToolCallBegin { + invocation: invocation.clone(), + start_time: Instant::now(), + }, + ); + + ts_println!( + self, + "{} {}", + "tool".style(self.magenta), + invocation.style(self.bold), + ); + } + EventMsg::McpToolCallEnd(tool_call_end_event) => { + let is_success = tool_call_end_event.is_success(); + let McpToolCallEndEvent { call_id, result } = tool_call_end_event; + // Retrieve start time and invocation for duration calculation and labeling. + let info = self.call_id_to_tool_call.remove(&call_id); + + let (duration, invocation) = if let Some(McpToolCallBegin { + invocation, + start_time, + .. + }) = info + { + (format!(" in {}", format_elapsed(start_time)), invocation) + } else { + (String::new(), format!("tool('{call_id}')")) + }; + + let status_str = if is_success { "success" } else { "failed" }; + let title_style = if is_success { self.green } else { self.red }; + let title = format!("{invocation} {status_str}{duration}:"); + + ts_println!(self, "{}", title.style(title_style)); + + if let Ok(res) = result { + let val: serde_json::Value = res.into(); + let pretty = + serde_json::to_string_pretty(&val).unwrap_or_else(|_| val.to_string()); + + for line in pretty.lines().take(MAX_OUTPUT_LINES_FOR_EXEC_TOOL_CALL) { + println!("{}", line.style(self.dimmed)); + } + } + } + EventMsg::PatchApplyBegin(PatchApplyBeginEvent { + call_id, + auto_approved, + changes, + }) => { + // Store metadata so we can calculate duration later when we + // receive the corresponding PatchApplyEnd event. + self.call_id_to_patch.insert( + call_id.clone(), + PatchApplyBegin { + start_time: Instant::now(), + auto_approved, + }, + ); + + ts_println!( + self, + "{} auto_approved={}:", + "apply_patch".style(self.magenta), + auto_approved, + ); + + // Pretty-print the patch summary with colored diff markers so + // it's easy to scan in the terminal output. + for (path, change) in changes.iter() { + match change { + FileChange::Add { content } => { + let header = format!( + "{} {}", + format_file_change(change), + path.to_string_lossy() + ); + println!("{}", header.style(self.magenta)); + for line in content.lines() { + println!("{}", line.style(self.green)); + } + } + FileChange::Delete => { + let header = format!( + "{} {}", + format_file_change(change), + path.to_string_lossy() + ); + println!("{}", header.style(self.magenta)); + } + FileChange::Update { + unified_diff, + move_path, + } => { + let header = if let Some(dest) = move_path { + format!( + "{} {} -> {}", + format_file_change(change), + path.to_string_lossy(), + dest.to_string_lossy() + ) + } else { + format!("{} {}", format_file_change(change), path.to_string_lossy()) + }; + println!("{}", header.style(self.magenta)); + + // Colorize diff lines. We keep file header lines + // (--- / +++) without extra coloring so they are + // still readable. + for diff_line in unified_diff.lines() { + if diff_line.starts_with('+') && !diff_line.starts_with("+++") { + println!("{}", diff_line.style(self.green)); + } else if diff_line.starts_with('-') + && !diff_line.starts_with("---") + { + println!("{}", diff_line.style(self.red)); + } else { + println!("{diff_line}"); + } + } + } + } + } + } + EventMsg::PatchApplyEnd(PatchApplyEndEvent { + call_id, + stdout, + stderr, + success, + }) => { + let patch_begin = self.call_id_to_patch.remove(&call_id); + + // Compute duration and summary label similar to exec commands. + let (duration, label) = if let Some(PatchApplyBegin { + start_time, + auto_approved, + }) = patch_begin + { + ( + format!(" in {}", format_elapsed(start_time)), + format!("apply_patch(auto_approved={auto_approved})"), + ) + } else { + (String::new(), format!("apply_patch('{call_id}')")) + }; + + let (exit_code, output, title_style) = if success { + (0, stdout, self.green) + } else { + (1, stderr, self.red) + }; + + let title = format!("{label} exited {exit_code}{duration}:"); + ts_println!(self, "{}", title.style(title_style)); + for line in output.lines() { + println!("{}", line.style(self.dimmed)); + } + } + EventMsg::ExecApprovalRequest(_) => { + // Should we exit? + } + EventMsg::ApplyPatchApprovalRequest(_) => { + // Should we exit? + } + EventMsg::AgentReasoning(agent_reasoning_event) => { + if self.show_agent_reasoning { + if !self.reasoning_started { + ts_println!( + self, + "{}\n{}", + "codex".style(self.italic).style(self.magenta), + agent_reasoning_event.text, + ); + } else { + println!(); + self.reasoning_started = false; + } + } + } + EventMsg::SessionConfigured(session_configured_event) => { + let SessionConfiguredEvent { + session_id, + model, + history_log_id: _, + history_entry_count: _, + } = session_configured_event; + + ts_println!( + self, + "{} {}", + "codex session".style(self.magenta).style(self.bold), + session_id.to_string().style(self.dimmed) + ); + + ts_println!(self, "model: {}", model); + println!(); + } + EventMsg::GetHistoryEntryResponse(_) => { + // Currently ignored in exec output. + } + } + } +} + +fn escape_command(command: &[String]) -> String { + try_join(command.iter().map(|s| s.as_str())).unwrap_or_else(|_| command.join(" ")) +} + +fn format_file_change(change: &FileChange) -> &'static str { + match change { + FileChange::Add { .. } => "A", + FileChange::Delete => "D", + FileChange::Update { + move_path: Some(_), .. + } => "R", + FileChange::Update { + move_path: None, .. + } => "M", + } +} diff --git a/codex-rs/exec/src/event_processor_with_json_output.rs b/codex-rs/exec/src/event_processor_with_json_output.rs new file mode 100644 index 0000000000..699460bbed --- /dev/null +++ b/codex-rs/exec/src/event_processor_with_json_output.rs @@ -0,0 +1,48 @@ +use std::collections::HashMap; + +use codex_core::config::Config; +use codex_core::protocol::Event; +use codex_core::protocol::EventMsg; +use serde_json::json; + +use crate::event_processor::EventProcessor; +use crate::event_processor::create_config_summary_entries; + +pub(crate) struct EventProcessorWithJsonOutput; + +impl EventProcessorWithJsonOutput { + pub fn new() -> Self { + Self {} + } +} + +impl EventProcessor for EventProcessorWithJsonOutput { + fn print_config_summary(&mut self, config: &Config, prompt: &str) { + let entries = create_config_summary_entries(config) + .into_iter() + .map(|(key, value)| (key.to_string(), value)) + .collect::>(); + #[allow(clippy::expect_used)] + let config_json = + serde_json::to_string(&entries).expect("Failed to serialize config summary to JSON"); + println!("{config_json}"); + + let prompt_json = json!({ + "prompt": prompt, + }); + println!("{prompt_json}"); + } + + fn process_event(&mut self, event: Event) { + match event.msg { + EventMsg::AgentMessageDelta(_) | EventMsg::AgentReasoningDelta(_) => { + // Suppress streaming events in JSON mode. + } + _ => { + if let Ok(line) = serde_json::to_string(&event) { + println!("{line}"); + } + } + } + } +} diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index afefed1a93..b557c89397 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -1,5 +1,7 @@ mod cli; mod event_processor; +mod event_processor_with_human_output; +mod event_processor_with_json_output; use std::io::IsTerminal; use std::io::Read; @@ -19,12 +21,15 @@ use codex_core::protocol::InputItem; use codex_core::protocol::Op; use codex_core::protocol::TaskCompleteEvent; use codex_core::util::is_inside_git_repo; -use event_processor::EventProcessor; +use event_processor_with_human_output::EventProcessorWithHumanOutput; +use event_processor_with_json_output::EventProcessorWithJsonOutput; use tracing::debug; use tracing::error; use tracing::info; use tracing_subscriber::EnvFilter; +use crate::event_processor::EventProcessor; + pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> anyhow::Result<()> { let Cli { images, @@ -36,6 +41,7 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> any skip_git_repo_check, color, last_message_file, + json: json_mode, sandbox_mode: sandbox_mode_cli_arg, prompt, config_overrides, @@ -115,7 +121,15 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> any }; let config = Config::load_with_cli_overrides(cli_kv_overrides, overrides)?; - let mut event_processor = EventProcessor::create_with_ansi(stdout_with_ansi, &config); + let mut event_processor: Box = if json_mode { + Box::new(EventProcessorWithJsonOutput::new()) + } else { + Box::new(EventProcessorWithHumanOutput::create_with_ansi( + stdout_with_ansi, + &config, + )) + }; + // Print the effective configuration and prompt so users can see what Codex // is using. event_processor.print_config_summary(&config, &prompt); From 327e2254f68ba7a19f62d0c07269242b72582715 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Thu, 17 Jul 2025 15:36:15 -0700 Subject: [PATCH 2/4] chore: rename toolchain file (#1604) Rename toolchain file so older versions of cargo can pick it up. --- AGENTS.md | 4 ++-- codex-rs/justfile | 1 + codex-rs/{toolchain.toml => rust-toolchain.toml} | 0 3 files changed, 3 insertions(+), 2 deletions(-) rename codex-rs/{toolchain.toml => rust-toolchain.toml} (100%) diff --git a/AGENTS.md b/AGENTS.md index 4188cc9873..27af48ae60 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,6 +4,6 @@ In the codex-rs folder where the rust code lives: - Never add or modify any code related to `CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR`. You operate in a sandbox where `CODEX_SANDBOX_NETWORK_DISABLED=1` will be set whenever you use the `shell` tool. Any existing code that uses `CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR` was authored with this fact in mind. It is often used to early exit out of tests that the author knew you would not be able to run given your sandbox limitations. -After making changes to the rust code run `just fmt` (in `codex-rs` directory) to format the code and `just fix` (in `codex-rs` directory) to fix any linter issues in the code. +Before creating a pull request with changes to `codex-rs`, run `just fmt` (in `codex-rs` directory) to format the code and `just fix` (in `codex-rs` directory) to fix any linter issues in the code, ensure the test suite passes by running `cargo test --all-features` in the `codex-rs` directory. -Ensure the test suite passes by running `cargo test --all-features` in the `codex-rs` directory. +When making individual changes prefer running tests on individual files or projects first. diff --git a/codex-rs/justfile b/codex-rs/justfile index 6c8e9f9e4d..3e1336be43 100644 --- a/codex-rs/justfile +++ b/codex-rs/justfile @@ -28,4 +28,5 @@ fix: cargo clippy --fix --all-features --tests --allow-dirty install: + rustup show active-toolchain cargo fetch diff --git a/codex-rs/toolchain.toml b/codex-rs/rust-toolchain.toml similarity index 100% rename from codex-rs/toolchain.toml rename to codex-rs/rust-toolchain.toml From 9cedeadf6ad1184873d99afd9b7f77d4646b0452 Mon Sep 17 00:00:00 2001 From: aibrahim-oai Date: Thu, 17 Jul 2025 17:00:17 -0700 Subject: [PATCH 3/4] change the default debounce rate to 10ms (#1606) changed the default debounce rate to 10ms because typing was laggy. Before: https://github.com/user-attachments/assets/e5d15fcb-6a2b-4837-b2b4-c3dcb4cc3409 After https://github.com/user-attachments/assets/6f0005eb-fd49-4130-ba68-635ee0f2831f --- codex-rs/tui/src/app.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 397c0dc9a4..d8af5d33be 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -26,7 +26,7 @@ use std::thread; use std::time::Duration; /// Time window for debouncing redraw requests. -const REDRAW_DEBOUNCE: Duration = Duration::from_millis(100); +const REDRAW_DEBOUNCE: Duration = Duration::from_millis(10); /// Top-level application state: which full-screen view is currently active. #[allow(clippy::large_enum_variant)] From 20afce71059c85784062b3986699544ed0fd02b7 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Fri, 18 Jul 2025 09:00:53 -0700 Subject: [PATCH 4/4] fix: disable debouncing RequestRedraw in the TUI for now --- codex-rs/tui/src/app.rs | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index d8af5d33be..2fa0cb6cbd 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -25,6 +25,13 @@ use std::sync::mpsc::channel; use std::thread; use std::time::Duration; +/// Debouncing is often a helpful performance optimization, though as shown in +/// https://github.com/openai/codex/pull/1610, it requires care to ensure that +/// it works well with interrupts via ctrl-C. For now, we favor correctness at +/// the cost of performance, but it would be worth revisiting this in the +/// future. +const DEBOUNCE_REDRAW_REQUESTS: bool = false; + /// Time window for debouncing redraw requests. const REDRAW_DEBOUNCE: Duration = Duration::from_millis(10); @@ -209,10 +216,14 @@ impl App<'_> { while let Ok(event) = self.app_event_rx.recv() { match event { AppEvent::RequestRedraw => { - self.schedule_redraw(); + if DEBOUNCE_REDRAW_REQUESTS { + self.schedule_redraw(); + } else { + self.redraw_immediately(terminal)?; + } } AppEvent::Redraw => { - self.draw_next_frame(terminal)?; + self.redraw_immediately(terminal)?; } AppEvent::KeyEvent(key_event) => { match key_event { @@ -386,6 +397,10 @@ impl App<'_> { } } + fn redraw_immediately(&mut self, terminal: &mut tui::Tui) -> Result<()> { + self.draw_next_frame(terminal) + } + fn dispatch_paste_event(&mut self, pasted: String) { match &mut self.app_state { AppState::Chat { widget } => widget.handle_paste(pasted),