From 0de154194dc2b57bd82a351c8ef82c95e3329bb3 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Mon, 15 Sep 2025 10:02:53 -0700 Subject: [PATCH 01/38] fix: change MIN_ANIMATION_HEIGHT so show_animation is calculated correctly (#3656) Reported height was `20` instead of `21`, so `area.height >= MIN_ANIMATION_HEIGHT` was `false` and therefore `show_animation` was `false`, so the animation never displayed. --- codex-rs/tui/src/onboarding/welcome.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/codex-rs/tui/src/onboarding/welcome.rs b/codex-rs/tui/src/onboarding/welcome.rs index 1e99fbf562..578e2b429f 100644 --- a/codex-rs/tui/src/onboarding/welcome.rs +++ b/codex-rs/tui/src/onboarding/welcome.rs @@ -17,7 +17,7 @@ use std::time::Duration; use std::time::Instant; const FRAME_TICK: Duration = FRAME_TICK_DEFAULT; -const MIN_ANIMATION_HEIGHT: u16 = 21; +const MIN_ANIMATION_HEIGHT: u16 = 20; const MIN_ANIMATION_WIDTH: u16 = 60; pub(crate) struct WelcomeWidget { From 0560079c414d191632bdfdb05568b92e2e974214 Mon Sep 17 00:00:00 2001 From: Jeremy Rose <172423086+nornagon-openai@users.noreply.github.com> Date: Mon, 15 Sep 2025 10:22:02 -0700 Subject: [PATCH 02/38] notifications on approvals and turn end (#3329) uses OSC 9 to notify when a turn ends or approval is required. won't work in vs code or terminal.app but iterm2/kitty/wezterm supports it :) --- codex-rs/core/src/config.rs | 57 ++++++++++++++++++++++ codex-rs/core/src/config_types.rs | 19 +++++++- codex-rs/tui/src/app.rs | 1 + codex-rs/tui/src/chatwidget.rs | 73 ++++++++++++++++++++++++++++ codex-rs/tui/src/chatwidget/tests.rs | 1 + codex-rs/tui/src/diff_render.rs | 2 +- codex-rs/tui/src/tui.rs | 47 ++++++++++++++++++ docs/config.md | 20 +++++++- 8 files changed, 216 insertions(+), 4 deletions(-) diff --git a/codex-rs/core/src/config.rs b/codex-rs/core/src/config.rs index f96933069d..0479e857c2 100644 --- a/codex-rs/core/src/config.rs +++ b/codex-rs/core/src/config.rs @@ -1,6 +1,7 @@ use crate::config_profile::ConfigProfile; use crate::config_types::History; use crate::config_types::McpServerConfig; +use crate::config_types::Notifications; use crate::config_types::ReasoningSummaryFormat; use crate::config_types::SandboxWorkspaceWrite; use crate::config_types::ShellEnvironmentPolicy; @@ -117,6 +118,10 @@ pub struct Config { /// If unset the feature is disabled. pub notify: Option>, + /// TUI notifications preference. When set, the TUI will send OSC 9 notifications on approvals + /// and turn completions when not focused. + pub tui_notifications: Notifications, + /// The directory that should be treated as the current working directory /// for the session. All relative paths inside the business-logic layer are /// resolved against this path. @@ -1043,6 +1048,11 @@ impl Config { include_view_image_tool, active_profile: active_profile_name, disable_paste_burst: cfg.disable_paste_burst.unwrap_or(false), + tui_notifications: cfg + .tui + .as_ref() + .map(|t| t.notifications.clone()) + .unwrap_or_default(), }; Ok(config) } @@ -1606,6 +1616,7 @@ model_verbosity = "high" include_view_image_tool: true, active_profile: Some("o3".to_string()), disable_paste_burst: false, + tui_notifications: Default::default(), }, o3_profile_config ); @@ -1663,6 +1674,7 @@ model_verbosity = "high" include_view_image_tool: true, active_profile: Some("gpt3".to_string()), disable_paste_burst: false, + tui_notifications: Default::default(), }; assert_eq!(expected_gpt3_profile_config, gpt3_profile_config); @@ -1735,6 +1747,7 @@ model_verbosity = "high" include_view_image_tool: true, active_profile: Some("zdr".to_string()), disable_paste_burst: false, + tui_notifications: Default::default(), }; assert_eq!(expected_zdr_profile_config, zdr_profile_config); @@ -1793,6 +1806,7 @@ model_verbosity = "high" include_view_image_tool: true, active_profile: Some("gpt5".to_string()), disable_paste_burst: false, + tui_notifications: Default::default(), }; assert_eq!(expected_gpt5_profile_config, gpt5_profile_config); @@ -1896,3 +1910,46 @@ trust_level = "trusted" Ok(()) } } + +#[cfg(test)] +mod notifications_tests { + use crate::config_types::Notifications; + use serde::Deserialize; + + #[derive(Deserialize, Debug, PartialEq)] + struct TuiTomlTest { + notifications: Notifications, + } + + #[derive(Deserialize, Debug, PartialEq)] + struct RootTomlTest { + tui: TuiTomlTest, + } + + #[test] + fn test_tui_notifications_true() { + let toml = r#" + [tui] + notifications = true + "#; + let parsed: RootTomlTest = toml::from_str(toml).expect("deserialize notifications=true"); + assert!(matches!( + parsed.tui.notifications, + Notifications::Enabled(true) + )); + } + + #[test] + fn test_tui_notifications_custom_array() { + let toml = r#" + [tui] + notifications = ["foo"] + "#; + let parsed: RootTomlTest = + toml::from_str(toml).expect("deserialize notifications=[\"foo\"]"); + assert!(matches!( + parsed.tui.notifications, + Notifications::Custom(ref v) if v == &vec!["foo".to_string()] + )); + } +} diff --git a/codex-rs/core/src/config_types.rs b/codex-rs/core/src/config_types.rs index 0722dfc0ea..ec8e8e67c6 100644 --- a/codex-rs/core/src/config_types.rs +++ b/codex-rs/core/src/config_types.rs @@ -76,9 +76,26 @@ pub enum HistoryPersistence { None, } +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(untagged)] +pub enum Notifications { + Enabled(bool), + Custom(Vec), +} + +impl Default for Notifications { + fn default() -> Self { + Self::Enabled(false) + } +} + /// Collection of settings that are specific to the TUI. #[derive(Deserialize, Debug, Clone, PartialEq, Default)] -pub struct Tui {} +pub struct Tui { + /// Enable desktop notifications from the TUI when the terminal is unfocused. + /// Defaults to `false`. + pub notifications: Notifications, +} #[derive(Deserialize, Debug, Clone, PartialEq, Default)] pub struct SandboxWorkspaceWrite { diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index bfa9c0caf2..205986c916 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -177,6 +177,7 @@ impl App { self.chat_widget.handle_paste(pasted); } TuiEvent::Draw => { + self.chat_widget.maybe_post_pending_notification(tui); if self .chat_widget .handle_paste_burst_tick(tui.frame_requester()) diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 367a63cf11..c03e7d3bc5 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -4,6 +4,7 @@ use std::path::PathBuf; use std::sync::Arc; use codex_core::config::Config; +use codex_core::config_types::Notifications; use codex_core::protocol::AgentMessageDeltaEvent; use codex_core::protocol::AgentMessageEvent; use codex_core::protocol::AgentReasoningDeltaEvent; @@ -59,6 +60,7 @@ use crate::bottom_pane::InputResult; use crate::bottom_pane::SelectionAction; use crate::bottom_pane::SelectionItem; use crate::clipboard_paste::paste_image_to_temp_png; +use crate::diff_render::display_path_for; use crate::get_git_diff::get_git_diff; use crate::history_cell; use crate::history_cell::CommandOutput; @@ -66,6 +68,7 @@ use crate::history_cell::ExecCell; use crate::history_cell::HistoryCell; use crate::history_cell::PatchEventType; use crate::slash_command::SlashCommand; +use crate::text_formatting::truncate_text; use crate::tui::FrameRequester; // streaming internals are provided by crate::streaming and crate::markdown_stream use crate::user_approval_widget::ApprovalRequest; @@ -136,6 +139,8 @@ pub(crate) struct ChatWidget { suppress_session_configured_redraw: bool, // User messages queued while a turn is in progress queued_user_messages: VecDeque, + // Pending notification to show when unfocused on next Draw + pending_notification: Option, } struct UserMessage { @@ -265,6 +270,8 @@ impl ChatWidget { // If there is a queued user message, send exactly one now to begin the next turn. self.maybe_send_next_queued_input(); + // Emit a notification when the turn completes (suppressed if focused). + self.notify(Notification::AgentTurnComplete); } pub(crate) fn set_token_info(&mut self, info: Option) { @@ -531,6 +538,9 @@ impl ChatWidget { self.flush_answer_stream_with_separator(); // Emit the proposed command into history (like proposed patches) self.add_to_history(history_cell::new_proposed_command(&ev.command)); + let command = shlex::try_join(ev.command.iter().map(|s| s.as_str())) + .unwrap_or_else(|_| ev.command.join(" ")); + self.notify(Notification::ExecApprovalRequested { command }); let request = ApprovalRequest::Exec { id, @@ -560,6 +570,10 @@ impl ChatWidget { }; self.bottom_pane.push_approval_request(request); self.request_redraw(); + self.notify(Notification::EditApprovalRequested { + cwd: self.config.cwd.clone(), + changes: ev.changes.keys().cloned().collect(), + }); } pub(crate) fn handle_exec_begin_now(&mut self, ev: ExecCommandBeginEvent) { @@ -686,6 +700,7 @@ impl ChatWidget { queued_user_messages: VecDeque::new(), show_welcome_banner: true, suppress_session_configured_redraw: false, + pending_notification: None, } } @@ -741,6 +756,7 @@ impl ChatWidget { queued_user_messages: VecDeque::new(), show_welcome_banner: true, suppress_session_configured_redraw: true, + pending_notification: None, } } @@ -1137,6 +1153,20 @@ impl ChatWidget { self.frame_requester.schedule_frame(); } + fn notify(&mut self, notification: Notification) { + if !notification.allowed_for(&self.config.tui_notifications) { + return; + } + self.pending_notification = Some(notification); + self.request_redraw(); + } + + pub(crate) fn maybe_post_pending_notification(&mut self, tui: &mut crate::tui::Tui) { + if let Some(notif) = self.pending_notification.take() { + tui.notify(notif.display()); + } + } + /// Mark the active exec cell as failed (✗) and flush it into history. fn finalize_active_exec_cell_as_failed(&mut self) { if let Some(cell) = self.active_exec_cell.take() { @@ -1449,6 +1479,49 @@ impl WidgetRef for &ChatWidget { } } +enum Notification { + AgentTurnComplete, + ExecApprovalRequested { command: String }, + EditApprovalRequested { cwd: PathBuf, changes: Vec }, +} + +impl Notification { + fn display(&self) -> String { + match self { + Notification::AgentTurnComplete => "Agent turn complete".to_string(), + Notification::ExecApprovalRequested { command } => { + format!("Approval requested: {}", truncate_text(command, 30)) + } + Notification::EditApprovalRequested { cwd, changes } => { + format!( + "Codex wants to edit {}", + if changes.len() == 1 { + #[allow(clippy::unwrap_used)] + display_path_for(changes.first().unwrap(), cwd) + } else { + format!("{} files", changes.len()) + } + ) + } + } + } + + fn type_name(&self) -> &str { + match self { + Notification::AgentTurnComplete => "agent-turn-complete", + Notification::ExecApprovalRequested { .. } + | Notification::EditApprovalRequested { .. } => "approval-requested", + } + } + + fn allowed_for(&self, settings: &Notifications) -> bool { + match settings { + Notifications::Enabled(enabled) => *enabled, + Notifications::Custom(allowed) => allowed.iter().any(|a| a == self.type_name()), + } + } +} + const EXAMPLE_PROMPTS: [&str; 6] = [ "Explain this codebase", "Summarize recent commits", diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 73845bfe09..fb4ea5e888 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -251,6 +251,7 @@ fn make_chatwidget_manual() -> ( show_welcome_banner: true, queued_user_messages: VecDeque::new(), suppress_session_configured_redraw: false, + pending_notification: None, }; (widget, rx, op_rx) } diff --git a/codex-rs/tui/src/diff_render.rs b/codex-rs/tui/src/diff_render.rs index 20d26106b2..2787f76607 100644 --- a/codex-rs/tui/src/diff_render.rs +++ b/codex-rs/tui/src/diff_render.rs @@ -265,7 +265,7 @@ fn render_changes_block( out } -fn display_path_for(path: &Path, cwd: &Path) -> String { +pub(crate) fn display_path_for(path: &Path, cwd: &Path) -> String { let path_in_same_repo = match (get_git_repo_root(cwd), get_git_repo_root(path)) { (Some(cwd_repo), Some(path_repo)) => cwd_repo == path_repo, _ => false, diff --git a/codex-rs/tui/src/tui.rs b/codex-rs/tui/src/tui.rs index 26c188668d..6d7b9e8101 100644 --- a/codex-rs/tui/src/tui.rs +++ b/codex-rs/tui/src/tui.rs @@ -17,7 +17,9 @@ use crossterm::SynchronizedUpdate; use crossterm::cursor; use crossterm::cursor::MoveTo; use crossterm::event::DisableBracketedPaste; +use crossterm::event::DisableFocusChange; use crossterm::event::EnableBracketedPaste; +use crossterm::event::EnableFocusChange; use crossterm::event::Event; use crossterm::event::KeyEvent; use crossterm::event::KeyboardEnhancementFlags; @@ -60,6 +62,8 @@ pub fn set_modes() -> Result<()> { | KeyboardEnhancementFlags::REPORT_ALTERNATE_KEYS ) ); + + let _ = execute!(stdout(), EnableFocusChange); Ok(()) } @@ -111,6 +115,7 @@ pub fn restore() -> Result<()> { // Pop may fail on platforms that didn't support the push; ignore errors. let _ = execute!(stdout(), PopKeyboardEnhancementFlags); execute!(stdout(), DisableBracketedPaste)?; + let _ = execute!(stdout(), DisableFocusChange); disable_raw_mode()?; let _ = execute!(stdout(), crossterm::cursor::Show); Ok(()) @@ -163,6 +168,8 @@ pub struct Tui { suspend_cursor_y: Arc, // Bottom line of inline viewport // True when overlay alt-screen UI is active alt_screen_active: Arc, + // True when terminal/tab is focused; updated internally from crossterm events + terminal_focused: Arc, } #[cfg(unix)] @@ -214,6 +221,16 @@ impl FrameRequester { } impl Tui { + /// Emit a desktop notification now if the terminal is unfocused. + /// Returns true if a notification was posted. + pub fn notify(&mut self, message: impl AsRef) -> bool { + if !self.terminal_focused.load(Ordering::Relaxed) { + let _ = execute!(stdout(), PostNotification(message.as_ref().to_string())); + true + } else { + false + } + } pub fn new(terminal: Terminal) -> Self { let (frame_schedule_tx, frame_schedule_rx) = tokio::sync::mpsc::unbounded_channel(); let (draw_tx, _) = tokio::sync::broadcast::channel(1); @@ -270,6 +287,7 @@ impl Tui { #[cfg(unix)] suspend_cursor_y: Arc::new(AtomicU16::new(0)), alt_screen_active: Arc::new(AtomicBool::new(false)), + terminal_focused: Arc::new(AtomicBool::new(true)), } } @@ -289,6 +307,7 @@ impl Tui { let alt_screen_active = self.alt_screen_active.clone(); #[cfg(unix)] let suspend_cursor_y = self.suspend_cursor_y.clone(); + let terminal_focused = self.terminal_focused.clone(); let event_stream = async_stream::stream! { loop { select! { @@ -332,6 +351,12 @@ impl Tui { Event::Paste(pasted) => { yield TuiEvent::Paste(pasted); } + Event::FocusGained => { + terminal_focused.store(true, Ordering::Relaxed); + } + Event::FocusLost => { + terminal_focused.store(false, Ordering::Relaxed); + } _ => {} } } @@ -535,3 +560,25 @@ impl Tui { })? } } + +/// Command that emits an OSC 9 desktop notification with a message. +#[derive(Debug, Clone)] +pub struct PostNotification(pub String); + +impl Command for PostNotification { + fn write_ansi(&self, f: &mut impl std::fmt::Write) -> std::fmt::Result { + write!(f, "\x1b]9;{}\x07", self.0) + } + + #[cfg(windows)] + fn execute_winapi(&self) -> std::io::Result<()> { + Err(std::io::Error::other( + "tried to execute PostNotification using WinAPI; use ANSI instead", + )) + } + + #[cfg(windows)] + fn is_ansi_code_supported(&self) -> bool { + true + } +} diff --git a/docs/config.md b/docs/config.md index 6f10cb3fbd..01b3efe0a1 100644 --- a/docs/config.md +++ b/docs/config.md @@ -504,6 +504,9 @@ To have Codex use this script for notifications, you would configure it via `not notify = ["python3", "/Users/mbolin/.codex/notify.py"] ``` +> [!NOTE] +> Use `notify` for automation and integrations: Codex invokes your external program with a single JSON argument for each event, independent of the TUI. If you only want lightweight desktop notifications while using the TUI, prefer `tui.notifications`, which uses terminal escape codes and requires no external program. You can enable both; `tui.notifications` covers in‑TUI alerts (e.g., approval prompts), while `notify` is best for system‑level hooks or custom notifiers. Currently, `notify` emits only `agent-turn-complete`, whereas `tui.notifications` supports `agent-turn-complete` and `approval-requested` with optional filtering. + ## history By default, Codex CLI records messages sent to the model in `$CODEX_HOME/history.jsonl`. Note that on UNIX, the file permissions are set to `o600`, so it should only be readable and writable by the owner. @@ -576,9 +579,21 @@ Options that are specific to the TUI. ```toml [tui] -# More to come here +# Send desktop notifications when approvals are required or a turn completes. +# Defaults to false. +notifications = true + +# You can optionally filter to specific notification types. +# Available types are "agent-turn-complete" and "approval-requested". +notifications = [ "agent-turn-complete", "approval-requested" ] ``` +> [!NOTE] +> Codex emits desktop notifications using terminal escape codes. Not all terminals support these (notably, macOS Terminal.app and VS Code's terminal do not support custom notifications. iTerm2, Ghostty and WezTerm do support these notifications). + +> [!NOTE] +> `tui.notifications` is built‑in and limited to the TUI session. For programmatic or cross‑environment notifications—or to integrate with OS‑specific notifiers—use the top‑level `notify` option to run an external program that receives event JSON. The two settings are independent and can be used together. + ## Config reference | Key | Type / Values | Notes | @@ -616,7 +631,8 @@ Options that are specific to the TUI. | `history.persistence` | `save-all` \| `none` | History file persistence (default: `save-all`). | | `history.max_bytes` | number | Currently ignored (not enforced). | | `file_opener` | `vscode` \| `vscode-insiders` \| `windsurf` \| `cursor` \| `none` | URI scheme for clickable citations (default: `vscode`). | -| `tui` | table | TUI‑specific options (reserved). | +| `tui` | table | TUI‑specific options. | +| `tui.notifications` | boolean \| array | Enable desktop notifications in the tui (default: false). | | `hide_agent_reasoning` | boolean | Hide model reasoning events. | | `show_raw_agent_reasoning` | boolean | Show raw reasoning (when available). | | `model_reasoning_effort` | `minimal` \| `low` \| `medium` \| `high` | Responses API reasoning effort. | From 2df7f7efe5ace16214ed6c9360f459871e3b5e75 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Mon, 15 Sep 2025 10:52:49 -0700 Subject: [PATCH 03/38] chore: restore prerelease logic in rust-release.yml (#3659) Revert #3645. --- .github/workflows/rust-release.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index 2b51c099eb..07af62a17c 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -211,8 +211,7 @@ jobs: files: dist/** # Mark as prerelease only when the version has a suffix after x.y.z # (e.g. -alpha, -beta). Otherwise publish a normal release. - # prerelease: ${{ contains(steps.release_name.outputs.name, '-') }} - prerelease: true + prerelease: ${{ contains(steps.release_name.outputs.name, '-') }} - uses: facebook/dotslash-publish-release@v2 env: From 39ed8a7d2672b606b2d3f597f5197207d95905ce Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 15 Sep 2025 11:58:57 -0700 Subject: [PATCH 04/38] chore(deps): bump serde_json from 1.0.143 to 1.0.145 in /codex-rs (#3617) Bumps [serde_json](https://github.com/serde-rs/json) from 1.0.143 to 1.0.145.
Release notes

Sourced from serde_json's releases.

v1.0.145

  • Raise serde version requirement to >=1.0.220

v1.0.144

  • Switch serde dependency to serde_core (#1285)
Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=serde_json&package-manager=cargo&previous-version=1.0.143&new-version=1.0.145)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- codex-rs/Cargo.lock | 23 +++++++++++++++++------ codex-rs/execpolicy/Cargo.toml | 2 +- codex-rs/file-search/Cargo.toml | 2 +- 3 files changed, 19 insertions(+), 8 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 39933bb7f1..a6f32b35c5 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -3926,18 +3926,28 @@ dependencies = [ [[package]] name = "serde" -version = "1.0.219" +version = "1.0.223" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +checksum = "a505d71960adde88e293da5cb5eda57093379f64e61cf77bf0e6a63af07a7bac" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.223" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20f57cbd357666aa7b3ac84a90b4ea328f1d4ddb6772b430caa5d9e1309bb9e9" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.219" +version = "1.0.223" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +checksum = "3d428d07faf17e306e699ec1e91996e5a165ba5d6bce5b5155173e91a8a01a56" dependencies = [ "proc-macro2", "quote", @@ -3957,15 +3967,16 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.143" +version = "1.0.145" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d401abef1d108fbd9cbaebc3e46611f4b1021f714a0597a71f41ee463f5f4a5a" +checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" dependencies = [ "indexmap 2.10.0", "itoa", "memchr", "ryu", "serde", + "serde_core", ] [[package]] diff --git a/codex-rs/execpolicy/Cargo.toml b/codex-rs/execpolicy/Cargo.toml index e0e3d75e42..337e95f09b 100644 --- a/codex-rs/execpolicy/Cargo.toml +++ b/codex-rs/execpolicy/Cargo.toml @@ -26,7 +26,7 @@ multimap = "0.10.0" path-absolutize = "3.1.1" regex-lite = "0.1" serde = { version = "1.0.194", features = ["derive"] } -serde_json = "1.0.143" +serde_json = "1.0.145" serde_with = { version = "3", features = ["macros"] } [dev-dependencies] diff --git a/codex-rs/file-search/Cargo.toml b/codex-rs/file-search/Cargo.toml index f02d8402aa..11fd57a465 100644 --- a/codex-rs/file-search/Cargo.toml +++ b/codex-rs/file-search/Cargo.toml @@ -17,5 +17,5 @@ clap = { version = "4", features = ["derive"] } ignore = "0.4.23" nucleo-matcher = "0.3.1" serde = { version = "1", features = ["derive"] } -serde_json = "1.0.143" +serde_json = "1.0.145" tokio = { version = "1", features = ["full"] } From ca8bd09d569066700eb6e176bf0eee7a743cc832 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Mon, 15 Sep 2025 12:22:29 -0700 Subject: [PATCH 05/38] chore: simplify dep so serde=1 in Cargo.toml (#3664) With this change, dependabot should just have to update `Cargo.lock` for `serde`, e.g.: - https://github.com/openai/codex/pull/3617 - https://github.com/openai/codex/pull/3618 --- codex-rs/execpolicy/Cargo.toml | 10 +++++----- codex-rs/file-search/Cargo.toml | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/codex-rs/execpolicy/Cargo.toml b/codex-rs/execpolicy/Cargo.toml index 337e95f09b..9f0a25c422 100644 --- a/codex-rs/execpolicy/Cargo.toml +++ b/codex-rs/execpolicy/Cargo.toml @@ -1,7 +1,7 @@ [package] +edition = "2024" name = "codex-execpolicy" version = { workspace = true } -edition = "2024" [[bin]] name = "codex-execpolicy" @@ -15,9 +15,8 @@ path = "src/lib.rs" workspace = true [dependencies] -anyhow = "1" -starlark = "0.13.0" allocative = "0.3.3" +anyhow = "1" clap = { version = "4", features = ["derive"] } derive_more = { version = "2", features = ["display"] } env_logger = "0.11.5" @@ -25,9 +24,10 @@ log = "0.4" multimap = "0.10.0" path-absolutize = "3.1.1" regex-lite = "0.1" -serde = { version = "1.0.194", features = ["derive"] } -serde_json = "1.0.145" +serde = { version = "1", features = ["derive"] } +serde_json = "1" serde_with = { version = "3", features = ["macros"] } +starlark = "0.13.0" [dev-dependencies] tempfile = "3.13.0" diff --git a/codex-rs/file-search/Cargo.toml b/codex-rs/file-search/Cargo.toml index 11fd57a465..023e6936c8 100644 --- a/codex-rs/file-search/Cargo.toml +++ b/codex-rs/file-search/Cargo.toml @@ -17,5 +17,5 @@ clap = { version = "4", features = ["derive"] } ignore = "0.4.23" nucleo-matcher = "0.3.1" serde = { version = "1", features = ["derive"] } -serde_json = "1.0.145" +serde_json = "1" tokio = { version = "1", features = ["full"] } From 88027552ddaf64d4dcaf0e6e097997d8ee625584 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 15 Sep 2025 12:56:20 -0700 Subject: [PATCH 06/38] chore(deps): bump serde from 1.0.219 to 1.0.223 in /codex-rs (#3618) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [serde](https://github.com/serde-rs/serde) from 1.0.219 to 1.0.223.
Release notes

Sourced from serde's releases.

v1.0.223

  • Fix serde_core documentation links (#2978)

v1.0.222

  • Make serialize_with attribute produce code that works if respanned to 2024 edition (#2950, thanks @​aytey)

v1.0.221

  • Documentation improvements (#2973)
  • Deprecate serde_if_integer128! macro (#2975)

v1.0.220

Commits
  • 6c316d7 Release 1.0.223
  • a4ac0c2 Merge pull request #2978 from dtolnay/htmlrooturl
  • ed76364 Change serde_core's html_root_url to docs.rs/serde_core
  • 57e21a1 Release 1.0.222
  • bb58726 Merge pull request #2950 from aytey/fix_lifetime_issue_2024
  • 3f69251 Delete unneeded field of MapDeserializer
  • fd4decf Merge pull request #2976 from dtolnay/content
  • 00b1b6b Move Content's Deserialize impl from serde_core to serde
  • cf141aa Move Content's Clone impl from serde_core to serde
  • ff3aee4 Release 1.0.221
  • Additional commits viewable in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=serde&package-manager=cargo&previous-version=1.0.219&new-version=1.0.223)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- codex-rs/Cargo.lock | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index a6f32b35c5..49862881de 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -3926,9 +3926,9 @@ dependencies = [ [[package]] name = "serde" -version = "1.0.223" +version = "1.0.224" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a505d71960adde88e293da5cb5eda57093379f64e61cf77bf0e6a63af07a7bac" +checksum = "6aaeb1e94f53b16384af593c71e20b095e958dab1d26939c1b70645c5cfbcc0b" dependencies = [ "serde_core", "serde_derive", @@ -3936,18 +3936,18 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.223" +version = "1.0.224" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20f57cbd357666aa7b3ac84a90b4ea328f1d4ddb6772b430caa5d9e1309bb9e9" +checksum = "32f39390fa6346e24defbcdd3d9544ba8a19985d0af74df8501fbfe9a64341ab" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.223" +version = "1.0.224" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d428d07faf17e306e699ec1e91996e5a165ba5d6bce5b5155173e91a8a01a56" +checksum = "87ff78ab5e8561c9a675bfc1785cb07ae721f0ee53329a595cefd8c04c2ac4e0" dependencies = [ "proc-macro2", "quote", From 404c126fc3ef902006507e7228b8dc0f663c952a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 15 Sep 2025 12:57:17 -0700 Subject: [PATCH 07/38] chore(deps): bump wildmatch from 2.4.0 to 2.5.0 in /codex-rs (#3619) Bumps [wildmatch](https://github.com/becheran/wildmatch) from 2.4.0 to 2.5.0.
Release notes

Sourced from wildmatch's releases.

v2.5.0

becheran/wildmatch#27

Commits
  • b39902c chore: Release wildmatch version 2.5.0
  • 87a8cf4 Merge pull request #28 from smichaku/micha/fix-unicode-case-insensitive-matching
  • a3ab490 fix: Fix unicode matching for non-ASCII characters
  • See full diff in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=wildmatch&package-manager=cargo&previous-version=2.4.0&new-version=2.5.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- codex-rs/Cargo.lock | 4 ++-- codex-rs/core/Cargo.toml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 49862881de..81fc8683cb 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -5292,9 +5292,9 @@ dependencies = [ [[package]] name = "wildmatch" -version = "2.4.0" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ce1ab1f8c62655ebe1350f589c61e505cf94d385bc6a12899442d9081e71fd" +checksum = "39b7d07a236abaef6607536ccfaf19b396dbe3f5110ddb73d39f4562902ed382" [[package]] name = "winapi" diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index 4fb10affb3..b4ed4a937a 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -57,7 +57,7 @@ tree-sitter = "0.25.9" tree-sitter-bash = "0.25.0" uuid = { version = "1", features = ["serde", "v4"] } which = "6" -wildmatch = "2.4.0" +wildmatch = "2.5.0" [target.'cfg(target_os = "linux")'.dependencies] From 45bccd36b038a28b23663189cc6f7557e49e06d0 Mon Sep 17 00:00:00 2001 From: easong-openai Date: Mon, 15 Sep 2025 17:34:04 -0700 Subject: [PATCH 08/38] fix permissions alignment --- codex-rs/tui/src/bottom_pane/chat_composer.rs | 11 +- codex-rs/tui/src/bottom_pane/command_popup.rs | 33 +- .../src/bottom_pane/list_selection_view.rs | 34 ++- .../src/bottom_pane/selection_popup_common.rs | 282 +++++++++++++----- ..._chat_composer__tests__slash_popup_mo.snap | 2 +- ..._list_selection_spacing_with_subtitle.snap | 2 +- ...st_selection_spacing_without_subtitle.snap | 2 +- 7 files changed, 280 insertions(+), 86 deletions(-) diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 2138d54098..bf11ff571d 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -142,14 +142,16 @@ impl ChatComposer { .desired_height(width.saturating_sub(LIVE_PREFIX_COLS)) + match &self.active_popup { ActivePopup::None => FOOTER_HEIGHT_WITH_HINT, - ActivePopup::Command(c) => c.calculate_required_height(), + ActivePopup::Command(c) => c.calculate_required_height(width), ActivePopup::File(c) => c.calculate_required_height(), } } pub fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { let popup_constraint = match &self.active_popup { - ActivePopup::Command(popup) => Constraint::Max(popup.calculate_required_height()), + ActivePopup::Command(popup) => { + Constraint::Max(popup.calculate_required_height(area.width)) + } ActivePopup::File(popup) => Constraint::Max(popup.calculate_required_height()), ActivePopup::None => Constraint::Max(FOOTER_HEIGHT_WITH_HINT), }; @@ -1232,7 +1234,10 @@ impl ChatComposer { impl WidgetRef for ChatComposer { fn render_ref(&self, area: Rect, buf: &mut Buffer) { let (popup_constraint, hint_spacing) = match &self.active_popup { - ActivePopup::Command(popup) => (Constraint::Max(popup.calculate_required_height()), 0), + ActivePopup::Command(popup) => ( + Constraint::Max(popup.calculate_required_height(area.width)), + 0, + ), ActivePopup::File(popup) => (Constraint::Max(popup.calculate_required_height()), 0), ActivePopup::None => ( Constraint::Length(FOOTER_HEIGHT_WITH_HINT), diff --git a/codex-rs/tui/src/bottom_pane/command_popup.rs b/codex-rs/tui/src/bottom_pane/command_popup.rs index a5961e3188..8de266f819 100644 --- a/codex-rs/tui/src/bottom_pane/command_popup.rs +++ b/codex-rs/tui/src/bottom_pane/command_popup.rs @@ -92,10 +92,35 @@ impl CommandPopup { .ensure_visible(matches_len, MAX_POPUP_ROWS.min(matches_len)); } - /// Determine the preferred height of the popup. This is the number of - /// rows required to show at most MAX_POPUP_ROWS commands. - pub(crate) fn calculate_required_height(&self) -> u16 { - self.filtered_items().len().clamp(1, MAX_POPUP_ROWS) as u16 + /// Determine the preferred height of the popup for a given width. + /// Accounts for wrapped descriptions so that long tooltips don't overflow. + pub(crate) fn calculate_required_height(&self, width: u16) -> u16 { + use super::selection_popup_common::GenericDisplayRow; + use super::selection_popup_common::measure_rows_height; + let matches = self.filtered(); + let rows_all: Vec = if matches.is_empty() { + Vec::new() + } else { + matches + .into_iter() + .map(|(item, indices, _)| match item { + CommandItem::Builtin(cmd) => GenericDisplayRow { + name: format!("/{}", cmd.command()), + match_indices: indices.map(|v| v.into_iter().map(|i| i + 1).collect()), + is_current: false, + description: Some(cmd.description().to_string()), + }, + CommandItem::UserPrompt(i) => GenericDisplayRow { + name: format!("/{}", self.prompts[i].name), + match_indices: indices.map(|v| v.into_iter().map(|i| i + 1).collect()), + is_current: false, + description: Some("send saved prompt".to_string()), + }, + }) + .collect() + }; + + measure_rows_height(&rows_all, &self.state, MAX_POPUP_ROWS, width) } /// Compute fuzzy-filtered matches over built-in commands and user prompts, diff --git a/codex-rs/tui/src/bottom_pane/list_selection_view.rs b/codex-rs/tui/src/bottom_pane/list_selection_view.rs index 7be91db202..5d5dbf0f33 100644 --- a/codex-rs/tui/src/bottom_pane/list_selection_view.rs +++ b/codex-rs/tui/src/bottom_pane/list_selection_view.rs @@ -17,6 +17,7 @@ use super::bottom_pane_view::BottomPaneView; use super::popup_consts::MAX_POPUP_ROWS; use super::scroll_state::ScrollState; use super::selection_popup_common::GenericDisplayRow; +use super::selection_popup_common::measure_rows_height; use super::selection_popup_common::render_rows; /// One selectable item in the generic selection list. @@ -135,11 +136,36 @@ impl BottomPaneView for ListSelectionView { CancellationEvent::Handled } - fn desired_height(&self, _width: u16) -> u16 { - let rows = (self.items.len()).clamp(1, MAX_POPUP_ROWS); + fn desired_height(&self, width: u16) -> u16 { + // Measure wrapped height for up to MAX_POPUP_ROWS items at the given width. + // Build the same display rows used by the renderer so wrapping math matches. + let rows: Vec = self + .items + .iter() + .enumerate() + .map(|(i, it)| { + let is_selected = self.state.selected_idx == Some(i); + let prefix = if is_selected { '>' } else { ' ' }; + let name_with_marker = if it.is_current { + format!("{} (current)", it.name) + } else { + it.name.clone() + }; + let display_name = format!("{} {}. {}", prefix, i + 1, name_with_marker); + GenericDisplayRow { + name: display_name, + match_indices: None, + is_current: it.is_current, + description: it.description.clone(), + } + }) + .collect(); + + let rows_height = measure_rows_height(&rows, &self.state, MAX_POPUP_ROWS, width); + // +1 for the title row, +1 for a spacer line beneath the header, - // +1 for optional subtitle, +1 for optional footer - let mut height = rows as u16 + 2; + // +1 for optional subtitle, +1 for optional footer (2 lines incl. spacing) + let mut height = rows_height + 2; if self.subtitle.is_some() { // +1 for subtitle (the spacer is accounted for above) height = height.saturating_add(1); diff --git a/codex-rs/tui/src/bottom_pane/selection_popup_common.rs b/codex-rs/tui/src/bottom_pane/selection_popup_common.rs index 3852d10bc1..61a26f9341 100644 --- a/codex-rs/tui/src/bottom_pane/selection_popup_common.rs +++ b/codex-rs/tui/src/bottom_pane/selection_popup_common.rs @@ -1,6 +1,7 @@ use ratatui::buffer::Buffer; use ratatui::layout::Rect; -use ratatui::prelude::Constraint; +// Note: Table-based layout previously used Constraint; the manual renderer +// below no longer requires it. use ratatui::style::Color; use ratatui::style::Modifier; use ratatui::style::Style; @@ -10,9 +11,7 @@ use ratatui::text::Span; use ratatui::widgets::Block; use ratatui::widgets::BorderType; use ratatui::widgets::Borders; -use ratatui::widgets::Cell; -use ratatui::widgets::Row; -use ratatui::widgets::Table; +use ratatui::widgets::Paragraph; use ratatui::widgets::Widget; use super::scroll_state::ScrollState; @@ -27,6 +26,61 @@ pub(crate) struct GenericDisplayRow { impl GenericDisplayRow {} +/// Compute a shared description-column start based on the widest visible name +/// plus two spaces of padding. Ensures at least one column is left for the +/// description. +fn compute_desc_col( + rows_all: &[GenericDisplayRow], + start_idx: usize, + visible_items: usize, + content_width: u16, +) -> usize { + let visible_range = start_idx..(start_idx + visible_items); + let max_name_width = rows_all + .iter() + .enumerate() + .filter(|(i, _)| visible_range.contains(i)) + .map(|(_, r)| Line::from(r.name.clone()).width()) + .max() + .unwrap_or(0); + let mut desc_col = max_name_width.saturating_add(2); + if (desc_col as u16) >= content_width { + desc_col = content_width.saturating_sub(1) as usize; + } + desc_col +} + +/// Build the full display line for a row with the description padded to start +/// at `desc_col`. Applies fuzzy-match bolding when indices are present and +/// dims the description. +fn build_full_line(row: &GenericDisplayRow, desc_col: usize) -> Line<'static> { + let mut name_spans: Vec = Vec::with_capacity(row.name.len()); + if let Some(idxs) = row.match_indices.as_ref() { + let mut idx_iter = idxs.iter().peekable(); + for (char_idx, ch) in row.name.chars().enumerate() { + if idx_iter.peek().is_some_and(|next| **next == char_idx) { + idx_iter.next(); + name_spans.push(ch.to_string().bold()); + } else { + name_spans.push(ch.to_string().into()); + } + } + } else { + name_spans.push(row.name.clone().into()); + } + + let this_name_width = Line::from(name_spans.clone()).width(); + let mut full_spans: Vec = name_spans; + if let Some(desc) = row.description.as_ref() { + let gap = desc_col.saturating_sub(this_name_width); + if gap > 0 { + full_spans.push(" ".repeat(gap).into()); + } + full_spans.push(desc.clone().dim()); + } + Line::from(full_spans) +} + /// Render a list of rows using the provided ScrollState, with shared styling /// and behavior for selection popups. pub(crate) fn render_rows( @@ -38,84 +92,168 @@ pub(crate) fn render_rows( _dim_non_selected: bool, empty_message: &str, ) { - let mut rows: Vec = Vec::new(); + // Always draw a dim left border to match other popups. + let block = Block::default() + .borders(Borders::LEFT) + .border_type(BorderType::QuadrantOutside) + .border_style(Style::default().add_modifier(Modifier::DIM)); + block.render(area, buf); + + // Content renders to the right of the border. + let content_area = Rect { + x: area.x.saturating_add(1), + y: area.y, + width: area.width.saturating_sub(1), + height: area.height, + }; + if rows_all.is_empty() { - rows.push(Row::new(vec![Cell::from(Line::from( - empty_message.dim().italic(), - ))])); - } else { - let max_rows_from_area = area.height as usize; - let visible_rows = max_results - .min(rows_all.len()) - .min(max_rows_from_area.max(1)); - - // Compute starting index based on scroll state and selection. - let mut start_idx = state.scroll_top.min(rows_all.len().saturating_sub(1)); - if let Some(sel) = state.selected_idx { - if sel < start_idx { - start_idx = sel; - } else if visible_rows > 0 { - let bottom = start_idx + visible_rows - 1; - if sel > bottom { - start_idx = sel + 1 - visible_rows; - } - } + if content_area.height > 0 { + let para = Paragraph::new(Line::from(empty_message.dim().italic())); + para.render( + Rect { + x: content_area.x, + y: content_area.y, + width: content_area.width, + height: 1, + }, + buf, + ); } + return; + } - for (i, row) in rows_all - .iter() - .enumerate() - .skip(start_idx) - .take(visible_rows) - { - let GenericDisplayRow { - name, - match_indices, - is_current: _is_current, - description, - } = row; - - // Highlight fuzzy indices when present. - let mut spans: Vec = Vec::with_capacity(name.len()); - if let Some(idxs) = match_indices.as_ref() { - let mut idx_iter = idxs.iter().peekable(); - for (char_idx, ch) in name.chars().enumerate() { - if idx_iter.peek().is_some_and(|next| **next == char_idx) { - idx_iter.next(); - spans.push(ch.to_string().bold()); - } else { - spans.push(ch.to_string().into()); - } - } - } else { - spans.push(name.clone().into()); - } + // Determine which logical rows (items) are visible given the selection and + // the max_results clamp. Scrolling is still item-based for simplicity. + let max_rows_from_area = content_area.height as usize; + let visible_items = max_results + .min(rows_all.len()) + .min(max_rows_from_area.max(1)); - if let Some(desc) = description.as_ref() { - spans.push(" ".into()); - spans.push(desc.clone().dim()); + let mut start_idx = state.scroll_top.min(rows_all.len().saturating_sub(1)); + if let Some(sel) = state.selected_idx { + if sel < start_idx { + start_idx = sel; + } else if visible_items > 0 { + let bottom = start_idx + visible_items - 1; + if sel > bottom { + start_idx = sel + 1 - visible_items; } + } + } + + let desc_col = compute_desc_col(rows_all, start_idx, visible_items, content_area.width); + + // Render items, wrapping descriptions and aligning wrapped lines under the + // shared description column. Stop when we run out of vertical space. + let mut cur_y = content_area.y; + for (i, row) in rows_all + .iter() + .enumerate() + .skip(start_idx) + .take(visible_items) + { + if cur_y >= content_area.y + content_area.height { + break; + } + + let GenericDisplayRow { + name, + match_indices, + is_current: _is_current, + description, + } = row; + + let full_line = build_full_line( + &GenericDisplayRow { + name: name.clone(), + match_indices: match_indices.clone(), + is_current: *_is_current, + description: description.clone(), + }, + desc_col, + ); - let mut cell = Cell::from(Line::from(spans)); + // Wrap with subsequent indent aligned to the description column. + use crate::wrapping::RtOptions; + use crate::wrapping::word_wrap_line; + let options = RtOptions::new(content_area.width as usize) + .initial_indent(Line::from("")) + .subsequent_indent(Line::from(" ".repeat(desc_col))); + let wrapped = word_wrap_line(&full_line, options); + + // Render the wrapped lines. + for mut line in wrapped { + if cur_y >= content_area.y + content_area.height { + break; + } if Some(i) == state.selected_idx { - cell = cell.style( - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD), - ); + // Match previous behavior: cyan + bold for the selected row. + line.style = Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD); } - rows.push(Row::new(vec![cell])); + let para = Paragraph::new(line); + para.render( + Rect { + x: content_area.x, + y: cur_y, + width: content_area.width, + height: 1, + }, + buf, + ); + cur_y = cur_y.saturating_add(1); } } +} - let table = Table::new(rows, vec![Constraint::Percentage(100)]) - .block( - Block::default() - .borders(Borders::LEFT) - .border_type(BorderType::QuadrantOutside) - .border_style(Style::default().add_modifier(Modifier::DIM)), - ) - .widths([Constraint::Percentage(100)]); +/// Compute the number of terminal rows required to render up to `max_results` +/// items from `rows_all` given the current scroll/selection state and the +/// available `width`. Accounts for description wrapping and alignment so the +/// caller can allocate sufficient vertical space. +pub(crate) fn measure_rows_height( + rows_all: &[GenericDisplayRow], + state: &ScrollState, + max_results: usize, + width: u16, +) -> u16 { + if rows_all.is_empty() { + return 1; // placeholder "no matches" line + } + + let content_width = width.saturating_sub(1).max(1); + + let visible_items = max_results.min(rows_all.len()); + let mut start_idx = state.scroll_top.min(rows_all.len().saturating_sub(1)); + if let Some(sel) = state.selected_idx { + if sel < start_idx { + start_idx = sel; + } else if visible_items > 0 { + let bottom = start_idx + visible_items - 1; + if sel > bottom { + start_idx = sel + 1 - visible_items; + } + } + } - table.render(area, buf); + let desc_col = compute_desc_col(rows_all, start_idx, visible_items, content_width); + + use crate::wrapping::RtOptions; + use crate::wrapping::word_wrap_line; + let mut total: u16 = 0; + for row in rows_all + .iter() + .enumerate() + .skip(start_idx) + .take(visible_items) + .map(|(_, r)| r) + { + let full_line = build_full_line(row, desc_col); + let opts = RtOptions::new(content_width as usize) + .initial_indent(Line::from("")) + .subsequent_indent(Line::from(" ".repeat(desc_col))); + total = total.saturating_add(word_wrap_line(&full_line, opts).len() as u16); + } + total.max(1) } diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_mo.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_mo.snap index b626f80ca1..f908fb6144 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_mo.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_mo.snap @@ -4,5 +4,5 @@ expression: terminal.backend() --- "▌ /mo " "▌ " -"▌/model choose what model and reasoning effort to use " +"▌/model choose what model and reasoning effort to use " "▌/mention mention a file " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_spacing_with_subtitle.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_spacing_with_subtitle.snap index aab7e7c9b6..65606ed7d0 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_spacing_with_subtitle.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_spacing_with_subtitle.snap @@ -6,6 +6,6 @@ expression: render_lines(&view) ▌ Switch between Codex approval presets ▌ ▌> 1. Read Only (current) Codex can read files -▌ 2. Full Access Codex can edit files +▌ 2. Full Access Codex can edit files Press Enter to confirm or Esc to go back diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_spacing_without_subtitle.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_spacing_without_subtitle.snap index 77552f2b93..b42a5f8c6b 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_spacing_without_subtitle.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_spacing_without_subtitle.snap @@ -5,6 +5,6 @@ expression: render_lines(&view) ▌ Select Approval Mode ▌ ▌> 1. Read Only (current) Codex can read files -▌ 2. Full Access Codex can edit files +▌ 2. Full Access Codex can edit files Press Enter to confirm or Esc to go back From a8026d3846486e790827b26e9abf13e7f96e48bf Mon Sep 17 00:00:00 2001 From: Dylan Date: Mon, 15 Sep 2025 19:01:10 -0700 Subject: [PATCH 09/38] fix: read-only escalations (#3673) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Splitting out this smaller fix from #2694 - fixes the sandbox permissions so Chat / read-only mode tool definition matches expectations ## Testing - [x] Tested locally Screenshot 2025-09-15 at 2 51 19 PM --- codex-rs/core/src/openai_tools.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/codex-rs/core/src/openai_tools.rs b/codex-rs/core/src/openai_tools.rs index a511d6dd16..f4d724815e 100644 --- a/codex-rs/core/src/openai_tools.rs +++ b/codex-rs/core/src/openai_tools.rs @@ -273,7 +273,7 @@ fn create_shell_tool_for_sandbox(sandbox_policy: &SandboxPolicy) -> OpenAiTool { }, ); - if matches!(sandbox_policy, SandboxPolicy::WorkspaceWrite { .. }) { + if !matches!(sandbox_policy, SandboxPolicy::DangerFullAccess) { properties.insert( "with_escalated_permissions".to_string(), JsonSchema::Boolean { From 5e2c4f7e357e3b1a63d8a4fc79be245975f5af8a Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Tue, 16 Sep 2025 08:43:29 -0700 Subject: [PATCH 10/38] Update azure model provider example (#3680) Make the section linkable. --- docs/config.md | 27 ++++++++++++++++----------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/docs/config.md b/docs/config.md index 01b3efe0a1..4f287b824e 100644 --- a/docs/config.md +++ b/docs/config.md @@ -69,17 +69,6 @@ base_url = "https://api.mistral.ai/v1" env_key = "MISTRAL_API_KEY" ``` -Note that Azure requires `api-version` to be passed as a query parameter, so be sure to specify it as part of `query_params` when defining the Azure provider: - -```toml -[model_providers.azure] -name = "Azure" -# Make sure you set the appropriate subdomain for this URL. -base_url = "https://YOUR_PROJECT_NAME.openai.azure.com/openai" -env_key = "AZURE_OPENAI_API_KEY" # Or "OPENAI_API_KEY", whichever you use. -query_params = { api-version = "2025-04-01-preview" } -``` - It is also possible to configure a provider to include extra HTTP headers with a request. These can be hardcoded values (`http_headers`) or values read from environment variables (`env_http_headers`): ```toml @@ -96,6 +85,22 @@ http_headers = { "X-Example-Header" = "example-value" } env_http_headers = { "X-Example-Features" = "EXAMPLE_FEATURES" } ``` +### Azure model provider example + +Note that Azure requires `api-version` to be passed as a query parameter, so be sure to specify it as part of `query_params` when defining the Azure provider: + +```toml +[model_providers.azure] +name = "Azure" +# Make sure you set the appropriate subdomain for this URL. +base_url = "https://YOUR_PROJECT_NAME.openai.azure.com/openai" +env_key = "AZURE_OPENAI_API_KEY" # Or "OPENAI_API_KEY", whichever you use. +query_params = { api-version = "2025-04-01-preview" } +wire_api = "responses" +``` + +Export your key before launching Codex: `export AZURE_OPENAI_API_KEY=…` + ### Per-provider network tuning The following optional settings control retry behaviour and streaming idle timeouts **per model provider**. They must be specified inside the corresponding `[model_providers.]` block in `config.toml`. (Older releases accepted top‑level keys; those are now ignored.) From 244687303b14a89b9bc998adabce7a7c9e1cc396 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Tue, 16 Sep 2025 14:02:15 -0400 Subject: [PATCH 11/38] Persist search items (#3745) Let's record the search items because they are part of the history. --- codex-rs/core/src/conversation_history.rs | 5 +++-- codex-rs/core/src/rollout/policy.rs | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/codex-rs/core/src/conversation_history.rs b/codex-rs/core/src/conversation_history.rs index 891a2ea472..7c23e4dc29 100644 --- a/codex-rs/core/src/conversation_history.rs +++ b/codex-rs/core/src/conversation_history.rs @@ -47,8 +47,9 @@ fn is_api_message(message: &ResponseItem) -> bool { | ResponseItem::CustomToolCall { .. } | ResponseItem::CustomToolCallOutput { .. } | ResponseItem::LocalShellCall { .. } - | ResponseItem::Reasoning { .. } => true, - ResponseItem::WebSearchCall { .. } | ResponseItem::Other => false, + | ResponseItem::Reasoning { .. } + | ResponseItem::WebSearchCall { .. } => true, + ResponseItem::Other => false, } } diff --git a/codex-rs/core/src/rollout/policy.rs b/codex-rs/core/src/rollout/policy.rs index 0d8fee92b0..2fd0efb0dc 100644 --- a/codex-rs/core/src/rollout/policy.rs +++ b/codex-rs/core/src/rollout/policy.rs @@ -25,8 +25,9 @@ pub(crate) fn should_persist_response_item(item: &ResponseItem) -> bool { | ResponseItem::FunctionCall { .. } | ResponseItem::FunctionCallOutput { .. } | ResponseItem::CustomToolCall { .. } - | ResponseItem::CustomToolCallOutput { .. } => true, - ResponseItem::WebSearchCall { .. } | ResponseItem::Other => false, + | ResponseItem::CustomToolCallOutput { .. } + | ResponseItem::WebSearchCall { .. } => true, + ResponseItem::Other => false, } } From 11285655c4dd02937b48f0bdb97c7ec5078b0714 Mon Sep 17 00:00:00 2001 From: Dylan Date: Tue, 16 Sep 2025 11:32:20 -0700 Subject: [PATCH 12/38] fix: Record EnvironmentContext in SendUserTurn (#3678) ## Summary SendUserTurn has not been correctly handling updates to policies. While the tui protocol handles this in `Op::OverrideTurnContext`, the SendUserTurn should be appending `EnvironmentContext` messages when the sandbox settings change. MCP client behavior should match the cli behavior, so we update `SendUserTurn` message to match. ## Testing - [x] Added prompt caching tests --- codex-rs/core/src/codex.rs | 15 +- codex-rs/core/src/environment_context.rs | 115 ++++++++ codex-rs/core/src/shell.rs | 12 +- codex-rs/core/tests/suite/prompt_caching.rs | 277 +++++++++++++++++++- 4 files changed, 410 insertions(+), 9 deletions(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 375183ab82..b170076d22 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -1348,10 +1348,21 @@ async fn submission_loop( cwd, is_review_mode: false, }; - // TODO: record the new environment context in the conversation history + + // if the environment context has changed, record it in the conversation history + let previous_env_context = EnvironmentContext::from(turn_context.as_ref()); + let new_env_context = EnvironmentContext::from(&fresh_turn_context); + if !new_env_context.equals_except_shell(&previous_env_context) { + sess.record_conversation_items(&[ResponseItem::from(new_env_context)]) + .await; + } + + // Install the new persistent context for subsequent tasks/turns. + turn_context = Arc::new(fresh_turn_context); + // no current task, spawn a new one with the per‑turn context let task = - AgentTask::spawn(sess.clone(), Arc::new(fresh_turn_context), sub.id, items); + AgentTask::spawn(sess.clone(), Arc::clone(&turn_context), sub.id, items); sess.set_task(task); } } diff --git a/codex-rs/core/src/environment_context.rs b/codex-rs/core/src/environment_context.rs index 89af9e1c91..8f3292a226 100644 --- a/codex-rs/core/src/environment_context.rs +++ b/codex-rs/core/src/environment_context.rs @@ -2,6 +2,7 @@ use serde::Deserialize; use serde::Serialize; use strum_macros::Display as DeriveDisplay; +use crate::codex::TurnContext; use crate::protocol::AskForApproval; use crate::protocol::SandboxPolicy; use crate::shell::Shell; @@ -71,6 +72,39 @@ impl EnvironmentContext { shell, } } + + /// Compares two environment contexts, ignoring the shell. Useful when + /// comparing turn to turn, since the initial environment_context will + /// include the shell, and then it is not configurable from turn to turn. + pub fn equals_except_shell(&self, other: &EnvironmentContext) -> bool { + let EnvironmentContext { + cwd, + approval_policy, + sandbox_mode, + network_access, + writable_roots, + // should compare all fields except shell + shell: _, + } = other; + + self.cwd == *cwd + && self.approval_policy == *approval_policy + && self.sandbox_mode == *sandbox_mode + && self.network_access == *network_access + && self.writable_roots == *writable_roots + } +} + +impl From<&TurnContext> for EnvironmentContext { + fn from(turn_context: &TurnContext) -> Self { + Self::new( + Some(turn_context.cwd.clone()), + Some(turn_context.approval_policy), + Some(turn_context.sandbox_policy.clone()), + // Shell is not configurable from turn to turn + None, + ) + } } impl EnvironmentContext { @@ -140,6 +174,9 @@ impl From for ResponseItem { #[cfg(test)] mod tests { + use crate::shell::BashShell; + use crate::shell::ZshShell; + use super::*; use pretty_assertions::assert_eq; @@ -210,4 +247,82 @@ mod tests { assert_eq!(context.serialize_to_xml(), expected); } + + #[test] + fn equals_except_shell_compares_approval_policy() { + // Approval policy + let context1 = EnvironmentContext::new( + Some(PathBuf::from("/repo")), + Some(AskForApproval::OnRequest), + Some(workspace_write_policy(vec!["/repo"], false)), + None, + ); + let context2 = EnvironmentContext::new( + Some(PathBuf::from("/repo")), + Some(AskForApproval::Never), + Some(workspace_write_policy(vec!["/repo"], true)), + None, + ); + assert!(!context1.equals_except_shell(&context2)); + } + + #[test] + fn equals_except_shell_compares_sandbox_policy() { + let context1 = EnvironmentContext::new( + Some(PathBuf::from("/repo")), + Some(AskForApproval::OnRequest), + Some(SandboxPolicy::new_read_only_policy()), + None, + ); + let context2 = EnvironmentContext::new( + Some(PathBuf::from("/repo")), + Some(AskForApproval::OnRequest), + Some(SandboxPolicy::new_workspace_write_policy()), + None, + ); + + assert!(!context1.equals_except_shell(&context2)); + } + + #[test] + fn equals_except_shell_compares_workspace_write_policy() { + let context1 = EnvironmentContext::new( + Some(PathBuf::from("/repo")), + Some(AskForApproval::OnRequest), + Some(workspace_write_policy(vec!["/repo", "/tmp", "/var"], false)), + None, + ); + let context2 = EnvironmentContext::new( + Some(PathBuf::from("/repo")), + Some(AskForApproval::OnRequest), + Some(workspace_write_policy(vec!["/repo", "/tmp"], true)), + None, + ); + + assert!(!context1.equals_except_shell(&context2)); + } + + #[test] + fn equals_except_shell_ignores_shell() { + let context1 = EnvironmentContext::new( + Some(PathBuf::from("/repo")), + Some(AskForApproval::OnRequest), + Some(workspace_write_policy(vec!["/repo"], false)), + Some(Shell::Bash(BashShell { + shell_path: "/bin/bash".into(), + bashrc_path: "/home/user/.bashrc".into(), + })), + ); + let context2 = EnvironmentContext::new( + Some(PathBuf::from("/repo")), + Some(AskForApproval::OnRequest), + Some(workspace_write_policy(vec!["/repo"], false)), + Some(Shell::Zsh(ZshShell { + shell_path: "/bin/zsh".into(), + zshrc_path: "/home/user/.zshrc".into(), + })), + ); + + assert!(context1.equals_except_shell(&context2)); + } } diff --git a/codex-rs/core/src/shell.rs b/codex-rs/core/src/shell.rs index 1734d5de65..cb278fdd46 100644 --- a/codex-rs/core/src/shell.rs +++ b/codex-rs/core/src/shell.rs @@ -5,20 +5,20 @@ use std::path::PathBuf; #[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)] pub struct ZshShell { - shell_path: String, - zshrc_path: String, + pub(crate) shell_path: String, + pub(crate) zshrc_path: String, } #[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)] pub struct BashShell { - shell_path: String, - bashrc_path: String, + pub(crate) shell_path: String, + pub(crate) bashrc_path: String, } #[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)] pub struct PowerShellConfig { - exe: String, // Executable name or path, e.g. "pwsh" or "powershell.exe". - bash_exe_fallback: Option, // In case the model generates a bash command. + pub(crate) exe: String, // Executable name or path, e.g. "pwsh" or "powershell.exe". + pub(crate) bash_exe_fallback: Option, // In case the model generates a bash command. } #[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)] diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index c6731fee34..a69f57a201 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -12,6 +12,7 @@ use codex_core::protocol::Op; use codex_core::protocol::SandboxPolicy; use codex_core::protocol_config_types::ReasoningEffort; use codex_core::protocol_config_types::ReasoningSummary; +use codex_core::shell::Shell; use codex_core::shell::default_user_shell; use core_test_support::load_default_config_for_test; use core_test_support::load_sse_fixture_with_id; @@ -23,6 +24,30 @@ use wiremock::ResponseTemplate; use wiremock::matchers::method; use wiremock::matchers::path; +fn text_user_input(text: String) -> serde_json::Value { + serde_json::json!({ + "type": "message", + "role": "user", + "content": [ { "type": "input_text", "text": text } ] + }) +} + +fn default_env_context_str(cwd: &str, shell: &Shell) -> String { + format!( + r#" + {} + on-request + read-only + restricted +{}"#, + cwd, + match shell.name() { + Some(name) => format!(" {name}\n"), + None => String::new(), + } + ) +} + /// Build minimal SSE stream with completed marker using the JSON fixture. fn sse_completed(id: &str) -> String { load_sse_fixture_with_id("tests/fixtures/completed_template.json", id) @@ -546,12 +571,262 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() { "role": "user", "content": [ { "type": "input_text", "text": "hello 2" } ] }); + let expected_env_text_2 = format!( + r#" + {} + never + workspace-write + enabled + + {} + +"#, + new_cwd.path().to_string_lossy(), + writable.path().to_string_lossy(), + ); + let expected_env_msg_2 = serde_json::json!({ + "type": "message", + "role": "user", + "content": [ { "type": "input_text", "text": expected_env_text_2 } ] + }); let expected_body2 = serde_json::json!( [ body1["input"].as_array().unwrap().as_slice(), - [expected_user_message_2].as_slice(), + [expected_env_msg_2, expected_user_message_2].as_slice(), ] .concat() ); assert_eq!(body2["input"], expected_body2); } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn send_user_turn_with_no_changes_does_not_send_environment_context() { + use pretty_assertions::assert_eq; + + let server = MockServer::start().await; + + let sse = sse_completed("resp"); + let template = ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_raw(sse, "text/event-stream"); + + Mock::given(method("POST")) + .and(path("/v1/responses")) + .respond_with(template) + .expect(2) + .mount(&server) + .await; + + let model_provider = ModelProviderInfo { + base_url: Some(format!("{}/v1", server.uri())), + ..built_in_model_providers()["openai"].clone() + }; + + let cwd = TempDir::new().unwrap(); + let codex_home = TempDir::new().unwrap(); + let mut config = load_default_config_for_test(&codex_home); + config.cwd = cwd.path().to_path_buf(); + config.model_provider = model_provider; + config.user_instructions = Some("be consistent and helpful".to_string()); + + let default_cwd = config.cwd.clone(); + let default_approval_policy = config.approval_policy; + let default_sandbox_policy = config.sandbox_policy.clone(); + let default_model = config.model.clone(); + let default_effort = config.model_reasoning_effort; + let default_summary = config.model_reasoning_summary; + + let conversation_manager = + ConversationManager::with_auth(CodexAuth::from_api_key("Test API Key")); + let codex = conversation_manager + .new_conversation(config) + .await + .expect("create new conversation") + .conversation; + + codex + .submit(Op::UserTurn { + items: vec![InputItem::Text { + text: "hello 1".into(), + }], + cwd: default_cwd.clone(), + approval_policy: default_approval_policy, + sandbox_policy: default_sandbox_policy.clone(), + model: default_model.clone(), + effort: default_effort, + summary: default_summary, + }) + .await + .unwrap(); + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await; + + codex + .submit(Op::UserTurn { + items: vec![InputItem::Text { + text: "hello 2".into(), + }], + cwd: default_cwd.clone(), + approval_policy: default_approval_policy, + sandbox_policy: default_sandbox_policy.clone(), + model: default_model.clone(), + effort: default_effort, + summary: default_summary, + }) + .await + .unwrap(); + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await; + + let requests = server.received_requests().await.unwrap(); + assert_eq!(requests.len(), 2, "expected two POST requests"); + + let body1 = requests[0].body_json::().unwrap(); + let body2 = requests[1].body_json::().unwrap(); + + let shell = default_user_shell().await; + let expected_ui_text = + "\n\nbe consistent and helpful\n\n"; + let expected_ui_msg = text_user_input(expected_ui_text.to_string()); + + let expected_env_msg_1 = text_user_input(default_env_context_str( + &cwd.path().to_string_lossy(), + &shell, + )); + let expected_user_message_1 = text_user_input("hello 1".to_string()); + + let expected_input_1 = serde_json::Value::Array(vec![ + expected_ui_msg.clone(), + expected_env_msg_1.clone(), + expected_user_message_1.clone(), + ]); + assert_eq!(body1["input"], expected_input_1); + + let expected_user_message_2 = text_user_input("hello 2".to_string()); + let expected_input_2 = serde_json::Value::Array(vec![ + expected_ui_msg, + expected_env_msg_1, + expected_user_message_1, + expected_user_message_2, + ]); + assert_eq!(body2["input"], expected_input_2); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn send_user_turn_with_changes_sends_environment_context() { + use pretty_assertions::assert_eq; + + let server = MockServer::start().await; + + let sse = sse_completed("resp"); + let template = ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_raw(sse, "text/event-stream"); + + Mock::given(method("POST")) + .and(path("/v1/responses")) + .respond_with(template) + .expect(2) + .mount(&server) + .await; + + let model_provider = ModelProviderInfo { + base_url: Some(format!("{}/v1", server.uri())), + ..built_in_model_providers()["openai"].clone() + }; + + let cwd = TempDir::new().unwrap(); + let codex_home = TempDir::new().unwrap(); + let mut config = load_default_config_for_test(&codex_home); + config.cwd = cwd.path().to_path_buf(); + config.model_provider = model_provider; + config.user_instructions = Some("be consistent and helpful".to_string()); + + let default_cwd = config.cwd.clone(); + let default_approval_policy = config.approval_policy; + let default_sandbox_policy = config.sandbox_policy.clone(); + let default_model = config.model.clone(); + let default_effort = config.model_reasoning_effort; + let default_summary = config.model_reasoning_summary; + + let conversation_manager = + ConversationManager::with_auth(CodexAuth::from_api_key("Test API Key")); + let codex = conversation_manager + .new_conversation(config.clone()) + .await + .expect("create new conversation") + .conversation; + + codex + .submit(Op::UserTurn { + items: vec![InputItem::Text { + text: "hello 1".into(), + }], + cwd: default_cwd.clone(), + approval_policy: default_approval_policy, + sandbox_policy: default_sandbox_policy.clone(), + model: default_model, + effort: default_effort, + summary: default_summary, + }) + .await + .unwrap(); + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await; + + codex + .submit(Op::UserTurn { + items: vec![InputItem::Text { + text: "hello 2".into(), + }], + cwd: default_cwd.clone(), + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::DangerFullAccess, + model: "o3".to_string(), + effort: Some(ReasoningEffort::High), + summary: ReasoningSummary::Detailed, + }) + .await + .unwrap(); + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await; + + let requests = server.received_requests().await.unwrap(); + assert_eq!(requests.len(), 2, "expected two POST requests"); + + let body1 = requests[0].body_json::().unwrap(); + let body2 = requests[1].body_json::().unwrap(); + + let shell = default_user_shell().await; + let expected_ui_text = + "\n\nbe consistent and helpful\n\n"; + let expected_ui_msg = serde_json::json!({ + "type": "message", + "role": "user", + "content": [ { "type": "input_text", "text": expected_ui_text } ] + }); + let expected_env_text_1 = default_env_context_str(&default_cwd.to_string_lossy(), &shell); + let expected_env_msg_1 = text_user_input(expected_env_text_1); + let expected_user_message_1 = text_user_input("hello 1".to_string()); + let expected_input_1 = serde_json::Value::Array(vec![ + expected_ui_msg.clone(), + expected_env_msg_1.clone(), + expected_user_message_1.clone(), + ]); + assert_eq!(body1["input"], expected_input_1); + + let expected_env_msg_2 = text_user_input(format!( + r#" + {} + never + danger-full-access + enabled +"#, + default_cwd.to_string_lossy() + )); + let expected_user_message_2 = text_user_input("hello 2".to_string()); + let expected_input_2 = serde_json::Value::Array(vec![ + expected_ui_msg, + expected_env_msg_1, + expected_user_message_1, + expected_env_msg_2, + expected_user_message_2, + ]); + assert_eq!(body2["input"], expected_input_2); +} From 7fe4021f9593bd9f271fe2c58f673eb6eb1e74e1 Mon Sep 17 00:00:00 2001 From: dedrisian-oai Date: Tue, 16 Sep 2025 13:36:51 -0700 Subject: [PATCH 13/38] Review mode core updates (#3701) 1. Adds the environment prompt (including cwd) to review thread 2. Prepends the review prompt as a user message (temporary fix so the instructions are not replaced on backend) 3. Sets reasoning to low 4. Sets default review model to `gpt-5-codex` --- codex-rs/core/src/codex.rs | 30 +++++++++++----------- codex-rs/core/src/config.rs | 10 ++++---- codex-rs/core/src/lib.rs | 1 + codex-rs/core/tests/suite/review.rs | 39 ++++++++++++++++++++++------- 4 files changed, 52 insertions(+), 28 deletions(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index b170076d22..59b3b6ae44 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -1154,20 +1154,16 @@ impl AgentTask { fn abort(self, reason: TurnAbortReason) { // TOCTOU? if !self.handle.is_finished() { - if self.kind == AgentTaskKind::Review { - let sess = self.sess.clone(); - let sub_id = self.sub_id.clone(); - tokio::spawn(async move { - exit_review_mode(sess, sub_id, None).await; - }); - } self.handle.abort(); let event = Event { - id: self.sub_id, + id: self.sub_id.clone(), msg: EventMsg::TurnAborted(TurnAbortedEvent { reason }), }; let sess = self.sess; tokio::spawn(async move { + if self.kind == AgentTaskKind::Review { + exit_review_mode(sess.clone(), self.sub_id, None).await; + } sess.send_event(event).await; }); } @@ -1560,7 +1556,8 @@ async fn spawn_review_thread( experimental_unified_exec_tool: config.use_experimental_unified_exec_tool, }); - let base_instructions = Some(REVIEW_PROMPT.to_string()); + let base_instructions = REVIEW_PROMPT.to_string(); + let review_prompt = review_request.prompt.clone(); let provider = parent_turn_context.client.get_provider(); let auth_manager = parent_turn_context.client.get_auth_manager(); let model_family = review_model_family.clone(); @@ -1569,16 +1566,19 @@ async fn spawn_review_thread( let mut per_turn_config = (*config).clone(); per_turn_config.model = model.clone(); per_turn_config.model_family = model_family.clone(); + per_turn_config.model_reasoning_effort = Some(ReasoningEffortConfig::Low); + per_turn_config.model_reasoning_summary = ReasoningSummaryConfig::Detailed; if let Some(model_info) = get_model_info(&model_family) { per_turn_config.model_context_window = Some(model_info.context_window); } + let per_turn_config = Arc::new(per_turn_config); let client = ModelClient::new( - Arc::new(per_turn_config), + per_turn_config.clone(), auth_manager, provider, - parent_turn_context.client.get_reasoning_effort(), - parent_turn_context.client.get_reasoning_summary(), + per_turn_config.model_reasoning_effort, + per_turn_config.model_reasoning_summary, sess.conversation_id, ); @@ -1586,7 +1586,7 @@ async fn spawn_review_thread( client, tools_config, user_instructions: None, - base_instructions, + base_instructions: Some(base_instructions.clone()), approval_policy: parent_turn_context.approval_policy, sandbox_policy: parent_turn_context.sandbox_policy.clone(), shell_environment_policy: parent_turn_context.shell_environment_policy.clone(), @@ -1596,7 +1596,7 @@ async fn spawn_review_thread( // Seed the child task with the review prompt as the initial user message. let input: Vec = vec![InputItem::Text { - text: review_request.prompt.clone(), + text: format!("{base_instructions}\n\n---\n\nNow, here's your task: {review_prompt}"), }]; let tc = Arc::new(review_turn_context); @@ -1654,6 +1654,8 @@ async fn run_task( let is_review_mode = turn_context.is_review_mode; let mut review_thread_history: Vec = Vec::new(); if is_review_mode { + // Seed review threads with environment context so the model knows the working directory. + review_thread_history.extend(sess.build_initial_context(turn_context.as_ref())); review_thread_history.push(initial_input_for_turn.into()); } else { sess.record_input_and_rollout_usermsg(&initial_input_for_turn) diff --git a/codex-rs/core/src/config.rs b/codex-rs/core/src/config.rs index 0479e857c2..6a613abbba 100644 --- a/codex-rs/core/src/config.rs +++ b/codex-rs/core/src/config.rs @@ -38,7 +38,7 @@ use toml_edit::Item as TomlItem; use toml_edit::Table as TomlTable; const OPENAI_DEFAULT_MODEL: &str = "gpt-5"; -const OPENAI_DEFAULT_REVIEW_MODEL: &str = "gpt-5"; +const OPENAI_DEFAULT_REVIEW_MODEL: &str = "gpt-5-codex"; pub const GPT_5_CODEX_MEDIUM_MODEL: &str = "gpt-5-codex"; /// Maximum number of bytes of the documentation that will be embedded. Larger @@ -1581,7 +1581,7 @@ model_verbosity = "high" assert_eq!( Config { model: "o3".to_string(), - review_model: "gpt-5".to_string(), + review_model: OPENAI_DEFAULT_REVIEW_MODEL.to_string(), model_family: find_family_for_model("o3").expect("known model slug"), model_context_window: Some(200_000), model_max_output_tokens: Some(100_000), @@ -1639,7 +1639,7 @@ model_verbosity = "high" )?; let expected_gpt3_profile_config = Config { model: "gpt-3.5-turbo".to_string(), - review_model: "gpt-5".to_string(), + review_model: OPENAI_DEFAULT_REVIEW_MODEL.to_string(), model_family: find_family_for_model("gpt-3.5-turbo").expect("known model slug"), model_context_window: Some(16_385), model_max_output_tokens: Some(4_096), @@ -1712,7 +1712,7 @@ model_verbosity = "high" )?; let expected_zdr_profile_config = Config { model: "o3".to_string(), - review_model: "gpt-5".to_string(), + review_model: OPENAI_DEFAULT_REVIEW_MODEL.to_string(), model_family: find_family_for_model("o3").expect("known model slug"), model_context_window: Some(200_000), model_max_output_tokens: Some(100_000), @@ -1771,7 +1771,7 @@ model_verbosity = "high" )?; let expected_gpt5_profile_config = Config { model: "gpt-5".to_string(), - review_model: "gpt-5".to_string(), + review_model: OPENAI_DEFAULT_REVIEW_MODEL.to_string(), model_family: find_family_for_model("gpt-5").expect("known model slug"), model_context_window: Some(272_000), model_max_output_tokens: Some(128_000), diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 7b9c3dc9f0..aa2e176b07 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -88,6 +88,7 @@ pub use codex_protocol::config_types as protocol_config_types; pub use client::ModelClient; pub use client_common::Prompt; +pub use client_common::REVIEW_PROMPT; pub use client_common::ResponseEvent; pub use client_common::ResponseStream; pub use codex_protocol::models::ContentItem; diff --git a/codex-rs/core/tests/suite/review.rs b/codex-rs/core/tests/suite/review.rs index f65913a2ca..1ea56d5ae3 100644 --- a/codex-rs/core/tests/suite/review.rs +++ b/codex-rs/core/tests/suite/review.rs @@ -2,8 +2,10 @@ use codex_core::CodexAuth; use codex_core::CodexConversation; use codex_core::ConversationManager; use codex_core::ModelProviderInfo; +use codex_core::REVIEW_PROMPT; use codex_core::built_in_model_providers; use codex_core::config::Config; +use codex_core::protocol::ENVIRONMENT_CONTEXT_OPEN_TAG; use codex_core::protocol::EventMsg; use codex_core::protocol::ExitedReviewModeEvent; use codex_core::protocol::InputItem; @@ -419,17 +421,36 @@ async fn review_input_isolated_from_parent_history() { .await; let _complete = wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await; - // Assert the request `input` contains only the single review user message. + // Assert the request `input` contains the environment context followed by the review prompt. let request = &server.received_requests().await.unwrap()[0]; let body = request.body_json::().unwrap(); - let expected_input = serde_json::json!([ - { - "type": "message", - "role": "user", - "content": [{"type": "input_text", "text": review_prompt}] - } - ]); - assert_eq!(body["input"], expected_input); + let input = body["input"].as_array().expect("input array"); + assert_eq!( + input.len(), + 2, + "expected environment context and review prompt" + ); + + let env_msg = &input[0]; + assert_eq!(env_msg["type"].as_str().unwrap(), "message"); + assert_eq!(env_msg["role"].as_str().unwrap(), "user"); + let env_text = env_msg["content"][0]["text"].as_str().expect("env text"); + assert!( + env_text.starts_with(ENVIRONMENT_CONTEXT_OPEN_TAG), + "environment context must be the first item" + ); + assert!( + env_text.contains(""), + "environment context should include cwd" + ); + + let review_msg = &input[1]; + assert_eq!(review_msg["type"].as_str().unwrap(), "message"); + assert_eq!(review_msg["role"].as_str().unwrap(), "user"); + assert_eq!( + review_msg["content"][0]["text"].as_str().unwrap(), + format!("{REVIEW_PROMPT}\n\n---\n\nNow, here's your task: Please review only this",) + ); server.verify().await; } From b8d2b1a5764c31678238286eb4c9b80c9ff3f366 Mon Sep 17 00:00:00 2001 From: Jeremy Rose <172423086+nornagon-openai@users.noreply.github.com> Date: Tue, 16 Sep 2025 16:42:43 -0700 Subject: [PATCH 14/38] restyle thinking outputs (#3755) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Screenshot 2025-09-16 at 2 23 18 PM --- codex-rs/tui/src/chatwidget.rs | 7 +- codex-rs/tui/src/history_cell.rs | 116 +++++++++++++++++++--------- codex-rs/tui/src/markdown_stream.rs | 4 +- codex-rs/tui/src/wrapping.rs | 2 - 4 files changed, 85 insertions(+), 44 deletions(-) diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index c03e7d3bc5..fb91d75456 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -226,12 +226,11 @@ impl ChatWidget { // At the end of a reasoning block, record transcript-only content. self.full_reasoning_buffer.push_str(&self.reasoning_buffer); if !self.full_reasoning_buffer.is_empty() { - for cell in history_cell::new_reasoning_summary_block( + let cell = history_cell::new_reasoning_summary_block( self.full_reasoning_buffer.clone(), &self.config, - ) { - self.add_boxed_history(cell); - } + ); + self.add_boxed_history(cell); } self.reasoning_buffer.clear(); self.full_reasoning_buffer.clear(); diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index d6add6d328..9cdd29dd02 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -121,6 +121,45 @@ impl HistoryCell for UserHistoryCell { } } +#[derive(Debug)] +pub(crate) struct ReasoningSummaryCell { + _header: Vec>, + content: Vec>, +} + +impl ReasoningSummaryCell { + pub(crate) fn new(header: Vec>, content: Vec>) -> Self { + Self { + _header: header, + content, + } + } +} + +impl HistoryCell for ReasoningSummaryCell { + fn display_lines(&self, width: u16) -> Vec> { + let summary_lines = self + .content + .iter() + .map(|l| l.clone().dim().italic()) + .collect::>(); + + word_wrap_lines( + &summary_lines, + RtOptions::new(width as usize) + .initial_indent("• ".into()) + .subsequent_indent(" ".into()), + ) + } + + fn transcript_lines(&self) -> Vec> { + let mut out: Vec> = Vec::new(); + out.push("thinking".magenta().bold().into()); + out.extend(self.content.clone()); + out + } +} + #[derive(Debug)] pub(crate) struct AgentMessageCell { lines: Vec>, @@ -1417,7 +1456,7 @@ pub(crate) fn new_reasoning_block( pub(crate) fn new_reasoning_summary_block( full_reasoning_buffer: String, config: &Config, -) -> Vec> { +) -> Box { if config.model_family.reasoning_summary_format == ReasoningSummaryFormat::Experimental { // Experimental format is following: // ** header ** @@ -1434,27 +1473,19 @@ pub(crate) fn new_reasoning_summary_block( // then we don't have a summary to inject into history if after_close_idx < full_reasoning_buffer.len() { let header_buffer = full_reasoning_buffer[..after_close_idx].to_string(); - let summary_buffer = full_reasoning_buffer[after_close_idx..].to_string(); - - let mut header_lines: Vec> = Vec::new(); - header_lines.push(Line::from("Thinking".magenta().italic())); + let mut header_lines = Vec::new(); append_markdown(&header_buffer, &mut header_lines, config); - let mut summary_lines: Vec> = Vec::new(); - summary_lines.push(Line::from("Thinking".magenta().bold())); + let summary_buffer = full_reasoning_buffer[after_close_idx..].to_string(); + let mut summary_lines = Vec::new(); append_markdown(&summary_buffer, &mut summary_lines, config); - return vec![ - Box::new(TranscriptOnlyHistoryCell { - lines: header_lines, - }), - Box::new(AgentMessageCell::new(summary_lines, true)), - ]; + return Box::new(ReasoningSummaryCell::new(header_lines, summary_lines)); } } } } - vec![Box::new(new_reasoning_block(full_reasoning_buffer, config))] + Box::new(new_reasoning_block(full_reasoning_buffer, config)) } struct OutputLinesParams { @@ -1558,6 +1589,7 @@ mod tests { use codex_core::config::ConfigOverrides; use codex_core::config::ConfigToml; use dirs::home_dir; + use pretty_assertions::assert_eq; fn test_config() -> Config { Config::load_from_base_config_with_overrides( @@ -2076,17 +2108,35 @@ mod tests { let rendered = render_lines(&lines).join("\n"); insta::assert_snapshot!(rendered); } + #[test] + fn reasoning_summary_block() { + let mut config = test_config(); + config.model_family.reasoning_summary_format = ReasoningSummaryFormat::Experimental; + + let cell = new_reasoning_summary_block( + "**High level reasoning**\n\nDetailed reasoning goes here.".to_string(), + &config, + ); + + let rendered_display = render_lines(&cell.display_lines(80)); + assert_eq!(rendered_display, vec!["• Detailed reasoning goes here."]); + + let rendered_transcript = render_transcript(cell.as_ref()); + assert_eq!( + rendered_transcript, + vec!["thinking", "Detailed reasoning goes here."] + ); + } #[test] fn reasoning_summary_block_returns_reasoning_cell_when_feature_disabled() { let mut config = test_config(); config.model_family.reasoning_summary_format = ReasoningSummaryFormat::Experimental; - let cells = + let cell = new_reasoning_summary_block("Detailed reasoning goes here.".to_string(), &config); - assert_eq!(cells.len(), 1); - let rendered = render_transcript(cells[0].as_ref()); + let rendered = render_transcript(cell.as_ref()); assert_eq!(rendered, vec!["thinking", "Detailed reasoning goes here."]); } @@ -2095,13 +2145,12 @@ mod tests { let mut config = test_config(); config.model_family.reasoning_summary_format = ReasoningSummaryFormat::Experimental; - let cells = new_reasoning_summary_block( + let cell = new_reasoning_summary_block( "**High level reasoning without closing".to_string(), &config, ); - assert_eq!(cells.len(), 1); - let rendered = render_transcript(cells[0].as_ref()); + let rendered = render_transcript(cell.as_ref()); assert_eq!( rendered, vec!["thinking", "**High level reasoning without closing"] @@ -2113,25 +2162,23 @@ mod tests { let mut config = test_config(); config.model_family.reasoning_summary_format = ReasoningSummaryFormat::Experimental; - let cells = new_reasoning_summary_block( + let cell = new_reasoning_summary_block( "**High level reasoning without closing**".to_string(), &config, ); - assert_eq!(cells.len(), 1); - let rendered = render_transcript(cells[0].as_ref()); + let rendered = render_transcript(cell.as_ref()); assert_eq!( rendered, vec!["thinking", "High level reasoning without closing"] ); - let cells = new_reasoning_summary_block( + let cell = new_reasoning_summary_block( "**High level reasoning without closing**\n\n ".to_string(), &config, ); - assert_eq!(cells.len(), 1); - let rendered = render_transcript(cells[0].as_ref()); + let rendered = render_transcript(cell.as_ref()); assert_eq!( rendered, vec!["thinking", "High level reasoning without closing"] @@ -2143,21 +2190,18 @@ mod tests { let mut config = test_config(); config.model_family.reasoning_summary_format = ReasoningSummaryFormat::Experimental; - let cells = new_reasoning_summary_block( + let cell = new_reasoning_summary_block( "**High level plan**\n\nWe should fix the bug next.".to_string(), &config, ); - assert_eq!(cells.len(), 2); - - let header_lines = render_transcript(cells[0].as_ref()); - assert_eq!(header_lines, vec!["Thinking", "High level plan"]); - - let summary_lines = render_transcript(cells[1].as_ref()); + let rendered_display = render_lines(&cell.display_lines(80)); + assert_eq!(rendered_display, vec!["• We should fix the bug next."]); + let rendered_transcript = render_transcript(cell.as_ref()); assert_eq!( - summary_lines, - vec!["codex", "Thinking", "We should fix the bug next."] - ) + rendered_transcript, + vec!["thinking", "We should fix the bug next."] + ); } } diff --git a/codex-rs/tui/src/markdown_stream.rs b/codex-rs/tui/src/markdown_stream.rs index 7245afb102..12b4308210 100644 --- a/codex-rs/tui/src/markdown_stream.rs +++ b/codex-rs/tui/src/markdown_stream.rs @@ -333,11 +333,11 @@ mod tests { ); for (i, l) in non_blank.iter().enumerate() { assert_eq!( - l.style.fg, + l.spans[0].style.fg, Some(Color::Green), "wrapped line {} should preserve green style, got {:?}", i, - l.style.fg + l.spans[0].style.fg ); } } diff --git a/codex-rs/tui/src/wrapping.rs b/codex-rs/tui/src/wrapping.rs index bb309784b4..c97f0e3c6c 100644 --- a/codex-rs/tui/src/wrapping.rs +++ b/codex-rs/tui/src/wrapping.rs @@ -187,7 +187,6 @@ where // Build first wrapped line with initial indent. let mut first_line = rt_opts.initial_indent.clone(); - first_line.style = first_line.style.patch(line.style); { let sliced = slice_line_spans(line, &span_bounds, first_line_range); let mut spans = first_line.spans; @@ -216,7 +215,6 @@ where continue; } let mut subsequent_line = rt_opts.subsequent_indent.clone(); - subsequent_line.style = subsequent_line.style.patch(line.style); let offset_range = (r.start + base)..(r.end + base); let sliced = slice_line_spans(line, &span_bounds, &offset_range); let mut spans = subsequent_line.spans; From 72733e34c4caca9c38e894ccb6621834587b9cf7 Mon Sep 17 00:00:00 2001 From: dedrisian-oai Date: Tue, 16 Sep 2025 18:43:32 -0700 Subject: [PATCH 15/38] Add dev message upon review out (#3758) Proposal: We want to record a dev message like so: ``` { "type": "message", "role": "user", "content": [ { "type": "input_text", "text": " User initiated a review task. Here's the full review output from reviewer model. User may select one or more comments to resolve. review {findings_str} " } ] }, ``` Without showing in the chat transcript. Rough idea, but it fixes issue where the user finishes a review thread, and asks the parent "fix the rest of the review issues" thinking that the parent knows about it. ### Question: Why not a tool call? Because the agent didn't make the call, it was a human. + we haven't implemented sub-agents yet, and we'll need to think about the way we represent these human-led tool calls for the agent. --- codex-rs/core/src/codex.rs | 47 ++++++++++++++++- codex-rs/core/src/lib.rs | 1 + codex-rs/core/src/review_format.rs | 55 +++++++++++++++++++ codex-rs/core/tests/suite/review.rs | 82 +++++++++++++++++++++++++++++ 4 files changed, 183 insertions(+), 2 deletions(-) create mode 100644 codex-rs/core/src/review_format.rs diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 59b3b6ae44..b3b75ec76a 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -11,6 +11,7 @@ use std::time::Duration; use crate::AuthManager; use crate::client_common::REVIEW_PROMPT; use crate::event_mapping::map_response_item_to_event_messages; +use crate::review_format::format_review_findings_block; use async_channel::Receiver; use async_channel::Sender; use codex_apply_patch::ApplyPatchAction; @@ -3259,7 +3260,8 @@ fn convert_call_tool_result_to_function_call_output_payload( } } -/// Emits an ExitedReviewMode Event with optional ReviewOutput. +/// Emits an ExitedReviewMode Event with optional ReviewOutput, +/// and records a developer message with the review output. async fn exit_review_mode( session: Arc, task_sub_id: String, @@ -3267,9 +3269,50 @@ async fn exit_review_mode( ) { let event = Event { id: task_sub_id, - msg: EventMsg::ExitedReviewMode(ExitedReviewModeEvent { review_output }), + msg: EventMsg::ExitedReviewMode(ExitedReviewModeEvent { + review_output: review_output.clone(), + }), }; session.send_event(event).await; + + let mut user_message = String::new(); + if let Some(out) = review_output { + let mut findings_str = String::new(); + let text = out.overall_explanation.trim(); + if !text.is_empty() { + findings_str.push_str(text); + } + if !out.findings.is_empty() { + let block = format_review_findings_block(&out.findings, None); + findings_str.push_str(&format!("\n{block}")); + } + user_message.push_str(&format!( + r#" + User initiated a review task. Here's the full review output from reviewer model. User may select one or more comments to resolve. + review + + {findings_str} + + +"#)); + } else { + user_message.push_str(r#" + User initiated a review task, but was interrupted. If user asks about this, tell them to re-initiate a review with `/review` and wait for it to complete. + review + + None. + + +"#); + } + + session + .record_conversation_items(&[ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { text: user_message }], + }]) + .await; } #[cfg(test)] diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index aa2e176b07..e024effbe2 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -46,6 +46,7 @@ pub use model_provider_info::built_in_model_providers; pub use model_provider_info::create_oss_provider_with_base_url; mod conversation_manager; mod event_mapping; +pub mod review_format; pub use codex_protocol::protocol::InitialHistory; pub use conversation_manager::ConversationManager; pub use conversation_manager::NewConversation; diff --git a/codex-rs/core/src/review_format.rs b/codex-rs/core/src/review_format.rs new file mode 100644 index 0000000000..272010d564 --- /dev/null +++ b/codex-rs/core/src/review_format.rs @@ -0,0 +1,55 @@ +use crate::protocol::ReviewFinding; + +// Note: We keep this module UI-agnostic. It returns plain strings that +// higher layers (e.g., TUI) may style as needed. + +fn format_location(item: &ReviewFinding) -> String { + let path = item.code_location.absolute_file_path.display(); + let start = item.code_location.line_range.start; + let end = item.code_location.line_range.end; + format!("{path}:{start}-{end}") +} + +/// Format a full review findings block as plain text lines. +/// +/// - When `selection` is `Some`, each item line includes a checkbox marker: +/// "[x]" for selected items and "[ ]" for unselected. Missing indices +/// default to selected. +/// - When `selection` is `None`, the marker is omitted and a simple bullet is +/// rendered ("- Title — path:start-end"). +pub fn format_review_findings_block( + findings: &[ReviewFinding], + selection: Option<&[bool]>, +) -> String { + let mut lines: Vec = Vec::new(); + + // Header + let header = if findings.len() > 1 { + "Full review comments:" + } else { + "Review comment:" + }; + lines.push(header.to_string()); + + for (idx, item) in findings.iter().enumerate() { + lines.push(String::new()); + + let title = &item.title; + let location = format_location(item); + + if let Some(flags) = selection { + // Default to selected if index is out of bounds. + let checked = flags.get(idx).copied().unwrap_or(true); + let marker = if checked { "[x]" } else { "[ ]" }; + lines.push(format!("- {marker} {title} — {location}")); + } else { + lines.push(format!("- {title} — {location}")); + } + + for body_line in item.body.lines() { + lines.push(format!(" {body_line}")); + } + } + + lines.join("\n") +} diff --git a/codex-rs/core/tests/suite/review.rs b/codex-rs/core/tests/suite/review.rs index 1ea56d5ae3..a20807e4ec 100644 --- a/codex-rs/core/tests/suite/review.rs +++ b/codex-rs/core/tests/suite/review.rs @@ -1,10 +1,13 @@ use codex_core::CodexAuth; use codex_core::CodexConversation; +use codex_core::ContentItem; use codex_core::ConversationManager; use codex_core::ModelProviderInfo; use codex_core::REVIEW_PROMPT; +use codex_core::ResponseItem; use codex_core::built_in_model_providers; use codex_core::config::Config; +use codex_core::protocol::ConversationPathResponseEvent; use codex_core::protocol::ENVIRONMENT_CONTEXT_OPEN_TAG; use codex_core::protocol::EventMsg; use codex_core::protocol::ExitedReviewModeEvent; @@ -15,6 +18,8 @@ use codex_core::protocol::ReviewFinding; use codex_core::protocol::ReviewLineRange; use codex_core::protocol::ReviewOutputEvent; use codex_core::protocol::ReviewRequest; +use codex_core::protocol::RolloutItem; +use codex_core::protocol::RolloutLine; use codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR; use core_test_support::load_default_config_for_test; use core_test_support::load_sse_fixture_with_id_from_str; @@ -117,6 +122,46 @@ async fn review_op_emits_lifecycle_and_review_output() { assert_eq!(expected, review); let _complete = wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await; + // Also verify that a user message with the header and a formatted finding + // was recorded back in the parent session's rollout. + codex.submit(Op::GetPath).await.unwrap(); + let history_event = + wait_for_event(&codex, |ev| matches!(ev, EventMsg::ConversationPath(_))).await; + let path = match history_event { + EventMsg::ConversationPath(ConversationPathResponseEvent { path, .. }) => path, + other => panic!("expected ConversationPath event, got {other:?}"), + }; + let text = std::fs::read_to_string(&path).expect("read rollout file"); + + let mut saw_header = false; + let mut saw_finding_line = false; + for line in text.lines() { + if line.trim().is_empty() { + continue; + } + let v: serde_json::Value = serde_json::from_str(line).expect("jsonl line"); + let rl: RolloutLine = serde_json::from_value(v).expect("rollout line"); + if let RolloutItem::ResponseItem(ResponseItem::Message { role, content, .. }) = rl.item + && role == "user" + { + for c in content { + if let ContentItem::InputText { text } = c { + if text.contains("full review output from reviewer model") { + saw_header = true; + } + if text.contains("- Prefer Stylize helpers — /tmp/file.rs:10-20") { + saw_finding_line = true; + } + } + } + } + } + assert!(saw_header, "user header missing from rollout"); + assert!( + saw_finding_line, + "formatted finding line missing from rollout" + ); + server.verify().await; } @@ -452,6 +497,43 @@ async fn review_input_isolated_from_parent_history() { format!("{REVIEW_PROMPT}\n\n---\n\nNow, here's your task: Please review only this",) ); + // Also verify that a user interruption note was recorded in the rollout. + codex.submit(Op::GetPath).await.unwrap(); + let history_event = + wait_for_event(&codex, |ev| matches!(ev, EventMsg::ConversationPath(_))).await; + let path = match history_event { + EventMsg::ConversationPath(ConversationPathResponseEvent { path, .. }) => path, + other => panic!("expected ConversationPath event, got {other:?}"), + }; + let text = std::fs::read_to_string(&path).expect("read rollout file"); + let mut saw_interruption_message = false; + for line in text.lines() { + if line.trim().is_empty() { + continue; + } + let v: serde_json::Value = serde_json::from_str(line).expect("jsonl line"); + let rl: RolloutLine = serde_json::from_value(v).expect("rollout line"); + if let RolloutItem::ResponseItem(ResponseItem::Message { role, content, .. }) = rl.item + && role == "user" + { + for c in content { + if let ContentItem::InputText { text } = c + && text.contains("User initiated a review task, but was interrupted.") + { + saw_interruption_message = true; + break; + } + } + } + if saw_interruption_message { + break; + } + } + assert!( + saw_interruption_message, + "expected user interruption message in rollout" + ); + server.verify().await; } From 791d7b125f4166ef576531075688aac339350011 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Tue, 16 Sep 2025 20:33:59 -0700 Subject: [PATCH 16/38] fix: make GitHub Action publish to npm using trusted publishing (#3431) --- .github/workflows/rust-release.yml | 23 ++++++ codex-cli/package.json | 3 +- docs/release_management.md | 9 +-- scripts/publish_to_npm.py | 118 ----------------------------- 4 files changed, 26 insertions(+), 127 deletions(-) delete mode 100755 scripts/publish_to_npm.py diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index 07af62a17c..804ac83d96 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -11,6 +11,9 @@ on: tags: - "rust-v*.*.*" +permissions: + id-token: write # Required for OIDC + concurrency: group: ${{ github.workflow }} cancel-in-progress: true @@ -187,6 +190,20 @@ jobs: version="${GITHUB_REF_NAME#rust-v}" echo "name=${version}" >> $GITHUB_OUTPUT + # Publish to npm using OIDC authentication. + # July 31, 2025: https://github.blog/changelog/2025-07-31-npm-trusted-publishing-with-oidc-is-generally-available/ + # npm docs: https://docs.npmjs.com/trusted-publishers + - name: Setup Node.js + uses: actions/setup-node@v5 + with: + node-version: 22 + registry-url: "https://registry.npmjs.org" + scope: "@openai" + + # Trusted publishing requires npm CLI version 11.5.1 or later. + - name: Update npm + run: npm install -g npm@latest + - name: Stage npm package env: GH_TOKEN: ${{ github.token }} @@ -220,6 +237,12 @@ jobs: tag: ${{ github.ref_name }} config: .github/dotslash-config.json + # No NODE_AUTH_TOKEN needed because we use OIDC. + - name: Publish to npm + # Do not publish alphas to npm. + if: ${{ !contains(steps.release_name.outputs.name, '-') }} + run: npm publish "${GITHUB_WORKSPACE}/dist/npm/codex-npm-${{ steps.release_name.outputs.name }}.tgz" + update-branch: name: Update latest-alpha-cli branch permissions: diff --git a/codex-cli/package.json b/codex-cli/package.json index 614ca1a832..02124f3257 100644 --- a/codex-cli/package.json +++ b/codex-cli/package.json @@ -15,7 +15,8 @@ ], "repository": { "type": "git", - "url": "git+https://github.com/openai/codex.git" + "url": "git+https://github.com/openai/codex.git", + "directory": "codex-cli" }, "dependencies": { "@vscode/ripgrep": "^1.15.14" diff --git a/docs/release_management.md b/docs/release_management.md index ed12de6e4a..d2b3264554 100644 --- a/docs/release_management.md +++ b/docs/release_management.md @@ -30,14 +30,7 @@ When the workflow finishes, the GitHub Release is "done," but you still have to ## Publishing to npm -After the GitHub Release is done, you can publish to npm. Note the GitHub Release includes the appropriate artifact for npm (which is the output of `npm pack`), which should be named `codex-npm-VERSION.tgz`. To publish to npm, run: - -``` -VERSION=0.21.0 -./scripts/publish_to_npm.py "$VERSION" -``` - -Note that you must have permissions to publish to https://www.npmjs.com/package/@openai/codex for this to succeed. +The GitHub Action is responsible for publishing to npm. ## Publishing to Homebrew diff --git a/scripts/publish_to_npm.py b/scripts/publish_to_npm.py deleted file mode 100755 index f79843ffc8..0000000000 --- a/scripts/publish_to_npm.py +++ /dev/null @@ -1,118 +0,0 @@ -#!/usr/bin/env python3 - -""" -Download a release artifact for the npm package and publish it. - -Given a release version like `0.20.0`, this script: - - Downloads the `codex-npm-.tgz` asset from the GitHub release - tagged `rust-v` in the `openai/codex` repository using `gh`. - - Runs `npm publish` on the downloaded tarball to publish `@openai/codex`. - -Flags: - - `--dry-run` delegates to `npm publish --dry-run`. The artifact is still - downloaded so npm can inspect the archive contents without publishing. - -Requirements: - - GitHub CLI (`gh`) must be installed and authenticated to access the repo. - - npm must be logged in with an account authorized to publish - `@openai/codex`. This may trigger a browser for 2FA. -""" - -import argparse -import os -import subprocess -import sys -import tempfile -from pathlib import Path - - -def run_checked(cmd: list[str], cwd: Path | None = None) -> None: - """Run a subprocess command and raise if it fails.""" - proc = subprocess.run(cmd, cwd=str(cwd) if cwd else None) - proc.check_returncode() - - -def main() -> int: - parser = argparse.ArgumentParser( - description=( - "Download the npm release artifact for a given version and publish it." - ) - ) - parser.add_argument( - "version", - help="Release version to publish, e.g. 0.20.0 (without the 'v' prefix)", - ) - parser.add_argument( - "--dir", - type=Path, - help=( - "Optional directory to download the artifact into. Defaults to a temporary directory." - ), - ) - parser.add_argument( - "-n", - "--dry-run", - action="store_true", - help="Delegate to `npm publish --dry-run` (still downloads the artifact).", - ) - args = parser.parse_args() - - version: str = args.version.lstrip("v") - tag = f"rust-v{version}" - asset_name = f"codex-npm-{version}.tgz" - - download_dir_context_manager = ( - tempfile.TemporaryDirectory() if args.dir is None else None - ) - # Use provided dir if set, else the temporary one created above - download_dir: Path = args.dir if args.dir else Path(download_dir_context_manager.name) # type: ignore[arg-type] - download_dir.mkdir(parents=True, exist_ok=True) - - # 1) Download the artifact using gh - repo = "openai/codex" - gh_cmd = [ - "gh", - "release", - "download", - tag, - "--repo", - repo, - "--pattern", - asset_name, - "--dir", - str(download_dir), - ] - print(f"Downloading {asset_name} from {repo}@{tag} into {download_dir}...") - # Even in --dry-run we download so npm can inspect the tarball. - run_checked(gh_cmd) - - artifact_path = download_dir / asset_name - if not args.dry_run and not artifact_path.is_file(): - print( - f"Error: expected artifact not found after download: {artifact_path}", - file=sys.stderr, - ) - return 1 - - # 2) Publish to npm - npm_cmd = ["npm", "publish"] - if args.dry_run: - npm_cmd.append("--dry-run") - npm_cmd.append(str(artifact_path)) - - # Ensure CI is unset so npm can open a browser for 2FA if needed. - env = os.environ.copy() - if env.get("CI"): - env.pop("CI") - - print("Running:", " ".join(npm_cmd)) - proc = subprocess.run(npm_cmd, env=env) - proc.check_returncode() - - print("Publish complete.") - # Keep the temporary directory alive until here; it is cleaned up on exit - return 0 - - -if __name__ == "__main__": - sys.exit(main()) From 5d87f5d24a2fe0364ecc90521a9581e5a31d6df1 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Tue, 16 Sep 2025 21:36:13 -0700 Subject: [PATCH 17/38] fix: ensure pnpm is installed before running `npm install` (#3763) Note we do the same thing in `ci.yml`: https://github.com/openai/codex/blob/791d7b125f4166ef576531075688aac339350011/.github/workflows/ci.yml#L17-L25 --- .github/workflows/rust-release.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index 804ac83d96..1db294baab 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -193,6 +193,14 @@ jobs: # Publish to npm using OIDC authentication. # July 31, 2025: https://github.blog/changelog/2025-07-31-npm-trusted-publishing-with-oidc-is-generally-available/ # npm docs: https://docs.npmjs.com/trusted-publishers + + # package.json has `packageManager: "pnpm@`, so we must get pnpm on the + # PATH before setting up Node.js. + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + run_install: false + - name: Setup Node.js uses: actions/setup-node@v5 with: From 5332f6e2156e83a2dcd654a98a72696aa455f750 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Tue, 16 Sep 2025 22:55:53 -0700 Subject: [PATCH 18/38] fix: make publish-npm its own job with specific permissions (#3767) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The build for `v0.37.0-alpha.3` failed on the `Create GitHub Release` step: https://github.com/openai/codex/actions/runs/17786866086/job/50556513221 with: ``` ⚠️ GitHub release failed with status: 403 {"message":"Resource not accessible by integration","documentation_url":"https://docs.github.com/rest/releases/releases#create-a-release","status":"403"} Skip retry — your GitHub token/PAT does not have the required permission to create a release ``` I believe I should have not introduced a top-level `permissions` for the workflow in https://github.com/openai/codex/pull/3431 because that affected the `permissions` for each job in the workflow. This PR introduces `publish-npm` as its own job, which allows us to: - consolidate all the Node.js-related steps required for publishing - limit the reach of the `id-token: write` permission - skip it altogether if is an alpha build With this PR, each of `release`, `publish-npm`, and `update-branch` has an explicit `permissions` block. --- .github/workflows/rust-release.yml | 72 ++++++++++++++++++------------ 1 file changed, 44 insertions(+), 28 deletions(-) diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index 1db294baab..8e6ac6582c 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -11,9 +11,6 @@ on: tags: - "rust-v*.*.*" -permissions: - id-token: write # Required for OIDC - concurrency: group: ${{ github.workflow }} cancel-in-progress: true @@ -170,6 +167,12 @@ jobs: needs: build name: release runs-on: ubuntu-latest + permissions: + contents: write + actions: read + outputs: + version: ${{ steps.release_name.outputs.name }} + tag: ${{ github.ref_name }} steps: - name: Checkout repository @@ -190,28 +193,6 @@ jobs: version="${GITHUB_REF_NAME#rust-v}" echo "name=${version}" >> $GITHUB_OUTPUT - # Publish to npm using OIDC authentication. - # July 31, 2025: https://github.blog/changelog/2025-07-31-npm-trusted-publishing-with-oidc-is-generally-available/ - # npm docs: https://docs.npmjs.com/trusted-publishers - - # package.json has `packageManager: "pnpm@`, so we must get pnpm on the - # PATH before setting up Node.js. - - name: Setup pnpm - uses: pnpm/action-setup@v4 - with: - run_install: false - - - name: Setup Node.js - uses: actions/setup-node@v5 - with: - node-version: 22 - registry-url: "https://registry.npmjs.org" - scope: "@openai" - - # Trusted publishing requires npm CLI version 11.5.1 or later. - - name: Update npm - run: npm install -g npm@latest - - name: Stage npm package env: GH_TOKEN: ${{ github.token }} @@ -245,11 +226,46 @@ jobs: tag: ${{ github.ref_name }} config: .github/dotslash-config.json + # Publish to npm using OIDC authentication. + # July 31, 2025: https://github.blog/changelog/2025-07-31-npm-trusted-publishing-with-oidc-is-generally-available/ + # npm docs: https://docs.npmjs.com/trusted-publishers + publish-npm: + # Skip this step for pre-releases (alpha/beta). + if: ${{ !contains(needs.release.outputs.version, '-') }} + name: publish-npm + needs: release + runs-on: ubuntu-latest + permissions: + id-token: write # Required for OIDC + contents: read + + steps: + - name: Setup Node.js + uses: actions/setup-node@v5 + with: + node-version: 22 + registry-url: "https://registry.npmjs.org" + scope: "@openai" + + # Trusted publishing requires npm CLI version 11.5.1 or later. + - name: Update npm + run: npm install -g npm@latest + + - name: Download npm tarball from release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + version="${{ needs.release.outputs.version }}" + tag="${{ needs.release.outputs.tag }}" + mkdir -p dist/npm + gh release download "$tag" \ + --pattern "codex-npm-${version}.tgz" \ + --dir dist/npm + # No NODE_AUTH_TOKEN needed because we use OIDC. - name: Publish to npm - # Do not publish alphas to npm. - if: ${{ !contains(steps.release_name.outputs.name, '-') }} - run: npm publish "${GITHUB_WORKSPACE}/dist/npm/codex-npm-${{ steps.release_name.outputs.name }}.tgz" + run: npm publish "${GITHUB_WORKSPACE}/dist/npm/codex-npm-${{ needs.release.outputs.version }}.tgz" update-branch: name: Update latest-alpha-cli branch From e5fdb5b0fd159c0ea80e872ff2111b49391fbbc7 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Wed, 17 Sep 2025 11:05:22 -0700 Subject: [PATCH 19/38] fix: specify --repo when calling gh (#3806) Often, `gh` infers `--repo` when it is run from a Git clone, but our `publish-npm` step is designed to avoid the overhead of cloning the repo, so add the `--repo` option explicitly to fix things. --- .github/workflows/rust-release.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index 8e6ac6582c..e3776f149c 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -260,6 +260,7 @@ jobs: tag="${{ needs.release.outputs.tag }}" mkdir -p dist/npm gh release download "$tag" \ + --repo "${GITHUB_REPOSITORY}" \ --pattern "codex-npm-${version}.tgz" \ --dir dist/npm From 208089e58e9e5c49a3669dc995d8854560480a66 Mon Sep 17 00:00:00 2001 From: Abhishek Bhardwaj Date: Wed, 17 Sep 2025 11:06:59 -0700 Subject: [PATCH 20/38] AGENTS.md: Add instruction to install missing commands (#3807) This change instructs the model to install any missing command. Else tokens are wasted when it tries to run commands that aren't available multiple times before installing them. --- AGENTS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/AGENTS.md b/AGENTS.md index 697de7009f..288719447e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,6 +4,7 @@ In the codex-rs folder where the rust code lives: - Crate names are prefixed with `codex-`. For example, the `core` folder's crate is named `codex-core` - When using format! and you can inline variables into {}, always do that. +- Install any commands the repo relies on (for example `just`, `rg`, or `cargo-insta`) if they aren't already available before running instructions here. - Never add or modify any code related to `CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR` or `CODEX_SANDBOX_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. - Similarly, when you spawn a process using Seatbelt (`/usr/bin/sandbox-exec`), `CODEX_SANDBOX=seatbelt` will be set on the child process. Integration tests that want to run Seatbelt themselves cannot be run under Seatbelt, so checks for `CODEX_SANDBOX=seatbelt` are also often used to early exit out of tests, as appropriate. From 530382db0512e72cdd4e94271e2cbdb1d14b0b73 Mon Sep 17 00:00:00 2001 From: Jeremy Rose <172423086+nornagon-openai@users.noreply.github.com> Date: Wed, 17 Sep 2025 11:23:46 -0700 Subject: [PATCH 21/38] Use agent reply text in turn notifications (#3756) Instead of "Agent turn complete", turn-complete notifications now include the first handful of chars from the agent's final message. --- codex-rs/tui/src/chatwidget.rs | 37 ++++++++++++++++++++++++++++------ 1 file changed, 31 insertions(+), 6 deletions(-) diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index fb91d75456..7ab954f263 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -255,7 +255,7 @@ impl ChatWidget { self.request_redraw(); } - fn on_task_complete(&mut self) { + fn on_task_complete(&mut self, last_agent_message: Option) { // If a stream is currently active, finalize only that stream to flush any tail // without emitting stray headers for other streams. if self.stream.is_write_cycle_active() { @@ -270,7 +270,9 @@ impl ChatWidget { // If there is a queued user message, send exactly one now to begin the next turn. self.maybe_send_next_queued_input(); // Emit a notification when the turn completes (suppressed if focused). - self.notify(Notification::AgentTurnComplete); + self.notify(Notification::AgentTurnComplete { + response: last_agent_message.unwrap_or_default(), + }); } pub(crate) fn set_token_info(&mut self, info: Option) { @@ -1082,7 +1084,9 @@ impl ChatWidget { } EventMsg::AgentReasoningSectionBreak(_) => self.on_reasoning_section_break(), EventMsg::TaskStarted(_) => self.on_task_started(), - EventMsg::TaskComplete(TaskCompleteEvent { .. }) => self.on_task_complete(), + EventMsg::TaskComplete(TaskCompleteEvent { last_agent_message }) => { + self.on_task_complete(last_agent_message) + } EventMsg::TokenCount(ev) => self.set_token_info(ev.info), EventMsg::Error(ErrorEvent { message }) => self.on_error(message), EventMsg::TurnAborted(ev) => match ev.reason { @@ -1479,7 +1483,7 @@ impl WidgetRef for &ChatWidget { } enum Notification { - AgentTurnComplete, + AgentTurnComplete { response: String }, ExecApprovalRequested { command: String }, EditApprovalRequested { cwd: PathBuf, changes: Vec }, } @@ -1487,7 +1491,10 @@ enum Notification { impl Notification { fn display(&self) -> String { match self { - Notification::AgentTurnComplete => "Agent turn complete".to_string(), + Notification::AgentTurnComplete { response } => { + Notification::agent_turn_preview(response) + .unwrap_or_else(|| "Agent turn complete".to_string()) + } Notification::ExecApprovalRequested { command } => { format!("Approval requested: {}", truncate_text(command, 30)) } @@ -1507,7 +1514,7 @@ impl Notification { fn type_name(&self) -> &str { match self { - Notification::AgentTurnComplete => "agent-turn-complete", + Notification::AgentTurnComplete { .. } => "agent-turn-complete", Notification::ExecApprovalRequested { .. } | Notification::EditApprovalRequested { .. } => "approval-requested", } @@ -1519,8 +1526,26 @@ impl Notification { Notifications::Custom(allowed) => allowed.iter().any(|a| a == self.type_name()), } } + + fn agent_turn_preview(response: &str) -> Option { + let mut normalized = String::new(); + for part in response.split_whitespace() { + if !normalized.is_empty() { + normalized.push(' '); + } + normalized.push_str(part); + } + let trimmed = normalized.trim(); + if trimmed.is_empty() { + None + } else { + Some(truncate_text(trimmed, AGENT_NOTIFICATION_PREVIEW_GRAPHEMES)) + } + } } +const AGENT_NOTIFICATION_PREVIEW_GRAPHEMES: usize = 200; + const EXAMPLE_PROMPTS: [&str; 6] = [ "Explain this codebase", "Summarize recent commits", From c9505488a120299b339814d73f57817ee79e114f Mon Sep 17 00:00:00 2001 From: Thibault Sottiaux Date: Wed, 17 Sep 2025 16:48:20 -0700 Subject: [PATCH 22/38] chore: update "Codex CLI harness, sandboxing, and approvals" section (#3822) --- codex-rs/core/gpt_5_codex_prompt.md | 30 ++++++++++++++++------------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/codex-rs/core/gpt_5_codex_prompt.md b/codex-rs/core/gpt_5_codex_prompt.md index 2c49fafec6..9a298f460f 100644 --- a/codex-rs/core/gpt_5_codex_prompt.md +++ b/codex-rs/core/gpt_5_codex_prompt.md @@ -26,37 +26,41 @@ When using the planning tool: ## Codex CLI harness, sandboxing, and approvals -The Codex CLI harness supports several different sandboxing, and approval configurations that the user can choose from. +The Codex CLI harness supports several different configurations for sandboxing and escalation approvals that the user can choose from. -Filesystem sandboxing defines which files can be read or written. The options are: -- **read-only**: You can only read files. -- **workspace-write**: You can read files. You can write to files in this folder, but not outside it. -- **danger-full-access**: No filesystem sandboxing. +Filesystem sandboxing defines which files can be read or written. The options for `sandbox_mode` are: +- **read-only**: The sandbox only permits reading files. +- **workspace-write**: The sandbox permits reading files, and editing files in `cwd` and `writable_roots`. Editing files in other directories requires approval. +- **danger-full-access**: No filesystem sandboxing - all commands are permitted. -Network sandboxing defines whether network can be accessed without approval. Options are +Network sandboxing defines whether network can be accessed without approval. Options for `network_access` are: - **restricted**: Requires approval - **enabled**: No approval needed -Approvals are your mechanism to get user consent to perform more privileged actions. Although they introduce friction to the user because your work is paused until the user responds, you should leverage them to accomplish your important work. Do not let these settings or the sandbox deter you from attempting to accomplish the user's task unless it is set to "never", in which case never ask for approvals. - -Approval options are +Approvals are your mechanism to get user consent to run shell commands without the sandbox. Possible configuration options for `approval_policy` are - **untrusted**: The harness will escalate most commands for user approval, apart from a limited allowlist of safe "read" commands. - **on-failure**: The harness will allow all commands to run in the sandbox (if enabled), and failures will be escalated to the user for approval to run again without the sandbox. - **on-request**: Commands will be run in the sandbox by default, and you can specify in your tool call if you want to escalate a command to run without sandboxing. (Note that this mode is not always available. If it is, you'll see parameters for it in the `shell` command description.) - **never**: This is a non-interactive mode where you may NEVER ask the user for approval to run commands. Instead, you must always persist and work around constraints to solve the task for the user. You MUST do your utmost best to finish the task and validate your work before yielding. If this mode is paired with `danger-full-access`, take advantage of it to deliver the best outcome for the user. Further, in this mode, your default testing philosophy is overridden: Even if you don't see local patterns for testing, you may add tests and scripts to validate your work. Just remove them before yielding. -When you are running with approvals `on-request`, and sandboxing enabled, here are scenarios where you'll need to request approval: -- You need to run a command that writes to a directory that requires it (e.g. running tests that write to /tmp) +When you are running with `approval_policy == on-request`, and sandboxing enabled, here are scenarios where you'll need to request approval: +- You need to run a command that writes to a directory that requires it (e.g. running tests that write to /var) - You need to run a GUI app (e.g., open/xdg-open/osascript) to open browsers or files. - You are running sandboxed and need to run a command that requires network access (e.g. installing packages) -- If you run a command that is important to solving the user's query, but it fails because of sandboxing, rerun the command with approval. +- If you run a command that is important to solving the user's query, but it fails because of sandboxing, rerun the command with approval. ALWAYS proceed to use the `with_escalated_permissions` and `justification` parameters - do not message the user before requesting approval for the command. - You are about to take a potentially destructive action such as an `rm` or `git reset` that the user did not explicitly ask for - (for all of these, you should weigh alternative paths that do not require approval) -When sandboxing is set to read-only, you'll need to request approval for any command that isn't a read. +When `sandbox_mode` is set to read-only, you'll need to request approval for any command that isn't a read. You will be told what filesystem sandboxing, network sandboxing, and approval mode are active in a developer or user message. If you are not told about this, assume that you are running with workspace-write, network sandboxing enabled, and approval on-failure. +Although they introduce friction to the user because your work is paused until the user responds, you should leverage them when necessary to accomplish important work. If the completing the task requires escalated permissions, Do not let these settings or the sandbox deter you from attempting to accomplish the user's task unless it is set to "never", in which case never ask for approvals. + +When requesting approval to execute a command that will require escalated privileges: + - Provide the `with_escalated_permissions` parameter with the boolean value true + - Include a short, 1 sentence explanation for why you need to enable `with_escalated_permissions` in the justification parameter + ## Special user requests - If the user makes a simple request (such as asking for the time) which you can fulfill by running a terminal command (such as `date`), you should do so. From 4c97eeb32a776a323f840f168ef96c2ad12b3178 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Thu, 18 Sep 2025 10:43:45 +0100 Subject: [PATCH 23/38] bug: Ignore tests for now (#3777) Ignore flaky / long tests for now --- codex-rs/core/src/unified_exec/mod.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/codex-rs/core/src/unified_exec/mod.rs b/codex-rs/core/src/unified_exec/mod.rs index 99b19796b8..8b81345d4d 100644 --- a/codex-rs/core/src/unified_exec/mod.rs +++ b/codex-rs/core/src/unified_exec/mod.rs @@ -557,6 +557,7 @@ mod tests { #[cfg(unix)] #[tokio::test] + #[ignore] // Ignored while we have a better way to test this. async fn requests_with_large_timeout_are_capped() -> Result<(), UnifiedExecError> { let manager = UnifiedExecSessionManager::default(); @@ -578,6 +579,7 @@ mod tests { #[cfg(unix)] #[tokio::test] + #[ignore] // Ignored while we have a better way to test this. async fn completed_commands_do_not_persist_sessions() -> Result<(), UnifiedExecError> { let manager = UnifiedExecSessionManager::default(); let result = manager From d4aba772cbfb8df33ef5e3a326cd1ecf251385e9 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Thu, 18 Sep 2025 07:37:03 -0700 Subject: [PATCH 24/38] Switch to uuid_v7 and tighten ConversationId usage (#3819) Make sure conversations have a timestamp. --- codex-rs/core/Cargo.toml | 9 ++- codex-rs/mcp-server/Cargo.toml | 2 +- .../mcp-server/src/codex_message_processor.rs | 6 +- codex-rs/mcp-server/src/message_processor.rs | 5 +- .../mcp-server/tests/suite/list_resume.rs | 2 +- .../mcp-server/tests/suite/send_message.rs | 2 +- codex-rs/protocol/Cargo.toml | 8 ++- codex-rs/protocol/src/mcp_protocol.rs | 61 +++++++++++++++---- codex-rs/protocol/src/protocol.rs | 3 +- 9 files changed, 73 insertions(+), 25 deletions(-) diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index b4ed4a937a..9973a00442 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -4,9 +4,9 @@ name = "codex-core" version = { workspace = true } [lib] +doctest = false name = "codex_core" path = "src/lib.rs" -doctest = false [lints] workspace = true @@ -41,7 +41,12 @@ similar = "2.7.0" strum_macros = "0.27.2" tempfile = "3" thiserror = "2.0.16" -time = { version = "0.3", features = ["formatting", "parsing", "local-offset", "macros"] } +time = { version = "0.3", features = [ + "formatting", + "parsing", + "local-offset", + "macros", +] } tokio = { version = "1", features = [ "io-std", "macros", diff --git a/codex-rs/mcp-server/Cargo.toml b/codex-rs/mcp-server/Cargo.toml index e068ba648f..9eb5f6eba6 100644 --- a/codex-rs/mcp-server/Cargo.toml +++ b/codex-rs/mcp-server/Cargo.toml @@ -36,7 +36,7 @@ tokio = { version = "1", features = [ toml = "0.9" tracing = { version = "0.1.41", features = ["log"] } tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } -uuid = { version = "1", features = ["serde", "v4"] } +uuid = { version = "1", features = ["serde", "v7"] } [dev-dependencies] assert_cmd = "2" diff --git a/codex-rs/mcp-server/src/codex_message_processor.rs b/codex-rs/mcp-server/src/codex_message_processor.rs index ceb47889be..af0ffefeda 100644 --- a/codex-rs/mcp-server/src/codex_message_processor.rs +++ b/codex-rs/mcp-server/src/codex_message_processor.rs @@ -814,7 +814,7 @@ impl CodexMessageProcessor { return; }; - let required_suffix = format!("{}.jsonl", conversation_id.0); + let required_suffix = format!("{conversation_id}.jsonl"); let Some(file_name) = canonical_rollout_path.file_name().map(OsStr::to_owned) else { let error = JSONRPCErrorError { code: INVALID_REQUEST_ERROR_CODE, @@ -1414,13 +1414,13 @@ mod tests { #[test] fn extract_conversation_summary_prefers_plain_user_messages() { let conversation_id = - ConversationId(Uuid::parse_str("3f941c35-29b3-493b-b0a4-e25800d9aeb0").unwrap()); + ConversationId::from_string("3f941c35-29b3-493b-b0a4-e25800d9aeb0").unwrap(); let timestamp = Some("2025-09-05T16:53:11.850Z".to_string()); let path = PathBuf::from("rollout.jsonl"); let head = vec![ json!({ - "id": conversation_id.0, + "id": conversation_id.to_string(), "timestamp": timestamp, "cwd": "/", "originator": "codex", diff --git a/codex-rs/mcp-server/src/message_processor.rs b/codex-rs/mcp-server/src/message_processor.rs index c696d4538b..5868d60fc0 100644 --- a/codex-rs/mcp-server/src/message_processor.rs +++ b/codex-rs/mcp-server/src/message_processor.rs @@ -36,7 +36,6 @@ use serde_json::json; use std::sync::Arc; use tokio::sync::Mutex; use tokio::task; -use uuid::Uuid; pub(crate) struct MessageProcessor { codex_message_processor: CodexMessageProcessor, @@ -484,8 +483,8 @@ impl MessageProcessor { return; } }; - let conversation_id = match Uuid::parse_str(&conversation_id) { - Ok(id) => ConversationId::from(id), + let conversation_id = match ConversationId::from_string(&conversation_id) { + Ok(id) => id, Err(e) => { tracing::error!("Failed to parse conversation_id: {e}"); let result = CallToolResult { diff --git a/codex-rs/mcp-server/tests/suite/list_resume.rs b/codex-rs/mcp-server/tests/suite/list_resume.rs index 9b5748cab5..9302b42990 100644 --- a/codex-rs/mcp-server/tests/suite/list_resume.rs +++ b/codex-rs/mcp-server/tests/suite/list_resume.rs @@ -142,7 +142,7 @@ async fn test_list_and_resume_conversations() { } = to_response::(resume_resp) .expect("deserialize resumeConversation response"); // conversation id should be a valid UUID - let _: uuid::Uuid = conversation_id.into(); + assert!(!conversation_id.to_string().is_empty()); } fn create_fake_rollout(codex_home: &Path, filename_ts: &str, meta_rfc3339: &str, preview: &str) { diff --git a/codex-rs/mcp-server/tests/suite/send_message.rs b/codex-rs/mcp-server/tests/suite/send_message.rs index 8f607776fc..158cb12d1c 100644 --- a/codex-rs/mcp-server/tests/suite/send_message.rs +++ b/codex-rs/mcp-server/tests/suite/send_message.rs @@ -136,7 +136,7 @@ async fn test_send_message_session_not_found() { .expect("timeout") .expect("init"); - let unknown = ConversationId(uuid::Uuid::new_v4()); + let unknown = ConversationId::new(); let req_id = mcp .send_send_user_message_request(SendUserMessageParams { conversation_id: unknown, diff --git a/codex-rs/protocol/Cargo.toml b/codex-rs/protocol/Cargo.toml index f88297a07b..bbe2ed3f65 100644 --- a/codex-rs/protocol/Cargo.toml +++ b/codex-rs/protocol/Cargo.toml @@ -23,8 +23,12 @@ strum = "0.27.2" strum_macros = "0.27.2" sys-locale = "0.3.2" tracing = "0.1.41" -ts-rs = { version = "11", features = ["uuid-impl", "serde-json-impl", "no-serde-warnings"] } -uuid = { version = "1", features = ["serde", "v4"] } +ts-rs = { version = "11", features = [ + "uuid-impl", + "serde-json-impl", + "no-serde-warnings", +] } +uuid = { version = "1", features = ["serde", "v7"] } [dev-dependencies] pretty_assertions = "1.4.1" diff --git a/codex-rs/protocol/src/mcp_protocol.rs b/codex-rs/protocol/src/mcp_protocol.rs index fe77d20b66..c9137d0f94 100644 --- a/codex-rs/protocol/src/mcp_protocol.rs +++ b/codex-rs/protocol/src/mcp_protocol.rs @@ -19,13 +19,23 @@ use strum_macros::Display; use ts_rs::TS; use uuid::Uuid; -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS, Hash)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, TS, Hash)] #[ts(type = "string")] -pub struct ConversationId(pub Uuid); +pub struct ConversationId { + uuid: Uuid, +} impl ConversationId { pub fn new() -> Self { - Self(Uuid::new_v4()) + Self { + uuid: Uuid::now_v7(), + } + } + + pub fn from_string(s: &str) -> Result { + Ok(Self { + uuid: Uuid::parse_str(s)?, + }) } } @@ -37,19 +47,27 @@ impl Default for ConversationId { impl Display for ConversationId { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.0) + write!(f, "{}", self.uuid) } } -impl From for ConversationId { - fn from(value: Uuid) -> Self { - Self(value) +impl Serialize for ConversationId { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.collect_str(&self.uuid) } } -impl From for Uuid { - fn from(value: ConversationId) -> Self { - value.0 +impl<'de> Deserialize<'de> for ConversationId { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + let uuid = Uuid::parse_str(&value).map_err(serde::de::Error::custom)?; + Ok(Self { uuid }) } } @@ -719,6 +737,27 @@ mod tests { #[test] fn test_conversation_id_default_is_not_zeroes() { let id = ConversationId::default(); - assert_ne!(id.0, Uuid::nil()); + assert_ne!(id.uuid, Uuid::nil()); + } + + #[test] + fn conversation_id_serializes_as_plain_string() { + let id = ConversationId::from_string("67e55044-10b1-426f-9247-bb680e5fe0c8").unwrap(); + + assert_eq!( + json!("67e55044-10b1-426f-9247-bb680e5fe0c8"), + serde_json::to_value(id).unwrap() + ); + } + + #[test] + fn conversation_id_deserializes_from_plain_string() { + let id: ConversationId = + serde_json::from_value(json!("67e55044-10b1-426f-9247-bb680e5fe0c8")).unwrap(); + + assert_eq!( + ConversationId::from_string("67e55044-10b1-426f-9247-bb680e5fe0c8").unwrap(), + id, + ); } } diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index c3aebcdd42..dddaeee23f 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -1252,7 +1252,8 @@ mod tests { /// amount of nesting. #[test] fn serialize_event() { - let conversation_id = ConversationId(uuid::uuid!("67e55044-10b1-426f-9247-bb680e5fe0c8")); + let conversation_id = + ConversationId::from_string("67e55044-10b1-426f-9247-bb680e5fe0c8").unwrap(); let rollout_file = NamedTempFile::new().unwrap(); let event = Event { id: "1234".to_string(), From 1b3c8b8e946c453d5e1e68eb7e81a2546c3135db Mon Sep 17 00:00:00 2001 From: jif-oai Date: Thu, 18 Sep 2025 16:27:15 +0100 Subject: [PATCH 25/38] Unify animations (#3729) Unify the animation in a single code and add the CTRL + . in the onboarding --- codex-rs/tui/src/ascii_animation.rs | 110 +++++++++++++++++ codex-rs/tui/src/lib.rs | 1 + codex-rs/tui/src/new_model_popup.rs | 50 ++------ .../tui/src/onboarding/onboarding_screen.rs | 28 +++-- codex-rs/tui/src/onboarding/welcome.rs | 112 +++++++++++++----- 5 files changed, 220 insertions(+), 81 deletions(-) create mode 100644 codex-rs/tui/src/ascii_animation.rs diff --git a/codex-rs/tui/src/ascii_animation.rs b/codex-rs/tui/src/ascii_animation.rs new file mode 100644 index 0000000000..ee90191181 --- /dev/null +++ b/codex-rs/tui/src/ascii_animation.rs @@ -0,0 +1,110 @@ +use std::convert::TryFrom; +use std::time::Duration; +use std::time::Instant; + +use rand::Rng as _; + +use crate::frames::ALL_VARIANTS; +use crate::frames::FRAME_TICK_DEFAULT; +use crate::tui::FrameRequester; + +/// Drives ASCII art animations shared across popups and onboarding widgets. +pub(crate) struct AsciiAnimation { + request_frame: FrameRequester, + variants: &'static [&'static [&'static str]], + variant_idx: usize, + frame_tick: Duration, + start: Instant, +} + +impl AsciiAnimation { + pub(crate) fn new(request_frame: FrameRequester) -> Self { + Self::with_variants(request_frame, ALL_VARIANTS, 0) + } + + pub(crate) fn with_variants( + request_frame: FrameRequester, + variants: &'static [&'static [&'static str]], + variant_idx: usize, + ) -> Self { + assert!( + !variants.is_empty(), + "AsciiAnimation requires at least one animation variant", + ); + let clamped_idx = variant_idx.min(variants.len() - 1); + Self { + request_frame, + variants, + variant_idx: clamped_idx, + frame_tick: FRAME_TICK_DEFAULT, + start: Instant::now(), + } + } + + pub(crate) fn schedule_next_frame(&self) { + let tick_ms = self.frame_tick.as_millis(); + if tick_ms == 0 { + self.request_frame.schedule_frame(); + return; + } + let elapsed_ms = self.start.elapsed().as_millis(); + let rem_ms = elapsed_ms % tick_ms; + let delay_ms = if rem_ms == 0 { + tick_ms + } else { + tick_ms - rem_ms + }; + if let Ok(delay_ms_u64) = u64::try_from(delay_ms) { + self.request_frame + .schedule_frame_in(Duration::from_millis(delay_ms_u64)); + } else { + self.request_frame.schedule_frame(); + } + } + + pub(crate) fn current_frame(&self) -> &'static str { + let frames = self.frames(); + if frames.is_empty() { + return ""; + } + let tick_ms = self.frame_tick.as_millis(); + if tick_ms == 0 { + return frames[0]; + } + let elapsed_ms = self.start.elapsed().as_millis(); + let idx = ((elapsed_ms / tick_ms) % frames.len() as u128) as usize; + frames[idx] + } + + pub(crate) fn pick_random_variant(&mut self) -> bool { + if self.variants.len() <= 1 { + return false; + } + let mut rng = rand::rng(); + let mut next = self.variant_idx; + while next == self.variant_idx { + next = rng.random_range(0..self.variants.len()); + } + self.variant_idx = next; + self.request_frame.schedule_frame(); + true + } + + pub(crate) fn request_frame(&self) { + self.request_frame.schedule_frame(); + } + + fn frames(&self) -> &'static [&'static str] { + self.variants[self.variant_idx] + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn frame_tick_must_be_nonzero() { + assert!(FRAME_TICK_DEFAULT.as_millis() > 0); + } +} diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 995ca17a6b..3f4d66ee4d 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -32,6 +32,7 @@ mod app; mod app_backtrack; mod app_event; mod app_event_sender; +mod ascii_animation; mod backtrack_helpers; mod bottom_pane; mod chatwidget; diff --git a/codex-rs/tui/src/new_model_popup.rs b/codex-rs/tui/src/new_model_popup.rs index 12325074c0..2106e46c67 100644 --- a/codex-rs/tui/src/new_model_popup.rs +++ b/codex-rs/tui/src/new_model_popup.rs @@ -1,5 +1,4 @@ -use crate::frames::ALL_VARIANTS as FRAME_VARIANTS; -use crate::frames::FRAME_TICK_DEFAULT; +use crate::ascii_animation::AsciiAnimation; use crate::tui::FrameRequester; use crate::tui::Tui; use crate::tui::TuiEvent; @@ -7,7 +6,6 @@ use color_eyre::eyre::Result; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyModifiers; -use rand::Rng as _; use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::prelude::Widget; @@ -17,10 +15,8 @@ use ratatui::widgets::Clear; use ratatui::widgets::Paragraph; use ratatui::widgets::WidgetRef; use ratatui::widgets::Wrap; -use std::time::Duration; use tokio_stream::StreamExt; -const FRAME_TICK: Duration = FRAME_TICK_DEFAULT; const MIN_ANIMATION_HEIGHT: u16 = 24; const MIN_ANIMATION_WIDTH: u16 = 60; @@ -39,9 +35,7 @@ enum ModelUpgradeOption { struct ModelUpgradePopup { highlighted: ModelUpgradeOption, decision: Option, - request_frame: FrameRequester, - frame_idx: usize, - variant_idx: usize, + animation: AsciiAnimation, } impl ModelUpgradePopup { @@ -49,9 +43,7 @@ impl ModelUpgradePopup { Self { highlighted: ModelUpgradeOption::TryNewModel, decision: None, - request_frame, - frame_idx: 0, - variant_idx: 0, + animation: AsciiAnimation::new(request_frame), } } @@ -65,7 +57,7 @@ impl ModelUpgradePopup { KeyCode::Esc => self.select(ModelUpgradeOption::KeepCurrent), KeyCode::Char('.') => { if key_event.modifiers.contains(KeyModifiers::CONTROL) { - self.pick_random_variant(); + let _ = self.animation.pick_random_variant(); } } _ => {} @@ -75,37 +67,13 @@ impl ModelUpgradePopup { fn highlight(&mut self, option: ModelUpgradeOption) { if self.highlighted != option { self.highlighted = option; - self.request_frame.schedule_frame(); + self.animation.request_frame(); } } fn select(&mut self, option: ModelUpgradeOption) { self.decision = Some(option.into()); - self.request_frame.schedule_frame(); - } - - fn advance_animation(&mut self) { - let len = self.frames().len(); - self.frame_idx = (self.frame_idx + 1) % len; - self.request_frame.schedule_frame_in(FRAME_TICK); - } - - fn frames(&self) -> &'static [&'static str] { - FRAME_VARIANTS[self.variant_idx] - } - - fn pick_random_variant(&mut self) { - let total = FRAME_VARIANTS.len(); - if total <= 1 { - return; - } - let mut rng = rand::rng(); - let mut next = self.variant_idx; - while next == self.variant_idx { - next = rng.random_range(0..total); - } - self.variant_idx = next; - self.request_frame.schedule_frame(); + self.animation.request_frame(); } } @@ -121,6 +89,7 @@ impl From for ModelUpgradeDecision { impl WidgetRef for &ModelUpgradePopup { fn render_ref(&self, area: Rect, buf: &mut Buffer) { Clear.render(area, buf); + self.animation.schedule_next_frame(); // Skip the animation entirely when the viewport is too small so we don't clip frames. let show_animation = @@ -128,7 +97,7 @@ impl WidgetRef for &ModelUpgradePopup { let mut lines: Vec = Vec::new(); if show_animation { - let frame = self.frames()[self.frame_idx]; + let frame = self.animation.current_frame(); lines.extend(frame.lines().map(|l| l.into())); // Spacer between animation and text content. lines.push("".into()); @@ -188,8 +157,6 @@ pub(crate) async fn run_model_upgrade_popup(tui: &mut Tui) -> Result Result popup.handle_key_event(key_event), TuiEvent::Draw => { - popup.advance_animation(); let _ = tui.draw(u16::MAX, |frame| { frame.render_widget_ref(&popup, frame.area()); }); diff --git a/codex-rs/tui/src/onboarding/onboarding_screen.rs b/codex-rs/tui/src/onboarding/onboarding_screen.rs index 12a2b4b134..5806424b32 100644 --- a/codex-rs/tui/src/onboarding/onboarding_screen.rs +++ b/codex-rs/tui/src/onboarding/onboarding_screen.rs @@ -7,6 +7,7 @@ use crossterm::event::KeyEventKind; use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::prelude::Widget; +use ratatui::style::Color; use ratatui::widgets::Clear; use ratatui::widgets::WidgetRef; @@ -24,7 +25,6 @@ use crate::tui::TuiEvent; use color_eyre::eyre::Result; use std::sync::Arc; use std::sync::RwLock; -use std::time::Instant; #[allow(clippy::large_enum_variant)] enum Step { @@ -73,11 +73,10 @@ impl OnboardingScreen { } = args; let cwd = config.cwd.clone(); let codex_home = config.codex_home; - let mut steps: Vec = vec![Step::Welcome(WelcomeWidget { - is_logged_in: !matches!(login_status, LoginStatus::NotAuthenticated), - request_frame: tui.frame_requester(), - start: Instant::now(), - })]; + let mut steps: Vec = vec![Step::Welcome(WelcomeWidget::new( + !matches!(login_status, LoginStatus::NotAuthenticated), + tui.frame_requester(), + ))]; if show_login_screen { steps.push(Step::Auth(AuthModeWidget { request_frame: tui.frame_requester(), @@ -189,6 +188,13 @@ impl KeyboardHandler for OnboardingScreen { self.is_done = true; } _ => { + if let Some(Step::Welcome(widget)) = self + .steps + .iter_mut() + .find(|step| matches!(step, Step::Welcome(_))) + { + widget.handle_key_event(key_event); + } if let Some(active_step) = self.current_steps_mut().into_iter().last() { active_step.handle_key_event(key_event); } @@ -226,8 +232,12 @@ impl WidgetRef for &OnboardingScreen { for yy in 0..height { let mut any = false; for xx in 0..width { - let sym = tmp[(xx, yy)].symbol(); - if !sym.trim().is_empty() { + let cell = &tmp[(xx, yy)]; + let has_symbol = !cell.symbol().trim().is_empty(); + let has_style = cell.fg != Color::Reset + || cell.bg != Color::Reset + || !cell.modifier.is_empty(); + if has_symbol || has_style { any = true; break; } @@ -271,7 +281,7 @@ impl WidgetRef for &OnboardingScreen { impl KeyboardHandler for Step { fn handle_key_event(&mut self, key_event: KeyEvent) { match self { - Step::Welcome(_) => (), + Step::Welcome(widget) => widget.handle_key_event(key_event), Step::Auth(widget) => widget.handle_key_event(key_event), Step::TrustDirectory(widget) => widget.handle_key_event(key_event), } diff --git a/codex-rs/tui/src/onboarding/welcome.rs b/codex-rs/tui/src/onboarding/welcome.rs index 578e2b429f..c62b6402d8 100644 --- a/codex-rs/tui/src/onboarding/welcome.rs +++ b/codex-rs/tui/src/onboarding/welcome.rs @@ -1,60 +1,68 @@ +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyEventKind; +use crossterm::event::KeyModifiers; use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::prelude::Widget; use ratatui::style::Stylize; use ratatui::text::Line; +use ratatui::widgets::Clear; use ratatui::widgets::Paragraph; use ratatui::widgets::WidgetRef; use ratatui::widgets::Wrap; -use crate::frames::FRAME_TICK_DEFAULT; -use crate::frames::FRAMES_DEFAULT; +use crate::ascii_animation::AsciiAnimation; +use crate::onboarding::onboarding_screen::KeyboardHandler; use crate::onboarding::onboarding_screen::StepStateProvider; use crate::tui::FrameRequester; use super::onboarding_screen::StepState; -use std::time::Duration; -use std::time::Instant; -const FRAME_TICK: Duration = FRAME_TICK_DEFAULT; const MIN_ANIMATION_HEIGHT: u16 = 20; const MIN_ANIMATION_WIDTH: u16 = 60; pub(crate) struct WelcomeWidget { pub is_logged_in: bool, - pub request_frame: FrameRequester, - pub start: Instant, + animation: AsciiAnimation, } -impl WidgetRef for &WelcomeWidget { - fn render_ref(&self, area: Rect, buf: &mut Buffer) { - let elapsed_ms = self.start.elapsed().as_millis(); - - // Align next draw to the next FRAME_TICK boundary to reduce jitter. +impl KeyboardHandler for WelcomeWidget { + fn handle_key_event(&mut self, key_event: KeyEvent) { + if key_event.kind == KeyEventKind::Press + && key_event.code == KeyCode::Char('.') + && key_event.modifiers.contains(KeyModifiers::CONTROL) { - let tick_ms = FRAME_TICK.as_millis(); - let rem_ms = elapsed_ms % tick_ms; - let delay_ms = if rem_ms == 0 { - tick_ms - } else { - tick_ms - rem_ms - }; - // Safe cast: delay_ms < tick_ms and FRAME_TICK is small. - self.request_frame - .schedule_frame_in(Duration::from_millis(delay_ms as u64)); + tracing::warn!("Welcome background to press '.'"); + let _ = self.animation.pick_random_variant(); } + } +} + +impl WelcomeWidget { + pub(crate) fn new(is_logged_in: bool, request_frame: FrameRequester) -> Self { + Self { + is_logged_in, + animation: AsciiAnimation::new(request_frame), + } + } +} + +impl WidgetRef for &WelcomeWidget { + fn render_ref(&self, area: Rect, buf: &mut Buffer) { + Clear.render(area, buf); + self.animation.schedule_next_frame(); - let frames = &FRAMES_DEFAULT; - let idx = ((elapsed_ms / FRAME_TICK.as_millis()) % frames.len() as u128) as usize; // Skip the animation entirely when the viewport is too small so we don't clip frames. let show_animation = area.height >= MIN_ANIMATION_HEIGHT && area.width >= MIN_ANIMATION_WIDTH; let mut lines: Vec = Vec::new(); if show_animation { - let frame_line_count = frames[idx].lines().count(); - lines.reserve(frame_line_count + 2); - lines.extend(frames[idx].lines().map(|l| l.into())); + let frame = self.animation.current_frame(); + // let frame_line_count = frame.lines().count(); + // lines.reserve(frame_line_count + 2); + lines.extend(frame.lines().map(|l| l.into())); lines.push("".into()); } lines.push(Line::from(vec![ @@ -82,10 +90,54 @@ impl StepStateProvider for WelcomeWidget { #[cfg(test)] mod tests { use super::*; + use ratatui::buffer::Buffer; + use ratatui::layout::Rect; + + static VARIANT_A: [&str; 1] = ["frame-a"]; + static VARIANT_B: [&str; 1] = ["frame-b"]; + static VARIANTS: [&[&str]; 2] = [&VARIANT_A, &VARIANT_B]; + + #[test] + fn welcome_renders_animation_on_first_draw() { + let widget = WelcomeWidget::new(false, FrameRequester::test_dummy()); + let area = Rect::new(0, 0, MIN_ANIMATION_WIDTH, MIN_ANIMATION_HEIGHT); + let mut buf = Buffer::empty(area); + (&widget).render(area, &mut buf); + + let mut found = false; + let mut last_non_empty: Option = None; + for y in 0..area.height { + for x in 0..area.width { + if !buf[(x, y)].symbol().trim().is_empty() { + found = true; + last_non_empty = Some(y); + break; + } + } + } + + assert!(found, "expected welcome animation to render characters"); + let measured_rows = last_non_empty.map(|v| v + 2).unwrap_or(0); + assert!( + measured_rows >= MIN_ANIMATION_HEIGHT, + "expected measurement to report at least {MIN_ANIMATION_HEIGHT} rows, got {measured_rows}" + ); + } - /// A number of things break down if FRAME_TICK is zero. #[test] - fn frame_tick_must_be_nonzero() { - assert!(FRAME_TICK.as_millis() > 0); + fn ctrl_dot_changes_animation_variant() { + let mut widget = WelcomeWidget { + is_logged_in: false, + animation: AsciiAnimation::with_variants(FrameRequester::test_dummy(), &VARIANTS, 0), + }; + + let before = widget.animation.current_frame(); + widget.handle_key_event(KeyEvent::new(KeyCode::Char('.'), KeyModifiers::CONTROL)); + let after = widget.animation.current_frame(); + + assert_ne!( + before, after, + "expected ctrl+. to switch welcome animation variant" + ); } } From 4a5d6f7c7154fbdfd7731289409298df8a230c4d Mon Sep 17 00:00:00 2001 From: jif-oai Date: Thu, 18 Sep 2025 16:34:16 +0100 Subject: [PATCH 26/38] Make ESC button work when auto-compaction (#3857) Only emit a task finished when the compaction comes from a `/compact` --- codex-rs/core/src/codex/compact.rs | 34 +++++++++++++--------------- codex-rs/core/tests/suite/compact.rs | 15 ++++++++++++ 2 files changed, 31 insertions(+), 18 deletions(-) diff --git a/codex-rs/core/src/codex/compact.rs b/codex-rs/core/src/codex/compact.rs index a465f937d4..0e20646423 100644 --- a/codex-rs/core/src/codex/compact.rs +++ b/codex-rs/core/src/codex/compact.rs @@ -79,15 +79,29 @@ pub(super) async fn run_compact_task( input: Vec, compact_instructions: String, ) { + let start_event = Event { + id: sub_id.clone(), + msg: EventMsg::TaskStarted(TaskStartedEvent { + model_context_window: turn_context.client.get_model_context_window(), + }), + }; + sess.send_event(start_event).await; run_compact_task_inner( - sess, + sess.clone(), turn_context, - sub_id, + sub_id.clone(), input, compact_instructions, true, ) .await; + let event = Event { + id: sub_id, + msg: EventMsg::TaskComplete(TaskCompleteEvent { + last_agent_message: None, + }), + }; + sess.send_event(event).await; } async fn run_compact_task_inner( @@ -98,15 +112,6 @@ async fn run_compact_task_inner( compact_instructions: String, remove_task_on_completion: bool, ) { - let model_context_window = turn_context.client.get_model_context_window(); - let start_event = Event { - id: sub_id.clone(), - msg: EventMsg::TaskStarted(TaskStartedEvent { - model_context_window, - }), - }; - sess.send_event(start_event).await; - let initial_input_for_turn: ResponseInputItem = ResponseInputItem::from(input); let instructions_override = compact_instructions; let turn_input = sess.turn_input_with_history(vec![initial_input_for_turn.clone().into()]); @@ -195,13 +200,6 @@ async fn run_compact_task_inner( }), }; sess.send_event(event).await; - let event = Event { - id: sub_id.clone(), - msg: EventMsg::TaskComplete(TaskCompleteEvent { - last_agent_message: None, - }), - }; - sess.send_event(event).await; } fn content_items_to_text(content: &[ContentItem]) -> Option { diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index 361315f724..8db70f3559 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -861,8 +861,18 @@ async fn auto_compact_allows_multiple_attempts_when_interleaved_with_other_turn_ .await .unwrap(); + let mut auto_compact_lifecycle_events = Vec::new(); loop { let event = codex.next_event().await.unwrap(); + if event.id.starts_with("auto-compact-") + && matches!( + event.msg, + EventMsg::TaskStarted(_) | EventMsg::TaskComplete(_) + ) + { + auto_compact_lifecycle_events.push(event); + continue; + } if let EventMsg::TaskComplete(_) = &event.msg && !event.id.starts_with("auto-compact-") { @@ -870,6 +880,11 @@ async fn auto_compact_allows_multiple_attempts_when_interleaved_with_other_turn_ } } + assert!( + auto_compact_lifecycle_events.is_empty(), + "auto compact should not emit task lifecycle events" + ); + let request_bodies: Vec = responder .recorded_requests() .into_iter() From 84a0ba9bf5121d288bee58ba865fc9b962b7f1d3 Mon Sep 17 00:00:00 2001 From: Jeremy Rose <172423086+nornagon-openai@users.noreply.github.com> Date: Thu, 18 Sep 2025 09:28:32 -0700 Subject: [PATCH 27/38] hint for codex resume on tui exit (#3757) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Screenshot 2025-09-16 at 4 25 19 PM --- codex-rs/Cargo.lock | 1 + codex-rs/cli/src/main.rs | 7 +++++-- codex-rs/tui/Cargo.toml | 1 + codex-rs/tui/src/app.rs | 14 ++++++++++++-- codex-rs/tui/src/lib.rs | 10 +++++++--- codex-rs/tui/src/main.rs | 20 +++++++++++++++++--- 6 files changed, 43 insertions(+), 10 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 81fc8683cb..e3930b507a 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -929,6 +929,7 @@ dependencies = [ "libc", "mcp-types", "once_cell", + "owo-colors", "path-clean", "pathdiff", "pretty_assertions", diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 7ce98a3937..e66855fe73 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -177,8 +177,11 @@ async fn cli_main(codex_linux_sandbox_exe: Option) -> anyhow::Result<() root_config_overrides.clone(), ); let usage = codex_tui::run_main(interactive, codex_linux_sandbox_exe).await?; - if !usage.is_zero() { - println!("{}", codex_core::protocol::FinalOutput::from(usage)); + if !usage.token_usage.is_zero() { + println!( + "{}", + codex_core::protocol::FinalOutput::from(usage.token_usage) + ); } } Some(Subcommand::Exec(mut exec_cli)) => { diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index 06e892b720..c7a5315844 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -85,6 +85,7 @@ unicode-segmentation = "1.12.0" unicode-width = "0.1" url = "2" pathdiff = "0.2" +owo-colors = "4.2.0" [target.'cfg(unix)'.dependencies] libc = "0.2" diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 205986c916..c28b2d2713 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -15,6 +15,7 @@ use codex_core::config::persist_model_selection; use codex_core::model_family::find_family_for_model; use codex_core::protocol::TokenUsage; use codex_core::protocol_config_types::ReasoningEffort as ReasoningEffortConfig; +use codex_protocol::mcp_protocol::ConversationId; use color_eyre::eyre::Result; use color_eyre::eyre::WrapErr; use crossterm::event::KeyCode; @@ -33,6 +34,12 @@ use tokio::select; use tokio::sync::mpsc::unbounded_channel; // use uuid::Uuid; +#[derive(Debug, Clone)] +pub struct AppExitInfo { + pub token_usage: TokenUsage, + pub conversation_id: Option, +} + pub(crate) struct App { pub(crate) server: Arc, pub(crate) app_event_tx: AppEventSender, @@ -70,7 +77,7 @@ impl App { initial_prompt: Option, initial_images: Vec, resume_selection: ResumeSelection, - ) -> Result { + ) -> Result { use tokio_stream::StreamExt; let (app_event_tx, mut app_event_rx) = unbounded_channel(); let app_event_tx = AppEventSender::new(app_event_tx); @@ -153,7 +160,10 @@ impl App { } } {} tui.terminal.clear()?; - Ok(app.token_usage()) + Ok(AppExitInfo { + token_usage: app.token_usage(), + conversation_id: app.chat_widget.conversation_id(), + }) } pub(crate) async fn handle_tui_event( diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 3f4d66ee4d..04ead75058 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -4,6 +4,7 @@ #![deny(clippy::print_stdout, clippy::print_stderr)] #![deny(clippy::disallowed_methods)] use app::App; +pub use app::AppExitInfo; use codex_core::AuthManager; use codex_core::BUILT_IN_OSS_MODEL_PROVIDER_ID; use codex_core::CodexAuth; @@ -86,7 +87,7 @@ use codex_core::internal_storage::InternalStorage; pub async fn run_main( cli: Cli, codex_linux_sandbox_exe: Option, -) -> std::io::Result { +) -> std::io::Result { let (sandbox_mode, approval_policy) = if cli.full_auto { ( Some(SandboxMode::WorkspaceWrite), @@ -258,7 +259,7 @@ async fn run_ratatui_app( mut internal_storage: InternalStorage, active_profile: Option, should_show_trust_screen: bool, -) -> color_eyre::Result { +) -> color_eyre::Result { let mut config = config; color_eyre::install()?; @@ -370,7 +371,10 @@ async fn run_ratatui_app( resume_picker::ResumeSelection::Exit => { restore(); session_log::log_session_end(); - return Ok(codex_core::protocol::TokenUsage::default()); + return Ok(AppExitInfo { + token_usage: codex_core::protocol::TokenUsage::default(), + conversation_id: None, + }); } other => other, } diff --git a/codex-rs/tui/src/main.rs b/codex-rs/tui/src/main.rs index 2dbd797dd4..a9b426fe45 100644 --- a/codex-rs/tui/src/main.rs +++ b/codex-rs/tui/src/main.rs @@ -3,6 +3,8 @@ use codex_arg0::arg0_dispatch_or_else; use codex_common::CliConfigOverrides; use codex_tui::Cli; use codex_tui::run_main; +use owo_colors::OwoColorize; +use supports_color::Stream; #[derive(Parser, Debug)] struct TopCli { @@ -21,9 +23,21 @@ fn main() -> anyhow::Result<()> { .config_overrides .raw_overrides .splice(0..0, top_cli.config_overrides.raw_overrides); - let usage = run_main(inner, codex_linux_sandbox_exe).await?; - if !usage.is_zero() { - println!("{}", codex_core::protocol::FinalOutput::from(usage)); + let exit_info = run_main(inner, codex_linux_sandbox_exe).await?; + let token_usage = exit_info.token_usage; + let conversation_id = exit_info.conversation_id; + if !token_usage.is_zero() { + println!("{}", codex_core::protocol::FinalOutput::from(token_usage),); + if let Some(session_id) = conversation_id { + let command = format!("codex resume {session_id}"); + let prefix = "To continue this session, run "; + let suffix = "."; + if supports_color::on(Stream::Stdout).is_some() { + println!("{}{}{}", prefix, command.cyan(), suffix); + } else { + println!("{prefix}{command}{suffix}"); + } + } } Ok(()) }) From 992b531180fb07dc983dc339a109a6abd4ce8c6b Mon Sep 17 00:00:00 2001 From: jif-oai Date: Thu, 18 Sep 2025 18:18:06 +0100 Subject: [PATCH 28/38] fix: some nit Rust reference issues (#3849) Fix some small references issue. No behavioural change. Just making the code cleaner --- codex-rs/core/src/client_common.rs | 9 ++++----- codex-rs/core/src/openai_tools.rs | 4 ++-- codex-rs/core/src/shell.rs | 24 ++++++++++++++---------- 3 files changed, 20 insertions(+), 17 deletions(-) diff --git a/codex-rs/core/src/client_common.rs b/codex-rs/core/src/client_common.rs index eead654b9e..3a5eb5b16b 100644 --- a/codex-rs/core/src/client_common.rs +++ b/codex-rs/core/src/client_common.rs @@ -34,13 +34,11 @@ pub struct Prompt { } impl Prompt { - pub(crate) fn get_full_instructions(&self, model: &ModelFamily) -> Cow<'_, str> { + pub(crate) fn get_full_instructions<'a>(&'a self, model: &'a ModelFamily) -> Cow<'a, str> { let base = self .base_instructions_override .as_deref() .unwrap_or(model.base_instructions.deref()); - let mut sections: Vec<&str> = vec![base]; - // When there are no custom instructions, add apply_patch_tool_instructions if: // - the model needs special instructions (4.1) // AND @@ -54,9 +52,10 @@ impl Prompt { && model.needs_special_apply_patch_instructions && !is_apply_patch_tool_present { - sections.push(APPLY_PATCH_TOOL_INSTRUCTIONS); + Cow::Owned(format!("{base}\n{APPLY_PATCH_TOOL_INSTRUCTIONS}")) + } else { + Cow::Borrowed(base) } - Cow::Owned(sections.join("\n")) } pub(crate) fn get_formatted_input(&self) -> Vec { diff --git a/codex-rs/core/src/openai_tools.rs b/codex-rs/core/src/openai_tools.rs index f4d724815e..b57d1b7692 100644 --- a/codex-rs/core/src/openai_tools.rs +++ b/codex-rs/core/src/openai_tools.rs @@ -333,7 +333,7 @@ pub(crate) struct ApplyPatchToolArgs { /// Responses API: /// https://platform.openai.com/docs/guides/function-calling?api-mode=responses pub fn create_tools_json_for_responses_api( - tools: &Vec, + tools: &[OpenAiTool], ) -> crate::error::Result> { let mut tools_json = Vec::new(); @@ -348,7 +348,7 @@ pub fn create_tools_json_for_responses_api( /// Chat Completions API: /// https://platform.openai.com/docs/guides/function-calling?api-mode=chat pub(crate) fn create_tools_json_for_chat_completions_api( - tools: &Vec, + tools: &[OpenAiTool], ) -> crate::error::Result> { // We start with the JSON for the Responses API and than rewrite it to match // the chat completions tool call format. diff --git a/codex-rs/core/src/shell.rs b/codex-rs/core/src/shell.rs index cb278fdd46..28cf61dd94 100644 --- a/codex-rs/core/src/shell.rs +++ b/codex-rs/core/src/shell.rs @@ -32,15 +32,19 @@ pub enum Shell { impl Shell { pub fn format_default_shell_invocation(&self, command: Vec) -> Option> { match self { - Shell::Zsh(zsh) => { - format_shell_invocation_with_rc(&command, &zsh.shell_path, &zsh.zshrc_path) - } - Shell::Bash(bash) => { - format_shell_invocation_with_rc(&command, &bash.shell_path, &bash.bashrc_path) - } + Shell::Zsh(zsh) => format_shell_invocation_with_rc( + command.as_slice(), + &zsh.shell_path, + &zsh.zshrc_path, + ), + Shell::Bash(bash) => format_shell_invocation_with_rc( + command.as_slice(), + &bash.shell_path, + &bash.bashrc_path, + ), Shell::PowerShell(ps) => { // If model generated a bash command, prefer a detected bash fallback - if let Some(script) = strip_bash_lc(&command) { + if let Some(script) = strip_bash_lc(command.as_slice()) { return match &ps.bash_exe_fallback { Some(bash) => Some(vec![ bash.to_string_lossy().to_string(), @@ -102,7 +106,7 @@ impl Shell { } fn format_shell_invocation_with_rc( - command: &Vec, + command: &[String], shell_path: &str, rc_path: &str, ) -> Option> { @@ -118,8 +122,8 @@ fn format_shell_invocation_with_rc( Some(vec![shell_path.to_string(), "-lc".to_string(), rc_command]) } -fn strip_bash_lc(command: &Vec) -> Option { - match command.as_slice() { +fn strip_bash_lc(command: &[String]) -> Option { + match command { // exactly three items [first, second, third] // first two must be "bash", "-lc" From 277fc6254e10b3a2eebbc089408666cea129b9b7 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Thu, 18 Sep 2025 18:21:52 +0100 Subject: [PATCH 29/38] chore: use tokio mutex and async function to prevent blocking a worker (#3850) ### Why Use `tokio::sync::Mutex` `std::sync::Mutex` are not _async-aware_. As a result, they will block the entire thread instead of just yielding the task. Furthermore they can be poisoned which is not the case of `tokio` Mutex. This allows the Tokio runtime to continue running other tasks while waiting for the lock, preventing deadlocks and performance bottlenecks. In general, this is preferred in async environment --- codex-rs/core/src/codex.rs | 163 +++++++++++++++-------------- codex-rs/core/src/codex/compact.rs | 17 +-- 2 files changed, 96 insertions(+), 84 deletions(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index b3b75ec76a..2317ae3dc6 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -3,8 +3,6 @@ use std::collections::HashMap; use std::collections::HashSet; use std::path::PathBuf; use std::sync::Arc; -use std::sync::Mutex; -use std::sync::MutexGuard; use std::sync::atomic::AtomicU64; use std::time::Duration; @@ -31,6 +29,7 @@ use mcp_types::CallToolResult; use serde::Deserialize; use serde::Serialize; use serde_json; +use tokio::sync::Mutex; use tokio::sync::oneshot; use tokio::task::AbortHandle; use tracing::debug; @@ -135,21 +134,6 @@ mod compact; use self::compact::build_compacted_history; use self::compact::collect_user_messages; -// A convenience extension trait for acquiring mutex locks where poisoning is -// unrecoverable and should abort the program. This avoids scattered `.unwrap()` -// calls on `lock()` while still surfacing a clear panic message when a lock is -// poisoned. -trait MutexExt { - fn lock_unchecked(&self) -> MutexGuard<'_, T>; -} - -impl MutexExt for Mutex { - fn lock_unchecked(&self) -> MutexGuard<'_, T> { - #[expect(clippy::expect_used)] - self.lock().expect("poisoned lock") - } -} - /// The high-level interface to the Codex system. /// It operates as a queue pair where you send submissions and receive events. pub struct Codex { @@ -272,7 +256,6 @@ struct State { pending_input: Vec, history: ConversationHistory, token_info: Option, - next_internal_sub_id: u64, } /// Context for an initialized model agent @@ -298,6 +281,7 @@ pub(crate) struct Session { codex_linux_sandbox_exe: Option, user_shell: shell::Shell, show_raw_agent_reasoning: bool, + next_internal_sub_id: AtomicU64, } /// The context needed for a single turn of the conversation. @@ -500,6 +484,7 @@ impl Session { codex_linux_sandbox_exe: config.codex_linux_sandbox_exe.clone(), user_shell: default_shell, show_raw_agent_reasoning: config.show_raw_agent_reasoning, + next_internal_sub_id: AtomicU64::new(0), }); // Dispatch the SessionConfiguredEvent first and then report any errors. @@ -528,16 +513,16 @@ impl Session { Ok((sess, turn_context)) } - pub fn set_task(&self, task: AgentTask) { - let mut state = self.state.lock_unchecked(); + pub async fn set_task(&self, task: AgentTask) { + let mut state = self.state.lock().await; if let Some(current_task) = state.current_task.take() { current_task.abort(TurnAbortReason::Replaced); } state.current_task = Some(task); } - pub fn remove_task(&self, sub_id: &str) { - let mut state = self.state.lock_unchecked(); + pub async fn remove_task(&self, sub_id: &str) { + let mut state = self.state.lock().await; if let Some(task) = &state.current_task && task.sub_id == sub_id { @@ -546,9 +531,9 @@ impl Session { } fn next_internal_sub_id(&self) -> String { - let mut state = self.state.lock_unchecked(); - let id = state.next_internal_sub_id; - state.next_internal_sub_id += 1; + let id = self + .next_internal_sub_id + .fetch_add(1, std::sync::atomic::Ordering::SeqCst); format!("auto-compact-{id}") } @@ -571,7 +556,7 @@ impl Session { let reconstructed_history = self.reconstruct_history_from_rollout(turn_context, &rollout_items); if !reconstructed_history.is_empty() { - self.record_into_history(&reconstructed_history); + self.record_into_history(&reconstructed_history).await; } // If persisting, persist all rollout items as-is (recorder filters) @@ -604,7 +589,7 @@ impl Session { let (tx_approve, rx_approve) = oneshot::channel(); let event_id = sub_id.clone(); let prev_entry = { - let mut state = self.state.lock_unchecked(); + let mut state = self.state.lock().await; state.pending_approvals.insert(sub_id, tx_approve) }; if prev_entry.is_some() { @@ -636,7 +621,7 @@ impl Session { let (tx_approve, rx_approve) = oneshot::channel(); let event_id = sub_id.clone(); let prev_entry = { - let mut state = self.state.lock_unchecked(); + let mut state = self.state.lock().await; state.pending_approvals.insert(sub_id, tx_approve) }; if prev_entry.is_some() { @@ -656,9 +641,9 @@ impl Session { rx_approve } - pub fn notify_approval(&self, sub_id: &str, decision: ReviewDecision) { + pub async fn notify_approval(&self, sub_id: &str, decision: ReviewDecision) { let entry = { - let mut state = self.state.lock_unchecked(); + let mut state = self.state.lock().await; state.pending_approvals.remove(sub_id) }; match entry { @@ -671,15 +656,15 @@ impl Session { } } - pub fn add_approved_command(&self, cmd: Vec) { - let mut state = self.state.lock_unchecked(); + pub async fn add_approved_command(&self, cmd: Vec) { + let mut state = self.state.lock().await; state.approved_commands.insert(cmd); } /// Records input items: always append to conversation history and /// persist these response items to rollout. async fn record_conversation_items(&self, items: &[ResponseItem]) { - self.record_into_history(items); + self.record_into_history(items).await; self.persist_rollout_response_items(items).await; } @@ -711,11 +696,9 @@ impl Session { } /// Append ResponseItems to the in-memory conversation history only. - fn record_into_history(&self, items: &[ResponseItem]) { - self.state - .lock_unchecked() - .history - .record_items(items.iter()); + async fn record_into_history(&self, items: &[ResponseItem]) { + let mut state = self.state.lock().await; + state.history.record_items(items.iter()); } async fn persist_rollout_response_items(&self, items: &[ResponseItem]) { @@ -743,8 +726,8 @@ impl Session { async fn persist_rollout_items(&self, items: &[RolloutItem]) { let recorder = { - let guard = self.rollout.lock_unchecked(); - guard.as_ref().cloned() + let guard = self.rollout.lock().await; + guard.clone() }; if let Some(rec) = recorder && let Err(e) = rec.record_items(items).await @@ -753,12 +736,12 @@ impl Session { } } - fn update_token_usage_info( + async fn update_token_usage_info( &self, turn_context: &TurnContext, token_usage: &Option, ) -> Option { - let mut state = self.state.lock_unchecked(); + let mut state = self.state.lock().await; let info = TokenUsageInfo::new_or_append( &state.token_info, token_usage, @@ -973,13 +956,17 @@ impl Session { /// Build the full turn input by concatenating the current conversation /// history with additional items for this turn. - pub fn turn_input_with_history(&self, extra: Vec) -> Vec { - [self.state.lock_unchecked().history.contents(), extra].concat() + pub async fn turn_input_with_history(&self, extra: Vec) -> Vec { + let history = { + let state = self.state.lock().await; + state.history.contents() + }; + [history, extra].concat() } /// Returns the input if there was no task running to inject into - pub fn inject_input(&self, input: Vec) -> Result<(), Vec> { - let mut state = self.state.lock_unchecked(); + pub async fn inject_input(&self, input: Vec) -> Result<(), Vec> { + let mut state = self.state.lock().await; if state.current_task.is_some() { state.pending_input.push(input.into()); Ok(()) @@ -988,8 +975,8 @@ impl Session { } } - pub fn get_pending_input(&self) -> Vec { - let mut state = self.state.lock_unchecked(); + pub async fn get_pending_input(&self) -> Vec { + let mut state = self.state.lock().await; if state.pending_input.is_empty() { Vec::with_capacity(0) } else { @@ -1011,9 +998,9 @@ impl Session { .await } - fn interrupt_task(&self) { + pub async fn interrupt_task(&self) { info!("interrupt received: abort current task, if any"); - let mut state = self.state.lock_unchecked(); + let mut state = self.state.lock().await; state.pending_approvals.clear(); state.pending_input.clear(); if let Some(task) = state.current_task.take() { @@ -1021,6 +1008,16 @@ impl Session { } } + fn interrupt_task_sync(&self) { + if let Ok(mut state) = self.state.try_lock() { + state.pending_approvals.clear(); + state.pending_input.clear(); + if let Some(task) = state.current_task.take() { + task.abort(TurnAbortReason::Interrupted); + } + } + } + /// Spawn the configured notifier (if any) with the given JSON payload as /// the last argument. Failures are logged but otherwise ignored so that /// notification issues do not interfere with the main workflow. @@ -1053,7 +1050,7 @@ impl Session { impl Drop for Session { fn drop(&mut self) { - self.interrupt_task(); + self.interrupt_task_sync(); } } @@ -1184,7 +1181,7 @@ async fn submission_loop( debug!(?sub, "Submission"); match sub.op { Op::Interrupt => { - sess.interrupt_task(); + sess.interrupt_task().await; } Op::OverrideTurnContext { cwd, @@ -1277,11 +1274,11 @@ async fn submission_loop( } Op::UserInput { items } => { // attempt to inject input into current task - if let Err(items) = sess.inject_input(items) { + if let Err(items) = sess.inject_input(items).await { // no current task, spawn a new one let task = AgentTask::spawn(sess.clone(), Arc::clone(&turn_context), sub.id, items); - sess.set_task(task); + sess.set_task(task).await; } } Op::UserTurn { @@ -1294,7 +1291,7 @@ async fn submission_loop( summary, } => { // attempt to inject input into current task - if let Err(items) = sess.inject_input(items) { + if let Err(items) = sess.inject_input(items).await { // Derive a fresh TurnContext for this turn using the provided overrides. let provider = turn_context.client.get_provider(); let auth_manager = turn_context.client.get_auth_manager(); @@ -1360,20 +1357,20 @@ async fn submission_loop( // no current task, spawn a new one with the per‑turn context let task = AgentTask::spawn(sess.clone(), Arc::clone(&turn_context), sub.id, items); - sess.set_task(task); + sess.set_task(task).await; } } Op::ExecApproval { id, decision } => match decision { ReviewDecision::Abort => { - sess.interrupt_task(); + sess.interrupt_task().await; } - other => sess.notify_approval(&id, other), + other => sess.notify_approval(&id, other).await, }, Op::PatchApproval { id, decision } => match decision { ReviewDecision::Abort => { - sess.interrupt_task(); + sess.interrupt_task().await; } - other => sess.notify_approval(&id, other), + other => sess.notify_approval(&id, other).await, }, Op::AddToHistory { text } => { let id = sess.conversation_id; @@ -1452,15 +1449,19 @@ async fn submission_loop( } Op::Compact => { // Attempt to inject input into current task - if let Err(items) = sess.inject_input(vec![InputItem::Text { - text: compact::COMPACT_TRIGGER_TEXT.to_string(), - }]) { + if let Err(items) = sess + .inject_input(vec![InputItem::Text { + text: compact::COMPACT_TRIGGER_TEXT.to_string(), + }]) + .await + { compact::spawn_compact_task( sess.clone(), Arc::clone(&turn_context), sub.id, items, - ); + ) + .await; } } Op::Shutdown => { @@ -1468,7 +1469,10 @@ async fn submission_loop( // Gracefully flush and shutdown rollout recorder on session end so tests // that inspect the rollout file do not race with the background writer. - let recorder_opt = sess.rollout.lock_unchecked().take(); + let recorder_opt = { + let mut guard = sess.rollout.lock().await; + guard.take() + }; if let Some(rec) = recorder_opt && let Err(e) = rec.shutdown().await { @@ -1493,7 +1497,7 @@ async fn submission_loop( let sub_id = sub.id.clone(); // Flush rollout writes before returning the path so readers observe a consistent file. let (path, rec_opt) = { - let guard = sess.rollout.lock_unchecked(); + let guard = sess.rollout.lock().await; match guard.as_ref() { Some(rec) => (rec.get_rollout_path(), Some(rec.clone())), None => { @@ -1604,7 +1608,7 @@ async fn spawn_review_thread( // Clone sub_id for the upcoming announcement before moving it into the task. let sub_id_for_event = sub_id.clone(); let task = AgentTask::review(sess.clone(), tc.clone(), sub_id, input); - sess.set_task(task); + sess.set_task(task).await; // Announce entering review mode so UIs can switch modes. sess.send_event(Event { @@ -1675,6 +1679,7 @@ async fn run_task( // may support this, the model might not. let pending_input = sess .get_pending_input() + .await .into_iter() .map(ResponseItem::from) .collect::>(); @@ -1696,7 +1701,7 @@ async fn run_task( review_thread_history.clone() } else { sess.record_conversation_items(&pending_input).await; - sess.turn_input_with_history(pending_input) + sess.turn_input_with_history(pending_input).await }; let turn_input_messages: Vec = turn_input @@ -1908,7 +1913,7 @@ async fn run_task( .await; } - sess.remove_task(&sub_id); + sess.remove_task(&sub_id).await; let event = Event { id: sub_id, msg: EventMsg::TaskComplete(TaskCompleteEvent { last_agent_message }), @@ -2141,7 +2146,9 @@ async fn try_run_turn( response_id: _, token_usage, } => { - let info = sess.update_token_usage_info(turn_context, &token_usage); + let info = sess + .update_token_usage_info(turn_context, &token_usage) + .await; let _ = sess .send_event(Event { id: sub_id.to_string(), @@ -2475,7 +2482,10 @@ async fn handle_function_call( } }; let abs = turn_context.resolve_path(Some(args.path)); - let output = match sess.inject_input(vec![InputItem::LocalImage { path: abs }]) { + let output = match sess + .inject_input(vec![InputItem::LocalImage { path: abs }]) + .await + { Ok(()) => FunctionCallOutputPayload { content: "attached local image path".to_string(), success: Some(true), @@ -2789,7 +2799,7 @@ async fn handle_container_exec_with_params( } None => { let safety = { - let state = sess.state.lock_unchecked(); + let state = sess.state.lock().await; assess_command_safety( ¶ms.command, turn_context.approval_policy, @@ -2818,7 +2828,7 @@ async fn handle_container_exec_with_params( match rx_approve.await.unwrap_or_default() { ReviewDecision::Approved => (), ReviewDecision::ApprovedForSession => { - sess.add_approved_command(params.command.clone()); + sess.add_approved_command(params.command.clone()).await; } ReviewDecision::Denied | ReviewDecision::Abort => { return ResponseInputItem::FunctionCallOutput { @@ -2991,7 +3001,7 @@ async fn handle_sandbox_error( // remainder of the session so future // executions skip the sandbox directly. // TODO(ragona): Isn't this a bug? It always saves the command in an | fork? - sess.add_approved_command(params.command.clone()); + sess.add_approved_command(params.command.clone()).await; // Inform UI we are retrying without sandbox. sess.notify_background_event(&sub_id, "retrying command without sandbox") .await; @@ -3356,7 +3366,7 @@ mod tests { }), )); - let actual = session.state.lock_unchecked().history.contents(); + let actual = tokio_test::block_on(async { session.state.lock().await.history.contents() }); assert_eq!(expected, actual); } @@ -3369,7 +3379,7 @@ mod tests { session.record_initial_history(&turn_context, InitialHistory::Forked(rollout_items)), ); - let actual = session.state.lock_unchecked().history.contents(); + let actual = tokio_test::block_on(async { session.state.lock().await.history.contents() }); assert_eq!(expected, actual); } @@ -3611,6 +3621,7 @@ mod tests { codex_linux_sandbox_exe: None, user_shell: shell::Shell::Unknown, show_raw_agent_reasoning: config.show_raw_agent_reasoning, + next_internal_sub_id: AtomicU64::new(0), }; (session, turn_context) } diff --git a/codex-rs/core/src/codex/compact.rs b/codex-rs/core/src/codex/compact.rs index 0e20646423..684fd44ed5 100644 --- a/codex-rs/core/src/codex/compact.rs +++ b/codex-rs/core/src/codex/compact.rs @@ -1,7 +1,6 @@ use std::sync::Arc; use super::AgentTask; -use super::MutexExt; use super::Session; use super::TurnContext; use super::get_last_assistant_message_from_turn; @@ -37,7 +36,7 @@ struct HistoryBridgeTemplate<'a> { summary_text: &'a str, } -pub(super) fn spawn_compact_task( +pub(super) async fn spawn_compact_task( sess: Arc, turn_context: Arc, sub_id: String, @@ -50,7 +49,7 @@ pub(super) fn spawn_compact_task( input, SUMMARIZATION_PROMPT.to_string(), ); - sess.set_task(task); + sess.set_task(task).await; } pub(super) async fn run_inline_auto_compact_task( @@ -114,7 +113,9 @@ async fn run_compact_task_inner( ) { let initial_input_for_turn: ResponseInputItem = ResponseInputItem::from(input); let instructions_override = compact_instructions; - let turn_input = sess.turn_input_with_history(vec![initial_input_for_turn.clone().into()]); + let turn_input = sess + .turn_input_with_history(vec![initial_input_for_turn.clone().into()]) + .await; let prompt = Prompt { input: turn_input, @@ -173,10 +174,10 @@ async fn run_compact_task_inner( } if remove_task_on_completion { - sess.remove_task(&sub_id); + sess.remove_task(&sub_id).await; } let history_snapshot = { - let state = sess.state.lock_unchecked(); + let state = sess.state.lock().await; state.history.contents() }; let summary_text = get_last_assistant_message_from_turn(&history_snapshot).unwrap_or_default(); @@ -184,7 +185,7 @@ async fn run_compact_task_inner( let initial_context = sess.build_initial_context(turn_context.as_ref()); let new_history = build_compacted_history(initial_context, &user_messages, &summary_text); { - let mut state = sess.state.lock_unchecked(); + let mut state = sess.state.lock().await; state.history.replace(new_history); } @@ -288,7 +289,7 @@ async fn drain_to_completed( }; match event { Ok(ResponseEvent::OutputItemDone(item)) => { - let mut state = sess.state.lock_unchecked(); + let mut state = sess.state.lock().await; state.history.record_items(std::slice::from_ref(&item)); } Ok(ResponseEvent::Completed { .. }) => { From 71038381aa0f51aa62e1a2bcc7cbf26a05b141f3 Mon Sep 17 00:00:00 2001 From: Jeremy Rose <172423086+nornagon-openai@users.noreply.github.com> Date: Thu, 18 Sep 2025 11:25:09 -0700 Subject: [PATCH 30/38] fix error on missing notifications in [tui] (#3867) Fixes #3811. --- codex-rs/core/src/config.rs | 14 ++++++++++++++ codex-rs/core/src/config_types.rs | 1 + 2 files changed, 15 insertions(+) diff --git a/codex-rs/core/src/config.rs b/codex-rs/core/src/config.rs index 6a613abbba..c84d84e0ff 100644 --- a/codex-rs/core/src/config.rs +++ b/codex-rs/core/src/config.rs @@ -1163,6 +1163,7 @@ pub fn log_dir(cfg: &Config) -> std::io::Result { #[cfg(test)] mod tests { use crate::config_types::HistoryPersistence; + use crate::config_types::Notifications; use super::*; use pretty_assertions::assert_eq; @@ -1201,6 +1202,19 @@ persistence = "none" ); } + #[test] + fn tui_config_missing_notifications_field_defaults_to_disabled() { + let cfg = r#" +[tui] +"#; + + let parsed = toml::from_str::(cfg) + .expect("TUI config without notifications should succeed"); + let tui = parsed.tui.expect("config should include tui section"); + + assert_eq!(tui.notifications, Notifications::Enabled(false)); + } + #[test] fn test_sandbox_config_parsing() { let sandbox_full_access = r#" diff --git a/codex-rs/core/src/config_types.rs b/codex-rs/core/src/config_types.rs index ec8e8e67c6..3737649871 100644 --- a/codex-rs/core/src/config_types.rs +++ b/codex-rs/core/src/config_types.rs @@ -94,6 +94,7 @@ impl Default for Notifications { pub struct Tui { /// Enable desktop notifications from the TUI when the terminal is unfocused. /// Defaults to `false`. + #[serde(default)] pub notifications: Notifications, } From b34e9063963e52aa32a06f13a4034929d018d3cd Mon Sep 17 00:00:00 2001 From: Jeremy Rose <172423086+nornagon-openai@users.noreply.github.com> Date: Thu, 18 Sep 2025 13:55:53 -0700 Subject: [PATCH 31/38] Reland "refactor transcript view to handle HistoryCells" (#3753) Reland of #3538 --- codex-rs/core/src/codex.rs | 9 +- codex-rs/core/src/codex/compact.rs | 4 +- codex-rs/core/src/conversation_manager.rs | 69 ++-- codex-rs/core/src/lib.rs | 2 + .../core/tests/suite/compact_resume_fork.rs | 19 +- .../core/tests/suite/fork_conversation.rs | 18 +- codex-rs/tui/src/app.rs | 21 +- codex-rs/tui/src/app_backtrack.rs | 269 +++++++++++----- codex-rs/tui/src/backtrack_helpers.rs | 153 --------- codex-rs/tui/src/history_cell.rs | 11 +- codex-rs/tui/src/lib.rs | 1 - codex-rs/tui/src/pager_overlay.rs | 302 +++++++++++------- ...ts__transcript_overlay_snapshot_basic.snap | 4 +- 13 files changed, 477 insertions(+), 405 deletions(-) delete mode 100644 codex-rs/tui/src/backtrack_helpers.rs diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 2317ae3dc6..1de37f6f6a 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -130,7 +130,7 @@ use codex_protocol::models::ResponseItem; use codex_protocol::models::ShellToolCallParams; use codex_protocol::protocol::InitialHistory; -mod compact; +pub mod compact; use self::compact::build_compacted_history; use self::compact::collect_user_messages; @@ -710,7 +710,7 @@ impl Session { self.persist_rollout_items(&rollout_items).await; } - fn build_initial_context(&self, turn_context: &TurnContext) -> Vec { + pub(crate) fn build_initial_context(&self, turn_context: &TurnContext) -> Vec { let mut items = Vec::::with_capacity(2); if let Some(user_instructions) = turn_context.user_instructions.as_deref() { items.push(UserInstructions::new(user_instructions.to_string()).into()); @@ -3325,6 +3325,9 @@ async fn exit_review_mode( .await; } +#[cfg(test)] +pub(crate) use tests::make_session_and_context; + #[cfg(test)] mod tests { use super::*; @@ -3565,7 +3568,7 @@ mod tests { }) } - fn make_session_and_context() -> (Session, TurnContext) { + pub(crate) fn make_session_and_context() -> (Session, TurnContext) { let (tx_event, _rx_event) = async_channel::unbounded(); let codex_home = tempfile::tempdir().expect("create temp dir"); let config = Config::load_from_base_config_with_overrides( diff --git a/codex-rs/core/src/codex/compact.rs b/codex-rs/core/src/codex/compact.rs index 684fd44ed5..05d57cd945 100644 --- a/codex-rs/core/src/codex/compact.rs +++ b/codex-rs/core/src/codex/compact.rs @@ -203,7 +203,7 @@ async fn run_compact_task_inner( sess.send_event(event).await; } -fn content_items_to_text(content: &[ContentItem]) -> Option { +pub fn content_items_to_text(content: &[ContentItem]) -> Option { let mut pieces = Vec::new(); for item in content { match item { @@ -235,7 +235,7 @@ pub(crate) fn collect_user_messages(items: &[ResponseItem]) -> Vec { .collect() } -fn is_session_prefix_message(text: &str) -> bool { +pub fn is_session_prefix_message(text: &str) -> bool { matches!( InputMessageKind::from(("user", text)), InputMessageKind::UserInstructions | InputMessageKind::EnvironmentContext diff --git a/codex-rs/core/src/conversation_manager.rs b/codex-rs/core/src/conversation_manager.rs index 7147eca233..51854248a4 100644 --- a/codex-rs/core/src/conversation_manager.rs +++ b/codex-rs/core/src/conversation_manager.rs @@ -3,6 +3,8 @@ use crate::CodexAuth; use crate::codex::Codex; use crate::codex::CodexSpawnOk; use crate::codex::INITIAL_SUBMIT_ID; +use crate::codex::compact::content_items_to_text; +use crate::codex::compact::is_session_prefix_message; use crate::codex_conversation::CodexConversation; use crate::config::Config; use crate::error::CodexErr; @@ -134,19 +136,19 @@ impl ConversationManager { self.conversations.write().await.remove(conversation_id) } - /// Fork an existing conversation by dropping the last `drop_last_messages` - /// user/assistant messages from its transcript and starting a new + /// Fork an existing conversation by taking messages up to the given position + /// (not including the message at the given position) and starting a new /// conversation with identical configuration (unless overridden by the /// caller's `config`). The new conversation will have a fresh id. pub async fn fork_conversation( &self, - num_messages_to_drop: usize, + nth_user_message: usize, config: Config, path: PathBuf, ) -> CodexResult { // Compute the prefix up to the cut point. let history = RolloutRecorder::get_rollout_history(&path).await?; - let history = truncate_after_dropping_last_messages(history, num_messages_to_drop); + let history = truncate_before_nth_user_message(history, nth_user_message); // Spawn a new conversation with the computed initial history. let auth_manager = self.auth_manager.clone(); @@ -159,33 +161,30 @@ impl ConversationManager { } } -/// Return a prefix of `items` obtained by dropping the last `n` user messages -/// and all items that follow them. -fn truncate_after_dropping_last_messages(history: InitialHistory, n: usize) -> InitialHistory { - if n == 0 { - return InitialHistory::Forked(history.get_rollout_items()); - } - - // Work directly on rollout items, and cut the vector at the nth-from-last user message input. +/// Return a prefix of `items` obtained by cutting strictly before the nth user message +/// (0-based) and all items that follow it. +fn truncate_before_nth_user_message(history: InitialHistory, n: usize) -> InitialHistory { + // Work directly on rollout items, and cut the vector at the nth user message input. let items: Vec = history.get_rollout_items(); // Find indices of user message inputs in rollout order. let mut user_positions: Vec = Vec::new(); for (idx, item) in items.iter().enumerate() { - if let RolloutItem::ResponseItem(ResponseItem::Message { role, .. }) = item + if let RolloutItem::ResponseItem(ResponseItem::Message { role, content, .. }) = item && role == "user" + && content_items_to_text(content).is_some_and(|text| !is_session_prefix_message(&text)) { user_positions.push(idx); } } - // If fewer than n user messages exist, treat as empty. - if user_positions.len() < n { + // If fewer than or equal to n user messages exist, treat as empty (out of range). + if user_positions.len() <= n { return InitialHistory::New; } - // Cut strictly before the nth-from-last user message (do not keep the nth itself). - let cut_idx = user_positions[user_positions.len() - n]; + // Cut strictly before the nth user message (do not keep the nth itself). + let cut_idx = user_positions[n]; let rolled: Vec = items.into_iter().take(cut_idx).collect(); if rolled.is_empty() { @@ -198,9 +197,11 @@ fn truncate_after_dropping_last_messages(history: InitialHistory, n: usize) -> I #[cfg(test)] mod tests { use super::*; + use crate::codex::make_session_and_context; use codex_protocol::models::ContentItem; use codex_protocol::models::ReasoningItemReasoningSummary; use codex_protocol::models::ResponseItem; + use pretty_assertions::assert_eq; fn user_msg(text: &str) -> ResponseItem { ResponseItem::Message { @@ -252,7 +253,7 @@ mod tests { .cloned() .map(RolloutItem::ResponseItem) .collect(); - let truncated = truncate_after_dropping_last_messages(InitialHistory::Forked(initial), 1); + let truncated = truncate_before_nth_user_message(InitialHistory::Forked(initial), 1); let got_items = truncated.get_rollout_items(); let expected_items = vec![ RolloutItem::ResponseItem(items[0].clone()), @@ -269,7 +270,37 @@ mod tests { .cloned() .map(RolloutItem::ResponseItem) .collect(); - let truncated2 = truncate_after_dropping_last_messages(InitialHistory::Forked(initial2), 2); + let truncated2 = truncate_before_nth_user_message(InitialHistory::Forked(initial2), 2); assert!(matches!(truncated2, InitialHistory::New)); } + + #[test] + fn ignores_session_prefix_messages_when_truncating() { + let (session, turn_context) = make_session_and_context(); + let mut items = session.build_initial_context(&turn_context); + items.push(user_msg("feature request")); + items.push(assistant_msg("ack")); + items.push(user_msg("second question")); + items.push(assistant_msg("answer")); + + let rollout_items: Vec = items + .iter() + .cloned() + .map(RolloutItem::ResponseItem) + .collect(); + + let truncated = truncate_before_nth_user_message(InitialHistory::Forked(rollout_items), 1); + let got_items = truncated.get_rollout_items(); + + let expected: Vec = vec![ + RolloutItem::ResponseItem(items[0].clone()), + RolloutItem::ResponseItem(items[1].clone()), + RolloutItem::ResponseItem(items[2].clone()), + ]; + + assert_eq!( + serde_json::to_value(&got_items).unwrap(), + serde_json::to_value(&expected).unwrap() + ); + } } diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index e024effbe2..f73bef4051 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -92,6 +92,8 @@ pub use client_common::Prompt; pub use client_common::REVIEW_PROMPT; pub use client_common::ResponseEvent; pub use client_common::ResponseStream; +pub use codex::compact::content_items_to_text; +pub use codex::compact::is_session_prefix_message; pub use codex_protocol::models::ContentItem; pub use codex_protocol::models::LocalShellAction; pub use codex_protocol::models::LocalShellExecAction; diff --git a/codex-rs/core/tests/suite/compact_resume_fork.rs b/codex-rs/core/tests/suite/compact_resume_fork.rs index 34b43ece91..1bedbcab08 100644 --- a/codex-rs/core/tests/suite/compact_resume_fork.rs +++ b/codex-rs/core/tests/suite/compact_resume_fork.rs @@ -74,7 +74,7 @@ async fn compact_resume_and_fork_preserve_model_history_view() { "compact+resume test expects resumed path {resumed_path:?} to exist", ); - let forked = fork_conversation(&manager, &config, resumed_path, 1).await; + let forked = fork_conversation(&manager, &config, resumed_path, 2).await; user_turn(&forked, "AFTER_FORK").await; // 3. Capture the requests to the model and validate the history slices. @@ -100,17 +100,15 @@ async fn compact_resume_and_fork_preserve_model_history_view() { "after-resume input should have at least as many items as after-compact", ); assert_eq!(compact_arr.as_slice(), &resume_arr[..compact_arr.len()]); - eprint!( - "len of compact: {}, len of fork: {}", - compact_arr.len(), - fork_arr.len() - ); - eprintln!("input_after_fork:{}", json!(input_after_fork)); + assert!( compact_arr.len() <= fork_arr.len(), "after-fork input should have at least as many items as after-compact", ); - assert_eq!(compact_arr.as_slice(), &fork_arr[..compact_arr.len()]); + assert_eq!( + &compact_arr.as_slice()[..compact_arr.len()], + &fork_arr[..compact_arr.len()] + ); let prompt = requests[0]["instructions"] .as_str() @@ -824,14 +822,15 @@ async fn resume_conversation( conversation } +#[cfg(test)] async fn fork_conversation( manager: &ConversationManager, config: &Config, path: std::path::PathBuf, - back_steps: usize, + nth_user_message: usize, ) -> Arc { let NewConversation { conversation, .. } = manager - .fork_conversation(back_steps, config.clone(), path) + .fork_conversation(nth_user_message, config.clone(), path) .await .expect("fork conversation"); conversation diff --git a/codex-rs/core/tests/suite/fork_conversation.rs b/codex-rs/core/tests/suite/fork_conversation.rs index 08e3f29d28..f3027811e8 100644 --- a/codex-rs/core/tests/suite/fork_conversation.rs +++ b/codex-rs/core/tests/suite/fork_conversation.rs @@ -5,6 +5,8 @@ use codex_core::ModelProviderInfo; use codex_core::NewConversation; use codex_core::ResponseItem; use codex_core::built_in_model_providers; +use codex_core::content_items_to_text; +use codex_core::is_session_prefix_message; use codex_core::protocol::ConversationPathResponseEvent; use codex_core::protocol::EventMsg; use codex_core::protocol::InputItem; @@ -104,13 +106,16 @@ async fn fork_conversation_twice_drops_to_first_message() { items }; - // Compute expected prefixes after each fork by truncating base rollout at nth-from-last user input. + // Compute expected prefixes after each fork by truncating base rollout + // strictly before the nth user input (0-based). let base_items = read_items(&base_path); let find_user_input_positions = |items: &[RolloutItem]| -> Vec { let mut pos = Vec::new(); for (i, it) in items.iter().enumerate() { if let RolloutItem::ResponseItem(ResponseItem::Message { role, content, .. }) = it && role == "user" + && content_items_to_text(content) + .is_some_and(|text| !is_session_prefix_message(&text)) { // Consider any user message as an input boundary; recorder stores both EventMsg and ResponseItem. // We specifically look for input items, which are represented as ContentItem::InputText. @@ -126,11 +131,8 @@ async fn fork_conversation_twice_drops_to_first_message() { }; let user_inputs = find_user_input_positions(&base_items); - // After dropping last user input (n=1), cut strictly before that input if present, else empty. - let cut1 = user_inputs - .get(user_inputs.len().saturating_sub(1)) - .copied() - .unwrap_or(0); + // After cutting at nth user input (n=1 → second user message), cut strictly before that input. + let cut1 = user_inputs.get(1).copied().unwrap_or(0); let expected_after_first: Vec = base_items[..cut1].to_vec(); // After dropping again (n=1 on fork1), compute expected relative to fork1's rollout. @@ -161,12 +163,12 @@ async fn fork_conversation_twice_drops_to_first_message() { serde_json::to_value(&expected_after_first).unwrap() ); - // Fork again with n=1 → drops the (new) last user message, leaving only the first. + // Fork again with n=0 → drops the (new) last user message, leaving only the first. let NewConversation { conversation: codex_fork2, .. } = conversation_manager - .fork_conversation(1, config_for_fork.clone(), fork1_path.clone()) + .fork_conversation(0, config_for_fork.clone(), fork1_path.clone()) .await .expect("fork 2"); diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index c28b2d2713..bd9150cad0 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -3,6 +3,7 @@ use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; use crate::chatwidget::ChatWidget; use crate::file_search::FileSearchManager; +use crate::history_cell::HistoryCell; use crate::pager_overlay::Overlay; use crate::resume_picker::ResumeSelection; use crate::tui; @@ -52,7 +53,7 @@ pub(crate) struct App { pub(crate) file_search: FileSearchManager, - pub(crate) transcript_lines: Vec>, + pub(crate) transcript_cells: Vec>, // Pager overlay state (Transcript or Static like Diff) pub(crate) overlay: Option, @@ -138,7 +139,7 @@ impl App { active_profile, file_search, enhanced_keys_supported, - transcript_lines: Vec::new(), + transcript_cells: Vec::new(), overlay: None, deferred_history_lines: Vec::new(), has_emitted_history_lines: false, @@ -225,15 +226,12 @@ impl App { tui.frame_requester().schedule_frame(); } AppEvent::InsertHistoryCell(cell) => { - let mut cell_transcript = cell.transcript_lines(); - if !cell.is_stream_continuation() && !self.transcript_lines.is_empty() { - cell_transcript.insert(0, Line::from("")); - } + let cell: Arc = cell.into(); if let Some(Overlay::Transcript(t)) = &mut self.overlay { - t.insert_lines(cell_transcript.clone()); + t.insert_cell(cell.clone()); tui.frame_requester().schedule_frame(); } - self.transcript_lines.extend(cell_transcript.clone()); + self.transcript_cells.push(cell.clone()); let mut display = cell.display_lines(tui.terminal.last_known_screen_size.width); if !display.is_empty() { // Only insert a separating blank line for new cells that are not @@ -380,7 +378,7 @@ impl App { } => { // Enter alternate screen and set viewport to full size. let _ = tui.enter_alt_screen(); - self.overlay = Some(Overlay::new_transcript(self.transcript_lines.clone())); + self.overlay = Some(Overlay::new_transcript(self.transcript_cells.clone())); tui.frame_requester().schedule_frame(); } // Esc primes/advances backtracking only in normal (not working) mode @@ -405,7 +403,7 @@ impl App { kind: KeyEventKind::Press, .. } if self.backtrack.primed - && self.backtrack.count > 0 + && self.backtrack.nth_user_message != usize::MAX && self.chat_widget.composer_is_empty() => { // Delegate to helper for clarity; preserves behavior. @@ -439,7 +437,6 @@ mod tests { use codex_core::AuthManager; use codex_core::CodexAuth; use codex_core::ConversationManager; - use ratatui::text::Line; use std::sync::Arc; use std::sync::atomic::AtomicBool; @@ -462,7 +459,7 @@ mod tests { config, active_profile: None, file_search, - transcript_lines: Vec::>::new(), + transcript_cells: Vec::new(), overlay: None, deferred_history_lines: Vec::new(), has_emitted_history_lines: false, diff --git a/codex-rs/tui/src/app_backtrack.rs b/codex-rs/tui/src/app_backtrack.rs index 4e716c90ec..c07be57af1 100644 --- a/codex-rs/tui/src/app_backtrack.rs +++ b/codex-rs/tui/src/app_backtrack.rs @@ -1,7 +1,8 @@ use std::path::PathBuf; +use std::sync::Arc; use crate::app::App; -use crate::backtrack_helpers; +use crate::history_cell::UserHistoryCell; use crate::pager_overlay::Overlay; use crate::tui; use crate::tui::TuiEvent; @@ -19,11 +20,11 @@ pub(crate) struct BacktrackState { pub(crate) primed: bool, /// Session id of the base conversation to fork from. pub(crate) base_id: Option, - /// Current step count (Nth last user message). - pub(crate) count: usize, + /// Index in the transcript of the last user message. + pub(crate) nth_user_message: usize, /// True when the transcript overlay is showing a backtrack preview. pub(crate) overlay_preview_active: bool, - /// Pending fork request: (base_id, drop_count, prefill). + /// Pending fork request: (base_id, nth_user_message, prefill). pub(crate) pending: Option<(ConversationId, usize, String)>, } @@ -96,9 +97,9 @@ impl App { &mut self, prefill: String, base_id: ConversationId, - drop_last_messages: usize, + nth_user_message: usize, ) { - self.backtrack.pending = Some((base_id, drop_last_messages, prefill)); + self.backtrack.pending = Some((base_id, nth_user_message, prefill)); self.app_event_tx.send(crate::app_event::AppEvent::CodexOp( codex_core::protocol::Op::GetPath, )); @@ -107,7 +108,7 @@ impl App { /// Open transcript overlay (enters alternate screen and shows full transcript). pub(crate) fn open_transcript_overlay(&mut self, tui: &mut tui::Tui) { let _ = tui.enter_alt_screen(); - self.overlay = Some(Overlay::new_transcript(self.transcript_lines.clone())); + self.overlay = Some(Overlay::new_transcript(self.transcript_cells.clone())); tui.frame_requester().schedule_frame(); } @@ -130,15 +131,17 @@ impl App { /// Re-render the full transcript into the terminal scrollback in one call. /// Useful when switching sessions to ensure prior history remains visible. pub(crate) fn render_transcript_once(&mut self, tui: &mut tui::Tui) { - if !self.transcript_lines.is_empty() { - tui.insert_history_lines(self.transcript_lines.clone()); + if !self.transcript_cells.is_empty() { + for cell in &self.transcript_cells { + tui.insert_history_lines(cell.transcript_lines()); + } } } /// Initialize backtrack state and show composer hint. fn prime_backtrack(&mut self) { self.backtrack.primed = true; - self.backtrack.count = 0; + self.backtrack.nth_user_message = usize::MAX; self.backtrack.base_id = self.chat_widget.conversation_id(); self.chat_widget.show_esc_backtrack_hint(); } @@ -157,51 +160,44 @@ impl App { self.backtrack.primed = true; self.backtrack.base_id = self.chat_widget.conversation_id(); self.backtrack.overlay_preview_active = true; - let sel = self.compute_backtrack_selection(tui, 1); - self.apply_backtrack_selection(sel); + let last_user_cell_position = self + .transcript_cells + .iter() + .filter_map(|c| c.as_any().downcast_ref::()) + .count() as i64 + - 1; + if last_user_cell_position >= 0 { + self.apply_backtrack_selection(last_user_cell_position as usize); + } tui.frame_requester().schedule_frame(); } /// Step selection to the next older user message and update overlay. fn step_backtrack_and_highlight(&mut self, tui: &mut tui::Tui) { - let next = self.backtrack.count.saturating_add(1); - let sel = self.compute_backtrack_selection(tui, next); - self.apply_backtrack_selection(sel); + let last_user_cell_position = self + .transcript_cells + .iter() + .filter(|c| c.as_any().is::()) + .take(self.backtrack.nth_user_message) + .count() + .saturating_sub(1); + self.apply_backtrack_selection(last_user_cell_position); tui.frame_requester().schedule_frame(); } - /// Compute normalized target, scroll offset, and highlight for requested step. - fn compute_backtrack_selection( - &self, - tui: &tui::Tui, - requested_n: usize, - ) -> (usize, Option, Option<(usize, usize)>) { - let nth = backtrack_helpers::normalize_backtrack_n(&self.transcript_lines, requested_n); - let header_idx = - backtrack_helpers::find_nth_last_user_header_index(&self.transcript_lines, nth); - let offset = header_idx.map(|idx| { - backtrack_helpers::wrapped_offset_before( - &self.transcript_lines, - idx, - tui.terminal.viewport_area.width, - ) - }); - let hl = backtrack_helpers::highlight_range_for_nth_last_user(&self.transcript_lines, nth); - (nth, offset, hl) - } - /// Apply a computed backtrack selection to the overlay and internal counter. - fn apply_backtrack_selection( - &mut self, - selection: (usize, Option, Option<(usize, usize)>), - ) { - let (nth, offset, hl) = selection; - self.backtrack.count = nth; + fn apply_backtrack_selection(&mut self, nth_user_message: usize) { + self.backtrack.nth_user_message = nth_user_message; if let Some(Overlay::Transcript(t)) = &mut self.overlay { - if let Some(off) = offset { - t.set_scroll_offset(off); + let cell = self + .transcript_cells + .iter() + .enumerate() + .filter(|(_, c)| c.as_any().is::()) + .nth(nth_user_message); + if let Some((idx, _)) = cell { + t.set_highlight_cell(Some(idx)); } - t.set_highlight_range(hl); } } @@ -219,13 +215,19 @@ impl App { /// Handle Enter in overlay backtrack preview: confirm selection and reset state. fn overlay_confirm_backtrack(&mut self, tui: &mut tui::Tui) { + let nth_user_message = self.backtrack.nth_user_message; if let Some(base_id) = self.backtrack.base_id { - let drop_last_messages = self.backtrack.count; - let prefill = - backtrack_helpers::nth_last_user_text(&self.transcript_lines, drop_last_messages) - .unwrap_or_default(); + let user_cells = self + .transcript_cells + .iter() + .filter_map(|c| c.as_any().downcast_ref::()) + .collect::>(); + let prefill = user_cells + .get(nth_user_message) + .map(|c| c.message.clone()) + .unwrap_or_default(); self.close_transcript_overlay(tui); - self.request_backtrack(prefill, base_id, drop_last_messages); + self.request_backtrack(prefill, base_id, nth_user_message); } self.reset_backtrack_state(); } @@ -244,11 +246,15 @@ impl App { /// Computes the prefill from the selected user message and requests history. pub(crate) fn confirm_backtrack_from_main(&mut self) { if let Some(base_id) = self.backtrack.base_id { - let drop_last_messages = self.backtrack.count; - let prefill = - backtrack_helpers::nth_last_user_text(&self.transcript_lines, drop_last_messages) - .unwrap_or_default(); - self.request_backtrack(prefill, base_id, drop_last_messages); + let prefill = self + .transcript_cells + .iter() + .filter(|c| c.as_any().is::()) + .nth(self.backtrack.nth_user_message) + .and_then(|c| c.as_any().downcast_ref::()) + .map(|c| c.message.clone()) + .unwrap_or_default(); + self.request_backtrack(prefill, base_id, self.backtrack.nth_user_message); } self.reset_backtrack_state(); } @@ -257,7 +263,7 @@ impl App { pub(crate) fn reset_backtrack_state(&mut self) { self.backtrack.primed = false; self.backtrack.base_id = None; - self.backtrack.count = 0; + self.backtrack.nth_user_message = usize::MAX; // In case a hint is somehow still visible (e.g., race with overlay open/close). self.chat_widget.clear_esc_backtrack_hint(); } @@ -271,9 +277,9 @@ impl App { ) -> Result<()> { if let Some((base_id, _, _)) = self.backtrack.pending.as_ref() && ev.conversation_id == *base_id - && let Some((_, drop_count, prefill)) = self.backtrack.pending.take() + && let Some((_, nth_user_message, prefill)) = self.backtrack.pending.take() { - self.fork_and_switch_to_new_conversation(tui, ev, drop_count, prefill) + self.fork_and_switch_to_new_conversation(tui, ev, nth_user_message, prefill) .await; } Ok(()) @@ -284,17 +290,17 @@ impl App { &mut self, tui: &mut tui::Tui, ev: ConversationPathResponseEvent, - drop_count: usize, + nth_user_message: usize, prefill: String, ) { let cfg = self.chat_widget.config_ref().clone(); // Perform the fork via a thin wrapper for clarity/testability. let result = self - .perform_fork(ev.path.clone(), drop_count, cfg.clone()) + .perform_fork(ev.path.clone(), nth_user_message, cfg.clone()) .await; match result { Ok(new_conv) => { - self.install_forked_conversation(tui, cfg, new_conv, drop_count, &prefill) + self.install_forked_conversation(tui, cfg, new_conv, nth_user_message, &prefill) } Err(e) => tracing::error!("error forking conversation: {e:#}"), } @@ -304,10 +310,12 @@ impl App { async fn perform_fork( &self, path: PathBuf, - drop_count: usize, + nth_user_message: usize, cfg: codex_core::config::Config, ) -> codex_core::error::Result { - self.server.fork_conversation(drop_count, cfg, path).await + self.server + .fork_conversation(nth_user_message, cfg, path) + .await } /// Install a forked conversation into the ChatWidget and update UI to reflect selection. @@ -316,7 +324,7 @@ impl App { tui: &mut tui::Tui, cfg: codex_core::config::Config, new_conv: codex_core::NewConversation, - drop_count: usize, + nth_user_message: usize, prefill: &str, ) { let conv = new_conv.conversation; @@ -333,7 +341,7 @@ impl App { self.chat_widget = crate::chatwidget::ChatWidget::new_from_existing(init, conv, session_configured); // Trim transcript up to the selected user message and re-render it. - self.trim_transcript_for_backtrack(drop_count); + self.trim_transcript_for_backtrack(nth_user_message); self.render_transcript_once(tui); if !prefill.is_empty() { self.chat_widget.set_composer_text(prefill.to_string()); @@ -341,14 +349,129 @@ impl App { tui.frame_requester().schedule_frame(); } - /// Trim transcript_lines to preserve only content up to the selected user message. - fn trim_transcript_for_backtrack(&mut self, drop_count: usize) { - if let Some(cut_idx) = - backtrack_helpers::find_nth_last_user_header_index(&self.transcript_lines, drop_count) - { - self.transcript_lines.truncate(cut_idx); - } else { - self.transcript_lines.clear(); - } + /// Trim transcript_cells to preserve only content up to the selected user message. + fn trim_transcript_for_backtrack(&mut self, nth_user_message: usize) { + trim_transcript_cells_to_nth_user(&mut self.transcript_cells, nth_user_message); + } +} + +fn trim_transcript_cells_to_nth_user( + transcript_cells: &mut Vec>, + nth_user_message: usize, +) { + if nth_user_message == usize::MAX { + return; + } + + let cut_idx = transcript_cells + .iter() + .enumerate() + .filter_map(|(idx, cell)| cell.as_any().is::().then_some(idx)) + .nth(nth_user_message) + .unwrap_or(transcript_cells.len()); + transcript_cells.truncate(cut_idx); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::history_cell::AgentMessageCell; + use crate::history_cell::HistoryCell; + use ratatui::prelude::Line; + use std::sync::Arc; + + #[test] + fn trim_transcript_for_first_user_drops_user_and_newer_cells() { + let mut cells: Vec> = vec![ + Arc::new(UserHistoryCell { + message: "first user".to_string(), + }) as Arc, + Arc::new(AgentMessageCell::new(vec![Line::from("assistant")], true)) + as Arc, + ]; + + trim_transcript_cells_to_nth_user(&mut cells, 0); + + assert!(cells.is_empty()); + } + + #[test] + fn trim_transcript_preserves_cells_before_selected_user() { + let mut cells: Vec> = vec![ + Arc::new(AgentMessageCell::new(vec![Line::from("intro")], true)) + as Arc, + Arc::new(UserHistoryCell { + message: "first".to_string(), + }) as Arc, + Arc::new(AgentMessageCell::new(vec![Line::from("after")], false)) + as Arc, + ]; + + trim_transcript_cells_to_nth_user(&mut cells, 0); + + assert_eq!(cells.len(), 1); + let agent = cells[0] + .as_any() + .downcast_ref::() + .expect("agent cell"); + let agent_lines = agent.display_lines(u16::MAX); + assert_eq!(agent_lines.len(), 1); + let intro_text: String = agent_lines[0] + .spans + .iter() + .map(|span| span.content.as_ref()) + .collect(); + assert_eq!(intro_text, "> intro"); + } + + #[test] + fn trim_transcript_for_later_user_keeps_prior_history() { + let mut cells: Vec> = vec![ + Arc::new(AgentMessageCell::new(vec![Line::from("intro")], true)) + as Arc, + Arc::new(UserHistoryCell { + message: "first".to_string(), + }) as Arc, + Arc::new(AgentMessageCell::new(vec![Line::from("between")], false)) + as Arc, + Arc::new(UserHistoryCell { + message: "second".to_string(), + }) as Arc, + Arc::new(AgentMessageCell::new(vec![Line::from("tail")], false)) + as Arc, + ]; + + trim_transcript_cells_to_nth_user(&mut cells, 1); + + assert_eq!(cells.len(), 3); + let agent_intro = cells[0] + .as_any() + .downcast_ref::() + .expect("intro agent"); + let intro_lines = agent_intro.display_lines(u16::MAX); + let intro_text: String = intro_lines[0] + .spans + .iter() + .map(|span| span.content.as_ref()) + .collect(); + assert_eq!(intro_text, "> intro"); + + let user_first = cells[1] + .as_any() + .downcast_ref::() + .expect("first user"); + assert_eq!(user_first.message, "first"); + + let agent_between = cells[2] + .as_any() + .downcast_ref::() + .expect("between agent"); + let between_lines = agent_between.display_lines(u16::MAX); + let between_text: String = between_lines[0] + .spans + .iter() + .map(|span| span.content.as_ref()) + .collect(); + assert_eq!(between_text, " between"); } } diff --git a/codex-rs/tui/src/backtrack_helpers.rs b/codex-rs/tui/src/backtrack_helpers.rs deleted file mode 100644 index e2306b80b1..0000000000 --- a/codex-rs/tui/src/backtrack_helpers.rs +++ /dev/null @@ -1,153 +0,0 @@ -use ratatui::text::Line; - -/// Convenience: compute the highlight range for the Nth last user message. -pub(crate) fn highlight_range_for_nth_last_user( - lines: &[Line<'_>], - n: usize, -) -> Option<(usize, usize)> { - let header = find_nth_last_user_header_index(lines, n)?; - Some(highlight_range_from_header(lines, header)) -} - -/// Compute the wrapped display-line offset before `header_idx`, for a given width. -pub(crate) fn wrapped_offset_before(lines: &[Line<'_>], header_idx: usize, width: u16) -> usize { - let before = &lines[0..header_idx]; - crate::wrapping::word_wrap_lines(before, width as usize).len() -} - -/// Find the header index for the Nth last user message in the transcript. -/// Returns `None` if `n == 0` or there are fewer than `n` user messages. -pub(crate) fn find_nth_last_user_header_index(lines: &[Line<'_>], n: usize) -> Option { - if n == 0 { - return None; - } - let mut found = 0usize; - for (idx, line) in lines.iter().enumerate().rev() { - let content: String = line - .spans - .iter() - .map(|s| s.content.as_ref()) - .collect::>() - .join(""); - if content.trim() == "user" { - found += 1; - if found == n { - return Some(idx); - } - } - } - None -} - -/// Normalize a requested backtrack step `n` against the available user messages. -/// - Returns `0` if there are no user messages. -/// - Returns `n` if the Nth last user message exists. -/// - Otherwise wraps to `1` (the most recent user message). -pub(crate) fn normalize_backtrack_n(lines: &[Line<'_>], n: usize) -> usize { - if n == 0 { - return 0; - } - if find_nth_last_user_header_index(lines, n).is_some() { - return n; - } - if find_nth_last_user_header_index(lines, 1).is_some() { - 1 - } else { - 0 - } -} - -/// Extract the text content of the Nth last user message. -/// The message body is considered to be the lines following the "user" header -/// until the first blank line. -pub(crate) fn nth_last_user_text(lines: &[Line<'_>], n: usize) -> Option { - let header_idx = find_nth_last_user_header_index(lines, n)?; - extract_message_text_after_header(lines, header_idx) -} - -/// Extract message text starting after `header_idx` until the first blank line. -fn extract_message_text_after_header(lines: &[Line<'_>], header_idx: usize) -> Option { - let start = header_idx + 1; - let mut out: Vec = Vec::new(); - for line in lines.iter().skip(start) { - let is_blank = line - .spans - .iter() - .all(|s| s.content.as_ref().trim().is_empty()); - if is_blank { - break; - } - let text: String = line - .spans - .iter() - .map(|s| s.content.as_ref()) - .collect::>() - .join(""); - out.push(text); - } - if out.is_empty() { - None - } else { - Some(out.join("\n")) - } -} - -/// Given a header index, return the inclusive range for the message block -/// [header_idx, end) where end is the first blank line after the header or the -/// end of the transcript. -fn highlight_range_from_header(lines: &[Line<'_>], header_idx: usize) -> (usize, usize) { - let mut end = header_idx + 1; - while end < lines.len() { - let is_blank = lines[end] - .spans - .iter() - .all(|s| s.content.as_ref().trim().is_empty()); - if is_blank { - break; - } - end += 1; - } - (header_idx, end) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn line(s: &str) -> Line<'static> { - s.to_string().into() - } - - fn transcript_with_users(count: usize) -> Vec> { - // Build a transcript with `count` user messages, each followed by one body line and a blank line. - let mut v = Vec::new(); - for i in 0..count { - v.push(line("user")); - v.push(line(&format!("message {i}"))); - v.push(line("")); - } - v - } - - #[test] - fn normalize_wraps_to_one_when_past_oldest() { - let lines = transcript_with_users(2); - assert_eq!(normalize_backtrack_n(&lines, 1), 1); - assert_eq!(normalize_backtrack_n(&lines, 2), 2); - // Requesting 3rd when only 2 exist wraps to 1 - assert_eq!(normalize_backtrack_n(&lines, 3), 1); - } - - #[test] - fn normalize_returns_zero_when_no_user_messages() { - let lines = transcript_with_users(0); - assert_eq!(normalize_backtrack_n(&lines, 1), 0); - assert_eq!(normalize_backtrack_n(&lines, 5), 0); - } - - #[test] - fn normalize_keeps_valid_n() { - let lines = transcript_with_users(3); - assert_eq!(normalize_backtrack_n(&lines, 2), 2); - } -} diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 9cdd29dd02..ad3d5a9030 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -44,6 +44,7 @@ use ratatui::style::Stylize; use ratatui::widgets::Paragraph; use ratatui::widgets::WidgetRef; use ratatui::widgets::Wrap; +use std::any::Any; use std::collections::HashMap; use std::io::Cursor; use std::path::Path; @@ -70,7 +71,7 @@ pub(crate) enum PatchEventType { /// Represents an event to display in the conversation history. Returns its /// `Vec>` representation to make it easier to display in a /// scrollable list. -pub(crate) trait HistoryCell: std::fmt::Debug + Send + Sync { +pub(crate) trait HistoryCell: std::fmt::Debug + Send + Sync + Any { fn display_lines(&self, width: u16) -> Vec>; fn transcript_lines(&self) -> Vec> { @@ -90,9 +91,15 @@ pub(crate) trait HistoryCell: std::fmt::Debug + Send + Sync { } } +impl dyn HistoryCell { + pub(crate) fn as_any(&self) -> &dyn Any { + self + } +} + #[derive(Debug)] pub(crate) struct UserHistoryCell { - message: String, + pub message: String, } impl HistoryCell for UserHistoryCell { diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 04ead75058..308e563ded 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -34,7 +34,6 @@ mod app_backtrack; mod app_event; mod app_event_sender; mod ascii_animation; -mod backtrack_helpers; mod bottom_pane; mod chatwidget; mod citation_regex; diff --git a/codex-rs/tui/src/pager_overlay.rs b/codex-rs/tui/src/pager_overlay.rs index 0a7e54f06f..e5524b252b 100644 --- a/codex-rs/tui/src/pager_overlay.rs +++ b/codex-rs/tui/src/pager_overlay.rs @@ -1,6 +1,8 @@ use std::io::Result; +use std::sync::Arc; use std::time::Duration; +use crate::history_cell::HistoryCell; use crate::render::line_utils::push_owned_lines; use crate::tui; use crate::tui::TuiEvent; @@ -15,6 +17,7 @@ use ratatui::style::Styled; use ratatui::style::Stylize; use ratatui::text::Line; use ratatui::text::Span; +use ratatui::text::Text; use ratatui::widgets::Paragraph; use ratatui::widgets::WidgetRef; @@ -24,8 +27,8 @@ pub(crate) enum Overlay { } impl Overlay { - pub(crate) fn new_transcript(lines: Vec>) -> Self { - Self::Transcript(TranscriptOverlay::new(lines)) + pub(crate) fn new_transcript(cells: Vec>) -> Self { + Self::Transcript(TranscriptOverlay::new(cells)) } pub(crate) fn new_static_with_title(lines: Vec>, title: String) -> Self { @@ -73,21 +76,24 @@ fn render_key_hints(area: Rect, buf: &mut Buffer, pairs: &[(&str, &str)]) { /// Generic widget for rendering a pager view. struct PagerView { - lines: Vec>, + texts: Vec>, scroll_offset: usize, title: String, wrap_cache: Option, last_content_height: Option, + /// If set, on next render ensure this chunk is visible. + pending_scroll_chunk: Option, } impl PagerView { - fn new(lines: Vec>, title: String, scroll_offset: usize) -> Self { + fn new(texts: Vec>, title: String, scroll_offset: usize) -> Self { Self { - lines, + texts, scroll_offset, title, wrap_cache: None, last_content_height: None, + pending_scroll_chunk: None, } } @@ -96,6 +102,14 @@ impl PagerView { let content_area = self.scroll_area(area); self.update_last_content_height(content_area.height); self.ensure_wrapped(content_area.width); + // If there is a pending request to scroll a specific chunk into view, + // satisfy it now that wrapping is up to date for this width. + if let (Some(idx), Some(cache)) = + (self.pending_scroll_chunk.take(), self.wrap_cache.as_ref()) + && let Some(range) = cache.chunk_ranges.get(idx).cloned() + { + self.ensure_range_visible(range, content_area.height as usize, cache.wrapped.len()); + } // Compute page bounds without holding an immutable borrow on cache while mutating self let wrapped_len = self .wrap_cache @@ -108,40 +122,12 @@ impl PagerView { let start = self.scroll_offset; let end = (start + content_area.height as usize).min(wrapped_len); - let (wrapped, _src_idx) = self.cached(); + let wrapped = self.cached(); let page = &wrapped[start..end]; self.render_content_page_prepared(content_area, buf, page); self.render_bottom_bar(area, content_area, buf, wrapped); } - fn render_with_highlight( - &mut self, - area: Rect, - buf: &mut Buffer, - highlight: Option<(usize, usize)>, - ) { - self.render_header(area, buf); - let content_area = self.scroll_area(area); - self.update_last_content_height(content_area.height); - self.ensure_wrapped(content_area.width); - // Compute page bounds first to avoid borrow conflicts - let wrapped_len = self - .wrap_cache - .as_ref() - .map(|c| c.wrapped.len()) - .unwrap_or(0); - self.scroll_offset = self - .scroll_offset - .min(wrapped_len.saturating_sub(content_area.height as usize)); - let start = self.scroll_offset; - let end = (start + content_area.height as usize).min(wrapped_len); - - let (wrapped, src_idx) = self.cached(); - let page = self.page_with_optional_highlight(wrapped, src_idx, start, end, highlight); - self.render_content_page_prepared(content_area, buf, &page); - self.render_bottom_bar(area, content_area, buf, wrapped); - } - fn render_header(&self, area: Rect, buf: &mut Buffer) { Span::from("/ ".repeat(area.width as usize / 2)) .dim() @@ -270,7 +256,8 @@ impl PagerView { struct WrapCache { width: u16, wrapped: Vec>, - src_idx: Vec, + /// For each input Text chunk, the inclusive-excluded range of wrapped lines produced. + chunk_ranges: Vec>, base_len: usize, } @@ -278,72 +265,37 @@ impl PagerView { fn ensure_wrapped(&mut self, width: u16) { let width = width.max(1); let needs = match self.wrap_cache { - Some(ref c) => c.width != width || c.base_len != self.lines.len(), + Some(ref c) => c.width != width || c.base_len != self.texts.len(), None => true, }; if !needs { return; } let mut wrapped: Vec> = Vec::new(); - let mut src_idx: Vec = Vec::new(); - for (i, line) in self.lines.iter().enumerate() { - let ws = crate::wrapping::word_wrap_line(line, width as usize); - src_idx.extend(std::iter::repeat_n(i, ws.len())); - push_owned_lines(&ws, &mut wrapped); + let mut chunk_ranges: Vec> = Vec::with_capacity(self.texts.len()); + for text in &self.texts { + let start = wrapped.len(); + for line in &text.lines { + let ws = crate::wrapping::word_wrap_line(line, width as usize); + push_owned_lines(&ws, &mut wrapped); + } + let end = wrapped.len(); + chunk_ranges.push(start..end); } self.wrap_cache = Some(WrapCache { width, wrapped, - src_idx, - base_len: self.lines.len(), + chunk_ranges, + base_len: self.texts.len(), }); } - fn cached(&self) -> (&[Line<'static>], &[usize]) { + fn cached(&self) -> &[Line<'static>] { if let Some(cache) = self.wrap_cache.as_ref() { - (&cache.wrapped, &cache.src_idx) + &cache.wrapped } else { - (&[], &[]) - } - } - - fn page_with_optional_highlight<'a>( - &self, - wrapped: &'a [Line<'static>], - src_idx: &[usize], - start: usize, - end: usize, - highlight: Option<(usize, usize)>, - ) -> std::borrow::Cow<'a, [Line<'static>]> { - use ratatui::style::Modifier; - let (hi_start, hi_end) = match highlight { - Some(r) => r, - None => return std::borrow::Cow::Borrowed(&wrapped[start..end]), - }; - let mut out: Vec> = Vec::with_capacity(end - start); - let mut bold_done = false; - for (row, src_line) in wrapped - .iter() - .enumerate() - .skip(start) - .take(end.saturating_sub(start)) - { - let mut line = src_line.clone(); - if let Some(src) = src_idx.get(row).copied() - && src >= hi_start - && src < hi_end - { - for (i, s) in line.spans.iter_mut().enumerate() { - s.style.add_modifier |= Modifier::REVERSED; - if !bold_done && i == 0 { - s.style.add_modifier |= Modifier::BOLD; - bold_done = true; - } - } - } - out.push(line); + &[] } - std::borrow::Cow::Owned(out) } fn is_scrolled_to_bottom(&self) -> bool { @@ -363,38 +315,108 @@ impl PagerView { let max_scroll = cache.wrapped.len().saturating_sub(visible); self.scroll_offset >= max_scroll } + + /// Request that the given text chunk index be scrolled into view on next render. + fn scroll_chunk_into_view(&mut self, chunk_index: usize) { + self.pending_scroll_chunk = Some(chunk_index); + } + + fn ensure_range_visible( + &mut self, + range: std::ops::Range, + viewport_height: usize, + total_wrapped: usize, + ) { + if viewport_height == 0 || total_wrapped == 0 { + return; + } + let first = range.start.min(total_wrapped.saturating_sub(1)); + let last = range + .end + .saturating_sub(1) + .min(total_wrapped.saturating_sub(1)); + let current_top = self.scroll_offset.min(total_wrapped.saturating_sub(1)); + let current_bottom = current_top.saturating_add(viewport_height.saturating_sub(1)); + + if first < current_top { + self.scroll_offset = first; + } else if last > current_bottom { + // Scroll just enough so that 'last' is visible at the bottom + self.scroll_offset = last.saturating_sub(viewport_height.saturating_sub(1)); + } + } } pub(crate) struct TranscriptOverlay { view: PagerView, - highlight_range: Option<(usize, usize)>, + cells: Vec>, + highlight_cell: Option, is_done: bool, } impl TranscriptOverlay { - pub(crate) fn new(transcript_lines: Vec>) -> Self { + pub(crate) fn new(transcript_cells: Vec>) -> Self { Self { view: PagerView::new( - transcript_lines, + Self::render_cells_to_texts(&transcript_cells, None), "T R A N S C R I P T".to_string(), usize::MAX, ), - highlight_range: None, + cells: transcript_cells, + highlight_cell: None, is_done: false, } } - pub(crate) fn insert_lines(&mut self, lines: Vec>) { + fn render_cells_to_texts( + cells: &[Arc], + highlight_cell: Option, + ) -> Vec> { + let mut texts: Vec> = Vec::new(); + let mut first = true; + for (idx, cell) in cells.iter().enumerate() { + let mut lines: Vec> = Vec::new(); + if !cell.is_stream_continuation() && !first { + lines.push(Line::from("")); + } + let cell_lines = if Some(idx) == highlight_cell { + cell.transcript_lines() + .into_iter() + .map(|l| l.reversed()) + .collect() + } else { + cell.transcript_lines() + }; + lines.extend(cell_lines); + texts.push(Text::from(lines)); + first = false; + } + texts + } + + pub(crate) fn insert_cell(&mut self, cell: Arc) { let follow_bottom = self.view.is_scrolled_to_bottom(); - self.view.lines.extend(lines); + // Append as a new Text chunk (with a separating blank if needed) + let mut lines: Vec> = Vec::new(); + if !cell.is_stream_continuation() && !self.cells.is_empty() { + lines.push(Line::from("")); + } + lines.extend(cell.transcript_lines()); + self.view.texts.push(Text::from(lines)); + self.cells.push(cell); self.view.wrap_cache = None; if follow_bottom { self.view.scroll_offset = usize::MAX; } } - pub(crate) fn set_highlight_range(&mut self, range: Option<(usize, usize)>) { - self.highlight_range = range; + pub(crate) fn set_highlight_cell(&mut self, cell: Option) { + self.highlight_cell = cell; + self.view.wrap_cache = None; + self.view.texts = Self::render_cells_to_texts(&self.cells, self.highlight_cell); + if let Some(idx) = self.highlight_cell { + self.view.scroll_chunk_into_view(idx); + } } fn render_hints(&self, area: Rect, buf: &mut Buffer) { @@ -402,9 +424,7 @@ impl TranscriptOverlay { let line2 = Rect::new(area.x, area.y.saturating_add(1), area.width, 1); render_key_hints(line1, buf, PAGER_KEY_HINTS); let mut pairs: Vec<(&str, &str)> = vec![("q", "quit"), ("Esc", "edit prev")]; - if let Some((start, end)) = self.highlight_range - && end > start - { + if self.highlight_cell.is_some() { pairs.push(("⏎", "edit message")); } render_key_hints(line2, buf, &pairs); @@ -414,8 +434,7 @@ impl TranscriptOverlay { let top_h = area.height.saturating_sub(3); let top = Rect::new(area.x, area.y, area.width, top_h); let bottom = Rect::new(area.x, area.y + top_h, area.width, 3); - self.view - .render_with_highlight(top, buf, self.highlight_range); + self.view.render(top, buf); self.render_hints(bottom, buf); } } @@ -458,9 +477,6 @@ impl TranscriptOverlay { pub(crate) fn is_done(&self) -> bool { self.is_done } - pub(crate) fn set_scroll_offset(&mut self, offset: usize) { - self.view.scroll_offset = offset; - } } pub(crate) struct StaticOverlay { @@ -471,7 +487,7 @@ pub(crate) struct StaticOverlay { impl StaticOverlay { pub(crate) fn with_title(lines: Vec>, title: String) -> Self { Self { - view: PagerView::new(lines, title, 0), + view: PagerView::new(vec![Text::from(lines)], title, 0), is_done: false, } } @@ -534,9 +550,26 @@ mod tests { use ratatui::Terminal; use ratatui::backend::TestBackend; + #[derive(Debug)] + struct TestCell { + lines: Vec>, + } + + impl crate::history_cell::HistoryCell for TestCell { + fn display_lines(&self, _width: u16) -> Vec> { + self.lines.clone() + } + + fn transcript_lines(&self) -> Vec> { + self.lines.clone() + } + } + #[test] fn edit_prev_hint_is_visible() { - let mut overlay = TranscriptOverlay::new(vec![Line::from("hello")]); + let mut overlay = TranscriptOverlay::new(vec![Arc::new(TestCell { + lines: vec![Line::from("hello")], + })]); // Render into a small buffer and assert the backtrack hint is present let area = Rect::new(0, 0, 40, 10); @@ -561,9 +594,15 @@ mod tests { fn transcript_overlay_snapshot_basic() { // Prepare a transcript overlay with a few lines let mut overlay = TranscriptOverlay::new(vec![ - Line::from("alpha"), - Line::from("beta"), - Line::from("gamma"), + Arc::new(TestCell { + lines: vec![Line::from("alpha")], + }), + Arc::new(TestCell { + lines: vec![Line::from("beta")], + }), + Arc::new(TestCell { + lines: vec![Line::from("gamma")], + }), ]); let mut term = Terminal::new(TestBackend::new(40, 10)).expect("term"); term.draw(|f| overlay.render(f.area(), f.buffer_mut())) @@ -573,8 +612,15 @@ mod tests { #[test] fn transcript_overlay_keeps_scroll_pinned_at_bottom() { - let mut overlay = - TranscriptOverlay::new((0..20).map(|i| Line::from(format!("line{i}"))).collect()); + let mut overlay = TranscriptOverlay::new( + (0..20) + .map(|i| { + Arc::new(TestCell { + lines: vec![Line::from(format!("line{i}"))], + }) as Arc + }) + .collect(), + ); let mut term = Terminal::new(TestBackend::new(40, 12)).expect("term"); term.draw(|f| overlay.render(f.area(), f.buffer_mut())) .expect("draw"); @@ -584,22 +630,33 @@ mod tests { "expected initial render to leave view at bottom" ); - overlay.insert_lines(vec!["tail".into()]); + overlay.insert_cell(Arc::new(TestCell { + lines: vec!["tail".into()], + })); assert_eq!(overlay.view.scroll_offset, usize::MAX); } #[test] fn transcript_overlay_preserves_manual_scroll_position() { - let mut overlay = - TranscriptOverlay::new((0..20).map(|i| Line::from(format!("line{i}"))).collect()); + let mut overlay = TranscriptOverlay::new( + (0..20) + .map(|i| { + Arc::new(TestCell { + lines: vec![Line::from(format!("line{i}"))], + }) as Arc + }) + .collect(), + ); let mut term = Terminal::new(TestBackend::new(40, 12)).expect("term"); term.draw(|f| overlay.render(f.area(), f.buffer_mut())) .expect("draw"); overlay.view.scroll_offset = 0; - overlay.insert_lines(vec!["tail".into()]); + overlay.insert_cell(Arc::new(TestCell { + lines: vec!["tail".into()], + })); assert_eq!(overlay.view.scroll_offset, 0); } @@ -620,17 +677,21 @@ mod tests { #[test] fn pager_wrap_cache_reuses_for_same_width_and_rebuilds_on_change() { let long = "This is a long line that should wrap multiple times to ensure non-empty wrapped output."; - let mut pv = PagerView::new(vec![long.into(), long.into()], "T".to_string(), 0); + let mut pv = PagerView::new( + vec![Text::from(vec![long.into()]), Text::from(vec![long.into()])], + "T".to_string(), + 0, + ); // Build cache at width 24 pv.ensure_wrapped(24); - let (w1, _) = pv.cached(); + let w1 = pv.cached(); assert!(!w1.is_empty(), "expected wrapped output to be non-empty"); let ptr1 = w1.as_ptr(); // Re-run with same width: cache should be reused (pointer stability heuristic) pv.ensure_wrapped(24); - let (w2, _) = pv.cached(); + let w2 = pv.cached(); let ptr2 = w2.as_ptr(); assert_eq!(ptr1, ptr2, "cache should not rebuild for unchanged width"); @@ -638,7 +699,7 @@ mod tests { // Drop immutable borrow before mutating let prev_len = w2.len(); pv.ensure_wrapped(36); - let (w3, _) = pv.cached(); + let w3 = pv.cached(); assert_ne!( prev_len, w3.len(), @@ -649,15 +710,16 @@ mod tests { #[test] fn pager_wrap_cache_invalidates_on_append() { let long = "Another long line for wrapping behavior verification."; - let mut pv = PagerView::new(vec![long.into()], "T".to_string(), 0); + let mut pv = PagerView::new(vec![Text::from(vec![long.into()])], "T".to_string(), 0); pv.ensure_wrapped(28); - let (w1, _) = pv.cached(); + let w1 = pv.cached(); let len1 = w1.len(); // Append new lines should cause ensure_wrapped to rebuild due to len change - pv.lines.extend([long.into(), long.into()]); + pv.texts.push(Text::from(vec![long.into()])); + pv.texts.push(Text::from(vec![long.into()])); pv.ensure_wrapped(28); - let (w2, _) = pv.cached(); + let w2 = pv.cached(); assert!( w2.len() >= len1, "wrapped length should grow or stay same after append" diff --git a/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__transcript_overlay_snapshot_basic.snap b/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__transcript_overlay_snapshot_basic.snap index b9105e8021..0c03edef47 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__transcript_overlay_snapshot_basic.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__transcript_overlay_snapshot_basic.snap @@ -4,10 +4,10 @@ expression: term.backend() --- "/ T R A N S C R I P T / / / / / / / / / " "alpha " +" " "beta " +" " "gamma " -"~ " -"~ " "───────────────────────────────── 100% ─" " ↑/↓ scroll PgUp/PgDn page Home/End " " q quit Esc edit prev " From 62258df92fd1a6165c0497591cc1281f36212939 Mon Sep 17 00:00:00 2001 From: dedrisian-oai Date: Thu, 18 Sep 2025 14:14:16 -0700 Subject: [PATCH 32/38] feat: /review (#3774) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `/review` action in TUI Screenshot 2025-09-17 at 12 41 19 AM --- codex-rs/core/src/codex.rs | 4 +- codex-rs/core/src/review_format.rs | 10 +- .../src/event_processor_with_human_output.rs | 3 + codex-rs/protocol/src/protocol.rs | 1 + codex-rs/tui/src/chatwidget.rs | 101 +++++++++++++++--- codex-rs/tui/src/chatwidget/tests.rs | 81 ++++++++++++++ codex-rs/tui/src/history_cell.rs | 7 ++ codex-rs/tui/src/slash_command.rs | 3 + 8 files changed, 189 insertions(+), 21 deletions(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 1de37f6f6a..8caa2e8ee0 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -3303,7 +3303,7 @@ async fn exit_review_mode( {findings_str} - + "#)); } else { user_message.push_str(r#" @@ -3312,7 +3312,7 @@ async fn exit_review_mode( None. - + "#); } diff --git a/codex-rs/core/src/review_format.rs b/codex-rs/core/src/review_format.rs index 272010d564..6a64d06fa1 100644 --- a/codex-rs/core/src/review_format.rs +++ b/codex-rs/core/src/review_format.rs @@ -22,14 +22,14 @@ pub fn format_review_findings_block( selection: Option<&[bool]>, ) -> String { let mut lines: Vec = Vec::new(); + lines.push(String::new()); // Header - let header = if findings.len() > 1 { - "Full review comments:" + if findings.len() > 1 { + lines.push("Full review comments:".to_string()); } else { - "Review comment:" - }; - lines.push(header.to_string()); + lines.push("Review comment:".to_string()); + } for (idx, item) in findings.iter().enumerate() { lines.push(String::new()); diff --git a/codex-rs/exec/src/event_processor_with_human_output.rs b/codex-rs/exec/src/event_processor_with_human_output.rs index c126208fad..5ef7d51a26 100644 --- a/codex-rs/exec/src/event_processor_with_human_output.rs +++ b/codex-rs/exec/src/event_processor_with_human_output.rs @@ -558,6 +558,9 @@ impl EventProcessor for EventProcessorWithHumanOutput { TurnAbortReason::Replaced => { ts_println!(self, "task aborted: replaced by a new task"); } + TurnAbortReason::ReviewEnded => { + ts_println!(self, "task aborted: review ended"); + } }, EventMsg::ShutdownComplete => return CodexStatus::Shutdown, EventMsg::ConversationPath(_) => {} diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index dddaeee23f..8009ac9bf3 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -1240,6 +1240,7 @@ pub struct TurnAbortedEvent { pub enum TurnAbortReason { Interrupted, Replaced, + ReviewEnded, } #[cfg(test)] diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 7ab954f263..e024fd0fab 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -19,6 +19,7 @@ use codex_core::protocol::EventMsg; use codex_core::protocol::ExecApprovalRequestEvent; use codex_core::protocol::ExecCommandBeginEvent; use codex_core::protocol::ExecCommandEndEvent; +use codex_core::protocol::ExitedReviewModeEvent; use codex_core::protocol::InputItem; use codex_core::protocol::InputMessageKind; use codex_core::protocol::ListCustomPromptsResponseEvent; @@ -27,6 +28,7 @@ use codex_core::protocol::McpToolCallBeginEvent; use codex_core::protocol::McpToolCallEndEvent; use codex_core::protocol::Op; use codex_core::protocol::PatchApplyBeginEvent; +use codex_core::protocol::ReviewRequest; use codex_core::protocol::StreamErrorEvent; use codex_core::protocol::TaskCompleteEvent; use codex_core::protocol::TokenUsage; @@ -36,6 +38,7 @@ use codex_core::protocol::TurnDiffEvent; use codex_core::protocol::UserMessageEvent; use codex_core::protocol::WebSearchBeginEvent; use codex_core::protocol::WebSearchEndEvent; +use codex_protocol::mcp_protocol::ConversationId; use codex_protocol::parse_command::ParsedCommand; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; @@ -63,10 +66,12 @@ use crate::clipboard_paste::paste_image_to_temp_png; use crate::diff_render::display_path_for; use crate::get_git_diff::get_git_diff; use crate::history_cell; +use crate::history_cell::AgentMessageCell; use crate::history_cell::CommandOutput; use crate::history_cell::ExecCell; use crate::history_cell::HistoryCell; use crate::history_cell::PatchEventType; +use crate::markdown::append_markdown; use crate::slash_command::SlashCommand; use crate::text_formatting::truncate_text; use crate::tui::FrameRequester; @@ -91,7 +96,6 @@ use codex_core::protocol::AskForApproval; use codex_core::protocol::SandboxPolicy; use codex_core::protocol_config_types::ReasoningEffort as ReasoningEffortConfig; use codex_file_search::FileMatch; -use codex_protocol::mcp_protocol::ConversationId; // Track information about an in-flight exec command. struct RunningCommand { @@ -141,6 +145,8 @@ pub(crate) struct ChatWidget { queued_user_messages: VecDeque, // Pending notification to show when unfocused on next Draw pending_notification: Option, + // Simple review mode flag; used to adjust layout and banners. + is_review_mode: bool, } struct UserMessage { @@ -279,13 +285,10 @@ impl ChatWidget { self.bottom_pane.set_token_usage(info.clone()); self.token_info = info; } - /// Finalize any active exec as failed, push an error message into history, - /// and stop/clear running UI state. - fn finalize_turn_with_error_message(&mut self, message: String) { + /// Finalize any active exec as failed and stop/clear running UI state. + fn finalize_turn(&mut self) { // Ensure any spinner is replaced by a red ✗ and flushed into history. self.finalize_active_exec_cell_as_failed(); - // Emit the provided error message/history cell. - self.add_to_history(history_cell::new_error_event(message)); // Reset running state and clear streaming buffers. self.bottom_pane.set_task_running(false); self.running_commands.clear(); @@ -293,7 +296,8 @@ impl ChatWidget { } fn on_error(&mut self, message: String) { - self.finalize_turn_with_error_message(message); + self.finalize_turn(); + self.add_to_history(history_cell::new_error_event(message)); self.request_redraw(); // After an error ends the turn, try sending the next queued input. @@ -303,11 +307,15 @@ impl ChatWidget { /// Handle a turn aborted due to user interrupt (Esc). /// When there are queued user messages, restore them into the composer /// separated by newlines rather than auto‑submitting the next one. - fn on_interrupted_turn(&mut self) { + fn on_interrupted_turn(&mut self, reason: TurnAbortReason) { // Finalize, log a gentle prompt, and clear running state. - self.finalize_turn_with_error_message( - "Conversation interrupted - tell the model what to do differently".to_owned(), - ); + self.finalize_turn(); + + if reason != TurnAbortReason::ReviewEnded { + self.add_to_history(history_cell::new_error_event( + "Conversation interrupted - tell the model what to do differently".to_owned(), + )); + } // If any messages were queued during the task, restore them into the composer. if !self.queued_user_messages.is_empty() { @@ -702,6 +710,7 @@ impl ChatWidget { show_welcome_banner: true, suppress_session_configured_redraw: false, pending_notification: None, + is_review_mode: false, } } @@ -758,6 +767,7 @@ impl ChatWidget { show_welcome_banner: true, suppress_session_configured_redraw: true, pending_notification: None, + is_review_mode: false, } } @@ -872,6 +882,15 @@ impl ChatWidget { self.clear_token_usage(); self.app_event_tx.send(AppEvent::CodexOp(Op::Compact)); } + SlashCommand::Review => { + // Simplified flow: directly send a review op for current changes. + self.submit_op(Op::Review { + review_request: ReviewRequest { + prompt: "review current changes".to_string(), + user_facing_hint: "current changes".to_string(), + }, + }); + } SlashCommand::Model => { self.open_model_popup(); } @@ -1091,11 +1110,14 @@ impl ChatWidget { EventMsg::Error(ErrorEvent { message }) => self.on_error(message), EventMsg::TurnAborted(ev) => match ev.reason { TurnAbortReason::Interrupted => { - self.on_interrupted_turn(); + self.on_interrupted_turn(ev.reason); } TurnAbortReason::Replaced => { self.on_error("Turn aborted: replaced by a new task".to_owned()) } + TurnAbortReason::ReviewEnded => { + self.on_interrupted_turn(ev.reason); + } }, EventMsg::PlanUpdate(update) => self.on_plan_update(update), EventMsg::ExecApprovalRequest(ev) => { @@ -1132,9 +1154,60 @@ impl ChatWidget { self.app_event_tx .send(crate::app_event::AppEvent::ConversationHistory(ev)); } - EventMsg::EnteredReviewMode(_) => {} - EventMsg::ExitedReviewMode(_) => {} + EventMsg::EnteredReviewMode(review_request) => { + self.on_entered_review_mode(review_request) + } + EventMsg::ExitedReviewMode(review) => self.on_exited_review_mode(review), + } + } + + fn on_entered_review_mode(&mut self, review: ReviewRequest) { + // Enter review mode and emit a concise banner + self.is_review_mode = true; + let banner = format!(">> Code review started: {} <<", review.user_facing_hint); + self.add_to_history(history_cell::new_review_status_line(banner)); + self.request_redraw(); + } + + fn on_exited_review_mode(&mut self, review: ExitedReviewModeEvent) { + // Leave review mode; if output is present, flush pending stream + show results. + if let Some(output) = review.review_output { + self.flush_answer_stream_with_separator(); + self.flush_interrupt_queue(); + self.flush_active_exec_cell(); + + if output.findings.is_empty() { + let explanation = output.overall_explanation.trim().to_string(); + if explanation.is_empty() { + tracing::error!("Reviewer failed to output a response."); + self.add_to_history(history_cell::new_error_event( + "Reviewer failed to output a response.".to_owned(), + )); + } else { + // Show explanation when there are no structured findings. + let mut rendered: Vec> = vec!["".into()]; + append_markdown(&explanation, &mut rendered, &self.config); + let body_cell = AgentMessageCell::new(rendered, false); + self.app_event_tx + .send(AppEvent::InsertHistoryCell(Box::new(body_cell))); + } + } else { + let message_text = + codex_core::review_format::format_review_findings_block(&output.findings, None); + let mut message_lines: Vec> = Vec::new(); + append_markdown(&message_text, &mut message_lines, &self.config); + let body_cell = AgentMessageCell::new(message_lines, true); + self.app_event_tx + .send(AppEvent::InsertHistoryCell(Box::new(body_cell))); + } } + + self.is_review_mode = false; + // Append a finishing banner at the end of this turn. + self.add_to_history(history_cell::new_review_status_line( + "<< Code review finished >>".to_string(), + )); + self.request_redraw(); } fn on_user_message_event(&mut self, event: UserMessageEvent) { diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index fb4ea5e888..ffe3f3f707 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -19,10 +19,17 @@ use codex_core::protocol::EventMsg; use codex_core::protocol::ExecApprovalRequestEvent; use codex_core::protocol::ExecCommandBeginEvent; use codex_core::protocol::ExecCommandEndEvent; +use codex_core::protocol::ExitedReviewModeEvent; use codex_core::protocol::FileChange; use codex_core::protocol::InputMessageKind; +use codex_core::protocol::Op; use codex_core::protocol::PatchApplyBeginEvent; use codex_core::protocol::PatchApplyEndEvent; +use codex_core::protocol::ReviewCodeLocation; +use codex_core::protocol::ReviewFinding; +use codex_core::protocol::ReviewLineRange; +use codex_core::protocol::ReviewOutputEvent; +use codex_core::protocol::ReviewRequest; use codex_core::protocol::StreamErrorEvent; use codex_core::protocol::TaskCompleteEvent; use codex_core::protocol::TaskStartedEvent; @@ -184,6 +191,79 @@ fn resumed_initial_messages_render_history() { ); } +/// Entering review mode uses the hint provided by the review request. +#[test] +fn entered_review_mode_uses_request_hint() { + let (mut chat, mut rx, _ops) = make_chatwidget_manual(); + + chat.handle_codex_event(Event { + id: "review-start".into(), + msg: EventMsg::EnteredReviewMode(ReviewRequest { + prompt: "Review the latest changes".to_string(), + user_facing_hint: "feature branch".to_string(), + }), + }); + + let cells = drain_insert_history(&mut rx); + let banner = lines_to_single_string(cells.last().expect("review banner")); + assert_eq!(banner, ">> Code review started: feature branch <<\n"); + assert!(chat.is_review_mode); +} + +/// Entering review mode renders the current changes banner when requested. +#[test] +fn entered_review_mode_defaults_to_current_changes_banner() { + let (mut chat, mut rx, _ops) = make_chatwidget_manual(); + + chat.handle_codex_event(Event { + id: "review-start".into(), + msg: EventMsg::EnteredReviewMode(ReviewRequest { + prompt: "Review the current changes".to_string(), + user_facing_hint: "current changes".to_string(), + }), + }); + + let cells = drain_insert_history(&mut rx); + let banner = lines_to_single_string(cells.last().expect("review banner")); + assert_eq!(banner, ">> Code review started: current changes <<\n"); + assert!(chat.is_review_mode); +} + +/// Completing review with findings shows the selection popup and finishes with +/// the closing banner while clearing review mode state. +#[test] +fn exited_review_mode_emits_results_and_finishes() { + let (mut chat, mut rx, _ops) = make_chatwidget_manual(); + + let review = ReviewOutputEvent { + findings: vec![ReviewFinding { + title: "[P1] Fix bug".to_string(), + body: "Something went wrong".to_string(), + confidence_score: 0.9, + priority: 1, + code_location: ReviewCodeLocation { + absolute_file_path: PathBuf::from("src/lib.rs"), + line_range: ReviewLineRange { start: 10, end: 12 }, + }, + }], + overall_correctness: "needs work".to_string(), + overall_explanation: "Investigate the failure".to_string(), + overall_confidence_score: 0.5, + }; + + chat.handle_codex_event(Event { + id: "review-end".into(), + msg: EventMsg::ExitedReviewMode(ExitedReviewModeEvent { + review_output: Some(review), + }), + }); + + let cells = drain_insert_history(&mut rx); + let banner = lines_to_single_string(cells.last().expect("finished banner")); + assert_eq!(banner, "\n<< Code review finished >>\n"); + assert!(!chat.is_review_mode); +} + #[cfg_attr( target_os = "macos", ignore = "system configuration APIs are blocked under macOS seatbelt" @@ -252,6 +332,7 @@ fn make_chatwidget_manual() -> ( queued_user_messages: VecDeque::new(), suppress_session_configured_redraw: false, pending_notification: None, + is_review_mode: false, }; (widget, rx, op_rx) } diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index ad3d5a9030..a99a4dd836 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -236,6 +236,13 @@ impl HistoryCell for TranscriptOnlyHistoryCell { } } +/// Cyan history cell line showing the current review status. +pub(crate) fn new_review_status_line(message: String) -> PlainHistoryCell { + PlainHistoryCell { + lines: vec![Line::from(message.cyan())], + } +} + #[derive(Debug)] pub(crate) struct PatchHistoryCell { event_type: PatchEventType, diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index 3268a92a2d..433c0a6d7f 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -14,6 +14,7 @@ pub enum SlashCommand { // more frequently used commands should be listed first. Model, Approvals, + Review, New, Init, Compact, @@ -34,6 +35,7 @@ impl SlashCommand { SlashCommand::New => "start a new chat during a conversation", SlashCommand::Init => "create an AGENTS.md file with instructions for Codex", SlashCommand::Compact => "summarize conversation to prevent hitting the context limit", + SlashCommand::Review => "review my current changes and find issues", SlashCommand::Quit => "exit Codex", SlashCommand::Diff => "show git diff (including untracked files)", SlashCommand::Mention => "mention a file", @@ -61,6 +63,7 @@ impl SlashCommand { | SlashCommand::Compact | SlashCommand::Model | SlashCommand::Approvals + | SlashCommand::Review | SlashCommand::Logout => false, SlashCommand::Diff | SlashCommand::Mention From 8595237505a1e0faabc2af3db805b66ce3ae182d Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Thu, 18 Sep 2025 14:37:06 -0700 Subject: [PATCH 33/38] fix: ensure cwd for conversation and sandbox are separate concerns (#3874) Previous to this PR, both of these functions take a single `cwd`: https://github.com/openai/codex/blob/71038381aa0f51aa62e1a2bcc7cbf26a05b141f3/codex-rs/core/src/seatbelt.rs#L19-L25 https://github.com/openai/codex/blob/71038381aa0f51aa62e1a2bcc7cbf26a05b141f3/codex-rs/core/src/landlock.rs#L16-L23 whereas `cwd` and `sandbox_cwd` should be set independently (fixed in this PR). Added `sandbox_distinguishes_command_and_policy_cwds()` to `codex-rs/exec/tests/suite/sandbox.rs` to verify this. --- codex-rs/cli/src/debug_sandbox.rs | 24 +++- codex-rs/core/src/codex.rs | 5 + codex-rs/core/src/exec.rs | 18 ++- codex-rs/core/src/landlock.rs | 14 ++- codex-rs/core/src/seatbelt.rs | 11 +- codex-rs/core/src/shell.rs | 2 + codex-rs/core/tests/suite/exec.rs | 2 +- .../core/tests/suite/exec_stream_events.rs | 37 ++++-- codex-rs/core/tests/suite/seatbelt.rs | 10 +- codex-rs/exec/tests/suite/sandbox.rs | 113 +++++++++++++++++- .../linux-sandbox/tests/suite/landlock.rs | 7 +- .../mcp-server/src/codex_message_processor.rs | 2 + 12 files changed, 209 insertions(+), 36 deletions(-) diff --git a/codex-rs/cli/src/debug_sandbox.rs b/codex-rs/cli/src/debug_sandbox.rs index 6fe7f003c7..a7d7103c2f 100644 --- a/codex-rs/cli/src/debug_sandbox.rs +++ b/codex-rs/cli/src/debug_sandbox.rs @@ -64,7 +64,6 @@ async fn run_command_under_sandbox( sandbox_type: SandboxType, ) -> anyhow::Result<()> { let sandbox_mode = create_sandbox_mode(full_auto); - let cwd = std::env::current_dir()?; let config = Config::load_with_cli_overrides( config_overrides .parse_overrides() @@ -75,13 +74,29 @@ async fn run_command_under_sandbox( ..Default::default() }, )?; + + // In practice, this should be `std::env::current_dir()` because this CLI + // does not support `--cwd`, but let's use the config value for consistency. + let cwd = config.cwd.clone(); + // For now, we always use the same cwd for both the command and the + // sandbox policy. In the future, we could add a CLI option to set them + // separately. + let sandbox_policy_cwd = cwd.clone(); + let stdio_policy = StdioPolicy::Inherit; let env = create_env(&config.shell_environment_policy); let mut child = match sandbox_type { SandboxType::Seatbelt => { - spawn_command_under_seatbelt(command, &config.sandbox_policy, cwd, stdio_policy, env) - .await? + spawn_command_under_seatbelt( + command, + cwd, + &config.sandbox_policy, + sandbox_policy_cwd.as_path(), + stdio_policy, + env, + ) + .await? } SandboxType::Landlock => { #[expect(clippy::expect_used)] @@ -91,8 +106,9 @@ async fn run_command_under_sandbox( spawn_command_under_linux_sandbox( codex_linux_sandbox_exe, command, - &config.sandbox_policy, cwd, + &config.sandbox_policy, + sandbox_policy_cwd.as_path(), stdio_policy, env, ) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 8caa2e8ee0..e9f52b1c94 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -1,6 +1,7 @@ use std::borrow::Cow; use std::collections::HashMap; use std::collections::HashSet; +use std::path::Path; use std::path::PathBuf; use std::sync::Arc; use std::sync::atomic::AtomicU64; @@ -898,6 +899,7 @@ impl Session { exec_args.params, exec_args.sandbox_type, exec_args.sandbox_policy, + exec_args.sandbox_cwd, exec_args.codex_linux_sandbox_exe, exec_args.stdout_stream, ) @@ -2691,6 +2693,7 @@ pub struct ExecInvokeArgs<'a> { pub params: ExecParams, pub sandbox_type: SandboxType, pub sandbox_policy: &'a SandboxPolicy, + pub sandbox_cwd: &'a Path, pub codex_linux_sandbox_exe: &'a Option, pub stdout_stream: Option, } @@ -2882,6 +2885,7 @@ async fn handle_container_exec_with_params( params: params.clone(), sandbox_type, sandbox_policy: &turn_context.sandbox_policy, + sandbox_cwd: &turn_context.cwd, codex_linux_sandbox_exe: &sess.codex_linux_sandbox_exe, stdout_stream: if exec_command_context.apply_patch.is_some() { None @@ -3016,6 +3020,7 @@ async fn handle_sandbox_error( params, sandbox_type: SandboxType::None, sandbox_policy: &turn_context.sandbox_policy, + sandbox_cwd: &turn_context.cwd, codex_linux_sandbox_exe: &sess.codex_linux_sandbox_exe, stdout_stream: if exec_command_context.apply_patch.is_some() { None diff --git a/codex-rs/core/src/exec.rs b/codex-rs/core/src/exec.rs index 8fecb16fc3..9e11604c39 100644 --- a/codex-rs/core/src/exec.rs +++ b/codex-rs/core/src/exec.rs @@ -3,6 +3,7 @@ use std::os::unix::process::ExitStatusExt; use std::collections::HashMap; use std::io; +use std::path::Path; use std::path::PathBuf; use std::process::ExitStatus; use std::time::Duration; @@ -82,6 +83,7 @@ pub async fn process_exec_tool_call( params: ExecParams, sandbox_type: SandboxType, sandbox_policy: &SandboxPolicy, + sandbox_cwd: &Path, codex_linux_sandbox_exe: &Option, stdout_stream: Option, ) -> Result { @@ -94,12 +96,16 @@ pub async fn process_exec_tool_call( SandboxType::None => exec(params, sandbox_policy, stdout_stream.clone()).await, SandboxType::MacosSeatbelt => { let ExecParams { - command, cwd, env, .. + command, + cwd: command_cwd, + env, + .. } = params; let child = spawn_command_under_seatbelt( command, + command_cwd, sandbox_policy, - cwd, + sandbox_cwd, StdioPolicy::RedirectForShellTool, env, ) @@ -108,7 +114,10 @@ pub async fn process_exec_tool_call( } SandboxType::LinuxSeccomp => { let ExecParams { - command, cwd, env, .. + command, + cwd: command_cwd, + env, + .. } = params; let codex_linux_sandbox_exe = codex_linux_sandbox_exe @@ -117,8 +126,9 @@ pub async fn process_exec_tool_call( let child = spawn_command_under_linux_sandbox( codex_linux_sandbox_exe, command, + command_cwd, sandbox_policy, - cwd, + sandbox_cwd, StdioPolicy::RedirectForShellTool, env, ) diff --git a/codex-rs/core/src/landlock.rs b/codex-rs/core/src/landlock.rs index 8c8840c269..264ea747ca 100644 --- a/codex-rs/core/src/landlock.rs +++ b/codex-rs/core/src/landlock.rs @@ -16,21 +16,22 @@ use tokio::process::Child; pub async fn spawn_command_under_linux_sandbox

( codex_linux_sandbox_exe: P, command: Vec, + command_cwd: PathBuf, sandbox_policy: &SandboxPolicy, - cwd: PathBuf, + sandbox_policy_cwd: &Path, stdio_policy: StdioPolicy, env: HashMap, ) -> std::io::Result where P: AsRef, { - let args = create_linux_sandbox_command_args(command, sandbox_policy, &cwd); + let args = create_linux_sandbox_command_args(command, sandbox_policy, sandbox_policy_cwd); let arg0 = Some("codex-linux-sandbox"); spawn_child_async( codex_linux_sandbox_exe.as_ref().to_path_buf(), args, arg0, - cwd, + command_cwd, sandbox_policy, stdio_policy, env, @@ -42,10 +43,13 @@ where fn create_linux_sandbox_command_args( command: Vec, sandbox_policy: &SandboxPolicy, - cwd: &Path, + sandbox_policy_cwd: &Path, ) -> Vec { #[expect(clippy::expect_used)] - let sandbox_policy_cwd = cwd.to_str().expect("cwd must be valid UTF-8").to_string(); + let sandbox_policy_cwd = sandbox_policy_cwd + .to_str() + .expect("cwd must be valid UTF-8") + .to_string(); #[expect(clippy::expect_used)] let sandbox_policy_json = diff --git a/codex-rs/core/src/seatbelt.rs b/codex-rs/core/src/seatbelt.rs index 285e5d5691..09e93668bc 100644 --- a/codex-rs/core/src/seatbelt.rs +++ b/codex-rs/core/src/seatbelt.rs @@ -18,19 +18,20 @@ const MACOS_PATH_TO_SEATBELT_EXECUTABLE: &str = "/usr/bin/sandbox-exec"; pub async fn spawn_command_under_seatbelt( command: Vec, + command_cwd: PathBuf, sandbox_policy: &SandboxPolicy, - cwd: PathBuf, + sandbox_policy_cwd: &Path, stdio_policy: StdioPolicy, mut env: HashMap, ) -> std::io::Result { - let args = create_seatbelt_command_args(command, sandbox_policy, &cwd); + let args = create_seatbelt_command_args(command, sandbox_policy, sandbox_policy_cwd); let arg0 = None; env.insert(CODEX_SANDBOX_ENV_VAR.to_string(), "seatbelt".to_string()); spawn_child_async( PathBuf::from(MACOS_PATH_TO_SEATBELT_EXECUTABLE), args, arg0, - cwd, + command_cwd, sandbox_policy, stdio_policy, env, @@ -41,7 +42,7 @@ pub async fn spawn_command_under_seatbelt( fn create_seatbelt_command_args( command: Vec, sandbox_policy: &SandboxPolicy, - cwd: &Path, + sandbox_policy_cwd: &Path, ) -> Vec { let (file_write_policy, extra_cli_args) = { if sandbox_policy.has_full_disk_write_access() { @@ -51,7 +52,7 @@ fn create_seatbelt_command_args( Vec::::new(), ) } else { - let writable_roots = sandbox_policy.get_writable_roots_with_cwd(cwd); + let writable_roots = sandbox_policy.get_writable_roots_with_cwd(sandbox_policy_cwd); let mut writable_folder_policies: Vec = Vec::new(); let mut cli_args: Vec = Vec::new(); diff --git a/codex-rs/core/src/shell.rs b/codex-rs/core/src/shell.rs index 28cf61dd94..3bfef7e1e0 100644 --- a/codex-rs/core/src/shell.rs +++ b/codex-rs/core/src/shell.rs @@ -349,6 +349,7 @@ mod tests { }, SandboxType::None, &SandboxPolicy::DangerFullAccess, + temp_home.path(), &None, None, ) @@ -455,6 +456,7 @@ mod macos_tests { }, SandboxType::None, &SandboxPolicy::DangerFullAccess, + temp_home.path(), &None, None, ) diff --git a/codex-rs/core/tests/suite/exec.rs b/codex-rs/core/tests/suite/exec.rs index 9e0cffe67f..280917b530 100644 --- a/codex-rs/core/tests/suite/exec.rs +++ b/codex-rs/core/tests/suite/exec.rs @@ -39,7 +39,7 @@ async fn run_test_cmd(tmp: TempDir, cmd: Vec<&str>) -> Result&2".to_string(), ]; + let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); let params = ExecParams { command: cmd, - cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), + cwd: cwd.clone(), timeout_ms: Some(5_000), env: HashMap::new(), with_escalated_permissions: None, @@ -114,6 +117,7 @@ async fn test_exec_stderr_stream_events_echo() { params, SandboxType::None, &policy, + cwd.as_path(), &None, Some(stdout_stream), ) @@ -152,9 +156,10 @@ async fn test_aggregated_output_interleaves_in_order() { "printf 'O1\\n'; sleep 0.01; printf 'E1\\n' 1>&2; sleep 0.01; printf 'O2\\n'; sleep 0.01; printf 'E2\\n' 1>&2".to_string(), ]; + let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); let params = ExecParams { command: cmd, - cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), + cwd: cwd.clone(), timeout_ms: Some(5_000), env: HashMap::new(), with_escalated_permissions: None, @@ -163,9 +168,16 @@ async fn test_aggregated_output_interleaves_in_order() { let policy = SandboxPolicy::new_read_only_policy(); - let result = process_exec_tool_call(params, SandboxType::None, &policy, &None, None) - .await - .expect("process_exec_tool_call"); + let result = process_exec_tool_call( + params, + SandboxType::None, + &policy, + cwd.as_path(), + &None, + None, + ) + .await + .expect("process_exec_tool_call"); assert_eq!(result.exit_code, 0); assert_eq!(result.stdout.text, "O1\nO2\n"); @@ -182,9 +194,10 @@ async fn test_exec_timeout_returns_partial_output() { "printf 'before\\n'; sleep 2; printf 'after\\n'".to_string(), ]; + let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); let params = ExecParams { command: cmd, - cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), + cwd: cwd.clone(), timeout_ms: Some(200), env: HashMap::new(), with_escalated_permissions: None, @@ -193,7 +206,15 @@ async fn test_exec_timeout_returns_partial_output() { let policy = SandboxPolicy::new_read_only_policy(); - let result = process_exec_tool_call(params, SandboxType::None, &policy, &None, None).await; + let result = process_exec_tool_call( + params, + SandboxType::None, + &policy, + cwd.as_path(), + &None, + None, + ) + .await; let Err(CodexErr::Sandbox(SandboxErr::Timeout { output })) = result else { panic!("expected timeout error"); diff --git a/codex-rs/core/tests/suite/seatbelt.rs b/codex-rs/core/tests/suite/seatbelt.rs index 69f1d8907e..78f599d42e 100644 --- a/codex-rs/core/tests/suite/seatbelt.rs +++ b/codex-rs/core/tests/suite/seatbelt.rs @@ -171,6 +171,8 @@ async fn python_getpwuid_works_under_seatbelt() { // ReadOnly is sufficient here since we are only exercising user lookup. let policy = SandboxPolicy::ReadOnly; + let command_cwd = std::env::current_dir().expect("getcwd"); + let sandbox_cwd = command_cwd.clone(); let mut child = spawn_command_under_seatbelt( vec![ @@ -179,8 +181,9 @@ async fn python_getpwuid_works_under_seatbelt() { // Print the passwd struct; success implies lookup worked. "import pwd, os; print(pwd.getpwuid(os.getuid()))".to_string(), ], + command_cwd, &policy, - std::env::current_dir().expect("should be able to get current dir"), + sandbox_cwd.as_path(), StdioPolicy::RedirectForShellTool, HashMap::new(), ) @@ -216,13 +219,16 @@ fn create_test_scenario(tmp: &TempDir) -> TestScenario { /// Note that `path` must be absolute. async fn touch(path: &Path, policy: &SandboxPolicy) -> bool { assert!(path.is_absolute(), "Path must be absolute: {path:?}"); + let command_cwd = std::env::current_dir().expect("getcwd"); + let sandbox_cwd = command_cwd.clone(); let mut child = spawn_command_under_seatbelt( vec![ "/usr/bin/touch".to_string(), path.to_string_lossy().to_string(), ], + command_cwd, policy, - std::env::current_dir().expect("should be able to get current dir"), + sandbox_cwd.as_path(), StdioPolicy::RedirectForShellTool, HashMap::new(), ) diff --git a/codex-rs/exec/tests/suite/sandbox.rs b/codex-rs/exec/tests/suite/sandbox.rs index a98e0a9760..5355a0b2eb 100644 --- a/codex-rs/exec/tests/suite/sandbox.rs +++ b/codex-rs/exec/tests/suite/sandbox.rs @@ -4,27 +4,39 @@ use codex_core::spawn::StdioPolicy; use std::collections::HashMap; use std::future::Future; use std::io; +use std::path::Path; use std::path::PathBuf; use std::process::ExitStatus; +use tokio::fs::create_dir_all; use tokio::process::Child; #[cfg(target_os = "macos")] async fn spawn_command_under_sandbox( command: Vec, + command_cwd: PathBuf, sandbox_policy: &SandboxPolicy, - cwd: PathBuf, + sandbox_cwd: &Path, stdio_policy: StdioPolicy, env: HashMap, ) -> std::io::Result { use codex_core::seatbelt::spawn_command_under_seatbelt; - spawn_command_under_seatbelt(command, sandbox_policy, cwd, stdio_policy, env).await + spawn_command_under_seatbelt( + command, + command_cwd, + sandbox_policy, + sandbox_cwd, + stdio_policy, + env, + ) + .await } #[cfg(target_os = "linux")] async fn spawn_command_under_sandbox( command: Vec, + command_cwd: PathBuf, sandbox_policy: &SandboxPolicy, - cwd: PathBuf, + sandbox_cwd: &Path, stdio_policy: StdioPolicy, env: HashMap, ) -> std::io::Result { @@ -33,8 +45,9 @@ async fn spawn_command_under_sandbox( spawn_command_under_linux_sandbox( codex_linux_sandbox_exe, command, + command_cwd, sandbox_policy, - cwd, + sandbox_cwd, stdio_policy, env, ) @@ -74,14 +87,17 @@ if __name__ == '__main__': p.join() "#; + let command_cwd = std::env::current_dir().expect("should be able to get current dir"); + let sandbox_cwd = command_cwd.clone(); let mut child = spawn_command_under_sandbox( vec![ "python3".to_string(), "-c".to_string(), python_code.to_string(), ], + command_cwd, &policy, - std::env::current_dir().expect("should be able to get current dir"), + sandbox_cwd.as_path(), StdioPolicy::Inherit, HashMap::new(), ) @@ -92,6 +108,88 @@ if __name__ == '__main__': assert!(status.success(), "python exited with {status:?}"); } +#[tokio::test] +async fn sandbox_distinguishes_command_and_policy_cwds() { + let temp = tempfile::tempdir().expect("should be able to create temp dir"); + let sandbox_root = temp.path().join("sandbox"); + let command_root = temp.path().join("command"); + create_dir_all(&sandbox_root).await.expect("mkdir"); + create_dir_all(&command_root).await.expect("mkdir"); + let canonical_sandbox_root = tokio::fs::canonicalize(&sandbox_root) + .await + .expect("canonicalize sandbox root"); + let canonical_allowed_path = canonical_sandbox_root.join("allowed.txt"); + + let disallowed_path = command_root.join("forbidden.txt"); + + // Note writable_roots is empty: verify that `canonical_allowed_path` is + // writable only because it is under the sandbox policy cwd, not because it + // is under a writable root. + let policy = SandboxPolicy::WorkspaceWrite { + writable_roots: vec![], + network_access: false, + exclude_tmpdir_env_var: true, + exclude_slash_tmp: true, + }; + + // Attempt to write inside the command cwd, which is outside of the sandbox policy cwd. + let mut child = spawn_command_under_sandbox( + vec![ + "bash".to_string(), + "-lc".to_string(), + "echo forbidden > forbidden.txt".to_string(), + ], + command_root.clone(), + &policy, + canonical_sandbox_root.as_path(), + StdioPolicy::Inherit, + HashMap::new(), + ) + .await + .expect("should spawn command writing to forbidden path"); + + let status = child + .wait() + .await + .expect("should wait for forbidden command"); + assert!( + !status.success(), + "sandbox unexpectedly allowed writing to command cwd: {status:?}" + ); + let forbidden_exists = tokio::fs::try_exists(&disallowed_path) + .await + .expect("try_exists failed"); + assert!( + !forbidden_exists, + "forbidden path should not have been created" + ); + + // Writing to the sandbox policy cwd after changing directories into it should succeed. + let mut child = spawn_command_under_sandbox( + vec![ + "/usr/bin/touch".to_string(), + canonical_allowed_path.to_string_lossy().into_owned(), + ], + command_root, + &policy, + canonical_sandbox_root.as_path(), + StdioPolicy::Inherit, + HashMap::new(), + ) + .await + .expect("should spawn command writing to sandbox root"); + + let status = child.wait().await.expect("should wait for allowed command"); + assert!( + status.success(), + "sandbox blocked allowed write: {status:?}" + ); + let allowed_exists = tokio::fs::try_exists(&canonical_allowed_path) + .await + .expect("try_exists allowed failed"); + assert!(allowed_exists, "allowed path should exist"); +} + fn unix_sock_body() { unsafe { let mut fds = [0i32; 2]; @@ -200,10 +298,13 @@ where cmds.push(test_selector.into()); // Your existing launcher: + let command_cwd = std::env::current_dir().expect("should be able to get current dir"); + let sandbox_cwd = command_cwd.clone(); let mut child = spawn_command_under_sandbox( cmds, + command_cwd, policy, - std::env::current_dir().expect("should be able to get current dir"), + sandbox_cwd.as_path(), stdio_policy, HashMap::from([("IN_SANDBOX".into(), "1".into())]), ) diff --git a/codex-rs/linux-sandbox/tests/suite/landlock.rs b/codex-rs/linux-sandbox/tests/suite/landlock.rs index fddb43c38e..ea4930fada 100644 --- a/codex-rs/linux-sandbox/tests/suite/landlock.rs +++ b/codex-rs/linux-sandbox/tests/suite/landlock.rs @@ -35,9 +35,11 @@ fn create_env_from_core_vars() -> HashMap { #[expect(clippy::print_stdout, clippy::expect_used, clippy::unwrap_used)] async fn run_cmd(cmd: &[&str], writable_roots: &[PathBuf], timeout_ms: u64) { + let cwd = std::env::current_dir().expect("cwd should exist"); + let sandbox_cwd = cwd.clone(); let params = ExecParams { command: cmd.iter().map(|elm| elm.to_string()).collect(), - cwd: std::env::current_dir().expect("cwd should exist"), + cwd, timeout_ms: Some(timeout_ms), env: create_env_from_core_vars(), with_escalated_permissions: None, @@ -59,6 +61,7 @@ async fn run_cmd(cmd: &[&str], writable_roots: &[PathBuf], timeout_ms: u64) { params, SandboxType::LinuxSeccomp, &sandbox_policy, + sandbox_cwd.as_path(), &codex_linux_sandbox_exe, None, ) @@ -133,6 +136,7 @@ async fn test_timeout() { #[expect(clippy::expect_used)] async fn assert_network_blocked(cmd: &[&str]) { let cwd = std::env::current_dir().expect("cwd should exist"); + let sandbox_cwd = cwd.clone(); let params = ExecParams { command: cmd.iter().map(|s| s.to_string()).collect(), cwd, @@ -151,6 +155,7 @@ async fn assert_network_blocked(cmd: &[&str]) { params, SandboxType::LinuxSeccomp, &sandbox_policy, + sandbox_cwd.as_path(), &codex_linux_sandbox_exe, None, ) diff --git a/codex-rs/mcp-server/src/codex_message_processor.rs b/codex-rs/mcp-server/src/codex_message_processor.rs index af0ffefeda..1d4a1b0fbb 100644 --- a/codex-rs/mcp-server/src/codex_message_processor.rs +++ b/codex-rs/mcp-server/src/codex_message_processor.rs @@ -589,12 +589,14 @@ impl CodexMessageProcessor { let codex_linux_sandbox_exe = self.config.codex_linux_sandbox_exe.clone(); let outgoing = self.outgoing.clone(); let req_id = request_id; + let sandbox_cwd = self.config.cwd.clone(); tokio::spawn(async move { match codex_core::exec::process_exec_tool_call( exec_params, sandbox_type, &effective_policy, + sandbox_cwd.as_path(), &codex_linux_sandbox_exe, None, ) From de64f5f0077d67eae73da1809549c5c967736553 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Thu, 18 Sep 2025 16:07:38 -0700 Subject: [PATCH 34/38] fix: update try_parse_word_only_commands_sequence() to return commands in order (#3881) Incidentally, we had a test for this in `accepts_multiple_commands_with_allowed_operators()`, but it was verifying the bad behavior. Oops! --- codex-rs/core/src/bash.rs | 9 ++++++--- codex-rs/core/src/parse_command.rs | 6 ++---- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/codex-rs/core/src/bash.rs b/codex-rs/core/src/bash.rs index 5b94daf252..4bed3a9c16 100644 --- a/codex-rs/core/src/bash.rs +++ b/codex-rs/core/src/bash.rs @@ -73,6 +73,9 @@ pub fn try_parse_word_only_commands_sequence(tree: &Tree, src: &str) -> Option> = vec![ - vec!["wc".to_string(), "-l".to_string()], - vec!["echo".to_string(), "hi there".to_string()], - vec!["pwd".to_string()], vec!["ls".to_string()], + vec!["pwd".to_string()], + vec!["echo".to_string(), "hi there".to_string()], + vec!["wc".to_string(), "-l".to_string()], ]; assert_eq!(cmds, expected); } diff --git a/codex-rs/core/src/parse_command.rs b/codex-rs/core/src/parse_command.rs index ea886d61f0..1d2948a406 100644 --- a/codex-rs/core/src/parse_command.rs +++ b/codex-rs/core/src/parse_command.rs @@ -1156,10 +1156,8 @@ fn parse_bash_lc_commands(original: &[String]) -> Option> { // bias toward the primary command when pipelines are present. // First, drop obvious small formatting helpers (e.g., wc/awk/etc). let had_multiple_commands = all_commands.len() > 1; - // The bash AST walker yields commands in right-to-left order for - // connector/pipeline sequences. Reverse to reflect actual execution order. - let mut filtered_commands = drop_small_formatting_commands(all_commands); - filtered_commands.reverse(); + // Commands arrive in source order; drop formatting helpers while preserving it. + let filtered_commands = drop_small_formatting_commands(all_commands); if filtered_commands.is_empty() { return Some(vec![ParsedCommand::Unknown { cmd: script.clone(), From a7fda7005344b754ddb3303704373ad0def1312c Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Thu, 18 Sep 2025 17:08:28 -0700 Subject: [PATCH 35/38] Use a unified shell tell to not break cache (#3814) Currently, we change the tool description according to the sandbox policy and approval policy. This breaks the cache when the user hits `/approvals`. This PR does the following: - Always use the shell with escalation parameter: - removes `create_shell_tool_for_sandbox` and always uses unified tool via `create_shell_tool` - Reject the func call when the model uses escalation parameter when it cannot. --- codex-rs/core/src/codex.rs | 127 +++++++++++++++++++++++++--- codex-rs/core/src/exec.rs | 2 +- codex-rs/core/src/openai_tools.rs | 132 ++++-------------------------- 3 files changed, 133 insertions(+), 128 deletions(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index e9f52b1c94..8852b25834 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -456,8 +456,6 @@ impl Session { client, tools_config: ToolsConfig::new(&ToolsConfigParams { model_family: &config.model_family, - approval_policy, - sandbox_policy: sandbox_policy.clone(), include_plan_tool: config.include_plan_tool, include_apply_patch_tool: config.include_apply_patch_tool, include_web_search_request: config.tools_web_search_request, @@ -1237,8 +1235,6 @@ async fn submission_loop( let tools_config = ToolsConfig::new(&ToolsConfigParams { model_family: &effective_family, - approval_policy: new_approval_policy, - sandbox_policy: new_sandbox_policy.clone(), include_plan_tool: config.include_plan_tool, include_apply_patch_tool: config.include_apply_patch_tool, include_web_search_request: config.tools_web_search_request, @@ -1325,8 +1321,6 @@ async fn submission_loop( client, tools_config: ToolsConfig::new(&ToolsConfigParams { model_family: &model_family, - approval_policy, - sandbox_policy: sandbox_policy.clone(), include_plan_tool: config.include_plan_tool, include_apply_patch_tool: config.include_apply_patch_tool, include_web_search_request: config.tools_web_search_request, @@ -1553,8 +1547,6 @@ async fn spawn_review_thread( .unwrap_or_else(|| parent_turn_context.client.get_model_family()); let tools_config = ToolsConfig::new(&ToolsConfigParams { model_family: &review_model_family, - approval_policy: parent_turn_context.approval_policy, - sandbox_policy: parent_turn_context.sandbox_policy.clone(), include_plan_tool: false, include_apply_patch_tool: config.include_apply_patch_tool, include_web_search_request: false, @@ -2724,6 +2716,21 @@ async fn handle_container_exec_with_params( sub_id: String, call_id: String, ) -> ResponseInputItem { + if params.with_escalated_permissions.unwrap_or(false) + && !matches!(turn_context.approval_policy, AskForApproval::OnRequest) + { + return ResponseInputItem::FunctionCallOutput { + call_id, + output: FunctionCallOutputPayload { + content: format!( + "approval policy is {policy:?}; reject command — you should not ask for escalated permissions if the approval policy is {policy:?}", + policy = turn_context.approval_policy + ), + success: None, + }, + }; + } + // check if this was a patch, and apply it if so let apply_patch_exec = match maybe_parse_apply_patch_verified(¶ms.command, ¶ms.cwd) { MaybeApplyPatchVerified::Body(changes) => { @@ -3345,6 +3352,7 @@ mod tests { use mcp_types::ContentBlock; use mcp_types::TextContent; use pretty_assertions::assert_eq; + use serde::Deserialize; use serde_json::json; use std::path::PathBuf; use std::sync::Arc; @@ -3594,8 +3602,6 @@ mod tests { ); let tools_config = ToolsConfig::new(&ToolsConfigParams { model_family: &config.model_family, - approval_policy: config.approval_policy, - sandbox_policy: config.sandbox_policy.clone(), include_plan_tool: config.include_plan_tool, include_apply_patch_tool: config.include_apply_patch_tool, include_web_search_request: config.tools_web_search_request, @@ -3735,4 +3741,105 @@ mod tests { (rollout_items, live_history.contents()) } + + #[tokio::test] + async fn rejects_escalated_permissions_when_policy_not_on_request() { + use crate::exec::ExecParams; + use crate::protocol::AskForApproval; + use crate::protocol::SandboxPolicy; + use crate::turn_diff_tracker::TurnDiffTracker; + use std::collections::HashMap; + + let (session, mut turn_context) = make_session_and_context(); + // Ensure policy is NOT OnRequest so the early rejection path triggers + turn_context.approval_policy = AskForApproval::OnFailure; + + let params = ExecParams { + command: if cfg!(windows) { + vec![ + "cmd.exe".to_string(), + "/C".to_string(), + "echo hi".to_string(), + ] + } else { + vec![ + "/bin/sh".to_string(), + "-c".to_string(), + "echo hi".to_string(), + ] + }, + cwd: turn_context.cwd.clone(), + timeout_ms: Some(1000), + env: HashMap::new(), + with_escalated_permissions: Some(true), + justification: Some("test".to_string()), + }; + + let params2 = ExecParams { + with_escalated_permissions: Some(false), + ..params.clone() + }; + + let mut turn_diff_tracker = TurnDiffTracker::new(); + + let sub_id = "test-sub".to_string(); + let call_id = "test-call".to_string(); + + let resp = handle_container_exec_with_params( + params, + &session, + &turn_context, + &mut turn_diff_tracker, + sub_id, + call_id, + ) + .await; + + let ResponseInputItem::FunctionCallOutput { output, .. } = resp else { + panic!("expected FunctionCallOutput"); + }; + + let expected = format!( + "approval policy is {policy:?}; reject command — you should not ask for escalated permissions if the approval policy is {policy:?}", + policy = turn_context.approval_policy + ); + + pretty_assertions::assert_eq!(output.content, expected); + + // Now retry the same command WITHOUT escalated permissions; should succeed. + // Force DangerFullAccess to avoid platform sandbox dependencies in tests. + turn_context.sandbox_policy = SandboxPolicy::DangerFullAccess; + + let resp2 = handle_container_exec_with_params( + params2, + &session, + &turn_context, + &mut turn_diff_tracker, + "test-sub".to_string(), + "test-call-2".to_string(), + ) + .await; + + let ResponseInputItem::FunctionCallOutput { output, .. } = resp2 else { + panic!("expected FunctionCallOutput on retry"); + }; + + #[derive(Deserialize, PartialEq, Eq, Debug)] + struct ResponseExecMetadata { + exit_code: i32, + } + + #[derive(Deserialize)] + struct ResponseExecOutput { + output: String, + metadata: ResponseExecMetadata, + } + + let exec_output: ResponseExecOutput = + serde_json::from_str(&output.content).expect("valid exec output json"); + + pretty_assertions::assert_eq!(exec_output.metadata, ResponseExecMetadata { exit_code: 0 }); + assert!(exec_output.output.contains("hi")); + pretty_assertions::assert_eq!(output.success, Some(true)); + } } diff --git a/codex-rs/core/src/exec.rs b/codex-rs/core/src/exec.rs index 9e11604c39..d84bbc9fcb 100644 --- a/codex-rs/core/src/exec.rs +++ b/codex-rs/core/src/exec.rs @@ -45,7 +45,7 @@ const AGGREGATE_BUFFER_INITIAL_CAPACITY: usize = 8 * 1024; // 8 KiB /// Aggregation still collects full output; only the live event stream is capped. pub(crate) const MAX_EXEC_OUTPUT_DELTAS_PER_CALL: usize = 10_000; -#[derive(Debug, Clone)] +#[derive(Clone, Debug)] pub struct ExecParams { pub command: Vec, pub cwd: PathBuf, diff --git a/codex-rs/core/src/openai_tools.rs b/codex-rs/core/src/openai_tools.rs index b57d1b7692..05b71ce6ea 100644 --- a/codex-rs/core/src/openai_tools.rs +++ b/codex-rs/core/src/openai_tools.rs @@ -7,8 +7,6 @@ use std::collections::HashMap; use crate::model_family::ModelFamily; use crate::plan_tool::PLAN_TOOL; -use crate::protocol::AskForApproval; -use crate::protocol::SandboxPolicy; use crate::tool_apply_patch::ApplyPatchToolType; use crate::tool_apply_patch::create_apply_patch_freeform_tool; use crate::tool_apply_patch::create_apply_patch_json_tool; @@ -57,10 +55,9 @@ pub(crate) enum OpenAiTool { #[derive(Debug, Clone)] pub enum ConfigShellToolType { - DefaultShell, - ShellWithRequest { sandbox_policy: SandboxPolicy }, - LocalShell, - StreamableShell, + Default, + Local, + Streamable, } #[derive(Debug, Clone)] @@ -75,8 +72,6 @@ pub(crate) struct ToolsConfig { pub(crate) struct ToolsConfigParams<'a> { pub(crate) model_family: &'a ModelFamily, - pub(crate) approval_policy: AskForApproval, - pub(crate) sandbox_policy: SandboxPolicy, pub(crate) include_plan_tool: bool, pub(crate) include_apply_patch_tool: bool, pub(crate) include_web_search_request: bool, @@ -89,8 +84,6 @@ impl ToolsConfig { pub fn new(params: &ToolsConfigParams) -> Self { let ToolsConfigParams { model_family, - approval_policy, - sandbox_policy, include_plan_tool, include_apply_patch_tool, include_web_search_request, @@ -98,18 +91,13 @@ impl ToolsConfig { include_view_image_tool, experimental_unified_exec_tool, } = params; - let mut shell_type = if *use_streamable_shell_tool { - ConfigShellToolType::StreamableShell + let shell_type = if *use_streamable_shell_tool { + ConfigShellToolType::Streamable } else if model_family.uses_local_shell_tool { - ConfigShellToolType::LocalShell + ConfigShellToolType::Local } else { - ConfigShellToolType::DefaultShell + ConfigShellToolType::Default }; - if matches!(approval_policy, AskForApproval::OnRequest) && !use_streamable_shell_tool { - shell_type = ConfigShellToolType::ShellWithRequest { - sandbox_policy: sandbox_policy.clone(), - } - } let apply_patch_tool_type = match model_family.apply_patch_tool_type { Some(ApplyPatchToolType::Freeform) => Some(ApplyPatchToolType::Freeform), @@ -170,40 +158,6 @@ pub(crate) enum JsonSchema { }, } -fn create_shell_tool() -> OpenAiTool { - let mut properties = BTreeMap::new(); - properties.insert( - "command".to_string(), - JsonSchema::Array { - items: Box::new(JsonSchema::String { description: None }), - description: Some("The command to execute".to_string()), - }, - ); - properties.insert( - "workdir".to_string(), - JsonSchema::String { - description: Some("The working directory to execute the command in".to_string()), - }, - ); - properties.insert( - "timeout_ms".to_string(), - JsonSchema::Number { - description: Some("The timeout for the command in milliseconds".to_string()), - }, - ); - - OpenAiTool::Function(ResponsesApiTool { - name: "shell".to_string(), - description: "Runs a shell command and returns its output".to_string(), - strict: false, - parameters: JsonSchema::Object { - properties, - required: Some(vec!["command".to_string()]), - additional_properties: Some(false), - }, - }) -} - fn create_unified_exec_tool() -> OpenAiTool { let mut properties = BTreeMap::new(); properties.insert( @@ -251,7 +205,7 @@ fn create_unified_exec_tool() -> OpenAiTool { }) } -fn create_shell_tool_for_sandbox(sandbox_policy: &SandboxPolicy) -> OpenAiTool { +fn create_shell_tool() -> OpenAiTool { let mut properties = BTreeMap::new(); properties.insert( "command".to_string(), @@ -273,20 +227,18 @@ fn create_shell_tool_for_sandbox(sandbox_policy: &SandboxPolicy) -> OpenAiTool { }, ); - if !matches!(sandbox_policy, SandboxPolicy::DangerFullAccess) { - properties.insert( + properties.insert( "with_escalated_permissions".to_string(), JsonSchema::Boolean { description: Some("Whether to request escalated permissions. Set to true if command needs to be run without sandbox restrictions".to_string()), }, ); - properties.insert( + properties.insert( "justification".to_string(), JsonSchema::String { description: Some("Only set if with_escalated_permissions is true. 1-sentence explanation of why we want to run this command.".to_string()), }, ); - } OpenAiTool::Function(ResponsesApiTool { name: "shell".to_string(), @@ -537,16 +489,13 @@ pub(crate) fn get_openai_tools( tools.push(create_unified_exec_tool()); } else { match &config.shell_type { - ConfigShellToolType::DefaultShell => { + ConfigShellToolType::Default => { tools.push(create_shell_tool()); } - ConfigShellToolType::ShellWithRequest { sandbox_policy } => { - tools.push(create_shell_tool_for_sandbox(sandbox_policy)); - } - ConfigShellToolType::LocalShell => { + ConfigShellToolType::Local => { tools.push(OpenAiTool::LocalShell {}); } - ConfigShellToolType::StreamableShell => { + ConfigShellToolType::Streamable => { tools.push(OpenAiTool::Function( crate::exec_command::create_exec_command_tool_for_responses_api(), )); @@ -636,8 +585,6 @@ mod tests { .expect("codex-mini-latest should be a valid model family"); let config = ToolsConfig::new(&ToolsConfigParams { model_family: &model_family, - approval_policy: AskForApproval::Never, - sandbox_policy: SandboxPolicy::ReadOnly, include_plan_tool: true, include_apply_patch_tool: false, include_web_search_request: true, @@ -658,8 +605,6 @@ mod tests { let model_family = find_family_for_model("o3").expect("o3 should be a valid model family"); let config = ToolsConfig::new(&ToolsConfigParams { model_family: &model_family, - approval_policy: AskForApproval::Never, - sandbox_policy: SandboxPolicy::ReadOnly, include_plan_tool: true, include_apply_patch_tool: false, include_web_search_request: true, @@ -680,8 +625,6 @@ mod tests { let model_family = find_family_for_model("o3").expect("o3 should be a valid model family"); let config = ToolsConfig::new(&ToolsConfigParams { model_family: &model_family, - approval_policy: AskForApproval::Never, - sandbox_policy: SandboxPolicy::ReadOnly, include_plan_tool: false, include_apply_patch_tool: false, include_web_search_request: true, @@ -786,8 +729,6 @@ mod tests { let model_family = find_family_for_model("o3").expect("o3 should be a valid model family"); let config = ToolsConfig::new(&ToolsConfigParams { model_family: &model_family, - approval_policy: AskForApproval::Never, - sandbox_policy: SandboxPolicy::ReadOnly, include_plan_tool: false, include_apply_patch_tool: false, include_web_search_request: false, @@ -864,8 +805,6 @@ mod tests { let model_family = find_family_for_model("o3").expect("o3 should be a valid model family"); let config = ToolsConfig::new(&ToolsConfigParams { model_family: &model_family, - approval_policy: AskForApproval::Never, - sandbox_policy: SandboxPolicy::ReadOnly, include_plan_tool: false, include_apply_patch_tool: false, include_web_search_request: true, @@ -927,8 +866,6 @@ mod tests { let model_family = find_family_for_model("o3").expect("o3 should be a valid model family"); let config = ToolsConfig::new(&ToolsConfigParams { model_family: &model_family, - approval_policy: AskForApproval::Never, - sandbox_policy: SandboxPolicy::ReadOnly, include_plan_tool: false, include_apply_patch_tool: false, include_web_search_request: true, @@ -985,8 +922,6 @@ mod tests { let model_family = find_family_for_model("o3").expect("o3 should be a valid model family"); let config = ToolsConfig::new(&ToolsConfigParams { model_family: &model_family, - approval_policy: AskForApproval::Never, - sandbox_policy: SandboxPolicy::ReadOnly, include_plan_tool: false, include_apply_patch_tool: false, include_web_search_request: true, @@ -1046,8 +981,6 @@ mod tests { let model_family = find_family_for_model("o3").expect("o3 should be a valid model family"); let config = ToolsConfig::new(&ToolsConfigParams { model_family: &model_family, - approval_policy: AskForApproval::Never, - sandbox_policy: SandboxPolicy::ReadOnly, include_plan_tool: false, include_apply_patch_tool: false, include_web_search_request: true, @@ -1100,29 +1033,8 @@ mod tests { } #[test] - fn test_shell_tool_for_sandbox_workspace_write() { - let sandbox_policy = SandboxPolicy::WorkspaceWrite { - writable_roots: vec!["workspace".into()], - network_access: false, - exclude_tmpdir_env_var: false, - exclude_slash_tmp: false, - }; - let tool = super::create_shell_tool_for_sandbox(&sandbox_policy); - let OpenAiTool::Function(ResponsesApiTool { - description, name, .. - }) = &tool - else { - panic!("expected function tool"); - }; - assert_eq!(name, "shell"); - - let expected = "Runs a shell command and returns its output."; - assert_eq!(description, expected); - } - - #[test] - fn test_shell_tool_for_sandbox_readonly() { - let tool = super::create_shell_tool_for_sandbox(&SandboxPolicy::ReadOnly); + fn test_shell_tool() { + let tool = super::create_shell_tool(); let OpenAiTool::Function(ResponsesApiTool { description, name, .. }) = &tool @@ -1134,18 +1046,4 @@ mod tests { let expected = "Runs a shell command and returns its output."; assert_eq!(description, expected); } - - #[test] - fn test_shell_tool_for_sandbox_danger_full_access() { - let tool = super::create_shell_tool_for_sandbox(&SandboxPolicy::DangerFullAccess); - let OpenAiTool::Function(ResponsesApiTool { - description, name, .. - }) = &tool - else { - panic!("expected function tool"); - }; - assert_eq!(name, "shell"); - - assert_eq!(description, "Runs a shell command and returns its output."); - } } From 881c7978f1362b7eb4009777d0907fdc7e81b4e3 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Thu, 18 Sep 2025 17:53:14 -0700 Subject: [PATCH 36/38] Move responses mocking helpers to a shared lib (#3878) These are generally useful --- codex-rs/Cargo.lock | 1 + codex-rs/core/tests/common/Cargo.toml | 1 + codex-rs/core/tests/common/lib.rs | 2 + codex-rs/core/tests/common/responses.rs | 100 +++++++++++ codex-rs/core/tests/suite/compact.rs | 160 ++++-------------- .../core/tests/suite/compact_resume_fork.rs | 8 +- 6 files changed, 141 insertions(+), 131 deletions(-) create mode 100644 codex-rs/core/tests/common/responses.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index e3930b507a..73c1117af1 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1077,6 +1077,7 @@ dependencies = [ "serde_json", "tempfile", "tokio", + "wiremock", ] [[package]] diff --git a/codex-rs/core/tests/common/Cargo.toml b/codex-rs/core/tests/common/Cargo.toml index 9cfd20cdb4..2d43051919 100644 --- a/codex-rs/core/tests/common/Cargo.toml +++ b/codex-rs/core/tests/common/Cargo.toml @@ -11,3 +11,4 @@ codex-core = { path = "../.." } serde_json = "1" tempfile = "3" tokio = { version = "1", features = ["time"] } +wiremock = "0.6" diff --git a/codex-rs/core/tests/common/lib.rs b/codex-rs/core/tests/common/lib.rs index 244d093e7d..95af79b220 100644 --- a/codex-rs/core/tests/common/lib.rs +++ b/codex-rs/core/tests/common/lib.rs @@ -7,6 +7,8 @@ use codex_core::config::Config; use codex_core::config::ConfigOverrides; use codex_core::config::ConfigToml; +pub mod responses; + /// Returns a default `Config` whose on-disk state is confined to the provided /// temporary directory. Using a per-test directory keeps tests hermetic and /// avoids clobbering a developer’s real `~/.codex`. diff --git a/codex-rs/core/tests/common/responses.rs b/codex-rs/core/tests/common/responses.rs new file mode 100644 index 0000000000..dc6f849ee7 --- /dev/null +++ b/codex-rs/core/tests/common/responses.rs @@ -0,0 +1,100 @@ +use serde_json::Value; +use wiremock::BodyPrintLimit; +use wiremock::Mock; +use wiremock::MockServer; +use wiremock::ResponseTemplate; +use wiremock::matchers::method; +use wiremock::matchers::path; + +/// Build an SSE stream body from a list of JSON events. +pub fn sse(events: Vec) -> String { + use std::fmt::Write as _; + let mut out = String::new(); + for ev in events { + let kind = ev.get("type").and_then(|v| v.as_str()).unwrap(); + writeln!(&mut out, "event: {kind}").unwrap(); + if !ev.as_object().map(|o| o.len() == 1).unwrap_or(false) { + write!(&mut out, "data: {ev}\n\n").unwrap(); + } else { + out.push('\n'); + } + } + out +} + +/// Convenience: SSE event for a completed response with a specific id. +pub fn ev_completed(id: &str) -> Value { + serde_json::json!({ + "type": "response.completed", + "response": { + "id": id, + "usage": {"input_tokens":0,"input_tokens_details":null,"output_tokens":0,"output_tokens_details":null,"total_tokens":0} + } + }) +} + +pub fn ev_completed_with_tokens(id: &str, total_tokens: u64) -> Value { + serde_json::json!({ + "type": "response.completed", + "response": { + "id": id, + "usage": { + "input_tokens": total_tokens, + "input_tokens_details": null, + "output_tokens": 0, + "output_tokens_details": null, + "total_tokens": total_tokens + } + } + }) +} + +/// Convenience: SSE event for a single assistant message output item. +pub fn ev_assistant_message(id: &str, text: &str) -> Value { + serde_json::json!({ + "type": "response.output_item.done", + "item": { + "type": "message", + "role": "assistant", + "id": id, + "content": [{"type": "output_text", "text": text}] + } + }) +} + +pub fn ev_function_call(call_id: &str, name: &str, arguments: &str) -> Value { + serde_json::json!({ + "type": "response.output_item.done", + "item": { + "type": "function_call", + "call_id": call_id, + "name": name, + "arguments": arguments + } + }) +} + +pub fn sse_response(body: String) -> ResponseTemplate { + ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_raw(body, "text/event-stream") +} + +pub async fn mount_sse_once(server: &MockServer, matcher: M, body: String) +where + M: wiremock::Match + Send + Sync + 'static, +{ + Mock::given(method("POST")) + .and(path("/v1/responses")) + .and(matcher) + .respond_with(sse_response(body)) + .mount(server) + .await; +} + +pub async fn start_mock_server() -> MockServer { + MockServer::builder() + .body_print_limit(BodyPrintLimit::Limited(80_000)) + .start() + .await +} diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index 8db70f3559..a58de304fb 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -1,5 +1,3 @@ -#![expect(clippy::unwrap_used)] - use codex_core::CodexAuth; use codex_core::ConversationManager; use codex_core::ModelProviderInfo; @@ -13,12 +11,10 @@ use codex_core::protocol::RolloutItem; use codex_core::protocol::RolloutLine; use codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR; use core_test_support::load_default_config_for_test; +use core_test_support::responses; use core_test_support::wait_for_event; -use serde_json::Value; use tempfile::TempDir; -use wiremock::BodyPrintLimit; use wiremock::Mock; -use wiremock::MockServer; use wiremock::Request; use wiremock::Respond; use wiremock::ResponseTemplate; @@ -26,106 +22,16 @@ use wiremock::matchers::method; use wiremock::matchers::path; use pretty_assertions::assert_eq; +use responses::ev_assistant_message; +use responses::ev_completed; +use responses::sse; +use responses::start_mock_server; use std::sync::Arc; use std::sync::Mutex; use std::sync::atomic::AtomicUsize; use std::sync::atomic::Ordering; - // --- Test helpers ----------------------------------------------------------- -/// Build an SSE stream body from a list of JSON events. -pub(super) fn sse(events: Vec) -> String { - use std::fmt::Write as _; - let mut out = String::new(); - for ev in events { - let kind = ev.get("type").and_then(|v| v.as_str()).unwrap(); - writeln!(&mut out, "event: {kind}").unwrap(); - if !ev.as_object().map(|o| o.len() == 1).unwrap_or(false) { - write!(&mut out, "data: {ev}\n\n").unwrap(); - } else { - out.push('\n'); - } - } - out -} - -/// Convenience: SSE event for a completed response with a specific id. -pub(super) fn ev_completed(id: &str) -> Value { - serde_json::json!({ - "type": "response.completed", - "response": { - "id": id, - "usage": {"input_tokens":0,"input_tokens_details":null,"output_tokens":0,"output_tokens_details":null,"total_tokens":0} - } - }) -} - -fn ev_completed_with_tokens(id: &str, total_tokens: u64) -> Value { - serde_json::json!({ - "type": "response.completed", - "response": { - "id": id, - "usage": { - "input_tokens": total_tokens, - "input_tokens_details": null, - "output_tokens": 0, - "output_tokens_details": null, - "total_tokens": total_tokens - } - } - }) -} - -/// Convenience: SSE event for a single assistant message output item. -pub(super) fn ev_assistant_message(id: &str, text: &str) -> Value { - serde_json::json!({ - "type": "response.output_item.done", - "item": { - "type": "message", - "role": "assistant", - "id": id, - "content": [{"type": "output_text", "text": text}] - } - }) -} - -fn ev_function_call(call_id: &str, name: &str, arguments: &str) -> Value { - serde_json::json!({ - "type": "response.output_item.done", - "item": { - "type": "function_call", - "call_id": call_id, - "name": name, - "arguments": arguments - } - }) -} - -pub(super) fn sse_response(body: String) -> ResponseTemplate { - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_raw(body, "text/event-stream") -} - -pub(super) async fn mount_sse_once(server: &MockServer, matcher: M, body: String) -where - M: wiremock::Match + Send + Sync + 'static, -{ - Mock::given(method("POST")) - .and(path("/v1/responses")) - .and(matcher) - .respond_with(sse_response(body)) - .mount(server) - .await; -} - -async fn start_mock_server() -> MockServer { - MockServer::builder() - .body_print_limit(BodyPrintLimit::Limited(80_000)) - .start() - .await -} - pub(super) const FIRST_REPLY: &str = "FIRST_REPLY"; pub(super) const SUMMARY_TEXT: &str = "SUMMARY_ONLY_CONTEXT"; pub(super) const SUMMARIZE_TRIGGER: &str = "Start Summarization"; @@ -175,19 +81,19 @@ async fn summarize_context_three_requests_and_instructions() { body.contains("\"text\":\"hello world\"") && !body.contains(&format!("\"text\":\"{SUMMARIZE_TRIGGER}\"")) }; - mount_sse_once(&server, first_matcher, sse1).await; + responses::mount_sse_once(&server, first_matcher, sse1).await; let second_matcher = |req: &wiremock::Request| { let body = std::str::from_utf8(&req.body).unwrap_or(""); body.contains(&format!("\"text\":\"{SUMMARIZE_TRIGGER}\"")) }; - mount_sse_once(&server, second_matcher, sse2).await; + responses::mount_sse_once(&server, second_matcher, sse2).await; let third_matcher = |req: &wiremock::Request| { let body = std::str::from_utf8(&req.body).unwrap_or(""); body.contains(&format!("\"text\":\"{THIRD_USER_MSG}\"")) }; - mount_sse_once(&server, third_matcher, sse3).await; + responses::mount_sse_once(&server, third_matcher, sse3).await; // Build config pointing to the mock server and spawn Codex. let model_provider = ModelProviderInfo { @@ -381,17 +287,17 @@ async fn auto_compact_runs_after_token_limit_hit() { let sse1 = sse(vec![ ev_assistant_message("m1", FIRST_REPLY), - ev_completed_with_tokens("r1", 70_000), + responses::ev_completed_with_tokens("r1", 70_000), ]); let sse2 = sse(vec![ ev_assistant_message("m2", "SECOND_REPLY"), - ev_completed_with_tokens("r2", 330_000), + responses::ev_completed_with_tokens("r2", 330_000), ]); let sse3 = sse(vec![ ev_assistant_message("m3", AUTO_SUMMARY_TEXT), - ev_completed_with_tokens("r3", 200), + responses::ev_completed_with_tokens("r3", 200), ]); let first_matcher = |req: &wiremock::Request| { @@ -403,7 +309,7 @@ async fn auto_compact_runs_after_token_limit_hit() { Mock::given(method("POST")) .and(path("/v1/responses")) .and(first_matcher) - .respond_with(sse_response(sse1)) + .respond_with(responses::sse_response(sse1)) .mount(&server) .await; @@ -416,7 +322,7 @@ async fn auto_compact_runs_after_token_limit_hit() { Mock::given(method("POST")) .and(path("/v1/responses")) .and(second_matcher) - .respond_with(sse_response(sse2)) + .respond_with(responses::sse_response(sse2)) .mount(&server) .await; @@ -427,7 +333,7 @@ async fn auto_compact_runs_after_token_limit_hit() { Mock::given(method("POST")) .and(path("/v1/responses")) .and(third_matcher) - .respond_with(sse_response(sse3)) + .respond_with(responses::sse_response(sse3)) .mount(&server) .await; @@ -522,17 +428,17 @@ async fn auto_compact_persists_rollout_entries() { let sse1 = sse(vec![ ev_assistant_message("m1", FIRST_REPLY), - ev_completed_with_tokens("r1", 70_000), + responses::ev_completed_with_tokens("r1", 70_000), ]); let sse2 = sse(vec![ ev_assistant_message("m2", "SECOND_REPLY"), - ev_completed_with_tokens("r2", 330_000), + responses::ev_completed_with_tokens("r2", 330_000), ]); let sse3 = sse(vec![ ev_assistant_message("m3", AUTO_SUMMARY_TEXT), - ev_completed_with_tokens("r3", 200), + responses::ev_completed_with_tokens("r3", 200), ]); let first_matcher = |req: &wiremock::Request| { @@ -544,7 +450,7 @@ async fn auto_compact_persists_rollout_entries() { Mock::given(method("POST")) .and(path("/v1/responses")) .and(first_matcher) - .respond_with(sse_response(sse1)) + .respond_with(responses::sse_response(sse1)) .mount(&server) .await; @@ -557,7 +463,7 @@ async fn auto_compact_persists_rollout_entries() { Mock::given(method("POST")) .and(path("/v1/responses")) .and(second_matcher) - .respond_with(sse_response(sse2)) + .respond_with(responses::sse_response(sse2)) .mount(&server) .await; @@ -568,7 +474,7 @@ async fn auto_compact_persists_rollout_entries() { Mock::given(method("POST")) .and(path("/v1/responses")) .and(third_matcher) - .respond_with(sse_response(sse3)) + .respond_with(responses::sse_response(sse3)) .mount(&server) .await; @@ -655,17 +561,17 @@ async fn auto_compact_stops_after_failed_attempt() { let sse1 = sse(vec![ ev_assistant_message("m1", FIRST_REPLY), - ev_completed_with_tokens("r1", 500), + responses::ev_completed_with_tokens("r1", 500), ]); let sse2 = sse(vec![ ev_assistant_message("m2", SUMMARY_TEXT), - ev_completed_with_tokens("r2", 50), + responses::ev_completed_with_tokens("r2", 50), ]); let sse3 = sse(vec![ ev_assistant_message("m3", STILL_TOO_BIG_REPLY), - ev_completed_with_tokens("r3", 500), + responses::ev_completed_with_tokens("r3", 500), ]); let first_matcher = |req: &wiremock::Request| { @@ -676,7 +582,7 @@ async fn auto_compact_stops_after_failed_attempt() { Mock::given(method("POST")) .and(path("/v1/responses")) .and(first_matcher) - .respond_with(sse_response(sse1.clone())) + .respond_with(responses::sse_response(sse1.clone())) .mount(&server) .await; @@ -687,7 +593,7 @@ async fn auto_compact_stops_after_failed_attempt() { Mock::given(method("POST")) .and(path("/v1/responses")) .and(second_matcher) - .respond_with(sse_response(sse2.clone())) + .respond_with(responses::sse_response(sse2.clone())) .mount(&server) .await; @@ -699,7 +605,7 @@ async fn auto_compact_stops_after_failed_attempt() { Mock::given(method("POST")) .and(path("/v1/responses")) .and(third_matcher) - .respond_with(sse_response(sse3.clone())) + .respond_with(responses::sse_response(sse3.clone())) .mount(&server) .await; @@ -769,27 +675,27 @@ async fn auto_compact_allows_multiple_attempts_when_interleaved_with_other_turn_ let sse1 = sse(vec![ ev_assistant_message("m1", FIRST_REPLY), - ev_completed_with_tokens("r1", 500), + responses::ev_completed_with_tokens("r1", 500), ]); let sse2 = sse(vec![ ev_assistant_message("m2", FIRST_AUTO_SUMMARY), - ev_completed_with_tokens("r2", 50), + responses::ev_completed_with_tokens("r2", 50), ]); let sse3 = sse(vec![ - ev_function_call(DUMMY_CALL_ID, DUMMY_FUNCTION_NAME, "{}"), - ev_completed_with_tokens("r3", 150), + responses::ev_function_call(DUMMY_CALL_ID, DUMMY_FUNCTION_NAME, "{}"), + responses::ev_completed_with_tokens("r3", 150), ]); let sse4 = sse(vec![ ev_assistant_message("m4", SECOND_LARGE_REPLY), - ev_completed_with_tokens("r4", 450), + responses::ev_completed_with_tokens("r4", 450), ]); let sse5 = sse(vec![ ev_assistant_message("m5", SECOND_AUTO_SUMMARY), - ev_completed_with_tokens("r5", 60), + responses::ev_completed_with_tokens("r5", 60), ]); let sse6 = sse(vec![ ev_assistant_message("m6", FINAL_REPLY), - ev_completed_with_tokens("r6", 120), + responses::ev_completed_with_tokens("r6", 120), ]); #[derive(Clone)] diff --git a/codex-rs/core/tests/suite/compact_resume_fork.rs b/codex-rs/core/tests/suite/compact_resume_fork.rs index 1bedbcab08..1e752826bb 100644 --- a/codex-rs/core/tests/suite/compact_resume_fork.rs +++ b/codex-rs/core/tests/suite/compact_resume_fork.rs @@ -10,10 +10,6 @@ use super::compact::FIRST_REPLY; use super::compact::SUMMARIZE_TRIGGER; use super::compact::SUMMARY_TEXT; -use super::compact::ev_assistant_message; -use super::compact::ev_completed; -use super::compact::mount_sse_once; -use super::compact::sse; use codex_core::CodexAuth; use codex_core::CodexConversation; use codex_core::ConversationManager; @@ -27,6 +23,10 @@ use codex_core::protocol::InputItem; use codex_core::protocol::Op; use codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR; use core_test_support::load_default_config_for_test; +use core_test_support::responses::ev_assistant_message; +use core_test_support::responses::ev_completed; +use core_test_support::responses::mount_sse_once; +use core_test_support::responses::sse; use core_test_support::wait_for_event; use pretty_assertions::assert_eq; use serde_json::Value; From 9b18875a42c6453ff79a8ae7a658e5e615f7b353 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Fri, 19 Sep 2025 06:46:25 -0700 Subject: [PATCH 37/38] Use helpers instead of fixtures (#3888) Move to using test helper method everywhere. --- codex-rs/core/tests/common/responses.rs | 33 +++++++++ .../tests/fixtures/sse_apply_patch_add.json | 25 ------- .../sse_apply_patch_freeform_add.json | 25 ------- .../sse_apply_patch_freeform_update.json | 25 ------- .../fixtures/sse_apply_patch_update.json | 25 ------- .../fixtures/sse_response_completed.json | 16 ----- codex-rs/exec/tests/suite/apply_patch.rs | 71 ++++++++++++++----- codex-rs/exec/tests/suite/common.rs | 6 +- 8 files changed, 86 insertions(+), 140 deletions(-) delete mode 100644 codex-rs/exec/tests/fixtures/sse_apply_patch_add.json delete mode 100644 codex-rs/exec/tests/fixtures/sse_apply_patch_freeform_add.json delete mode 100644 codex-rs/exec/tests/fixtures/sse_apply_patch_freeform_update.json delete mode 100644 codex-rs/exec/tests/fixtures/sse_apply_patch_update.json delete mode 100644 codex-rs/exec/tests/fixtures/sse_response_completed.json diff --git a/codex-rs/core/tests/common/responses.rs b/codex-rs/core/tests/common/responses.rs index dc6f849ee7..2f55f17a52 100644 --- a/codex-rs/core/tests/common/responses.rs +++ b/codex-rs/core/tests/common/responses.rs @@ -74,6 +74,39 @@ pub fn ev_function_call(call_id: &str, name: &str, arguments: &str) -> Value { }) } +/// Convenience: SSE event for an `apply_patch` custom tool call with raw patch +/// text. This mirrors the payload produced by the Responses API when the model +/// invokes `apply_patch` directly (before we convert it to a function call). +pub fn ev_apply_patch_custom_tool_call(call_id: &str, patch: &str) -> Value { + serde_json::json!({ + "type": "response.output_item.done", + "item": { + "type": "custom_tool_call", + "name": "apply_patch", + "input": patch, + "call_id": call_id + } + }) +} + +/// Convenience: SSE event for an `apply_patch` function call. The Responses API +/// wraps the patch content in a JSON string under the `input` key; we recreate +/// the same structure so downstream code exercises the full parsing path. +pub fn ev_apply_patch_function_call(call_id: &str, patch: &str) -> Value { + let arguments = serde_json::json!({ "input": patch }); + let arguments = serde_json::to_string(&arguments).expect("serialize apply_patch arguments"); + + serde_json::json!({ + "type": "response.output_item.done", + "item": { + "type": "function_call", + "name": "apply_patch", + "arguments": arguments, + "call_id": call_id + } + }) +} + pub fn sse_response(body: String) -> ResponseTemplate { ResponseTemplate::new(200) .insert_header("content-type", "text/event-stream") diff --git a/codex-rs/exec/tests/fixtures/sse_apply_patch_add.json b/codex-rs/exec/tests/fixtures/sse_apply_patch_add.json deleted file mode 100644 index 8d2bf261af..0000000000 --- a/codex-rs/exec/tests/fixtures/sse_apply_patch_add.json +++ /dev/null @@ -1,25 +0,0 @@ -[ - { - "type": "response.output_item.done", - "item": { - "type": "custom_tool_call", - "name": "apply_patch", - "input": "*** Begin Patch\n*** Add File: test.md\n+Hello world\n*** End Patch", - "call_id": "__ID__" - } - }, - { - "type": "response.completed", - "response": { - "id": "__ID__", - "usage": { - "input_tokens": 0, - "input_tokens_details": null, - "output_tokens": 0, - "output_tokens_details": null, - "total_tokens": 0 - }, - "output": [] - } - } -] diff --git a/codex-rs/exec/tests/fixtures/sse_apply_patch_freeform_add.json b/codex-rs/exec/tests/fixtures/sse_apply_patch_freeform_add.json deleted file mode 100644 index ce05e7d482..0000000000 --- a/codex-rs/exec/tests/fixtures/sse_apply_patch_freeform_add.json +++ /dev/null @@ -1,25 +0,0 @@ -[ - { - "type": "response.output_item.done", - "item": { - "type": "custom_tool_call", - "name": "apply_patch", - "input": "*** Begin Patch\n*** Add File: app.py\n+class BaseClass:\n+ def method():\n+ return False\n*** End Patch", - "call_id": "__ID__" - } - }, - { - "type": "response.completed", - "response": { - "id": "__ID__", - "usage": { - "input_tokens": 0, - "input_tokens_details": null, - "output_tokens": 0, - "output_tokens_details": null, - "total_tokens": 0 - }, - "output": [] - } - } -] diff --git a/codex-rs/exec/tests/fixtures/sse_apply_patch_freeform_update.json b/codex-rs/exec/tests/fixtures/sse_apply_patch_freeform_update.json deleted file mode 100644 index 8329d9628c..0000000000 --- a/codex-rs/exec/tests/fixtures/sse_apply_patch_freeform_update.json +++ /dev/null @@ -1,25 +0,0 @@ -[ - { - "type": "response.output_item.done", - "item": { - "type": "custom_tool_call", - "name": "apply_patch", - "input": "*** Begin Patch\n*** Update File: app.py\n@@ def method():\n- return False\n+\n+ return True\n*** End Patch", - "call_id": "__ID__" - } - }, - { - "type": "response.completed", - "response": { - "id": "__ID__", - "usage": { - "input_tokens": 0, - "input_tokens_details": null, - "output_tokens": 0, - "output_tokens_details": null, - "total_tokens": 0 - }, - "output": [] - } - } -] diff --git a/codex-rs/exec/tests/fixtures/sse_apply_patch_update.json b/codex-rs/exec/tests/fixtures/sse_apply_patch_update.json deleted file mode 100644 index 79689bece3..0000000000 --- a/codex-rs/exec/tests/fixtures/sse_apply_patch_update.json +++ /dev/null @@ -1,25 +0,0 @@ -[ - { - "type": "response.output_item.done", - "item": { - "type": "function_call", - "name": "apply_patch", - "arguments": "{\n \"input\": \"*** Begin Patch\\n*** Update File: test.md\\n@@\\n-Hello world\\n+Final text\\n*** End Patch\"\n}", - "call_id": "__ID__" - } - }, - { - "type": "response.completed", - "response": { - "id": "__ID__", - "usage": { - "input_tokens": 0, - "input_tokens_details": null, - "output_tokens": 0, - "output_tokens_details": null, - "total_tokens": 0 - }, - "output": [] - } - } -] diff --git a/codex-rs/exec/tests/fixtures/sse_response_completed.json b/codex-rs/exec/tests/fixtures/sse_response_completed.json deleted file mode 100644 index 1774dc5e84..0000000000 --- a/codex-rs/exec/tests/fixtures/sse_response_completed.json +++ /dev/null @@ -1,16 +0,0 @@ -[ - { - "type": "response.completed", - "response": { - "id": "__ID__", - "usage": { - "input_tokens": 0, - "input_tokens_details": null, - "output_tokens": 0, - "output_tokens_details": null, - "total_tokens": 0 - }, - "output": [] - } - } -] diff --git a/codex-rs/exec/tests/suite/apply_patch.rs b/codex-rs/exec/tests/suite/apply_patch.rs index 5537853b02..489f34f9ce 100644 --- a/codex-rs/exec/tests/suite/apply_patch.rs +++ b/codex-rs/exec/tests/suite/apply_patch.rs @@ -1,8 +1,12 @@ -#![allow(clippy::expect_used, clippy::unwrap_used)] +#![allow(clippy::expect_used, clippy::unwrap_used, unused_imports)] use anyhow::Context; use assert_cmd::prelude::*; use codex_core::CODEX_APPLY_PATCH_ARG1; +use core_test_support::responses::ev_apply_patch_custom_tool_call; +use core_test_support::responses::ev_apply_patch_function_call; +use core_test_support::responses::ev_completed; +use core_test_support::responses::sse; use std::fs; use std::process::Command; use tempfile::tempdir; @@ -55,15 +59,28 @@ async fn test_apply_patch_tool() -> anyhow::Result<()> { let tmp_cwd = tempdir().expect("failed to create temp dir"); let tmp_path = tmp_cwd.path().to_path_buf(); - run_e2e_exec_test( - tmp_cwd.path(), - vec![ - include_str!("../fixtures/sse_apply_patch_add.json").to_string(), - include_str!("../fixtures/sse_apply_patch_update.json").to_string(), - include_str!("../fixtures/sse_response_completed.json").to_string(), - ], - ) - .await; + let add_patch = r#"*** Begin Patch +*** Add File: test.md ++Hello world +*** End Patch"#; + let update_patch = r#"*** Begin Patch +*** Update File: test.md +@@ +-Hello world ++Final text +*** End Patch"#; + let response_streams = vec![ + sse(vec![ + ev_apply_patch_custom_tool_call("request_0", add_patch), + ev_completed("request_0"), + ]), + sse(vec![ + ev_apply_patch_function_call("request_1", update_patch), + ev_completed("request_1"), + ]), + sse(vec![ev_completed("request_2")]), + ]; + run_e2e_exec_test(tmp_cwd.path(), response_streams).await; let final_path = tmp_path.join("test.md"); let contents = std::fs::read_to_string(&final_path) @@ -86,15 +103,31 @@ async fn test_apply_patch_freeform_tool() -> anyhow::Result<()> { } let tmp_cwd = tempdir().expect("failed to create temp dir"); - run_e2e_exec_test( - tmp_cwd.path(), - vec![ - include_str!("../fixtures/sse_apply_patch_freeform_add.json").to_string(), - include_str!("../fixtures/sse_apply_patch_freeform_update.json").to_string(), - include_str!("../fixtures/sse_response_completed.json").to_string(), - ], - ) - .await; + let freeform_add_patch = r#"*** Begin Patch +*** Add File: app.py ++class BaseClass: ++ def method(): ++ return False +*** End Patch"#; + let freeform_update_patch = r#"*** Begin Patch +*** Update File: app.py +@@ def method(): +- return False ++ ++ return True +*** End Patch"#; + let response_streams = vec![ + sse(vec![ + ev_apply_patch_custom_tool_call("request_0", freeform_add_patch), + ev_completed("request_0"), + ]), + sse(vec![ + ev_apply_patch_custom_tool_call("request_1", freeform_update_patch), + ev_completed("request_1"), + ]), + sse(vec![ev_completed("request_2")]), + ]; + run_e2e_exec_test(tmp_cwd.path(), response_streams).await; // Verify final file contents let final_path = tmp_cwd.path().join("app.py"); diff --git a/codex-rs/exec/tests/suite/common.rs b/codex-rs/exec/tests/suite/common.rs index 4a3719aaba..19de9a3ef6 100644 --- a/codex-rs/exec/tests/suite/common.rs +++ b/codex-rs/exec/tests/suite/common.rs @@ -4,7 +4,6 @@ use anyhow::Context; use assert_cmd::prelude::*; -use core_test_support::load_sse_fixture_with_id_from_str; use std::path::Path; use std::process::Command; use std::sync::atomic::AtomicUsize; @@ -27,10 +26,7 @@ impl Respond for SeqResponder { match self.responses.get(call_num) { Some(body) => wiremock::ResponseTemplate::new(200) .insert_header("content-type", "text/event-stream") - .set_body_raw( - load_sse_fixture_with_id_from_str(body, &format!("request_{call_num}")), - "text/event-stream", - ), + .set_body_string(body.clone()), None => panic!("no response for {call_num}"), } } From ff389dc52f257d60d1978d34938dc7ab3cefdd15 Mon Sep 17 00:00:00 2001 From: Jeremy Rose <172423086+nornagon-openai@users.noreply.github.com> Date: Fri, 19 Sep 2025 12:08:04 -0700 Subject: [PATCH 38/38] fix alignment in slash command popup (#3937) --- codex-rs/tui/src/bottom_pane/command_popup.rs | 77 ++++++++----------- .../src/bottom_pane/selection_popup_common.rs | 27 ++++++- ..._chat_composer__tests__slash_popup_mo.snap | 4 +- ..._list_selection_spacing_with_subtitle.snap | 4 +- ...st_selection_spacing_without_subtitle.snap | 4 +- 5 files changed, 60 insertions(+), 56 deletions(-) diff --git a/codex-rs/tui/src/bottom_pane/command_popup.rs b/codex-rs/tui/src/bottom_pane/command_popup.rs index 8de266f819..a492a2e17e 100644 --- a/codex-rs/tui/src/bottom_pane/command_popup.rs +++ b/codex-rs/tui/src/bottom_pane/command_popup.rs @@ -95,32 +95,10 @@ impl CommandPopup { /// Determine the preferred height of the popup for a given width. /// Accounts for wrapped descriptions so that long tooltips don't overflow. pub(crate) fn calculate_required_height(&self, width: u16) -> u16 { - use super::selection_popup_common::GenericDisplayRow; use super::selection_popup_common::measure_rows_height; - let matches = self.filtered(); - let rows_all: Vec = if matches.is_empty() { - Vec::new() - } else { - matches - .into_iter() - .map(|(item, indices, _)| match item { - CommandItem::Builtin(cmd) => GenericDisplayRow { - name: format!("/{}", cmd.command()), - match_indices: indices.map(|v| v.into_iter().map(|i| i + 1).collect()), - is_current: false, - description: Some(cmd.description().to_string()), - }, - CommandItem::UserPrompt(i) => GenericDisplayRow { - name: format!("/{}", self.prompts[i].name), - match_indices: indices.map(|v| v.into_iter().map(|i| i + 1).collect()), - is_current: false, - description: Some("send saved prompt".to_string()), - }, - }) - .collect() - }; + let rows = self.rows_from_matches(self.filtered()); - measure_rows_height(&rows_all, &self.state, MAX_POPUP_ROWS, width) + measure_rows_height(&rows, &self.state, MAX_POPUP_ROWS, width) } /// Compute fuzzy-filtered matches over built-in commands and user prompts, @@ -172,6 +150,32 @@ impl CommandPopup { self.filtered().into_iter().map(|(c, _, _)| c).collect() } + fn rows_from_matches( + &self, + matches: Vec<(CommandItem, Option>, i32)>, + ) -> Vec { + matches + .into_iter() + .map(|(item, indices, _)| { + let (name, description) = match item { + CommandItem::Builtin(cmd) => { + (format!("/{}", cmd.command()), cmd.description().to_string()) + } + CommandItem::UserPrompt(i) => ( + format!("/{}", self.prompts[i].name), + "send saved prompt".to_string(), + ), + }; + GenericDisplayRow { + name, + match_indices: indices.map(|v| v.into_iter().map(|i| i + 1).collect()), + is_current: false, + description: Some(description), + } + }) + .collect() + } + /// Move the selection cursor one step up. pub(crate) fn move_up(&mut self) { let len = self.filtered_items().len(); @@ -198,32 +202,11 @@ impl CommandPopup { impl WidgetRef for CommandPopup { fn render_ref(&self, area: Rect, buf: &mut Buffer) { - let matches = self.filtered(); - let rows_all: Vec = if matches.is_empty() { - Vec::new() - } else { - matches - .into_iter() - .map(|(item, indices, _)| match item { - CommandItem::Builtin(cmd) => GenericDisplayRow { - name: format!("/{}", cmd.command()), - match_indices: indices.map(|v| v.into_iter().map(|i| i + 1).collect()), - is_current: false, - description: Some(cmd.description().to_string()), - }, - CommandItem::UserPrompt(i) => GenericDisplayRow { - name: format!("/{}", self.prompts[i].name), - match_indices: indices.map(|v| v.into_iter().map(|i| i + 1).collect()), - is_current: false, - description: Some("send saved prompt".to_string()), - }, - }) - .collect() - }; + let rows = self.rows_from_matches(self.filtered()); render_rows( area, buf, - &rows_all, + &rows, &self.state, MAX_POPUP_ROWS, false, diff --git a/codex-rs/tui/src/bottom_pane/selection_popup_common.rs b/codex-rs/tui/src/bottom_pane/selection_popup_common.rs index 61a26f9341..684924a44a 100644 --- a/codex-rs/tui/src/bottom_pane/selection_popup_common.rs +++ b/codex-rs/tui/src/bottom_pane/selection_popup_common.rs @@ -15,6 +15,7 @@ use ratatui::widgets::Paragraph; use ratatui::widgets::Widget; use super::scroll_state::ScrollState; +use crate::ui_consts::LIVE_PREFIX_COLS; /// A generic representation of a display row for selection popups. pub(crate) struct GenericDisplayRow { @@ -99,14 +100,34 @@ pub(crate) fn render_rows( .border_style(Style::default().add_modifier(Modifier::DIM)); block.render(area, buf); - // Content renders to the right of the border. + // Content renders to the right of the border with the same live prefix + // padding used by the composer so the popup aligns with the input text. + let prefix_cols = LIVE_PREFIX_COLS; let content_area = Rect { - x: area.x.saturating_add(1), + x: area.x.saturating_add(prefix_cols), y: area.y, - width: area.width.saturating_sub(1), + width: area.width.saturating_sub(prefix_cols), height: area.height, }; + // Clear the padding column(s) so stale characters never peek between the + // border and the popup contents. + let padding_cols = prefix_cols.saturating_sub(1); + if padding_cols > 0 { + let pad_start = area.x.saturating_add(1); + let pad_end = pad_start + .saturating_add(padding_cols) + .min(area.x.saturating_add(area.width)); + let pad_bottom = area.y.saturating_add(area.height); + for x in pad_start..pad_end { + for y in area.y..pad_bottom { + if let Some(cell) = buf.cell_mut((x, y)) { + cell.set_symbol(" "); + } + } + } + } + if rows_all.is_empty() { if content_area.height > 0 { let para = Paragraph::new(Line::from(empty_message.dim().italic())); diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_mo.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_mo.snap index f908fb6144..9c667c0d08 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_mo.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_mo.snap @@ -4,5 +4,5 @@ expression: terminal.backend() --- "▌ /mo " "▌ " -"▌/model choose what model and reasoning effort to use " -"▌/mention mention a file " +"▌ /model choose what model and reasoning effort to use " +"▌ /mention mention a file " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_spacing_with_subtitle.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_spacing_with_subtitle.snap index 65606ed7d0..ced53b7ce6 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_spacing_with_subtitle.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_spacing_with_subtitle.snap @@ -5,7 +5,7 @@ expression: render_lines(&view) ▌ Select Approval Mode ▌ Switch between Codex approval presets ▌ -▌> 1. Read Only (current) Codex can read files -▌ 2. Full Access Codex can edit files +▌ > 1. Read Only (current) Codex can read files +▌ 2. Full Access Codex can edit files Press Enter to confirm or Esc to go back diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_spacing_without_subtitle.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_spacing_without_subtitle.snap index b42a5f8c6b..b9858a4307 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_spacing_without_subtitle.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_spacing_without_subtitle.snap @@ -4,7 +4,7 @@ expression: render_lines(&view) --- ▌ Select Approval Mode ▌ -▌> 1. Read Only (current) Codex can read files -▌ 2. Full Access Codex can edit files +▌ > 1. Read Only (current) Codex can read files +▌ 2. Full Access Codex can edit files Press Enter to confirm or Esc to go back