diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 979c395657..07d749e7d1 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -961,6 +961,7 @@ dependencies = [ "codex-responses-api-proxy", "codex-rmcp-client", "codex-tui", + "crossterm", "ctor 0.5.0", "owo-colors", "predicates", @@ -969,6 +970,7 @@ dependencies = [ "serde_json", "supports-color", "tempfile", + "textwrap 0.16.2", "tokio", "tracing", ] diff --git a/codex-rs/cli/Cargo.toml b/codex-rs/cli/Cargo.toml index 5b04bee20b..ab0d9d3d0f 100644 --- a/codex-rs/cli/Cargo.toml +++ b/codex-rs/cli/Cargo.toml @@ -35,6 +35,7 @@ codex-tui = { workspace = true } codex-rmcp-client = { workspace = true } codex-cloud-tasks = { path = "../cloud-tasks" } ctor = { workspace = true } +crossterm = { workspace = true } owo-colors = { workspace = true } serde_json = { workspace = true } supports-color = { workspace = true } @@ -49,6 +50,7 @@ codex-infty = { path = "../codex-infty" } chrono = { workspace = true } serde = { workspace = true, features = ["derive"] } tracing = "0.1.41" +textwrap = { workspace = true } [dev-dependencies] assert_matches = { workspace = true } diff --git a/codex-rs/cli/src/infty.rs b/codex-rs/cli/src/infty.rs index a7a2e6bbd7..525b1da2da 100644 --- a/codex-rs/cli/src/infty.rs +++ b/codex-rs/cli/src/infty.rs @@ -4,6 +4,7 @@ use std::path::Path; use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; +use std::time::Instant; use anyhow::Context; use anyhow::Result; @@ -14,6 +15,7 @@ use chrono::Utc; use clap::Parser; use clap::Subcommand; use codex_common::CliConfigOverrides; +use codex_common::elapsed::format_duration; use codex_core::CodexAuth; use codex_core::auth::read_codex_api_key_from_env; use codex_core::auth::read_openai_api_key_from_env; @@ -32,7 +34,12 @@ use codex_infty::RunParams; use codex_infty::RunStore; use codex_infty::VerifierDecision; use codex_infty::VerifierVerdict; +use crossterm::terminal; +use owo_colors::OwoColorize; use serde::Serialize; +use supports_color::Stream; +use textwrap::Options as WrapOptions; +use textwrap::wrap; const DEFAULT_TIMEOUT_SECS: u64 = 60; @@ -121,8 +128,9 @@ struct RunSummary { roles: Vec, } -#[derive(Default)] -struct TerminalProgressReporter; +struct TerminalProgressReporter { + color_enabled: bool, +} impl TerminalProgressReporter { fn decision_label(decision: VerifierDecision) -> &'static str { @@ -131,15 +139,39 @@ impl TerminalProgressReporter { VerifierDecision::Fail => "fail", } } + + fn with_color(color_enabled: bool) -> Self { + Self { color_enabled } + } + + fn format_decision(&self, decision: VerifierDecision) -> String { + let label = Self::decision_label(decision); + if !self.color_enabled { + return label.to_string(); + } + match decision { + VerifierDecision::Pass => format!("{}", label.green().bold()), + VerifierDecision::Fail => format!("{}", label.red().bold()), + } + } } impl ProgressReporter for TerminalProgressReporter { fn objective_posted(&self, objective: &str) { - println!("→ objective sent to solver: {objective}"); + let line = format!("→ objective sent to solver: {objective}"); + if self.color_enabled { + println!("{}", line.cyan()); + } else { + println!("{line}"); + } } fn waiting_for_solver(&self) { - println!("Waiting for solver response..."); + if self.color_enabled { + println!("{}", "Waiting for solver response...".dimmed()); + } else { + println!("Waiting for solver response..."); + } } fn solver_event(&self, event: &EventMsg) { @@ -154,83 +186,158 @@ impl ProgressReporter for TerminalProgressReporter { } fn solver_agent_message(&self, agent_msg: &AgentMessageEvent) { - println!("[solver] {}", agent_msg.message); + let prefix = if self.color_enabled { + format!("{}", "[solver]".magenta().bold()) + } else { + "[solver]".to_string() + }; + println!("{prefix} {}", agent_msg.message); } fn direction_request(&self, prompt: &str) { - println!("→ solver requested direction: {prompt}"); + let line = format!("→ solver requested direction: {prompt}"); + if self.color_enabled { + println!("{}", line.yellow().bold()); + } else { + println!("{line}"); + } } fn director_response(&self, directive: &DirectiveResponse) { match directive.rationale.as_deref() { Some(rationale) if !rationale.is_empty() => { - println!( - "[director] directive: {} (rationale: {})", - directive.directive, rationale + let line = format!( + "[director] directive: {} (rationale: {rationale})", + directive.directive ); + if self.color_enabled { + println!("{}", line.blue()); + } else { + println!("{line}"); + } } _ => { - println!("[director] directive: {}", directive.directive); + let line = format!("[director] directive: {}", directive.directive); + if self.color_enabled { + println!("{}", line.blue()); + } else { + println!("{line}"); + } } } } fn verification_request(&self, claim_path: &str, notes: Option<&str>) { - println!("→ solver requested verification for {claim_path}"); + let line = format!("→ solver requested verification for {claim_path}"); + if self.color_enabled { + println!("{}", line.yellow().bold()); + } else { + println!("{line}"); + } if let Some(notes) = notes { if !notes.is_empty() { - println!(" notes: {notes}"); + let notes_line = format!(" notes: {notes}"); + if self.color_enabled { + println!("{}", notes_line.dimmed()); + } else { + println!("{notes_line}"); + } } } } fn verifier_verdict(&self, role: &str, verdict: &VerifierVerdict) { - println!( - "[{role}] verdict: {}", - Self::decision_label(verdict.verdict) - ); + let decision = self.format_decision(verdict.verdict); + let prefix = if self.color_enabled { + format!("{}", format!("[{role}]").magenta().bold()) + } else { + format!("[{role}]") + }; + println!("{prefix} verdict: {decision}"); if !verdict.reasons.is_empty() { - println!(" reasons: {}", verdict.reasons.join("; ")); + let reasons = verdict.reasons.join("; "); + let line = format!(" reasons: {reasons}"); + if self.color_enabled { + println!("{}", line.dimmed()); + } else { + println!("{line}"); + } } if !verdict.suggestions.is_empty() { - println!(" suggestions: {}", verdict.suggestions.join("; ")); + let suggestions = verdict.suggestions.join("; "); + let line = format!(" suggestions: {suggestions}"); + if self.color_enabled { + println!("{}", line.dimmed()); + } else { + println!("{line}"); + } } } fn verification_summary(&self, summary: &AggregatedVerifierVerdict) { - println!( - "Verification summary: {}", - Self::decision_label(summary.overall) - ); + println!(); + let decision = self.format_decision(summary.overall); + let heading = if self.color_enabled { + format!("{}", "Verification summary".bold()) + } else { + "Verification summary".to_string() + }; + println!("{heading}: {decision}"); for report in &summary.verdicts { - println!( - " {} → {}", - report.role, - Self::decision_label(report.verdict) - ); + let report_decision = self.format_decision(report.verdict); + let line = format!(" {} → {report_decision}", report.role); + println!("{line}"); if !report.reasons.is_empty() { - println!(" reasons: {}", report.reasons.join("; ")); + let reasons = report.reasons.join("; "); + let reason_line = format!(" reasons: {reasons}"); + if self.color_enabled { + println!("{}", reason_line.dimmed()); + } else { + println!("{reason_line}"); + } } if !report.suggestions.is_empty() { - println!(" suggestions: {}", report.suggestions.join("; ")); + let suggestions = report.suggestions.join("; "); + let suggestion_line = format!(" suggestions: {suggestions}"); + if self.color_enabled { + println!("{}", suggestion_line.dimmed()); + } else { + println!("{suggestion_line}"); + } } } } fn final_delivery(&self, deliverable_path: &Path, summary: Option<&str>) { - println!( + println!(); + let line = format!( "✓ solver reported final delivery at {}", deliverable_path.display() ); + if self.color_enabled { + println!("{}", line.green().bold()); + } else { + println!("{line}"); + } if let Some(summary) = summary { if !summary.is_empty() { - println!(" summary: {summary}"); + let hint = " (final summary will be shown below)"; + if self.color_enabled { + println!("{}", hint.dimmed()); + } else { + println!("{hint}"); + } } } } fn run_interrupted(&self) { - println!("Run interrupted by Ctrl+C. Shutting down sessions…"); + let line = "Run interrupted by Ctrl+C. Shutting down sessions…"; + if self.color_enabled { + println!("{}", line.red().bold()); + } else { + println!("{line}"); + } } } @@ -265,6 +372,7 @@ async fn run_create( let config = load_config(config_overrides).await?; let auth = load_auth(&config)?; let runs_root = resolve_runs_root(runs_root_override)?; + let color_enabled = supports_color::on(Stream::Stdout).is_some(); let mut run_id = if let Some(id) = args.run_id { id @@ -279,8 +387,9 @@ async fn run_create( bail!("run {run_id} already exists at {}", run_path.display()); } - let orchestrator = InftyOrchestrator::with_runs_root(auth, runs_root.clone()) - .with_progress(Arc::new(TerminalProgressReporter::default())); + let orchestrator = InftyOrchestrator::with_runs_root(auth, runs_root.clone()).with_progress( + Arc::new(TerminalProgressReporter::with_color(color_enabled)), + ); let run_params = RunParams { run_id: run_id.clone(), run_root: Some(run_path.clone()), @@ -296,22 +405,46 @@ async fn run_create( options.director_timeout = timeout; options.verifier_timeout = timeout; - println!("Starting run {run_id} at {}", run_path.display()); - println!( - "Objective: {}", - options.objective.as_deref().unwrap_or_default() - ); + let start = Instant::now(); + let start_header = format!("Starting run {run_id}"); + if color_enabled { + println!("{}", start_header.blue().bold()); + } else { + println!("{start_header}"); + } + let location_line = format!(" run directory: {}", run_path.display()); + if color_enabled { + println!("{}", location_line.dimmed()); + } else { + println!("{location_line}"); + } + if let Some(objective_text) = options.objective.as_deref() { + if !objective_text.trim().is_empty() { + let objective_line = format!(" objective: {objective_text}"); + if color_enabled { + println!("{}", objective_line.dimmed()); + } else { + println!("{objective_line}"); + } + } + } + println!(); + + let objective_snapshot = options.objective.clone(); let outcome = orchestrator .execute_new_run(run_params, options) .await .with_context(|| format!("failed to execute run {run_id}"))?; - println!( - "Run {run_id} completed. Deliverable: {}", - outcome.deliverable_path.display() + let duration = start.elapsed(); + print_run_summary_box( + color_enabled, + &run_id, + &run_path, + &outcome.deliverable_path, + outcome.summary.as_deref(), + objective_snapshot.as_deref(), + duration, ); - if let Some(summary) = outcome.summary { - println!("Summary: {summary}"); - } } else { let sessions = orchestrator .spawn_run(run_params) @@ -547,6 +680,131 @@ fn collect_run_summaries(root: &Path) -> Result> { Ok(summaries) } +impl Default for TerminalProgressReporter { + fn default() -> Self { + Self::with_color(supports_color::on(Stream::Stdout).is_some()) + } +} + +fn print_run_summary_box( + color_enabled: bool, + run_id: &str, + run_path: &Path, + deliverable_path: &Path, + summary: Option<&str>, + objective: Option<&str>, + duration: Duration, +) { + let mut items = Vec::new(); + items.push(("Run ID".to_string(), run_id.to_string())); + items.push(("Run Directory".to_string(), run_path.display().to_string())); + if let Some(objective) = objective { + if !objective.trim().is_empty() { + items.push(("Objective".to_string(), objective.trim().to_string())); + } + } + items.push(( + "Deliverable".to_string(), + deliverable_path.display().to_string(), + )); + items.push(("Total Time".to_string(), format_duration(duration))); + if let Some(summary) = summary { + let trimmed = summary.trim(); + if !trimmed.is_empty() { + items.push(("Summary".to_string(), trimmed.to_string())); + } + } + + let label_width = items + .iter() + .map(|(label, _)| label.len()) + .max() + .unwrap_or(0) + .max(12); + const DEFAULT_MAX_WIDTH: usize = 84; + const MIN_VALUE_WIDTH: usize = 20; + let label_padding = label_width + 7; + let min_total_width = label_padding + MIN_VALUE_WIDTH; + let available_width = terminal::size() + .ok() + .map(|(cols, _)| usize::from(cols).saturating_sub(2)) + .unwrap_or(DEFAULT_MAX_WIDTH); + let max_width = available_width.min(DEFAULT_MAX_WIDTH); + let lower_bound = min_total_width.min(available_width); + let mut total_width = max_width.max(lower_bound).max(label_padding + 1); + let mut value_width = total_width.saturating_sub(label_padding); + if value_width < MIN_VALUE_WIDTH { + value_width = MIN_VALUE_WIDTH; + total_width = label_padding + value_width; + } + let inner_width = total_width.saturating_sub(4); + let top_border = format!("+{}+", "=".repeat(total_width.saturating_sub(2))); + let separator = format!("+{}+", "-".repeat(total_width.saturating_sub(2))); + let title_line = format!( + "| {:^inner_width$} |", + "Run Summary", + inner_width = inner_width + ); + + println!(); + println!("{top_border}"); + if color_enabled { + println!("{}", title_line.bold()); + } else { + println!("{title_line}"); + } + println!("{separator}"); + + for (index, (label, value)) in items.iter().enumerate() { + let mut rows = Vec::new(); + for (idx, paragraph) in value.split('\n').enumerate() { + let trimmed = paragraph.trim(); + if trimmed.is_empty() { + if idx > 0 { + rows.push(String::new()); + } + continue; + } + let wrapped = wrap(trimmed, WrapOptions::new(value_width).break_words(false)); + if wrapped.is_empty() { + rows.push(String::new()); + } else { + rows.extend(wrapped.into_iter().map(|line| line.into_owned())); + } + } + if rows.is_empty() { + rows.push(String::new()); + } + + for (line_idx, line) in rows.iter().enumerate() { + let label_cell = if line_idx == 0 { label.as_str() } else { "" }; + let row_line = format!( + "| {label_cell: println!("{}", row_line.green()), + "Summary" => println!("{}", row_line.bold()), + _ => println!("{row_line}"), + } + } else { + println!("{row_line}"); + } + } + + if index + 1 < items.len() { + println!("{separator}"); + } + } + + println!("{top_border}"); + println!(); +} + #[cfg(test)] mod tests { use super::*; diff --git a/codex-rs/core/src/conversation_manager.rs b/codex-rs/core/src/conversation_manager.rs index 513935afd7..a83ab6b03c 100644 --- a/codex-rs/core/src/conversation_manager.rs +++ b/codex-rs/core/src/conversation_manager.rs @@ -113,8 +113,8 @@ impl ConversationManager { let new_conversation = self.finalize_spawn(codex, conversation_id).await?; - if let Some((defaults, params)) = cross_session { - if let Err(err) = self + if let Some((defaults, params)) = cross_session + && let Err(err) = self .register_cross_session( conversation_id, defaults, @@ -122,16 +122,12 @@ impl ConversationManager { Arc::clone(&new_conversation.conversation), ) .await - { - self.abort_conversation( - conversation_id, - Arc::clone(&new_conversation.conversation), - ) + { + self.abort_conversation(conversation_id, Arc::clone(&new_conversation.conversation)) .await; - return Err(CodexErr::Fatal(format!( - "failed to register cross-session for conversation {conversation_id}: {err}" - ))); - } + return Err(CodexErr::Fatal(format!( + "failed to register cross-session for conversation {conversation_id}: {err}" + ))); } Ok(new_conversation)