From ee8c4ad23a4a30a2753fd3db659e56db0913f69c Mon Sep 17 00:00:00 2001 From: ae Date: Wed, 20 Aug 2025 00:26:14 -0700 Subject: [PATCH 01/27] feat: copy tweaks (#2502) - For selectable options, use sentences starting in lowercase and not ending with periods. To be honest I don't love this style, but better to be consistent for now. - Tweak some other strings. - Put in more compelling suggestions on launch. Excited to put `/mcp` in there next. --- codex-rs/common/src/approval_presets.rs | 6 +++--- codex-rs/common/src/model_presets.rs | 8 ++++---- codex-rs/tui/src/chatwidget.rs | 2 +- codex-rs/tui/src/history_cell.rs | 3 ++- codex-rs/tui/src/slash_command.rs | 2 +- 5 files changed, 11 insertions(+), 10 deletions(-) diff --git a/codex-rs/common/src/approval_presets.rs b/codex-rs/common/src/approval_presets.rs index 6c3bf395ad..6dc0cff0ee 100644 --- a/codex-rs/common/src/approval_presets.rs +++ b/codex-rs/common/src/approval_presets.rs @@ -24,21 +24,21 @@ pub fn builtin_approval_presets() -> Vec { ApprovalPreset { id: "read-only", label: "Read Only", - description: "Codex can read files and answer questions. Codex requires approval to make edits, run commands, or access network.", + description: "Codex can read files and answer questions. Codex requires approval to make edits, run commands, or access network", approval: AskForApproval::OnRequest, sandbox: SandboxPolicy::ReadOnly, }, ApprovalPreset { id: "auto", label: "Auto", - description: "Codex can read files, make edits, and run commands in the workspace. Codex requires approval to work outside the workspace or access network.", + description: "Codex can read files, make edits, and run commands in the workspace. Codex requires approval to work outside the workspace or access network", approval: AskForApproval::OnRequest, sandbox: SandboxPolicy::new_workspace_write_policy(), }, ApprovalPreset { id: "full-access", label: "Full Access", - description: "Codex can read files, make edits, and run commands with network access, without approval. Exercise caution.", + description: "Codex can read files, make edits, and run commands with network access, without approval. Exercise caution", approval: AskForApproval::Never, sandbox: SandboxPolicy::DangerFullAccess, }, diff --git a/codex-rs/common/src/model_presets.rs b/codex-rs/common/src/model_presets.rs index 16ec9be199..686a2c0291 100644 --- a/codex-rs/common/src/model_presets.rs +++ b/codex-rs/common/src/model_presets.rs @@ -24,28 +24,28 @@ pub fn builtin_model_presets() -> &'static [ModelPreset] { ModelPreset { id: "gpt-5-minimal", label: "gpt-5 minimal", - description: "— Fastest responses with very limited reasoning; ideal for coding, instructions, or lightweight tasks.", + description: "— fastest responses with limited reasoning; ideal for coding, instructions, or lightweight tasks", model: "gpt-5", effort: ReasoningEffort::Minimal, }, ModelPreset { id: "gpt-5-low", label: "gpt-5 low", - description: "— Balances speed with some reasoning; useful for straightforward queries and short explanations.", + description: "— balances speed with some reasoning; useful for straightforward queries and short explanations", model: "gpt-5", effort: ReasoningEffort::Low, }, ModelPreset { id: "gpt-5-medium", label: "gpt-5 medium", - description: "— Default setting; provides a solid balance of reasoning depth and latency for general-purpose tasks.", + description: "— default setting; provides a solid balance of reasoning depth and latency for general-purpose tasks", model: "gpt-5", effort: ReasoningEffort::Medium, }, ModelPreset { id: "gpt-5-high", label: "gpt-5 high", - description: "— Maximizes reasoning depth for complex or ambiguous problems.", + description: "— maximizes reasoning depth for complex or ambiguous problems", model: "gpt-5", effort: ReasoningEffort::High, }, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 0c72b62134..8d981b9450 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -771,7 +771,7 @@ impl ChatWidget<'_> { } self.bottom_pane.show_selection_view( - "Select Approvals Mode".to_string(), + "Select Approval Mode".to_string(), None, Some("Press Enter to confirm or Esc to go back".to_string()), items, diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 021c09e337..3583aaa397 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -168,7 +168,8 @@ pub(crate) fn new_session_info( Line::from("".dim()), Line::from(format!(" /init - {}", SlashCommand::Init.description()).dim()), Line::from(format!(" /status - {}", SlashCommand::Status.description()).dim()), - Line::from(format!(" /diff - {}", SlashCommand::Diff.description()).dim()), + Line::from(format!(" /approvals - {}", SlashCommand::Approvals.description()).dim()), + Line::from(format!(" /model - {}", SlashCommand::Model.description()).dim()), Line::from("".dim()), ]; PlainHistoryCell { lines } diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index 13ce2ff0fa..6311661b28 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -38,7 +38,7 @@ impl SlashCommand { SlashCommand::Diff => "show git diff (including untracked files)", SlashCommand::Mention => "mention a file", SlashCommand::Status => "show current session configuration and token usage", - SlashCommand::Model => "choose a model preset (model + reasoning effort)", + SlashCommand::Model => "choose what model and reasoning effort to use", SlashCommand::Approvals => "choose what Codex can do without approval", SlashCommand::Mcp => "list configured MCP tools", SlashCommand::Logout => "log out of Codex", From 0ad4e11c84868635af667f7acef1d80dba9d369a Mon Sep 17 00:00:00 2001 From: Jeremy Rose <172423086+nornagon-openai@users.noreply.github.com> Date: Wed, 20 Aug 2025 09:54:26 -0700 Subject: [PATCH 02/27] detect terminal and include in request headers (#2437) This adds the terminal version to the UA header. --- codex-rs/core/src/lib.rs | 1 + codex-rs/core/src/terminal.rs | 72 +++++++++++++++++++++++++++++++++ codex-rs/core/src/user_agent.rs | 10 +++-- 3 files changed, 79 insertions(+), 4 deletions(-) create mode 100644 codex-rs/core/src/terminal.rs diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 28d35f5376..ad3947aa94 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -49,6 +49,7 @@ pub(crate) mod safety; pub mod seatbelt; pub mod shell; pub mod spawn; +pub mod terminal; pub mod turn_diff_tracker; pub mod user_agent; mod user_notification; diff --git a/codex-rs/core/src/terminal.rs b/codex-rs/core/src/terminal.rs new file mode 100644 index 0000000000..02104f8be5 --- /dev/null +++ b/codex-rs/core/src/terminal.rs @@ -0,0 +1,72 @@ +use std::sync::OnceLock; + +static TERMINAL: OnceLock = OnceLock::new(); + +pub fn user_agent() -> String { + TERMINAL.get_or_init(detect_terminal).to_string() +} + +/// Sanitize a header value to be used in a User-Agent string. +/// +/// This function replaces any characters that are not allowed in a User-Agent string with an underscore. +/// +/// # Arguments +/// +/// * `value` - The value to sanitize. +fn is_valid_header_value_char(c: char) -> bool { + c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' || c == '/' +} + +fn sanitize_header_value(value: String) -> String { + value.replace(|c| !is_valid_header_value_char(c), "_") +} + +fn detect_terminal() -> String { + sanitize_header_value( + if let Ok(tp) = std::env::var("TERM_PROGRAM") + && !tp.trim().is_empty() + { + let ver = std::env::var("TERM_PROGRAM_VERSION").ok(); + match ver { + Some(v) if !v.trim().is_empty() => format!("{tp}/{v}"), + _ => tp, + } + } else if let Ok(v) = std::env::var("WEZTERM_VERSION") { + if !v.trim().is_empty() { + format!("WezTerm/{v}") + } else { + "WezTerm".to_string() + } + } else if std::env::var("KITTY_WINDOW_ID").is_ok() + || std::env::var("TERM") + .map(|t| t.contains("kitty")) + .unwrap_or(false) + { + "kitty".to_string() + } else if std::env::var("ALACRITTY_SOCKET").is_ok() + || std::env::var("TERM") + .map(|t| t == "alacritty") + .unwrap_or(false) + { + "Alacritty".to_string() + } else if let Ok(v) = std::env::var("KONSOLE_VERSION") { + if !v.trim().is_empty() { + format!("Konsole/{v}") + } else { + "Konsole".to_string() + } + } else if std::env::var("GNOME_TERMINAL_SCREEN").is_ok() { + return "gnome-terminal".to_string(); + } else if let Ok(v) = std::env::var("VTE_VERSION") { + if !v.trim().is_empty() { + format!("VTE/{v}") + } else { + "VTE".to_string() + } + } else if std::env::var("WT_SESSION").is_ok() { + return "WindowsTerminal".to_string(); + } else { + std::env::var("TERM").unwrap_or_else(|_| "unknown".to_string()) + }, + ) +} diff --git a/codex-rs/core/src/user_agent.rs b/codex-rs/core/src/user_agent.rs index ddcfd4b7f1..a63170cebd 100644 --- a/codex-rs/core/src/user_agent.rs +++ b/codex-rs/core/src/user_agent.rs @@ -4,11 +4,12 @@ pub fn get_codex_user_agent(originator: Option<&str>) -> String { let build_version = env!("CARGO_PKG_VERSION"); let os_info = os_info::get(); format!( - "{}/{build_version} ({} {}; {})", + "{}/{build_version} ({} {}; {}) {}", originator.unwrap_or(DEFAULT_ORIGINATOR), os_info.os_type(), os_info.version(), os_info.architecture().unwrap_or("unknown"), + crate::terminal::user_agent() ) } @@ -27,9 +28,10 @@ mod tests { fn test_macos() { use regex_lite::Regex; let user_agent = get_codex_user_agent(None); - let re = - Regex::new(r"^codex_cli_rs/\d+\.\d+\.\d+ \(Mac OS \d+\.\d+\.\d+; (x86_64|arm64)\)$") - .unwrap(); + let re = Regex::new( + r"^codex_cli_rs/\d+\.\d+\.\d+ \(Mac OS \d+\.\d+\.\d+; (x86_64|arm64)\) (\S+)$", + ) + .unwrap(); assert!(re.is_match(&user_agent)); } } From 8481eb4c6e8bafee5cd86240566870df96246fa7 Mon Sep 17 00:00:00 2001 From: Jeremy Rose <172423086+nornagon-openai@users.noreply.github.com> Date: Wed, 20 Aug 2025 09:57:55 -0700 Subject: [PATCH 03/27] tui: tab-completing a command moves the cursor to the end (#2362) also tweak agents.md for faster `just fix` --- AGENTS.md | 2 +- codex-rs/justfile | 4 ++-- codex-rs/tui/src/bottom_pane/chat_composer.rs | 5 +++++ 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f1744a87e5..eb2cacd50e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,7 +8,7 @@ In the codex-rs folder where the rust code lives: - 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. -Before finalizing a change to `codex-rs`, run `just fmt` (in `codex-rs` directory) to format the code and `just fix` (in `codex-rs` directory) to fix any linter issues in the code. Additionally, run the tests: +Before finalizing a change to `codex-rs`, run `just fmt` (in `codex-rs` directory) to format the code and `just fix -p ` (in `codex-rs` directory) to fix any linter issues in the code. Additionally, run the tests: 1. Run the test for the specific project that was changed. For example, if changes were made in `codex-rs/tui`, run `cargo test -p codex-tui`. 2. Once those pass, if any changes were made in common, core, or protocol, run the complete test suite with `cargo test --all-features`. diff --git a/codex-rs/justfile b/codex-rs/justfile index 3e1336be43..beaa682daa 100644 --- a/codex-rs/justfile +++ b/codex-rs/justfile @@ -24,8 +24,8 @@ file-search *args: fmt: cargo fmt -- --config imports_granularity=Item -fix: - cargo clippy --fix --all-features --tests --allow-dirty +fix *args: + cargo clippy --fix --all-features --tests --allow-dirty "$@" install: rustup show active-toolchain diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 5d555aa083..29b03c89a7 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -275,6 +275,11 @@ impl ChatComposer { self.textarea.set_text(&format!("/{} ", cmd.command())); self.textarea.set_cursor(self.textarea.text().len()); } + // After completing the command, move cursor to the end. + if !self.textarea.text().is_empty() { + let end = self.textarea.text().len(); + self.textarea.set_cursor(end); + } } (InputResult::None, true) } From 61bbabe7d9ff63574b9883d1fe2c45e8ec30761e Mon Sep 17 00:00:00 2001 From: Jeremy Rose <172423086+nornagon-openai@users.noreply.github.com> Date: Wed, 20 Aug 2025 10:11:09 -0700 Subject: [PATCH 04/27] tui: switch to using tokio + EventStream for processing crossterm events (#2489) bringing the tui more into tokio-land to make it easier to factorize. fyi @bolinfest --- codex-rs/Cargo.lock | 2 + codex-rs/tui/Cargo.toml | 3 +- codex-rs/tui/src/app.rs | 622 +++++++++--------- codex-rs/tui/src/app_event_sender.rs | 6 +- .../src/bottom_pane/approval_modal_view.rs | 10 +- codex-rs/tui/src/bottom_pane/chat_composer.rs | 25 +- .../src/bottom_pane/chat_composer_history.rs | 4 +- codex-rs/tui/src/bottom_pane/mod.rs | 14 +- codex-rs/tui/src/chatwidget/tests.rs | 59 +- codex-rs/tui/src/insert_history.rs | 12 +- codex-rs/tui/src/lib.rs | 5 +- codex-rs/tui/src/status_indicator_widget.rs | 8 +- codex-rs/tui/src/user_approval_widget.rs | 16 +- 13 files changed, 396 insertions(+), 390 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index dc9385d990..d879d196ff 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -961,6 +961,7 @@ dependencies = [ "supports-color", "textwrap 0.16.2", "tokio", + "tokio-stream", "tracing", "tracing-appender", "tracing-subscriber", @@ -1161,6 +1162,7 @@ checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" dependencies = [ "bitflags 2.9.1", "crossterm_winapi", + "futures-core", "mio", "parking_lot", "rustix 0.38.44", diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index 9e0e31e172..b5ec8d04ec 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -38,7 +38,7 @@ codex-login = { path = "../login" } codex-ollama = { path = "../ollama" } codex-protocol = { path = "../protocol" } color-eyre = "0.6.3" -crossterm = { version = "0.28.1", features = ["bracketed-paste"] } +crossterm = { version = "0.28.1", features = ["bracketed-paste", "event-stream"] } diffy = "0.4.2" image = { version = "^0.25.6", default-features = false, features = ["jpeg"] } lazy_static = "1" @@ -68,6 +68,7 @@ tokio = { version = "1", features = [ "rt-multi-thread", "signal", ] } +tokio-stream = "0.1.17" tracing = { version = "0.1.41", features = ["log"] } tracing-appender = "0.2.3" tracing-subscriber = { version = "0.3.19", features = ["env-filter"] } diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index c7a1693617..6ce51e5dbe 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -27,11 +27,12 @@ use std::path::PathBuf; use std::sync::Arc; use std::sync::atomic::AtomicBool; use std::sync::atomic::Ordering; -use std::sync::mpsc::Receiver; -use std::sync::mpsc::channel; use std::thread; use std::time::Duration; use std::time::Instant; +use tokio::select; +use tokio::sync::mpsc::UnboundedReceiver; +use tokio::sync::mpsc::unbounded_channel; /// Time window for debouncing redraw requests. const REDRAW_DEBOUNCE: Duration = Duration::from_millis(1); @@ -53,7 +54,7 @@ enum AppState<'a> { pub(crate) struct App<'a> { server: Arc, app_event_tx: AppEventSender, - app_event_rx: Receiver, + app_event_rx: UnboundedReceiver, app_state: AppState<'a>, /// Config is stored here so we can recreate ChatWidgets as needed. @@ -92,52 +93,11 @@ impl App<'_> { ) -> Self { let conversation_manager = Arc::new(ConversationManager::default()); - let (app_event_tx, app_event_rx) = channel(); + let (app_event_tx, app_event_rx) = unbounded_channel(); let app_event_tx = AppEventSender::new(app_event_tx); let enhanced_keys_supported = supports_keyboard_enhancement().unwrap_or(false); - // Spawn a dedicated thread for reading the crossterm event loop and - // re-publishing the events as AppEvents, as appropriate. - { - let app_event_tx = app_event_tx.clone(); - std::thread::spawn(move || { - loop { - // This timeout is necessary to avoid holding the event lock - // that crossterm::event::read() acquires. In particular, - // reading the cursor position (crossterm::cursor::position()) - // needs to acquire the event lock, and so will fail if it - // can't acquire it within 2 sec. Resizing the terminal - // crashes the app if the cursor position can't be read. - if let Ok(true) = crossterm::event::poll(Duration::from_millis(100)) { - if let Ok(event) = crossterm::event::read() { - match event { - crossterm::event::Event::Key(key_event) => { - app_event_tx.send(AppEvent::KeyEvent(key_event)); - } - crossterm::event::Event::Resize(_, _) => { - app_event_tx.send(AppEvent::RequestRedraw); - } - crossterm::event::Event::Paste(pasted) => { - // Many terminals convert newlines to \r when pasting (e.g., iTerm2), - // but tui-textarea expects \n. Normalize CR to LF. - // [tui-textarea]: https://github.com/rhysd/tui-textarea/blob/4d18622eeac13b309e0ff6a55a46ac6706da68cf/src/textarea.rs#L782-L783 - // [iTerm2]: https://github.com/gnachman/iTerm2/blob/5d0c0d9f68523cbd0494dad5422998964a2ecd8d/sources/iTermPasteHelper.m#L206-L216 - let pasted = pasted.replace("\r", "\n"); - app_event_tx.send(AppEvent::Paste(pasted)); - } - _ => { - // Ignore any other events. - } - } - } - } else { - // Timeout expired, no `Event` is available - } - } - }); - } - let login_status = get_login_status(&config); let should_show_onboarding = should_show_onboarding(login_status, &config, show_trust_screen); @@ -179,7 +139,7 @@ impl App<'_> { // Spawn a single scheduler thread that coalesces both debounced redraw // requests and animation frame requests, and emits a single Redraw event // at the earliest requested time. - let (frame_tx, frame_rx) = channel::(); + let (frame_tx, frame_rx) = std::sync::mpsc::channel::(); { let app_event_tx = app_event_tx.clone(); std::thread::spawn(move || { @@ -234,306 +194,338 @@ impl App<'_> { let _ = self.frame_schedule_tx.send(Instant::now() + dur); } - pub(crate) fn run(&mut self, terminal: &mut tui::Tui) -> Result<()> { - // Schedule the first render immediately. - let _ = self.frame_schedule_tx.send(Instant::now()); + pub(crate) async fn run(&mut self, terminal: &mut tui::Tui) -> Result<()> { + use tokio_stream::StreamExt; - while let Ok(event) = self.app_event_rx.recv() { - match event { - AppEvent::InsertHistory(lines) => { - self.pending_history_lines.extend(lines); - self.app_event_tx.send(AppEvent::RequestRedraw); - } - AppEvent::RequestRedraw => { - self.schedule_frame_in(REDRAW_DEBOUNCE); - } - AppEvent::ScheduleFrameIn(dur) => { - self.schedule_frame_in(dur); - } - AppEvent::Redraw => { - std::io::stdout().sync_update(|_| self.draw_next_frame(terminal))??; - } - AppEvent::StartCommitAnimation => { - if self - .commit_anim_running - .compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed) - .is_ok() - { - let tx = self.app_event_tx.clone(); - let running = self.commit_anim_running.clone(); - thread::spawn(move || { - while running.load(Ordering::Relaxed) { - thread::sleep(Duration::from_millis(50)); - tx.send(AppEvent::CommitTick); - } - }); + self.handle_event(terminal, AppEvent::Redraw)?; + + let mut crossterm_events = crossterm::event::EventStream::new(); + + while let Some(event) = { + select! { + maybe_app_event = self.app_event_rx.recv() => { + maybe_app_event + }, + Some(Ok(event)) = crossterm_events.next() => { + match event { + crossterm::event::Event::Key(key_event) => { + Some(AppEvent::KeyEvent(key_event)) + } + crossterm::event::Event::Resize(_, _) => { + Some(AppEvent::Redraw) + } + crossterm::event::Event::Paste(pasted) => { + // Many terminals convert newlines to \r when pasting (e.g., iTerm2), + // but tui-textarea expects \n. Normalize CR to LF. + // [tui-textarea]: https://github.com/rhysd/tui-textarea/blob/4d18622eeac13b309e0ff6a55a46ac6706da68cf/src/textarea.rs#L782-L783 + // [iTerm2]: https://github.com/gnachman/iTerm2/blob/5d0c0d9f68523cbd0494dad5422998964a2ecd8d/sources/iTermPasteHelper.m#L206-L216 + let pasted = pasted.replace("\r", "\n"); + Some(AppEvent::Paste(pasted)) + } + _ => { + // Ignore any other events. + None + } } + }, + } + } && self.handle_event(terminal, event)? + {} + terminal.clear()?; + Ok(()) + } + + fn handle_event(&mut self, terminal: &mut tui::Tui, event: AppEvent) -> Result { + match event { + AppEvent::InsertHistory(lines) => { + self.pending_history_lines.extend(lines); + self.app_event_tx.send(AppEvent::RequestRedraw); + } + AppEvent::RequestRedraw => { + self.schedule_frame_in(REDRAW_DEBOUNCE); + } + AppEvent::ScheduleFrameIn(dur) => { + self.schedule_frame_in(dur); + } + AppEvent::Redraw => { + std::io::stdout().sync_update(|_| self.draw_next_frame(terminal))??; + } + AppEvent::StartCommitAnimation => { + if self + .commit_anim_running + .compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed) + .is_ok() + { + let tx = self.app_event_tx.clone(); + let running = self.commit_anim_running.clone(); + thread::spawn(move || { + while running.load(Ordering::Relaxed) { + thread::sleep(Duration::from_millis(50)); + tx.send(AppEvent::CommitTick); + } + }); } - AppEvent::StopCommitAnimation => { - self.commit_anim_running.store(false, Ordering::Release); + } + AppEvent::StopCommitAnimation => { + self.commit_anim_running.store(false, Ordering::Release); + } + AppEvent::CommitTick => { + if let AppState::Chat { widget } = &mut self.app_state { + widget.on_commit_tick(); } - AppEvent::CommitTick => { - if let AppState::Chat { widget } = &mut self.app_state { - widget.on_commit_tick(); + } + AppEvent::KeyEvent(key_event) => { + match key_event { + KeyEvent { + code: KeyCode::Char('c'), + modifiers: crossterm::event::KeyModifiers::CONTROL, + kind: KeyEventKind::Press, + .. + } => match &mut self.app_state { + AppState::Chat { widget } => { + widget.on_ctrl_c(); + } + AppState::Onboarding { .. } => { + self.app_event_tx.send(AppEvent::ExitRequest); + } + }, + KeyEvent { + code: KeyCode::Char('z'), + modifiers: crossterm::event::KeyModifiers::CONTROL, + kind: KeyEventKind::Press, + .. + } => { + #[cfg(unix)] + { + self.suspend(terminal)?; + } + // No-op on non-Unix platforms. } - } - AppEvent::KeyEvent(key_event) => { - match key_event { - KeyEvent { - code: KeyCode::Char('c'), - modifiers: crossterm::event::KeyModifiers::CONTROL, - kind: KeyEventKind::Press, - .. - } => match &mut self.app_state { + KeyEvent { + code: KeyCode::Char('d'), + modifiers: crossterm::event::KeyModifiers::CONTROL, + kind: KeyEventKind::Press, + .. + } => { + match &mut self.app_state { AppState::Chat { widget } => { - widget.on_ctrl_c(); + if widget.composer_is_empty() { + self.app_event_tx.send(AppEvent::ExitRequest); + } else { + // Treat Ctrl+D as a normal key event when the composer + // is not empty so that it doesn't quit the application + // prematurely. + self.dispatch_key_event(key_event); + } } AppState::Onboarding { .. } => { self.app_event_tx.send(AppEvent::ExitRequest); } - }, - KeyEvent { - code: KeyCode::Char('z'), - modifiers: crossterm::event::KeyModifiers::CONTROL, - kind: KeyEventKind::Press, - .. - } => { - #[cfg(unix)] - { - self.suspend(terminal)?; - } - // No-op on non-Unix platforms. } - KeyEvent { - code: KeyCode::Char('d'), - modifiers: crossterm::event::KeyModifiers::CONTROL, - kind: KeyEventKind::Press, - .. - } => { - match &mut self.app_state { - AppState::Chat { widget } => { - if widget.composer_is_empty() { - self.app_event_tx.send(AppEvent::ExitRequest); - } else { - // Treat Ctrl+D as a normal key event when the composer - // is not empty so that it doesn't quit the application - // prematurely. - self.dispatch_key_event(key_event); - } - } - AppState::Onboarding { .. } => { - self.app_event_tx.send(AppEvent::ExitRequest); + } + KeyEvent { + kind: KeyEventKind::Press | KeyEventKind::Repeat, + .. + } => { + self.dispatch_key_event(key_event); + } + _ => { + // Ignore Release key events. + } + }; + } + AppEvent::Paste(text) => { + self.dispatch_paste_event(text); + } + AppEvent::CodexEvent(event) => { + self.dispatch_codex_event(event); + } + AppEvent::ExitRequest => { + return Ok(false); + } + AppEvent::CodexOp(op) => match &mut self.app_state { + AppState::Chat { widget } => widget.submit_op(op), + AppState::Onboarding { .. } => {} + }, + AppEvent::DiffResult(text) => { + if let AppState::Chat { widget } = &mut self.app_state { + widget.add_diff_output(text); + } + } + AppEvent::DispatchCommand(command) => match command { + SlashCommand::New => { + // User accepted – switch to chat view. + let new_widget = Box::new(ChatWidget::new( + self.config.clone(), + self.server.clone(), + self.app_event_tx.clone(), + None, + Vec::new(), + self.enhanced_keys_supported, + )); + self.app_state = AppState::Chat { widget: new_widget }; + self.app_event_tx.send(AppEvent::RequestRedraw); + } + SlashCommand::Init => { + // Guard: do not run if a task is active. + if let AppState::Chat { widget } = &mut self.app_state { + const INIT_PROMPT: &str = include_str!("../prompt_for_init_command.md"); + widget.submit_text_message(INIT_PROMPT.to_string()); + } + } + SlashCommand::Compact => { + if let AppState::Chat { widget } = &mut self.app_state { + widget.clear_token_usage(); + self.app_event_tx.send(AppEvent::CodexOp(Op::Compact)); + } + } + SlashCommand::Model => { + if let AppState::Chat { widget } = &mut self.app_state { + widget.open_model_popup(); + } + } + SlashCommand::Approvals => { + if let AppState::Chat { widget } = &mut self.app_state { + widget.open_approvals_popup(); + } + } + SlashCommand::Quit => { + return Ok(false); + } + SlashCommand::Logout => { + if let Err(e) = codex_login::logout(&self.config.codex_home) { + tracing::error!("failed to logout: {e}"); + } + return Ok(false); + } + SlashCommand::Diff => { + if let AppState::Chat { widget } = &mut self.app_state { + widget.add_diff_in_progress(); + } + + let tx = self.app_event_tx.clone(); + tokio::spawn(async move { + let text = match get_git_diff().await { + Ok((is_git_repo, diff_text)) => { + if is_git_repo { + diff_text + } else { + "`/diff` — _not inside a git repository_".to_string() } } - } - KeyEvent { - kind: KeyEventKind::Press | KeyEventKind::Repeat, - .. - } => { - self.dispatch_key_event(key_event); - } - _ => { - // Ignore Release key events. - } - }; + Err(e) => format!("Failed to compute diff: {e}"), + }; + tx.send(AppEvent::DiffResult(text)); + }); } - AppEvent::Paste(text) => { - self.dispatch_paste_event(text); - } - AppEvent::CodexEvent(event) => { - self.dispatch_codex_event(event); - } - AppEvent::ExitRequest => { - break; - } - AppEvent::CodexOp(op) => match &mut self.app_state { - AppState::Chat { widget } => widget.submit_op(op), - AppState::Onboarding { .. } => {} - }, - AppEvent::DiffResult(text) => { + SlashCommand::Mention => { if let AppState::Chat { widget } = &mut self.app_state { - widget.add_diff_output(text); + widget.insert_str("@"); } } - AppEvent::DispatchCommand(command) => match command { - SlashCommand::New => { - // User accepted – switch to chat view. - let new_widget = Box::new(ChatWidget::new( - self.config.clone(), - self.server.clone(), - self.app_event_tx.clone(), - None, - Vec::new(), - self.enhanced_keys_supported, - )); - self.app_state = AppState::Chat { widget: new_widget }; - self.app_event_tx.send(AppEvent::RequestRedraw); + SlashCommand::Status => { + if let AppState::Chat { widget } = &mut self.app_state { + widget.add_status_output(); } - SlashCommand::Init => { - // Guard: do not run if a task is active. - if let AppState::Chat { widget } = &mut self.app_state { - const INIT_PROMPT: &str = include_str!("../prompt_for_init_command.md"); - widget.submit_text_message(INIT_PROMPT.to_string()); - } + } + SlashCommand::Mcp => { + if let AppState::Chat { widget } = &mut self.app_state { + widget.add_mcp_output(); } - SlashCommand::Compact => { - if let AppState::Chat { widget } = &mut self.app_state { - widget.clear_token_usage(); - self.app_event_tx.send(AppEvent::CodexOp(Op::Compact)); - } - } - SlashCommand::Model => { - if let AppState::Chat { widget } = &mut self.app_state { - widget.open_model_popup(); - } - } - SlashCommand::Approvals => { - if let AppState::Chat { widget } = &mut self.app_state { - widget.open_approvals_popup(); - } - } - SlashCommand::Quit => { - break; - } - SlashCommand::Logout => { - if let Err(e) = codex_login::logout(&self.config.codex_home) { - tracing::error!("failed to logout: {e}"); - } - break; - } - SlashCommand::Diff => { - if let AppState::Chat { widget } = &mut self.app_state { - widget.add_diff_in_progress(); - } + } + #[cfg(debug_assertions)] + SlashCommand::TestApproval => { + use codex_core::protocol::EventMsg; + use std::collections::HashMap; - let tx = self.app_event_tx.clone(); - tokio::spawn(async move { - let text = match get_git_diff().await { - Ok((is_git_repo, diff_text)) => { - if is_git_repo { - diff_text - } else { - "`/diff` — _not inside a git repository_".to_string() - } - } - Err(e) => format!("Failed to compute diff: {e}"), - }; - tx.send(AppEvent::DiffResult(text)); - }); - } - SlashCommand::Mention => { - if let AppState::Chat { widget } = &mut self.app_state { - widget.insert_str("@"); - } - } - SlashCommand::Status => { - if let AppState::Chat { widget } = &mut self.app_state { - widget.add_status_output(); - } - } - SlashCommand::Mcp => { - if let AppState::Chat { widget } = &mut self.app_state { - widget.add_mcp_output(); - } - } - #[cfg(debug_assertions)] - SlashCommand::TestApproval => { - use codex_core::protocol::EventMsg; - use std::collections::HashMap; + use codex_core::protocol::ApplyPatchApprovalRequestEvent; + use codex_core::protocol::FileChange; - use codex_core::protocol::ApplyPatchApprovalRequestEvent; - use codex_core::protocol::FileChange; - - self.app_event_tx.send(AppEvent::CodexEvent(Event { - id: "1".to_string(), - // msg: EventMsg::ExecApprovalRequest(ExecApprovalRequestEvent { - // call_id: "1".to_string(), - // command: vec!["git".into(), "apply".into()], - // cwd: self.config.cwd.clone(), - // reason: Some("test".to_string()), - // }), - msg: EventMsg::ApplyPatchApprovalRequest( - ApplyPatchApprovalRequestEvent { - call_id: "1".to_string(), - changes: HashMap::from([ - ( - PathBuf::from("/tmp/test.txt"), - FileChange::Add { - content: "test".to_string(), - }, - ), - ( - PathBuf::from("/tmp/test2.txt"), - FileChange::Update { - unified_diff: "+test\n-test2".to_string(), - move_path: None, - }, - ), - ]), - reason: None, - grant_root: Some(PathBuf::from("/tmp")), - }, - ), - })); - } - }, - AppEvent::OnboardingAuthComplete(result) => { - if let AppState::Onboarding { screen } = &mut self.app_state { - screen.on_auth_complete(result); - } + self.app_event_tx.send(AppEvent::CodexEvent(Event { + id: "1".to_string(), + // msg: EventMsg::ExecApprovalRequest(ExecApprovalRequestEvent { + // call_id: "1".to_string(), + // command: vec!["git".into(), "apply".into()], + // cwd: self.config.cwd.clone(), + // reason: Some("test".to_string()), + // }), + msg: EventMsg::ApplyPatchApprovalRequest(ApplyPatchApprovalRequestEvent { + call_id: "1".to_string(), + changes: HashMap::from([ + ( + PathBuf::from("/tmp/test.txt"), + FileChange::Add { + content: "test".to_string(), + }, + ), + ( + PathBuf::from("/tmp/test2.txt"), + FileChange::Update { + unified_diff: "+test\n-test2".to_string(), + move_path: None, + }, + ), + ]), + reason: None, + grant_root: Some(PathBuf::from("/tmp")), + }), + })); } - AppEvent::OnboardingComplete(ChatWidgetArgs { - config, - enhanced_keys_supported, - initial_images, - initial_prompt, - }) => { - self.app_state = AppState::Chat { - widget: Box::new(ChatWidget::new( - config, - self.server.clone(), - self.app_event_tx.clone(), - initial_prompt, - initial_images, - enhanced_keys_supported, - )), - } + }, + AppEvent::OnboardingAuthComplete(result) => { + if let AppState::Onboarding { screen } = &mut self.app_state { + screen.on_auth_complete(result); } - AppEvent::StartFileSearch(query) => { - if !query.is_empty() { - self.file_search.on_user_query(query); - } + } + AppEvent::OnboardingComplete(ChatWidgetArgs { + config, + enhanced_keys_supported, + initial_images, + initial_prompt, + }) => { + self.app_state = AppState::Chat { + widget: Box::new(ChatWidget::new( + config, + self.server.clone(), + self.app_event_tx.clone(), + initial_prompt, + initial_images, + enhanced_keys_supported, + )), } - AppEvent::FileSearchResult { query, matches } => { - if let AppState::Chat { widget } = &mut self.app_state { - widget.apply_file_search_result(query, matches); - } + } + AppEvent::StartFileSearch(query) => { + if !query.is_empty() { + self.file_search.on_user_query(query); } - AppEvent::UpdateReasoningEffort(effort) => { - if let AppState::Chat { widget } = &mut self.app_state { - widget.set_reasoning_effort(effort); - } + } + AppEvent::FileSearchResult { query, matches } => { + if let AppState::Chat { widget } = &mut self.app_state { + widget.apply_file_search_result(query, matches); } - AppEvent::UpdateModel(model) => { - if let AppState::Chat { widget } = &mut self.app_state { - widget.set_model(model); - } + } + AppEvent::UpdateReasoningEffort(effort) => { + if let AppState::Chat { widget } = &mut self.app_state { + widget.set_reasoning_effort(effort); } - AppEvent::UpdateAskForApprovalPolicy(policy) => { - if let AppState::Chat { widget } = &mut self.app_state { - widget.set_approval_policy(policy); - } + } + AppEvent::UpdateModel(model) => { + if let AppState::Chat { widget } = &mut self.app_state { + widget.set_model(model); } - AppEvent::UpdateSandboxPolicy(policy) => { - if let AppState::Chat { widget } = &mut self.app_state { - widget.set_sandbox_policy(policy); - } + } + AppEvent::UpdateAskForApprovalPolicy(policy) => { + if let AppState::Chat { widget } = &mut self.app_state { + widget.set_approval_policy(policy); + } + } + AppEvent::UpdateSandboxPolicy(policy) => { + if let AppState::Chat { widget } = &mut self.app_state { + widget.set_sandbox_policy(policy); } } } - terminal.clear()?; - - Ok(()) + Ok(true) } #[cfg(unix)] diff --git a/codex-rs/tui/src/app_event_sender.rs b/codex-rs/tui/src/app_event_sender.rs index 901bb41024..c1427b3ff0 100644 --- a/codex-rs/tui/src/app_event_sender.rs +++ b/codex-rs/tui/src/app_event_sender.rs @@ -1,15 +1,15 @@ -use std::sync::mpsc::Sender; +use tokio::sync::mpsc::UnboundedSender; use crate::app_event::AppEvent; use crate::session_log; #[derive(Clone, Debug)] pub(crate) struct AppEventSender { - pub app_event_tx: Sender, + pub app_event_tx: UnboundedSender, } impl AppEventSender { - pub(crate) fn new(app_event_tx: Sender) -> Self { + pub(crate) fn new(app_event_tx: UnboundedSender) -> Self { Self { app_event_tx } } diff --git a/codex-rs/tui/src/bottom_pane/approval_modal_view.rs b/codex-rs/tui/src/bottom_pane/approval_modal_view.rs index b7e6e5e69a..1b23acb59d 100644 --- a/codex-rs/tui/src/bottom_pane/approval_modal_view.rs +++ b/codex-rs/tui/src/bottom_pane/approval_modal_view.rs @@ -75,7 +75,7 @@ impl<'a> BottomPaneView<'a> for ApprovalModalView<'a> { mod tests { use super::*; use crate::app_event::AppEvent; - use std::sync::mpsc::channel; + use tokio::sync::mpsc::unbounded_channel; fn make_exec_request() -> ApprovalRequest { ApprovalRequest::Exec { @@ -87,15 +87,15 @@ mod tests { #[test] fn ctrl_c_aborts_and_clears_queue() { - let (tx_raw, _rx) = channel::(); - let tx = AppEventSender::new(tx_raw); + let (tx, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx); let first = make_exec_request(); let mut view = ApprovalModalView::new(first, tx); view.enqueue_request(make_exec_request()); - let (tx_raw2, _rx2) = channel::(); + let (tx2, _rx2) = unbounded_channel::(); let mut pane = BottomPane::new(super::super::BottomPaneParams { - app_event_tx: AppEventSender::new(tx_raw2), + app_event_tx: AppEventSender::new(tx2), has_input_focus: true, enhanced_keys_supported: false, placeholder_text: "Ask Codex to do anything".to_string(), diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 29b03c89a7..675d2292d5 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -750,6 +750,7 @@ mod tests { use crate::bottom_pane::InputResult; use crate::bottom_pane::chat_composer::LARGE_PASTE_CHAR_THRESHOLD; use crate::bottom_pane::textarea::TextArea; + use tokio::sync::mpsc::unbounded_channel; #[test] fn test_current_at_token_basic_cases() { @@ -906,7 +907,7 @@ mod tests { use crossterm::event::KeyEvent; use crossterm::event::KeyModifiers; - let (tx, _rx) = std::sync::mpsc::channel(); + let (tx, _rx) = unbounded_channel::(); let sender = AppEventSender::new(tx); let mut composer = ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string()); @@ -930,7 +931,7 @@ mod tests { use crossterm::event::KeyEvent; use crossterm::event::KeyModifiers; - let (tx, _rx) = std::sync::mpsc::channel(); + let (tx, _rx) = unbounded_channel::(); let sender = AppEventSender::new(tx); let mut composer = ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string()); @@ -960,7 +961,7 @@ mod tests { use crossterm::event::KeyModifiers; let large = "y".repeat(LARGE_PASTE_CHAR_THRESHOLD + 1); - let (tx, _rx) = std::sync::mpsc::channel(); + let (tx, _rx) = unbounded_channel::(); let sender = AppEventSender::new(tx); let mut composer = ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string()); @@ -982,7 +983,7 @@ mod tests { use ratatui::Terminal; use ratatui::backend::TestBackend; - let (tx, _rx) = std::sync::mpsc::channel(); + let (tx, _rx) = unbounded_channel::(); let sender = AppEventSender::new(tx); let mut terminal = match Terminal::new(TestBackend::new(100, 10)) { Ok(t) => t, @@ -1038,9 +1039,9 @@ mod tests { use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyModifiers; - use std::sync::mpsc::TryRecvError; + use tokio::sync::mpsc::error::TryRecvError; - let (tx, rx) = std::sync::mpsc::channel(); + let (tx, mut rx) = unbounded_channel::(); let sender = AppEventSender::new(tx); let mut composer = ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string()); @@ -1083,7 +1084,7 @@ mod tests { use crossterm::event::KeyEvent; use crossterm::event::KeyModifiers; - let (tx, _rx) = std::sync::mpsc::channel(); + let (tx, _rx) = unbounded_channel::(); let sender = AppEventSender::new(tx); let mut composer = ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string()); @@ -1104,9 +1105,9 @@ mod tests { use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyModifiers; - use std::sync::mpsc::TryRecvError; + use tokio::sync::mpsc::error::TryRecvError; - let (tx, rx) = std::sync::mpsc::channel(); + let (tx, mut rx) = unbounded_channel::(); let sender = AppEventSender::new(tx); let mut composer = ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string()); @@ -1146,7 +1147,7 @@ mod tests { use crossterm::event::KeyEvent; use crossterm::event::KeyModifiers; - let (tx, _rx) = std::sync::mpsc::channel(); + let (tx, _rx) = unbounded_channel::(); let sender = AppEventSender::new(tx); let mut composer = ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string()); @@ -1220,7 +1221,7 @@ mod tests { use crossterm::event::KeyEvent; use crossterm::event::KeyModifiers; - let (tx, _rx) = std::sync::mpsc::channel(); + let (tx, _rx) = unbounded_channel::(); let sender = AppEventSender::new(tx); let mut composer = ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string()); @@ -1287,7 +1288,7 @@ mod tests { use crossterm::event::KeyEvent; use crossterm::event::KeyModifiers; - let (tx, _rx) = std::sync::mpsc::channel(); + let (tx, _rx) = unbounded_channel::(); let sender = AppEventSender::new(tx); let mut composer = ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string()); diff --git a/codex-rs/tui/src/bottom_pane/chat_composer_history.rs b/codex-rs/tui/src/bottom_pane/chat_composer_history.rs index 04b745d1ff..87bcc438e9 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer_history.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer_history.rs @@ -192,7 +192,7 @@ mod tests { use super::*; use crate::app_event::AppEvent; use codex_core::protocol::Op; - use std::sync::mpsc::channel; + use tokio::sync::mpsc::unbounded_channel; #[test] fn duplicate_submissions_are_not_recorded() { @@ -219,7 +219,7 @@ mod tests { #[test] fn navigation_with_async_fetch() { - let (tx, rx) = channel::(); + let (tx, mut rx) = unbounded_channel::(); let tx = AppEventSender::new(tx); let mut history = ChatComposerHistory::new(); diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index b27ea6e945..71fb0bbb9f 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -359,7 +359,7 @@ mod tests { use crate::app_event::AppEvent; use ratatui::buffer::Buffer; use ratatui::layout::Rect; - use std::sync::mpsc::channel; + use tokio::sync::mpsc::unbounded_channel; fn exec_request() -> ApprovalRequest { ApprovalRequest::Exec { @@ -371,7 +371,7 @@ mod tests { #[test] fn ctrl_c_on_modal_consumes_and_shows_quit_hint() { - let (tx_raw, _rx) = channel::(); + let (tx_raw, _rx) = unbounded_channel::(); let tx = AppEventSender::new(tx_raw); let mut pane = BottomPane::new(BottomPaneParams { app_event_tx: tx, @@ -389,7 +389,7 @@ mod tests { #[test] fn overlay_not_shown_above_approval_modal() { - let (tx_raw, _rx) = channel::(); + let (tx_raw, _rx) = unbounded_channel::(); let tx = AppEventSender::new(tx_raw); let mut pane = BottomPane::new(BottomPaneParams { app_event_tx: tx, @@ -418,7 +418,7 @@ mod tests { #[test] fn composer_not_shown_after_denied_if_task_running() { - let (tx_raw, rx) = channel::(); + let (tx_raw, rx) = unbounded_channel::(); let tx = AppEventSender::new(tx_raw); let mut pane = BottomPane::new(BottomPaneParams { app_event_tx: tx.clone(), @@ -468,7 +468,7 @@ mod tests { #[test] fn status_indicator_visible_during_command_execution() { - let (tx_raw, _rx) = channel::(); + let (tx_raw, _rx) = unbounded_channel::(); let tx = AppEventSender::new(tx_raw); let mut pane = BottomPane::new(BottomPaneParams { app_event_tx: tx, @@ -500,7 +500,7 @@ mod tests { #[test] fn bottom_padding_present_for_status_view() { - let (tx_raw, _rx) = channel::(); + let (tx_raw, _rx) = unbounded_channel::(); let tx = AppEventSender::new(tx_raw); let mut pane = BottomPane::new(BottomPaneParams { app_event_tx: tx, @@ -552,7 +552,7 @@ mod tests { #[test] fn bottom_padding_shrinks_when_tiny() { - let (tx_raw, _rx) = channel::(); + let (tx_raw, _rx) = unbounded_channel::(); let tx = AppEventSender::new(tx_raw); let mut pane = BottomPane::new(BottomPaneParams { app_event_tx: tx, diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 3bb5d42f9f..82e7470c83 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -30,7 +30,6 @@ use std::io::BufRead; use std::io::BufReader; use std::io::Read; use std::path::PathBuf; -use std::sync::mpsc::channel; use tokio::sync::mpsc::unbounded_channel; fn test_config() -> Config { @@ -45,7 +44,7 @@ fn test_config() -> Config { #[test] fn final_answer_without_newline_is_flushed_immediately() { - let (mut chat, rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); // Set up a VT100 test terminal to capture ANSI visual output let width: u16 = 80; @@ -73,7 +72,7 @@ fn final_answer_without_newline_is_flushed_immediately() { }); // Drain history insertions and verify the final line is present. - let cells = drain_insert_history(&rx); + let cells = drain_insert_history(&mut rx); assert!( cells.iter().any(|lines| { let s = lines @@ -101,7 +100,7 @@ fn final_answer_without_newline_is_flushed_immediately() { #[tokio::test(flavor = "current_thread")] async fn helpers_are_available_and_do_not_panic() { - let (tx_raw, _rx) = channel::(); + let (tx_raw, _rx) = unbounded_channel::(); let tx = AppEventSender::new(tx_raw); let cfg = test_config(); let conversation_manager = Arc::new(ConversationManager::default()); @@ -113,10 +112,10 @@ async fn helpers_are_available_and_do_not_panic() { // --- Helpers for tests that need direct construction and event draining --- fn make_chatwidget_manual() -> ( ChatWidget<'static>, - std::sync::mpsc::Receiver, + tokio::sync::mpsc::UnboundedReceiver, tokio::sync::mpsc::UnboundedReceiver, ) { - let (tx_raw, rx) = channel::(); + let (tx_raw, rx) = unbounded_channel::(); let app_event_tx = AppEventSender::new(tx_raw); let (op_tx, op_rx) = unbounded_channel::(); let cfg = test_config(); @@ -148,7 +147,7 @@ fn make_chatwidget_manual() -> ( } fn drain_insert_history( - rx: &std::sync::mpsc::Receiver, + rx: &mut tokio::sync::mpsc::UnboundedReceiver, ) -> Vec>> { let mut out = Vec::new(); while let Ok(ev) = rx.try_recv() { @@ -196,7 +195,7 @@ fn open_fixture(name: &str) -> std::fs::File { #[test] fn exec_history_cell_shows_working_then_completed() { - let (mut chat, rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); // Begin command chat.handle_codex_event(Event { @@ -226,7 +225,7 @@ fn exec_history_cell_shows_working_then_completed() { }), }); - let cells = drain_insert_history(&rx); + let cells = drain_insert_history(&mut rx); assert_eq!( cells.len(), 1, @@ -241,7 +240,7 @@ fn exec_history_cell_shows_working_then_completed() { #[test] fn exec_history_cell_shows_working_then_failed() { - let (mut chat, rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); // Begin command chat.handle_codex_event(Event { @@ -271,7 +270,7 @@ fn exec_history_cell_shows_working_then_failed() { }), }); - let cells = drain_insert_history(&rx); + let cells = drain_insert_history(&mut rx); assert_eq!( cells.len(), 1, @@ -286,7 +285,7 @@ fn exec_history_cell_shows_working_then_failed() { #[tokio::test(flavor = "current_thread")] async fn binary_size_transcript_matches_ideal_fixture() { - let (mut chat, rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); // Set up a VT100 test terminal to capture ANSI visual output let width: u16 = 80; @@ -423,7 +422,7 @@ async fn binary_size_transcript_matches_ideal_fixture() { #[test] fn apply_patch_events_emit_history_cells() { - let (mut chat, rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); // 1) Approval request -> proposed patch summary cell let mut changes = HashMap::new(); @@ -443,7 +442,7 @@ fn apply_patch_events_emit_history_cells() { id: "s1".into(), msg: EventMsg::ApplyPatchApprovalRequest(ev), }); - let cells = drain_insert_history(&rx); + let cells = drain_insert_history(&mut rx); assert!(!cells.is_empty(), "expected pending patch cell to be sent"); let blob = lines_to_single_string(cells.last().unwrap()); assert!( @@ -468,7 +467,7 @@ fn apply_patch_events_emit_history_cells() { id: "s1".into(), msg: EventMsg::PatchApplyBegin(begin), }); - let cells = drain_insert_history(&rx); + let cells = drain_insert_history(&mut rx); assert!(!cells.is_empty(), "expected applying patch cell to be sent"); let blob = lines_to_single_string(cells.last().unwrap()); assert!( @@ -487,7 +486,7 @@ fn apply_patch_events_emit_history_cells() { id: "s1".into(), msg: EventMsg::PatchApplyEnd(end), }); - let cells = drain_insert_history(&rx); + let cells = drain_insert_history(&mut rx); assert!(!cells.is_empty(), "expected applied patch cell to be sent"); let blob = lines_to_single_string(cells.last().unwrap()); assert!( @@ -498,7 +497,7 @@ fn apply_patch_events_emit_history_cells() { #[test] fn apply_patch_approval_sends_op_with_submission_id() { - let (mut chat, rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); // Simulate receiving an approval request with a distinct submission id and call id let mut changes = HashMap::new(); changes.insert( @@ -539,7 +538,7 @@ fn apply_patch_approval_sends_op_with_submission_id() { #[test] fn apply_patch_full_flow_integration_like() { - let (mut chat, rx, mut op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(); // 1) Backend requests approval let mut changes = HashMap::new(); @@ -655,7 +654,7 @@ fn apply_patch_untrusted_shows_approval_modal() { #[test] fn apply_patch_request_shows_diff_summary() { - let (mut chat, rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); // Ensure we are in OnRequest so an approval is surfaced chat.config.approval_policy = codex_core::protocol::AskForApproval::OnRequest; @@ -680,7 +679,7 @@ fn apply_patch_request_shows_diff_summary() { }); // Drain history insertions and verify the diff summary is present - let cells = drain_insert_history(&rx); + let cells = drain_insert_history(&mut rx); assert!( !cells.is_empty(), "expected a history cell with the proposed patch summary" @@ -702,7 +701,7 @@ fn apply_patch_request_shows_diff_summary() { #[test] fn plan_update_renders_history_cell() { - let (mut chat, rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); let update = UpdatePlanArgs { explanation: Some("Adapting plan".to_string()), plan: vec![ @@ -724,7 +723,7 @@ fn plan_update_renders_history_cell() { id: "sub-1".into(), msg: EventMsg::PlanUpdate(update), }); - let cells = drain_insert_history(&rx); + let cells = drain_insert_history(&mut rx); assert!(!cells.is_empty(), "expected plan update cell to be sent"); let blob = lines_to_single_string(cells.last().unwrap()); assert!( @@ -738,7 +737,7 @@ fn plan_update_renders_history_cell() { #[test] fn headers_emitted_on_stream_begin_for_answer_and_reasoning() { - let (mut chat, rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); // Answer: no header until a newline commit chat.handle_codex_event(Event { @@ -796,7 +795,7 @@ fn headers_emitted_on_stream_begin_for_answer_and_reasoning() { ); // Reasoning: header immediately - let (mut chat2, rx2, _op_rx2) = make_chatwidget_manual(); + let (mut chat2, mut rx2, _op_rx2) = make_chatwidget_manual(); chat2.handle_codex_event(Event { id: "sub-b".into(), msg: EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent { @@ -826,7 +825,7 @@ fn headers_emitted_on_stream_begin_for_answer_and_reasoning() { #[test] fn multiple_agent_messages_in_single_turn_emit_multiple_headers() { - let (mut chat, rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); // Begin turn chat.handle_codex_event(Event { @@ -858,7 +857,7 @@ fn multiple_agent_messages_in_single_turn_emit_multiple_headers() { }), }); - let cells = drain_insert_history(&rx); + let cells = drain_insert_history(&mut rx); let mut header_count = 0usize; let mut combined = String::new(); for lines in &cells { @@ -894,7 +893,7 @@ fn multiple_agent_messages_in_single_turn_emit_multiple_headers() { #[test] fn final_reasoning_then_message_without_deltas_are_rendered() { - let (mut chat, rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); // No deltas; only final reasoning followed by final message. chat.handle_codex_event(Event { @@ -911,7 +910,7 @@ fn final_reasoning_then_message_without_deltas_are_rendered() { }); // Drain history and snapshot the combined visible content. - let cells = drain_insert_history(&rx); + let cells = drain_insert_history(&mut rx); let combined = cells .iter() .map(|lines| lines_to_single_string(lines)) @@ -921,7 +920,7 @@ fn final_reasoning_then_message_without_deltas_are_rendered() { #[test] fn deltas_then_same_final_message_are_rendered_snapshot() { - let (mut chat, rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); // Stream some reasoning deltas first. chat.handle_codex_event(Event { @@ -972,7 +971,7 @@ fn deltas_then_same_final_message_are_rendered_snapshot() { // Snapshot the combined visible content to ensure we render as expected // when deltas are followed by the identical final message. - let cells = drain_insert_history(&rx); + let cells = drain_insert_history(&mut rx); let combined = cells .iter() .map(|lines| lines_to_single_string(lines)) diff --git a/codex-rs/tui/src/insert_history.rs b/codex-rs/tui/src/insert_history.rs index ced667af8d..63826bbf82 100644 --- a/codex-rs/tui/src/insert_history.rs +++ b/codex-rs/tui/src/insert_history.rs @@ -38,7 +38,6 @@ pub fn insert_history_lines_to_writer( W: Write, { let screen_size = terminal.backend().size().unwrap_or(Size::new(0, 0)); - let cursor_pos = terminal.get_cursor_position().ok(); let mut area = terminal.get_frame().area(); @@ -104,9 +103,14 @@ pub fn insert_history_lines_to_writer( queue!(writer, ResetScrollRegion).ok(); // Restore the cursor position to where it was before we started. - if let Some(cursor_pos) = cursor_pos { - queue!(writer, MoveTo(cursor_pos.x, cursor_pos.y)).ok(); - } + queue!( + writer, + MoveTo( + terminal.last_known_cursor_pos.x, + terminal.last_known_cursor_pos.y + ) + ) + .ok(); } #[derive(Debug, Clone, PartialEq, Eq)] diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 0f8b2242cb..76487ef366 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -247,10 +247,11 @@ pub async fn run_main( } run_ratatui_app(cli, config, should_show_trust_screen) + .await .map_err(|err| std::io::Error::other(err.to_string())) } -fn run_ratatui_app( +async fn run_ratatui_app( cli: Cli, config: Config, should_show_trust_screen: bool, @@ -275,7 +276,7 @@ fn run_ratatui_app( let Cli { prompt, images, .. } = cli; let mut app = App::new(config.clone(), prompt, images, should_show_trust_screen); - let app_result = app.run(&mut terminal); + let app_result = app.run(&mut terminal).await; let usage = app.token_usage(); restore(); diff --git a/codex-rs/tui/src/status_indicator_widget.rs b/codex-rs/tui/src/status_indicator_widget.rs index f63fc836a6..70dd2ed0b0 100644 --- a/codex-rs/tui/src/status_indicator_widget.rs +++ b/codex-rs/tui/src/status_indicator_widget.rs @@ -213,11 +213,11 @@ mod tests { use super::*; use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; - use std::sync::mpsc::channel; + use tokio::sync::mpsc::unbounded_channel; #[test] fn renders_without_left_border_or_padding() { - let (tx_raw, _rx) = channel::(); + let (tx_raw, _rx) = unbounded_channel::(); let tx = AppEventSender::new(tx_raw); let mut w = StatusIndicatorWidget::new(tx); w.restart_with_text("Hello".to_string()); @@ -235,7 +235,7 @@ mod tests { #[test] fn working_header_is_present_on_last_line() { - let (tx_raw, _rx) = channel::(); + let (tx_raw, _rx) = unbounded_channel::(); let tx = AppEventSender::new(tx_raw); let mut w = StatusIndicatorWidget::new(tx); w.restart_with_text("Hi".to_string()); @@ -256,7 +256,7 @@ mod tests { #[test] fn header_starts_at_expected_position() { - let (tx_raw, _rx) = channel::(); + let (tx_raw, _rx) = unbounded_channel::(); let tx = AppEventSender::new(tx_raw); let mut w = StatusIndicatorWidget::new(tx); w.restart_with_text("Hello".to_string()); diff --git a/codex-rs/tui/src/user_approval_widget.rs b/codex-rs/tui/src/user_approval_widget.rs index c2d7f70af0..d317ff0d8a 100644 --- a/codex-rs/tui/src/user_approval_widget.rs +++ b/codex-rs/tui/src/user_approval_widget.rs @@ -424,11 +424,11 @@ mod tests { use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyModifiers; - use std::sync::mpsc::channel; + use tokio::sync::mpsc::unbounded_channel; #[test] fn lowercase_shortcut_is_accepted() { - let (tx_raw, rx) = channel::(); + let (tx_raw, mut rx) = unbounded_channel::(); let tx = AppEventSender::new(tx_raw); let req = ApprovalRequest::Exec { id: "1".to_string(), @@ -438,7 +438,10 @@ mod tests { let mut widget = UserApprovalWidget::new(req, tx); widget.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE)); assert!(widget.is_complete()); - let events: Vec = rx.try_iter().collect(); + let mut events: Vec = Vec::new(); + while let Ok(ev) = rx.try_recv() { + events.push(ev); + } assert!(events.iter().any(|e| matches!( e, AppEvent::CodexOp(Op::ExecApproval { @@ -450,7 +453,7 @@ mod tests { #[test] fn uppercase_shortcut_is_accepted() { - let (tx_raw, rx) = channel::(); + let (tx_raw, mut rx) = unbounded_channel::(); let tx = AppEventSender::new(tx_raw); let req = ApprovalRequest::Exec { id: "2".to_string(), @@ -460,7 +463,10 @@ mod tests { let mut widget = UserApprovalWidget::new(req, tx); widget.handle_key_event(KeyEvent::new(KeyCode::Char('Y'), KeyModifiers::NONE)); assert!(widget.is_complete()); - let events: Vec = rx.try_iter().collect(); + let mut events: Vec = Vec::new(); + while let Ok(ev) = rx.try_recv() { + events.push(ev); + } assert!(events.iter().any(|e| matches!( e, AppEvent::CodexOp(Op::ExecApproval { From 1a1516a80bc71662686e7716bf87a0894bba110d Mon Sep 17 00:00:00 2001 From: Dylan Date: Wed, 20 Aug 2025 12:16:01 -0700 Subject: [PATCH 05/27] [apply-patch] Fix applypatch for heredocs (#2477) ## Summary Follow up to #2186 for #2072 - we added handling for `applypatch` in default commands, but forgot to add detection to the heredocs logic. ## Testing - [x] Added unit tests --- codex-rs/apply-patch/src/lib.rs | 39 ++++++++++++++++++++++++++++++--- 1 file changed, 36 insertions(+), 3 deletions(-) diff --git a/codex-rs/apply-patch/src/lib.rs b/codex-rs/apply-patch/src/lib.rs index 1aae106682..15966ac29c 100644 --- a/codex-rs/apply-patch/src/lib.rs +++ b/codex-rs/apply-patch/src/lib.rs @@ -22,6 +22,8 @@ use tree_sitter_bash::LANGUAGE as BASH; /// Detailed instructions for gpt-4.1 on how to use the `apply_patch` tool. pub const APPLY_PATCH_TOOL_INSTRUCTIONS: &str = include_str!("../apply_patch_tool_instructions.md"); +const APPLY_PATCH_COMMANDS: [&str; 2] = ["apply_patch", "applypatch"]; + #[derive(Debug, Error, PartialEq)] pub enum ApplyPatchError { #[error(transparent)] @@ -82,7 +84,6 @@ pub struct ApplyPatchArgs { } pub fn maybe_parse_apply_patch(argv: &[String]) -> MaybeApplyPatch { - const APPLY_PATCH_COMMANDS: [&str; 2] = ["apply_patch", "applypatch"]; match argv { [cmd, body] if APPLY_PATCH_COMMANDS.contains(&cmd.as_str()) => match parse_patch(body) { Ok(source) => MaybeApplyPatch::Body(source), @@ -91,7 +92,9 @@ pub fn maybe_parse_apply_patch(argv: &[String]) -> MaybeApplyPatch { [bash, flag, script] if bash == "bash" && flag == "-lc" - && script.trim_start().starts_with("apply_patch") => + && APPLY_PATCH_COMMANDS + .iter() + .any(|cmd| script.trim_start().starts_with(cmd)) => { match extract_heredoc_body_from_apply_patch_command(script) { Ok(body) => match parse_patch(&body) { @@ -262,7 +265,10 @@ pub fn maybe_parse_apply_patch_verified(argv: &[String], cwd: &Path) -> MaybeApp fn extract_heredoc_body_from_apply_patch_command( src: &str, ) -> std::result::Result { - if !src.trim_start().starts_with("apply_patch") { + if !APPLY_PATCH_COMMANDS + .iter() + .any(|cmd| src.trim_start().starts_with(cmd)) + { return Err(ExtractHeredocError::CommandDidNotStartWithApplyPatch); } @@ -773,6 +779,33 @@ PATCH"#, } } + #[test] + fn test_heredoc_applypatch() { + let args = strs_to_strings(&[ + "bash", + "-lc", + r#"applypatch <<'PATCH' +*** Begin Patch +*** Add File: foo ++hi +*** End Patch +PATCH"#, + ]); + + match maybe_parse_apply_patch(&args) { + MaybeApplyPatch::Body(ApplyPatchArgs { hunks, patch: _ }) => { + assert_eq!( + hunks, + vec![Hunk::AddFile { + path: PathBuf::from("foo"), + contents: "hi\n".to_string() + }] + ); + } + result => panic!("expected MaybeApplyPatch::Body got {result:?}"), + } + } + #[test] fn test_add_file_hunk_creates_file_with_contents() { let dir = tempdir().unwrap(); From 0d12380c3b4814bd624d1c4f45534bb304e8383f Mon Sep 17 00:00:00 2001 From: Jeremy Rose <172423086+nornagon-openai@users.noreply.github.com> Date: Wed, 20 Aug 2025 13:47:24 -0700 Subject: [PATCH 06/27] refactor onboarding screen to a separate "app" (#2524) this is in preparation for adding more separate "modes" to the tui, in particular, a "transcript mode" to view a full history once #2316 lands. 1. split apart "tui events" from "app events". 2. remove onboarding-related events from AppEvent. 3. move several general drawing tools out of App and into a new Tui class --- codex-rs/Cargo.lock | 1 + codex-rs/tui/Cargo.toml | 1 + codex-rs/tui/src/app.rs | 650 ++++-------------- codex-rs/tui/src/app_event.rs | 22 - .../src/bottom_pane/approval_modal_view.rs | 13 +- .../tui/src/bottom_pane/bottom_pane_view.rs | 6 +- .../src/bottom_pane/list_selection_view.rs | 6 +- codex-rs/tui/src/bottom_pane/mod.rs | 33 +- .../src/bottom_pane/status_indicator_view.rs | 9 +- codex-rs/tui/src/chatwidget.rs | 15 +- codex-rs/tui/src/chatwidget/interrupts.rs | 2 +- codex-rs/tui/src/chatwidget/tests.rs | 14 +- codex-rs/tui/src/custom_terminal.rs | 2 +- codex-rs/tui/src/insert_history.rs | 4 +- codex-rs/tui/src/lib.rs | 114 ++- codex-rs/tui/src/onboarding/auth.rs | 146 ++-- .../tui/src/onboarding/continue_to_chat.rs | 34 - codex-rs/tui/src/onboarding/mod.rs | 2 +- .../tui/src/onboarding/onboarding_screen.rs | 158 +++-- .../tui/src/onboarding/trust_directory.rs | 15 +- codex-rs/tui/src/session_log.rs | 19 +- codex-rs/tui/src/status_indicator_widget.rs | 15 +- codex-rs/tui/src/tui.rs | 267 ++++++- codex-rs/tui/src/user_approval_widget.rs | 10 +- 24 files changed, 755 insertions(+), 803 deletions(-) delete mode 100644 codex-rs/tui/src/onboarding/continue_to_chat.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index d879d196ff..d6b98fcd1b 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -926,6 +926,7 @@ name = "codex-tui" version = "0.0.0" dependencies = [ "anyhow", + "async-stream", "base64 0.22.1", "chrono", "clap", diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index b5ec8d04ec..20ceb0b79c 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -22,6 +22,7 @@ workspace = true [dependencies] anyhow = "1" +async-stream = "0.3.6" base64 = "0.22.1" chrono = { version = "0.4", features = ["serde"] } clap = { version = "4", features = ["derive"] } diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 6ce51e5dbe..399acd401c 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -1,254 +1,142 @@ -use crate::LoginStatus; use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; use crate::chatwidget::ChatWidget; use crate::file_search::FileSearchManager; use crate::get_git_diff::get_git_diff; -use crate::get_login_status; -use crate::onboarding::onboarding_screen::KeyboardHandler; -use crate::onboarding::onboarding_screen::OnboardingScreen; -use crate::onboarding::onboarding_screen::OnboardingScreenArgs; use crate::slash_command::SlashCommand; use crate::tui; +use crate::tui::TuiEvent; use codex_core::ConversationManager; use codex_core::config::Config; use codex_core::protocol::Event; use codex_core::protocol::Op; +use codex_core::protocol::TokenUsage; use color_eyre::eyre::Result; -use crossterm::SynchronizedUpdate; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyEventKind; use crossterm::terminal::supports_keyboard_enhancement; -use ratatui::layout::Offset; -use ratatui::prelude::Backend; -use ratatui::text::Line; use std::path::PathBuf; use std::sync::Arc; use std::sync::atomic::AtomicBool; use std::sync::atomic::Ordering; use std::thread; use std::time::Duration; -use std::time::Instant; use tokio::select; -use tokio::sync::mpsc::UnboundedReceiver; use tokio::sync::mpsc::unbounded_channel; -/// Time window for debouncing redraw requests. -const REDRAW_DEBOUNCE: Duration = Duration::from_millis(1); - -/// Top-level application state: which full-screen view is currently active. -#[allow(clippy::large_enum_variant)] -enum AppState<'a> { - Onboarding { - screen: OnboardingScreen, - }, - /// The main chat UI is visible. - Chat { - /// Boxed to avoid a large enum variant and reduce the overall size of - /// `AppState`. - widget: Box>, - }, -} - -pub(crate) struct App<'a> { +pub(crate) struct App { server: Arc, app_event_tx: AppEventSender, - app_event_rx: UnboundedReceiver, - app_state: AppState<'a>, + chat_widget: ChatWidget, /// Config is stored here so we can recreate ChatWidgets as needed. config: Config, file_search: FileSearchManager, - pending_history_lines: Vec>, - enhanced_keys_supported: bool, /// Controls the animation thread that sends CommitTick events. commit_anim_running: Arc, - - /// Channel to schedule one-shot animation frames; coalesced by a single - /// scheduler thread. - frame_schedule_tx: std::sync::mpsc::Sender, } -/// Aggregate parameters needed to create a `ChatWidget`, as creation may be -/// deferred until after the Git warning screen is dismissed. -#[derive(Clone, Debug)] -pub(crate) struct ChatWidgetArgs { - pub(crate) config: Config, - initial_prompt: Option, - initial_images: Vec, - enhanced_keys_supported: bool, -} - -impl App<'_> { - pub(crate) fn new( +impl App { + pub async fn run( + tui: &mut tui::Tui, config: Config, initial_prompt: Option, - initial_images: Vec, - show_trust_screen: bool, - ) -> Self { - let conversation_manager = Arc::new(ConversationManager::default()); - - let (app_event_tx, app_event_rx) = unbounded_channel(); + initial_images: Vec, + ) -> Result { + use tokio_stream::StreamExt; + let (app_event_tx, mut app_event_rx) = unbounded_channel(); let app_event_tx = AppEventSender::new(app_event_tx); + let conversation_manager = Arc::new(ConversationManager::default()); + let enhanced_keys_supported = supports_keyboard_enhancement().unwrap_or(false); - let login_status = get_login_status(&config); - let should_show_onboarding = - should_show_onboarding(login_status, &config, show_trust_screen); - let app_state = if should_show_onboarding { - let show_login_screen = should_show_login_screen(login_status, &config); - let chat_widget_args = ChatWidgetArgs { - config: config.clone(), - initial_prompt, - initial_images, - enhanced_keys_supported, - }; - AppState::Onboarding { - screen: OnboardingScreen::new(OnboardingScreenArgs { - event_tx: app_event_tx.clone(), - codex_home: config.codex_home.clone(), - cwd: config.cwd.clone(), - show_trust_screen, - show_login_screen, - chat_widget_args, - login_status, - }), - } - } else { - let chat_widget = ChatWidget::new( - config.clone(), - conversation_manager.clone(), - app_event_tx.clone(), - initial_prompt, - initial_images, - enhanced_keys_supported, - ); - AppState::Chat { - widget: Box::new(chat_widget), - } - }; + let chat_widget = ChatWidget::new( + config.clone(), + conversation_manager.clone(), + tui.frame_requester(), + app_event_tx.clone(), + initial_prompt, + initial_images, + enhanced_keys_supported, + ); let file_search = FileSearchManager::new(config.cwd.clone(), app_event_tx.clone()); - // Spawn a single scheduler thread that coalesces both debounced redraw - // requests and animation frame requests, and emits a single Redraw event - // at the earliest requested time. - let (frame_tx, frame_rx) = std::sync::mpsc::channel::(); - { - let app_event_tx = app_event_tx.clone(); - std::thread::spawn(move || { - use std::sync::mpsc::RecvTimeoutError; - let mut next_deadline: Option = None; - loop { - if next_deadline.is_none() { - match frame_rx.recv() { - Ok(deadline) => next_deadline = Some(deadline), - Err(_) => break, - } - } - - #[expect(clippy::expect_used)] - let deadline = next_deadline.expect("deadline set"); - let now = Instant::now(); - let timeout = if deadline > now { - deadline - now - } else { - Duration::from_millis(0) - }; - - match frame_rx.recv_timeout(timeout) { - Ok(new_deadline) => { - next_deadline = - Some(next_deadline.map_or(new_deadline, |d| d.min(new_deadline))); - } - Err(RecvTimeoutError::Timeout) => { - app_event_tx.send(AppEvent::Redraw); - next_deadline = None; - } - Err(RecvTimeoutError::Disconnected) => break, - } - } - }); - } - Self { + let mut app = Self { server: conversation_manager, app_event_tx, - pending_history_lines: Vec::new(), - app_event_rx, - app_state, + chat_widget, config, file_search, enhanced_keys_supported, commit_anim_running: Arc::new(AtomicBool::new(false)), - frame_schedule_tx: frame_tx, - } - } + }; - fn schedule_frame_in(&self, dur: Duration) { - let _ = self.frame_schedule_tx.send(Instant::now() + dur); - } + let tui_events = tui.event_stream(); + tokio::pin!(tui_events); - pub(crate) async fn run(&mut self, terminal: &mut tui::Tui) -> Result<()> { - use tokio_stream::StreamExt; + tui.frame_requester().schedule_frame(); - self.handle_event(terminal, AppEvent::Redraw)?; - - let mut crossterm_events = crossterm::event::EventStream::new(); - - while let Some(event) = { - select! { - maybe_app_event = self.app_event_rx.recv() => { - maybe_app_event - }, - Some(Ok(event)) = crossterm_events.next() => { - match event { - crossterm::event::Event::Key(key_event) => { - Some(AppEvent::KeyEvent(key_event)) - } - crossterm::event::Event::Resize(_, _) => { - Some(AppEvent::Redraw) - } - crossterm::event::Event::Paste(pasted) => { - // Many terminals convert newlines to \r when pasting (e.g., iTerm2), - // but tui-textarea expects \n. Normalize CR to LF. - // [tui-textarea]: https://github.com/rhysd/tui-textarea/blob/4d18622eeac13b309e0ff6a55a46ac6706da68cf/src/textarea.rs#L782-L783 - // [iTerm2]: https://github.com/gnachman/iTerm2/blob/5d0c0d9f68523cbd0494dad5422998964a2ecd8d/sources/iTermPasteHelper.m#L206-L216 - let pasted = pasted.replace("\r", "\n"); - Some(AppEvent::Paste(pasted)) - } - _ => { - // Ignore any other events. - None - } - } - }, + while select! { + Some(event) = app_event_rx.recv() => { + app.handle_event(tui, event)? } - } && self.handle_event(terminal, event)? - {} - terminal.clear()?; - Ok(()) + Some(event) = tui_events.next() => { + app.handle_tui_event(tui, event).await? + } + } {} + tui.terminal.clear()?; + Ok(app.token_usage()) } - fn handle_event(&mut self, terminal: &mut tui::Tui, event: AppEvent) -> Result { + pub(crate) async fn handle_tui_event( + &mut self, + tui: &mut tui::Tui, + event: TuiEvent, + ) -> Result { + match event { + TuiEvent::Key(key_event) => { + self.handle_key_event(key_event).await; + } + TuiEvent::Paste(pasted) => { + // Many terminals convert newlines to \r when pasting (e.g., iTerm2), + // but tui-textarea expects \n. Normalize CR to LF. + // [tui-textarea]: https://github.com/rhysd/tui-textarea/blob/4d18622eeac13b309e0ff6a55a46ac6706da68cf/src/textarea.rs#L782-L783 + // [iTerm2]: https://github.com/gnachman/iTerm2/blob/5d0c0d9f68523cbd0494dad5422998964a2ecd8d/sources/iTermPasteHelper.m#L206-L216 + let pasted = pasted.replace("\r", "\n"); + self.chat_widget.handle_paste(pasted); + } + TuiEvent::Draw => { + tui.draw( + self.chat_widget.desired_height(tui.terminal.size()?.width), + |frame| { + frame.render_widget_ref(&self.chat_widget, frame.area()); + if let Some((x, y)) = self.chat_widget.cursor_pos(frame.area()) { + frame.set_cursor_position((x, y)); + } + }, + )?; + } + #[cfg(unix)] + TuiEvent::ResumeFromSuspend => { + let cursor_pos = tui.terminal.get_cursor_position()?; + tui.terminal + .set_viewport_area(ratatui::layout::Rect::new(0, cursor_pos.y, 0, 0)); + } + } + Ok(true) + } + + fn handle_event(&mut self, tui: &mut tui::Tui, event: AppEvent) -> Result { match event { AppEvent::InsertHistory(lines) => { - self.pending_history_lines.extend(lines); - self.app_event_tx.send(AppEvent::RequestRedraw); - } - AppEvent::RequestRedraw => { - self.schedule_frame_in(REDRAW_DEBOUNCE); - } - AppEvent::ScheduleFrameIn(dur) => { - self.schedule_frame_in(dur); - } - AppEvent::Redraw => { - std::io::stdout().sync_update(|_| self.draw_next_frame(terminal))??; + tui.insert_history_lines(lines); } AppEvent::StartCommitAnimation => { if self @@ -270,124 +158,48 @@ impl App<'_> { self.commit_anim_running.store(false, Ordering::Release); } AppEvent::CommitTick => { - if let AppState::Chat { widget } = &mut self.app_state { - widget.on_commit_tick(); - } - } - AppEvent::KeyEvent(key_event) => { - match key_event { - KeyEvent { - code: KeyCode::Char('c'), - modifiers: crossterm::event::KeyModifiers::CONTROL, - kind: KeyEventKind::Press, - .. - } => match &mut self.app_state { - AppState::Chat { widget } => { - widget.on_ctrl_c(); - } - AppState::Onboarding { .. } => { - self.app_event_tx.send(AppEvent::ExitRequest); - } - }, - KeyEvent { - code: KeyCode::Char('z'), - modifiers: crossterm::event::KeyModifiers::CONTROL, - kind: KeyEventKind::Press, - .. - } => { - #[cfg(unix)] - { - self.suspend(terminal)?; - } - // No-op on non-Unix platforms. - } - KeyEvent { - code: KeyCode::Char('d'), - modifiers: crossterm::event::KeyModifiers::CONTROL, - kind: KeyEventKind::Press, - .. - } => { - match &mut self.app_state { - AppState::Chat { widget } => { - if widget.composer_is_empty() { - self.app_event_tx.send(AppEvent::ExitRequest); - } else { - // Treat Ctrl+D as a normal key event when the composer - // is not empty so that it doesn't quit the application - // prematurely. - self.dispatch_key_event(key_event); - } - } - AppState::Onboarding { .. } => { - self.app_event_tx.send(AppEvent::ExitRequest); - } - } - } - KeyEvent { - kind: KeyEventKind::Press | KeyEventKind::Repeat, - .. - } => { - self.dispatch_key_event(key_event); - } - _ => { - // Ignore Release key events. - } - }; - } - AppEvent::Paste(text) => { - self.dispatch_paste_event(text); + self.chat_widget.on_commit_tick(); } AppEvent::CodexEvent(event) => { - self.dispatch_codex_event(event); + self.chat_widget.handle_codex_event(event); } AppEvent::ExitRequest => { return Ok(false); } - AppEvent::CodexOp(op) => match &mut self.app_state { - AppState::Chat { widget } => widget.submit_op(op), - AppState::Onboarding { .. } => {} - }, + AppEvent::CodexOp(op) => self.chat_widget.submit_op(op), AppEvent::DiffResult(text) => { - if let AppState::Chat { widget } = &mut self.app_state { - widget.add_diff_output(text); - } + self.chat_widget.add_diff_output(text); } AppEvent::DispatchCommand(command) => match command { SlashCommand::New => { // User accepted – switch to chat view. - let new_widget = Box::new(ChatWidget::new( + let new_widget = ChatWidget::new( self.config.clone(), self.server.clone(), + tui.frame_requester(), self.app_event_tx.clone(), None, Vec::new(), self.enhanced_keys_supported, - )); - self.app_state = AppState::Chat { widget: new_widget }; - self.app_event_tx.send(AppEvent::RequestRedraw); + ); + self.chat_widget = new_widget; + tui.frame_requester().schedule_frame(); } SlashCommand::Init => { // Guard: do not run if a task is active. - if let AppState::Chat { widget } = &mut self.app_state { - const INIT_PROMPT: &str = include_str!("../prompt_for_init_command.md"); - widget.submit_text_message(INIT_PROMPT.to_string()); - } + const INIT_PROMPT: &str = include_str!("../prompt_for_init_command.md"); + self.chat_widget + .submit_text_message(INIT_PROMPT.to_string()); } SlashCommand::Compact => { - if let AppState::Chat { widget } = &mut self.app_state { - widget.clear_token_usage(); - self.app_event_tx.send(AppEvent::CodexOp(Op::Compact)); - } + self.chat_widget.clear_token_usage(); + self.app_event_tx.send(AppEvent::CodexOp(Op::Compact)); } SlashCommand::Model => { - if let AppState::Chat { widget } = &mut self.app_state { - widget.open_model_popup(); - } + self.chat_widget.open_model_popup(); } SlashCommand::Approvals => { - if let AppState::Chat { widget } = &mut self.app_state { - widget.open_approvals_popup(); - } + self.chat_widget.open_approvals_popup(); } SlashCommand::Quit => { return Ok(false); @@ -399,10 +211,7 @@ impl App<'_> { return Ok(false); } SlashCommand::Diff => { - if let AppState::Chat { widget } = &mut self.app_state { - widget.add_diff_in_progress(); - } - + self.chat_widget.add_diff_in_progress(); let tx = self.app_event_tx.clone(); tokio::spawn(async move { let text = match get_git_diff().await { @@ -419,19 +228,13 @@ impl App<'_> { }); } SlashCommand::Mention => { - if let AppState::Chat { widget } = &mut self.app_state { - widget.insert_str("@"); - } + self.chat_widget.insert_str("@"); } SlashCommand::Status => { - if let AppState::Chat { widget } = &mut self.app_state { - widget.add_status_output(); - } + self.chat_widget.add_status_output(); } SlashCommand::Mcp => { - if let AppState::Chat { widget } = &mut self.app_state { - widget.add_mcp_output(); - } + self.chat_widget.add_mcp_output(); } #[cfg(debug_assertions)] SlashCommand::TestApproval => { @@ -472,256 +275,61 @@ impl App<'_> { })); } }, - AppEvent::OnboardingAuthComplete(result) => { - if let AppState::Onboarding { screen } = &mut self.app_state { - screen.on_auth_complete(result); - } - } - AppEvent::OnboardingComplete(ChatWidgetArgs { - config, - enhanced_keys_supported, - initial_images, - initial_prompt, - }) => { - self.app_state = AppState::Chat { - widget: Box::new(ChatWidget::new( - config, - self.server.clone(), - self.app_event_tx.clone(), - initial_prompt, - initial_images, - enhanced_keys_supported, - )), - } - } AppEvent::StartFileSearch(query) => { if !query.is_empty() { self.file_search.on_user_query(query); } } AppEvent::FileSearchResult { query, matches } => { - if let AppState::Chat { widget } = &mut self.app_state { - widget.apply_file_search_result(query, matches); - } + self.chat_widget.apply_file_search_result(query, matches); } AppEvent::UpdateReasoningEffort(effort) => { - if let AppState::Chat { widget } = &mut self.app_state { - widget.set_reasoning_effort(effort); - } + self.chat_widget.set_reasoning_effort(effort); } AppEvent::UpdateModel(model) => { - if let AppState::Chat { widget } = &mut self.app_state { - widget.set_model(model); - } + self.chat_widget.set_model(model); } AppEvent::UpdateAskForApprovalPolicy(policy) => { - if let AppState::Chat { widget } = &mut self.app_state { - widget.set_approval_policy(policy); - } + self.chat_widget.set_approval_policy(policy); } AppEvent::UpdateSandboxPolicy(policy) => { - if let AppState::Chat { widget } = &mut self.app_state { - widget.set_sandbox_policy(policy); - } + self.chat_widget.set_sandbox_policy(policy); } } Ok(true) } - #[cfg(unix)] - fn suspend(&mut self, terminal: &mut tui::Tui) -> Result<()> { - tui::restore()?; - // SAFETY: Unix-only code path. We intentionally send SIGTSTP to the - // current process group (pid 0) to trigger standard job-control - // suspension semantics. This FFI does not involve any raw pointers, - // is not called from a signal handler, and uses a constant signal. - // Errors from kill are acceptable (e.g., if already stopped) — the - // subsequent re-init path will still leave the terminal in a good state. - // We considered `nix`, but didn't think it was worth pulling in for this one call. - unsafe { libc::kill(0, libc::SIGTSTP) }; - *terminal = tui::init(&self.config)?; - terminal.clear()?; - self.app_event_tx.send(AppEvent::RequestRedraw); - Ok(()) - } - pub(crate) fn token_usage(&self) -> codex_core::protocol::TokenUsage { - match &self.app_state { - AppState::Chat { widget } => widget.token_usage().clone(), - AppState::Onboarding { .. } => codex_core::protocol::TokenUsage::default(), - } + self.chat_widget.token_usage().clone() } - fn draw_next_frame(&mut self, terminal: &mut tui::Tui) -> Result<()> { - if matches!(self.app_state, AppState::Onboarding { .. }) { - terminal.clear()?; - } - - let screen_size = terminal.size()?; - let last_known_screen_size = terminal.last_known_screen_size; - if screen_size != last_known_screen_size { - let cursor_pos = terminal.get_cursor_position()?; - let last_known_cursor_pos = terminal.last_known_cursor_pos; - if cursor_pos.y != last_known_cursor_pos.y { - // The terminal was resized. The only point of reference we have for where our viewport - // was moved is the cursor position. - // NB this assumes that the cursor was not wrapped as part of the resize. - let cursor_delta = cursor_pos.y as i32 - last_known_cursor_pos.y as i32; - - let new_viewport_area = terminal.viewport_area.offset(Offset { - x: 0, - y: cursor_delta, - }); - terminal.set_viewport_area(new_viewport_area); - terminal.clear()?; + async fn handle_key_event(&mut self, key_event: KeyEvent) { + match key_event { + KeyEvent { + code: KeyCode::Char('c'), + modifiers: crossterm::event::KeyModifiers::CONTROL, + kind: KeyEventKind::Press, + .. + } => { + self.chat_widget.on_ctrl_c(); + } + KeyEvent { + code: KeyCode::Char('d'), + modifiers: crossterm::event::KeyModifiers::CONTROL, + kind: KeyEventKind::Press, + .. + } if self.chat_widget.composer_is_empty() => { + self.app_event_tx.send(AppEvent::ExitRequest); + } + KeyEvent { + kind: KeyEventKind::Press | KeyEventKind::Repeat, + .. + } => { + self.chat_widget.handle_key_event(key_event); + } + _ => { + // Ignore Release key events. } - } - - let size = terminal.size()?; - let desired_height = match &self.app_state { - AppState::Chat { widget } => widget.desired_height(size.width), - AppState::Onboarding { .. } => size.height, }; - - let mut area = terminal.viewport_area; - area.height = desired_height.min(size.height); - area.width = size.width; - if area.bottom() > size.height { - terminal - .backend_mut() - .scroll_region_up(0..area.top(), area.bottom() - size.height)?; - area.y = size.height - area.height; - } - if area != terminal.viewport_area { - terminal.clear()?; - terminal.set_viewport_area(area); - } - if !self.pending_history_lines.is_empty() { - crate::insert_history::insert_history_lines( - terminal, - self.pending_history_lines.clone(), - ); - self.pending_history_lines.clear(); - } - terminal.draw(|frame| match &mut self.app_state { - AppState::Chat { widget } => { - if let Some((x, y)) = widget.cursor_pos(frame.area()) { - frame.set_cursor_position((x, y)); - } - frame.render_widget_ref(&**widget, frame.area()) - } - AppState::Onboarding { screen } => frame.render_widget_ref(&*screen, frame.area()), - })?; - Ok(()) - } - - /// Dispatch a KeyEvent to the current view and let it decide what to do - /// with it. - fn dispatch_key_event(&mut self, key_event: KeyEvent) { - match &mut self.app_state { - AppState::Chat { widget } => { - widget.handle_key_event(key_event); - } - AppState::Onboarding { screen } => match key_event.code { - KeyCode::Char('q') => { - self.app_event_tx.send(AppEvent::ExitRequest); - } - _ => screen.handle_key_event(key_event), - }, - } - } - - fn dispatch_paste_event(&mut self, pasted: String) { - match &mut self.app_state { - AppState::Chat { widget } => widget.handle_paste(pasted), - AppState::Onboarding { .. } => {} - } - } - - fn dispatch_codex_event(&mut self, event: Event) { - match &mut self.app_state { - AppState::Chat { widget } => widget.handle_codex_event(event), - AppState::Onboarding { .. } => {} - } - } -} - -fn should_show_onboarding( - login_status: LoginStatus, - config: &Config, - show_trust_screen: bool, -) -> bool { - if show_trust_screen { - return true; - } - - should_show_login_screen(login_status, config) -} - -fn should_show_login_screen(login_status: LoginStatus, config: &Config) -> bool { - // Only show the login screen for providers that actually require OpenAI auth - // (OpenAI or equivalents). For OSS/other providers, skip login entirely. - if !config.model_provider.requires_openai_auth { - return false; - } - - match login_status { - LoginStatus::NotAuthenticated => true, - LoginStatus::AuthMode(method) => method != config.preferred_auth_method, - } -} - -#[cfg(test)] -mod tests { - use super::*; - use codex_core::config::ConfigOverrides; - use codex_core::config::ConfigToml; - use codex_login::AuthMode; - - fn make_config(preferred: AuthMode) -> Config { - let mut cfg = Config::load_from_base_config_with_overrides( - ConfigToml::default(), - ConfigOverrides::default(), - std::env::temp_dir(), - ) - .expect("load default config"); - cfg.preferred_auth_method = preferred; - cfg - } - - #[test] - fn shows_login_when_not_authenticated() { - let cfg = make_config(AuthMode::ChatGPT); - assert!(should_show_login_screen( - LoginStatus::NotAuthenticated, - &cfg - )); - } - - #[test] - fn shows_login_when_api_key_but_prefers_chatgpt() { - let cfg = make_config(AuthMode::ChatGPT); - assert!(should_show_login_screen( - LoginStatus::AuthMode(AuthMode::ApiKey), - &cfg - )) - } - - #[test] - fn hides_login_when_api_key_and_prefers_api_key() { - let cfg = make_config(AuthMode::ApiKey); - assert!(!should_show_login_screen( - LoginStatus::AuthMode(AuthMode::ApiKey), - &cfg - )) - } - - #[test] - fn hides_login_when_chatgpt_and_prefers_chatgpt() { - let cfg = make_config(AuthMode::ChatGPT); - assert!(!should_show_login_screen( - LoginStatus::AuthMode(AuthMode::ChatGPT), - &cfg - )) } } diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index fc8b510ff8..325b2d5b6d 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -1,10 +1,7 @@ use codex_core::protocol::Event; use codex_file_search::FileMatch; -use crossterm::event::KeyEvent; use ratatui::text::Line; -use std::time::Duration; -use crate::app::ChatWidgetArgs; use crate::slash_command::SlashCommand; use codex_core::protocol::AskForApproval; use codex_core::protocol::SandboxPolicy; @@ -15,21 +12,6 @@ use codex_core::protocol_config_types::ReasoningEffort; pub(crate) enum AppEvent { CodexEvent(Event), - /// Request a redraw which will be debounced by the [`App`]. - RequestRedraw, - - /// Actually draw the next frame. - Redraw, - - /// Schedule a one-shot animation frame roughly after the given duration. - /// Multiple requests are coalesced by the central frame scheduler. - ScheduleFrameIn(Duration), - - KeyEvent(KeyEvent), - - /// Text pasted from the terminal clipboard. - Paste(String), - /// Request to exit the application gracefully. ExitRequest, @@ -63,10 +45,6 @@ pub(crate) enum AppEvent { StopCommitAnimation, CommitTick, - /// Onboarding: result of login_with_chatgpt. - OnboardingAuthComplete(Result<(), String>), - OnboardingComplete(ChatWidgetArgs), - /// Update the current reasoning effort in the running app and widget. UpdateReasoningEffort(ReasoningEffort), diff --git a/codex-rs/tui/src/bottom_pane/approval_modal_view.rs b/codex-rs/tui/src/bottom_pane/approval_modal_view.rs index 1b23acb59d..518d9d0351 100644 --- a/codex-rs/tui/src/bottom_pane/approval_modal_view.rs +++ b/codex-rs/tui/src/bottom_pane/approval_modal_view.rs @@ -12,13 +12,13 @@ use super::BottomPaneView; use super::CancellationEvent; /// Modal overlay asking the user to approve/deny a sequence of requests. -pub(crate) struct ApprovalModalView<'a> { - current: UserApprovalWidget<'a>, +pub(crate) struct ApprovalModalView { + current: UserApprovalWidget, queue: Vec, app_event_tx: AppEventSender, } -impl ApprovalModalView<'_> { +impl ApprovalModalView { pub fn new(request: ApprovalRequest, app_event_tx: AppEventSender) -> Self { Self { current: UserApprovalWidget::new(request, app_event_tx.clone()), @@ -41,13 +41,13 @@ impl ApprovalModalView<'_> { } } -impl<'a> BottomPaneView<'a> for ApprovalModalView<'a> { - fn handle_key_event(&mut self, _pane: &mut BottomPane<'a>, key_event: KeyEvent) { +impl BottomPaneView for ApprovalModalView { + fn handle_key_event(&mut self, _pane: &mut BottomPane, key_event: KeyEvent) { self.current.handle_key_event(key_event); self.maybe_advance(); } - fn on_ctrl_c(&mut self, _pane: &mut BottomPane<'a>) -> CancellationEvent { + fn on_ctrl_c(&mut self, _pane: &mut BottomPane) -> CancellationEvent { self.current.on_ctrl_c(); self.queue.clear(); CancellationEvent::Handled @@ -96,6 +96,7 @@ mod tests { let (tx2, _rx2) = unbounded_channel::(); let mut pane = BottomPane::new(super::super::BottomPaneParams { app_event_tx: AppEventSender::new(tx2), + frame_requester: crate::tui::FrameRequester::test_dummy(), has_input_focus: true, enhanced_keys_supported: false, placeholder_text: "Ask Codex to do anything".to_string(), diff --git a/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs b/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs index 31a32140cb..c98ed43e30 100644 --- a/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs +++ b/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs @@ -7,10 +7,10 @@ use super::BottomPane; use super::CancellationEvent; /// Trait implemented by every view that can be shown in the bottom pane. -pub(crate) trait BottomPaneView<'a> { +pub(crate) trait BottomPaneView { /// Handle a key event while the view is active. A redraw is always /// scheduled after this call. - fn handle_key_event(&mut self, _pane: &mut BottomPane<'a>, _key_event: KeyEvent) {} + fn handle_key_event(&mut self, _pane: &mut BottomPane, _key_event: KeyEvent) {} /// Return `true` if the view has finished and should be removed. fn is_complete(&self) -> bool { @@ -18,7 +18,7 @@ pub(crate) trait BottomPaneView<'a> { } /// Handle Ctrl-C while this view is active. - fn on_ctrl_c(&mut self, _pane: &mut BottomPane<'a>) -> CancellationEvent { + fn on_ctrl_c(&mut self, _pane: &mut BottomPane) -> CancellationEvent { CancellationEvent::Ignored } 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 3b03eb9c03..74b5274f26 100644 --- a/codex-rs/tui/src/bottom_pane/list_selection_view.rs +++ b/codex-rs/tui/src/bottom_pane/list_selection_view.rs @@ -105,8 +105,8 @@ impl ListSelectionView { } } -impl BottomPaneView<'_> for ListSelectionView { - fn handle_key_event(&mut self, _pane: &mut BottomPane<'_>, key_event: KeyEvent) { +impl BottomPaneView for ListSelectionView { + fn handle_key_event(&mut self, _pane: &mut BottomPane, key_event: KeyEvent) { match key_event { KeyEvent { code: KeyCode::Up, .. @@ -131,7 +131,7 @@ impl BottomPaneView<'_> for ListSelectionView { self.complete } - fn on_ctrl_c(&mut self, _pane: &mut BottomPane<'_>) -> CancellationEvent { + fn on_ctrl_c(&mut self, _pane: &mut BottomPane) -> CancellationEvent { self.complete = true; CancellationEvent::Handled } diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 71fb0bbb9f..b6d71198e7 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -1,7 +1,7 @@ //! Bottom pane: shows the ChatComposer or a BottomPaneView, if one is active. -use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; +use crate::tui::FrameRequester; use crate::user_approval_widget::ApprovalRequest; use bottom_pane_view::BottomPaneView; use codex_core::protocol::TokenUsage; @@ -39,15 +39,17 @@ pub(crate) use list_selection_view::SelectionItem; use status_indicator_view::StatusIndicatorView; /// Pane displayed in the lower half of the chat UI. -pub(crate) struct BottomPane<'a> { +pub(crate) struct BottomPane { /// Composer is retained even when a BottomPaneView is displayed so the /// input state is retained when the view is closed. composer: ChatComposer, /// If present, this is displayed instead of the `composer`. - active_view: Option + 'a>>, + active_view: Option>, app_event_tx: AppEventSender, + frame_requester: FrameRequester, + has_input_focus: bool, is_task_running: bool, ctrl_c_quit_hint: bool, @@ -59,12 +61,13 @@ pub(crate) struct BottomPane<'a> { pub(crate) struct BottomPaneParams { pub(crate) app_event_tx: AppEventSender, + pub(crate) frame_requester: FrameRequester, pub(crate) has_input_focus: bool, pub(crate) enhanced_keys_supported: bool, pub(crate) placeholder_text: String, } -impl BottomPane<'_> { +impl BottomPane { const BOTTOM_PAD_LINES: u16 = 2; pub fn new(params: BottomPaneParams) -> Self { let enhanced_keys_supported = params.enhanced_keys_supported; @@ -77,6 +80,7 @@ impl BottomPane<'_> { ), active_view: None, app_event_tx: params.app_event_tx, + frame_requester: params.frame_requester, has_input_focus: params.has_input_focus, is_task_running: false, ctrl_c_quit_hint: false, @@ -113,7 +117,10 @@ impl BottomPane<'_> { if !view.is_complete() { self.active_view = Some(view); } else if self.is_task_running { - let mut v = StatusIndicatorView::new(self.app_event_tx.clone()); + let mut v = StatusIndicatorView::new( + self.app_event_tx.clone(), + self.frame_requester.clone(), + ); v.update_text("waiting for model".to_string()); self.active_view = Some(Box::new(v)); self.status_view_active = true; @@ -144,7 +151,10 @@ impl BottomPane<'_> { self.active_view = Some(view); } else if self.is_task_running { // Modal aborted but task still running – restore status indicator. - let mut v = StatusIndicatorView::new(self.app_event_tx.clone()); + let mut v = StatusIndicatorView::new( + self.app_event_tx.clone(), + self.frame_requester.clone(), + ); v.update_text("waiting for model".to_string()); self.active_view = Some(Box::new(v)); self.status_view_active = true; @@ -199,6 +209,7 @@ impl BottomPane<'_> { if self.active_view.is_none() { self.active_view = Some(Box::new(StatusIndicatorView::new( self.app_event_tx.clone(), + self.frame_requester.clone(), ))); self.status_view_active = true; } @@ -292,7 +303,7 @@ impl BottomPane<'_> { /// Height (terminal rows) required by the current bottom pane. pub(crate) fn request_redraw(&self) { - self.app_event_tx.send(AppEvent::RequestRedraw) + self.frame_requester.schedule_frame(); } // --- History helpers --- @@ -322,7 +333,7 @@ impl BottomPane<'_> { } } -impl WidgetRef for &BottomPane<'_> { +impl WidgetRef for &BottomPane { fn render_ref(&self, area: Rect, buf: &mut Buffer) { if let Some(view) = &self.active_view { // Reserve bottom padding lines; keep at least 1 line for the view. @@ -375,6 +386,7 @@ mod tests { let tx = AppEventSender::new(tx_raw); let mut pane = BottomPane::new(BottomPaneParams { app_event_tx: tx, + frame_requester: crate::tui::FrameRequester::test_dummy(), has_input_focus: true, enhanced_keys_supported: false, placeholder_text: "Ask Codex to do anything".to_string(), @@ -393,6 +405,7 @@ mod tests { let tx = AppEventSender::new(tx_raw); let mut pane = BottomPane::new(BottomPaneParams { app_event_tx: tx, + frame_requester: crate::tui::FrameRequester::test_dummy(), has_input_focus: true, enhanced_keys_supported: false, placeholder_text: "Ask Codex to do anything".to_string(), @@ -422,6 +435,7 @@ mod tests { let tx = AppEventSender::new(tx_raw); let mut pane = BottomPane::new(BottomPaneParams { app_event_tx: tx.clone(), + frame_requester: crate::tui::FrameRequester::test_dummy(), has_input_focus: true, enhanced_keys_supported: false, placeholder_text: "Ask Codex to do anything".to_string(), @@ -472,6 +486,7 @@ mod tests { let tx = AppEventSender::new(tx_raw); let mut pane = BottomPane::new(BottomPaneParams { app_event_tx: tx, + frame_requester: crate::tui::FrameRequester::test_dummy(), has_input_focus: true, enhanced_keys_supported: false, placeholder_text: "Ask Codex to do anything".to_string(), @@ -504,6 +519,7 @@ mod tests { let tx = AppEventSender::new(tx_raw); let mut pane = BottomPane::new(BottomPaneParams { app_event_tx: tx, + frame_requester: crate::tui::FrameRequester::test_dummy(), has_input_focus: true, enhanced_keys_supported: false, placeholder_text: "Ask Codex to do anything".to_string(), @@ -556,6 +572,7 @@ mod tests { let tx = AppEventSender::new(tx_raw); let mut pane = BottomPane::new(BottomPaneParams { app_event_tx: tx, + frame_requester: crate::tui::FrameRequester::test_dummy(), has_input_focus: true, enhanced_keys_supported: false, placeholder_text: "Ask Codex to do anything".to_string(), diff --git a/codex-rs/tui/src/bottom_pane/status_indicator_view.rs b/codex-rs/tui/src/bottom_pane/status_indicator_view.rs index ccfd240b58..dcd1fc0b9f 100644 --- a/codex-rs/tui/src/bottom_pane/status_indicator_view.rs +++ b/codex-rs/tui/src/bottom_pane/status_indicator_view.rs @@ -6,6 +6,7 @@ use ratatui::widgets::WidgetRef; use crate::app_event_sender::AppEventSender; use crate::bottom_pane::BottomPane; use crate::status_indicator_widget::StatusIndicatorWidget; +use crate::tui::FrameRequester; use super::BottomPaneView; @@ -14,9 +15,9 @@ pub(crate) struct StatusIndicatorView { } impl StatusIndicatorView { - pub fn new(app_event_tx: AppEventSender) -> Self { + pub fn new(app_event_tx: AppEventSender, frame_requester: FrameRequester) -> Self { Self { - view: StatusIndicatorWidget::new(app_event_tx), + view: StatusIndicatorWidget::new(app_event_tx, frame_requester), } } @@ -25,7 +26,7 @@ impl StatusIndicatorView { } } -impl BottomPaneView<'_> for StatusIndicatorView { +impl BottomPaneView for StatusIndicatorView { fn should_hide_when_task_is_done(&mut self) -> bool { true } @@ -38,7 +39,7 @@ impl BottomPaneView<'_> for StatusIndicatorView { self.view.render_ref(area, buf); } - fn handle_key_event(&mut self, _pane: &mut BottomPane<'_>, key_event: KeyEvent) { + fn handle_key_event(&mut self, _pane: &mut BottomPane, key_event: KeyEvent) { if key_event.code == KeyCode::Esc { self.view.interrupt(); } diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 8d981b9450..d10f6e3cbf 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -52,6 +52,7 @@ use crate::history_cell::CommandOutput; use crate::history_cell::ExecCell; use crate::history_cell::HistoryCell; use crate::history_cell::PatchEventType; +use crate::tui::FrameRequester; // streaming internals are provided by crate::streaming and crate::markdown_stream use crate::user_approval_widget::ApprovalRequest; mod interrupts; @@ -77,10 +78,10 @@ struct RunningCommand { parsed_cmd: Vec, } -pub(crate) struct ChatWidget<'a> { +pub(crate) struct ChatWidget { app_event_tx: AppEventSender, codex_op_tx: UnboundedSender, - bottom_pane: BottomPane<'a>, + bottom_pane: BottomPane, active_exec_cell: Option, config: Config, initial_user_message: Option, @@ -98,6 +99,7 @@ pub(crate) struct ChatWidget<'a> { // Whether a redraw is needed after handling the current event needs_redraw: bool, session_id: Option, + frame_requester: FrameRequester, } struct UserMessage { @@ -124,7 +126,7 @@ fn create_initial_user_message(text: String, image_paths: Vec) -> Optio } } -impl ChatWidget<'_> { +impl ChatWidget { #[inline] fn mark_needs_redraw(&mut self) { self.needs_redraw = true; @@ -500,6 +502,7 @@ impl ChatWidget<'_> { pub(crate) fn new( config: Config, conversation_manager: Arc, + frame_requester: FrameRequester, app_event_tx: AppEventSender, initial_prompt: Option, initial_images: Vec, @@ -511,8 +514,10 @@ impl ChatWidget<'_> { Self { app_event_tx: app_event_tx.clone(), + frame_requester: frame_requester.clone(), codex_op_tx, bottom_pane: BottomPane::new(BottomPaneParams { + frame_requester, app_event_tx, has_input_focus: true, enhanced_keys_supported, @@ -672,7 +677,7 @@ impl ChatWidget<'_> { } fn request_redraw(&mut self) { - self.app_event_tx.send(AppEvent::RequestRedraw); + self.frame_requester.schedule_frame(); } pub(crate) fn add_diff_in_progress(&mut self) { @@ -880,7 +885,7 @@ impl ChatWidget<'_> { } } -impl WidgetRef for &ChatWidget<'_> { +impl WidgetRef for &ChatWidget { fn render_ref(&self, area: Rect, buf: &mut Buffer) { let [active_cell_area, bottom_pane_area] = self.layout_areas(area); (&self.bottom_pane).render(bottom_pane_area, buf); diff --git a/codex-rs/tui/src/chatwidget/interrupts.rs b/codex-rs/tui/src/chatwidget/interrupts.rs index 40fecb72f6..531de3e646 100644 --- a/codex-rs/tui/src/chatwidget/interrupts.rs +++ b/codex-rs/tui/src/chatwidget/interrupts.rs @@ -71,7 +71,7 @@ impl InterruptManager { self.queue.push_back(QueuedInterrupt::PatchEnd(ev)); } - pub(crate) fn flush_all(&mut self, chat: &mut ChatWidget<'_>) { + pub(crate) fn flush_all(&mut self, chat: &mut ChatWidget) { while let Some(q) = self.queue.pop_front() { match q { QueuedInterrupt::ExecApproval(id, ev) => chat.handle_exec_approval_now(id, ev), diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 82e7470c83..fab64c8864 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -104,14 +104,22 @@ async fn helpers_are_available_and_do_not_panic() { let tx = AppEventSender::new(tx_raw); let cfg = test_config(); let conversation_manager = Arc::new(ConversationManager::default()); - let mut w = ChatWidget::new(cfg, conversation_manager, tx, None, Vec::new(), false); + let mut w = ChatWidget::new( + cfg, + conversation_manager, + crate::tui::FrameRequester::test_dummy(), + tx, + None, + Vec::new(), + false, + ); // Basic construction sanity. let _ = &mut w; } // --- Helpers for tests that need direct construction and event draining --- fn make_chatwidget_manual() -> ( - ChatWidget<'static>, + ChatWidget, tokio::sync::mpsc::UnboundedReceiver, tokio::sync::mpsc::UnboundedReceiver, ) { @@ -121,6 +129,7 @@ fn make_chatwidget_manual() -> ( let cfg = test_config(); let bottom = BottomPane::new(BottomPaneParams { app_event_tx: app_event_tx.clone(), + frame_requester: crate::tui::FrameRequester::test_dummy(), has_input_focus: true, enhanced_keys_supported: false, placeholder_text: "Ask Codex to do anything".to_string(), @@ -142,6 +151,7 @@ fn make_chatwidget_manual() -> ( interrupts: InterruptManager::new(), needs_redraw: false, session_id: None, + frame_requester: crate::tui::FrameRequester::test_dummy(), }; (widget, rx, op_rx) } diff --git a/codex-rs/tui/src/custom_terminal.rs b/codex-rs/tui/src/custom_terminal.rs index 91153f92ed..ef642b1a17 100644 --- a/codex-rs/tui/src/custom_terminal.rs +++ b/codex-rs/tui/src/custom_terminal.rs @@ -245,7 +245,7 @@ where /// Index of the current buffer in the previous array current: usize, /// Whether the cursor is currently hidden - hidden_cursor: bool, + pub hidden_cursor: bool, /// Area of the viewport pub viewport_area: Rect, /// Last known size of the terminal. Used to detect if the internal buffers have to be resized. diff --git a/codex-rs/tui/src/insert_history.rs b/codex-rs/tui/src/insert_history.rs index 63826bbf82..b638870935 100644 --- a/codex-rs/tui/src/insert_history.rs +++ b/codex-rs/tui/src/insert_history.rs @@ -22,7 +22,7 @@ use textwrap::Options as TwOptions; use textwrap::WordSplitter; /// Insert `lines` above the viewport. -pub(crate) fn insert_history_lines(terminal: &mut tui::Tui, lines: Vec) { +pub(crate) fn insert_history_lines(terminal: &mut tui::Terminal, lines: Vec) { let mut out = std::io::stdout(); insert_history_lines_to_writer(terminal, &mut out, lines); } @@ -39,7 +39,7 @@ pub fn insert_history_lines_to_writer( { let screen_size = terminal.backend().size().unwrap_or(Size::new(0, 0)); - let mut area = terminal.get_frame().area(); + let mut area = terminal.viewport_area; // Pre-wrap lines using word-aware wrapping so terminal scrollback sees the same // formatting as the TUI. This avoids character-level hard wrapping by the terminal. diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 76487ef366..ac463be060 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -64,6 +64,11 @@ use color_eyre::owo_colors::OwoColorize; pub use cli::Cli; +use crate::onboarding::TrustDirectorySelection; +use crate::onboarding::onboarding_screen::OnboardingScreenArgs; +use crate::onboarding::onboarding_screen::run_onboarding_app; +use crate::tui::Tui; + // (tests access modules directly within the crate) pub async fn run_main( @@ -256,6 +261,7 @@ async fn run_ratatui_app( config: Config, should_show_trust_screen: bool, ) -> color_eyre::Result { + let mut config = config; color_eyre::install()?; // Forward panic reports through tracing so they appear in the UI status @@ -267,23 +273,44 @@ async fn run_ratatui_app( tracing::error!("panic: {info}"); prev_hook(info); })); - let mut terminal = tui::init(&config)?; + let mut terminal = tui::init()?; terminal.clear()?; + let mut tui = Tui::new(terminal); // Initialize high-fidelity session event logging if enabled. session_log::maybe_init(&config); let Cli { prompt, images, .. } = cli; - let mut app = App::new(config.clone(), prompt, images, should_show_trust_screen); - let app_result = app.run(&mut terminal).await; - let usage = app.token_usage(); + let login_status = get_login_status(&config); + let should_show_onboarding = + should_show_onboarding(login_status, &config, should_show_trust_screen); + if should_show_onboarding { + let directory_trust_decision = run_onboarding_app( + OnboardingScreenArgs { + codex_home: config.codex_home.clone(), + cwd: config.cwd.clone(), + show_login_screen: should_show_login_screen(login_status, &config), + show_trust_screen: should_show_trust_screen, + login_status, + preferred_auth_method: config.preferred_auth_method, + }, + &mut tui, + ) + .await?; + if let Some(TrustDirectorySelection::Trust) = directory_trust_decision { + config.approval_policy = AskForApproval::OnRequest; + config.sandbox_policy = SandboxPolicy::new_workspace_write_policy(); + } + } + + let app_result = App::run(&mut tui, config, prompt, images).await; restore(); // Mark the end of the recorded session. session_log::log_session_end(); // ignore error when collecting usage – report underlying error instead - app_result.map(|_| usage) + app_result } #[expect( @@ -357,3 +384,80 @@ fn determine_repo_trust_state( Ok(true) } } + +fn should_show_onboarding( + login_status: LoginStatus, + config: &Config, + show_trust_screen: bool, +) -> bool { + if show_trust_screen { + return true; + } + + should_show_login_screen(login_status, config) +} + +fn should_show_login_screen(login_status: LoginStatus, config: &Config) -> bool { + // Only show the login screen for providers that actually require OpenAI auth + // (OpenAI or equivalents). For OSS/other providers, skip login entirely. + if !config.model_provider.requires_openai_auth { + return false; + } + + match login_status { + LoginStatus::NotAuthenticated => true, + LoginStatus::AuthMode(method) => method != config.preferred_auth_method, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_config(preferred: AuthMode) -> Config { + let mut cfg = Config::load_from_base_config_with_overrides( + ConfigToml::default(), + ConfigOverrides::default(), + std::env::temp_dir(), + ) + .expect("load default config"); + cfg.preferred_auth_method = preferred; + cfg + } + + #[test] + fn shows_login_when_not_authenticated() { + let cfg = make_config(AuthMode::ChatGPT); + assert!(should_show_login_screen( + LoginStatus::NotAuthenticated, + &cfg + )); + } + + #[test] + fn shows_login_when_api_key_but_prefers_chatgpt() { + let cfg = make_config(AuthMode::ChatGPT); + assert!(should_show_login_screen( + LoginStatus::AuthMode(AuthMode::ApiKey), + &cfg + )) + } + + #[test] + fn hides_login_when_api_key_and_prefers_api_key() { + let cfg = make_config(AuthMode::ApiKey); + assert!(!should_show_login_screen( + LoginStatus::AuthMode(AuthMode::ApiKey), + &cfg + )) + } + + #[test] + fn hides_login_when_chatgpt_and_prefers_chatgpt() { + let cfg = make_config(AuthMode::ChatGPT); + assert!(!should_show_login_screen( + LoginStatus::AuthMode(AuthMode::ChatGPT), + &cfg + )) + } +} diff --git a/codex-rs/tui/src/onboarding/auth.rs b/codex-rs/tui/src/onboarding/auth.rs index 2ef70ca275..6f653cd0f9 100644 --- a/codex-rs/tui/src/onboarding/auth.rs +++ b/codex-rs/tui/src/onboarding/auth.rs @@ -1,3 +1,5 @@ +#![allow(clippy::unwrap_used)] + use codex_login::CLIENT_ID; use codex_login::ServerOptions; use codex_login::ShutdownHandle; @@ -18,19 +20,19 @@ use ratatui::widgets::WidgetRef; use ratatui::widgets::Wrap; use codex_login::AuthMode; +use std::sync::RwLock; use crate::LoginStatus; -use crate::app_event::AppEvent; -use crate::app_event_sender::AppEventSender; use crate::onboarding::onboarding_screen::KeyboardHandler; use crate::onboarding::onboarding_screen::StepStateProvider; use crate::shimmer::shimmer_spans; +use crate::tui::FrameRequester; use std::path::PathBuf; +use std::sync::Arc; use super::onboarding_screen::StepState; -// no additional imports -#[derive(Debug)] +#[derive(Clone)] pub(crate) enum SignInState { PickMode, ChatGptContinueInBrowser(ContinueInBrowserState), @@ -40,18 +42,17 @@ pub(crate) enum SignInState { EnvVarFound, } -#[derive(Debug)] +#[derive(Clone)] /// Used to manage the lifecycle of SpawnedLogin and ensure it gets cleaned up. pub(crate) struct ContinueInBrowserState { auth_url: String, - shutdown_handle: Option, - _login_wait_handle: Option>, + shutdown_flag: Option, } impl Drop for ContinueInBrowserState { fn drop(&mut self) { - if let Some(flag) = &self.shutdown_handle { - flag.shutdown(); + if let Some(handle) = &self.shutdown_flag { + handle.shutdown(); } } } @@ -69,20 +70,32 @@ impl KeyboardHandler for AuthModeWidget { self.start_chatgpt_login(); } KeyCode::Char('2') => self.verify_api_key(), - KeyCode::Enter => match self.sign_in_state { - SignInState::PickMode => match self.highlighted_mode { - AuthMode::ChatGPT => self.start_chatgpt_login(), - AuthMode::ApiKey => self.verify_api_key(), - }, - SignInState::EnvVarMissing => self.sign_in_state = SignInState::PickMode, - SignInState::ChatGptSuccessMessage => { - self.sign_in_state = SignInState::ChatGptSuccess + KeyCode::Enter => { + let sign_in_state = { (*self.sign_in_state.read().unwrap()).clone() }; + match sign_in_state { + SignInState::PickMode => match self.highlighted_mode { + AuthMode::ChatGPT => { + self.start_chatgpt_login(); + } + AuthMode::ApiKey => { + self.verify_api_key(); + } + }, + SignInState::EnvVarMissing => { + *self.sign_in_state.write().unwrap() = SignInState::PickMode; + } + SignInState::ChatGptSuccessMessage => { + *self.sign_in_state.write().unwrap() = SignInState::ChatGptSuccess; + } + _ => {} } - _ => {} - }, + } KeyCode::Esc => { - if matches!(self.sign_in_state, SignInState::ChatGptContinueInBrowser(_)) { - self.sign_in_state = SignInState::PickMode; + tracing::info!("Esc pressed"); + let sign_in_state = { (*self.sign_in_state.read().unwrap()).clone() }; + if matches!(sign_in_state, SignInState::ChatGptContinueInBrowser(_)) { + *self.sign_in_state.write().unwrap() = SignInState::PickMode; + self.request_frame.schedule_frame(); } } _ => {} @@ -90,12 +103,12 @@ impl KeyboardHandler for AuthModeWidget { } } -#[derive(Debug)] +#[derive(Clone)] pub(crate) struct AuthModeWidget { - pub event_tx: AppEventSender, + pub request_frame: FrameRequester, pub highlighted_mode: AuthMode, pub error: Option, - pub sign_in_state: SignInState, + pub sign_in_state: Arc>, pub codex_home: PathBuf, pub login_status: LoginStatus, pub preferred_auth_method: AuthMode, @@ -215,14 +228,13 @@ impl AuthModeWidget { fn render_continue_in_browser(&self, area: Rect, buf: &mut Buffer) { let mut spans = vec![Span::from("> ")]; // Schedule a follow-up frame to keep the shimmer animation going. - self.event_tx - .send(AppEvent::ScheduleFrameIn(std::time::Duration::from_millis( - 100, - ))); + self.request_frame + .schedule_frame_in(std::time::Duration::from_millis(100)); spans.extend(shimmer_spans("Finish signing in via your browser")); let mut lines = vec![Line::from(spans), Line::from("")]; - if let SignInState::ChatGptContinueInBrowser(state) = &self.sign_in_state + let sign_in_state = self.sign_in_state.read().unwrap(); + if let SignInState::ChatGptContinueInBrowser(state) = &*sign_in_state && !state.auth_url.is_empty() { lines.push(Line::from(" If the link doesn't open automatically, open the following link to authenticate:")); @@ -315,35 +327,45 @@ impl AuthModeWidget { // If we're already authenticated with ChatGPT, don't start a new login – // just proceed to the success message flow. if matches!(self.login_status, LoginStatus::AuthMode(AuthMode::ChatGPT)) { - self.sign_in_state = SignInState::ChatGptSuccess; - self.event_tx.send(AppEvent::RequestRedraw); + *self.sign_in_state.write().unwrap() = SignInState::ChatGptSuccess; + self.request_frame.schedule_frame(); return; } self.error = None; let opts = ServerOptions::new(self.codex_home.clone(), CLIENT_ID.to_string()); - let server = run_login_server(opts); - match server { + match run_login_server(opts) { Ok(child) => { - let auth_url = child.auth_url.clone(); - let shutdown_handle = child.cancel_handle(); - - let event_tx = self.event_tx.clone(); - let join_handle = tokio::spawn(async move { - spawn_completion_poller(child, event_tx).await; + let sign_in_state = self.sign_in_state.clone(); + let request_frame = self.request_frame.clone(); + tokio::spawn(async move { + let auth_url = child.auth_url.clone(); + { + *sign_in_state.write().unwrap() = + SignInState::ChatGptContinueInBrowser(ContinueInBrowserState { + auth_url, + shutdown_flag: Some(child.cancel_handle()), + }); + } + request_frame.schedule_frame(); + let r = child.block_until_done().await; + match r { + Ok(()) => { + *sign_in_state.write().unwrap() = SignInState::ChatGptSuccessMessage; + request_frame.schedule_frame(); + } + _ => { + *sign_in_state.write().unwrap() = SignInState::PickMode; + // self.error = Some(e.to_string()); + request_frame.schedule_frame(); + } + } }); - self.sign_in_state = - SignInState::ChatGptContinueInBrowser(ContinueInBrowserState { - auth_url, - shutdown_handle: Some(shutdown_handle), - _login_wait_handle: Some(join_handle), - }); - self.event_tx.send(AppEvent::RequestRedraw); } Err(e) => { - self.sign_in_state = SignInState::PickMode; + *self.sign_in_state.write().unwrap() = SignInState::PickMode; self.error = Some(e.to_string()); - self.event_tx.send(AppEvent::RequestRedraw); + self.request_frame.schedule_frame(); } } } @@ -353,33 +375,18 @@ impl AuthModeWidget { if matches!(self.login_status, LoginStatus::AuthMode(AuthMode::ApiKey)) { // We already have an API key configured (e.g., from auth.json or env), // so mark this step complete immediately. - self.sign_in_state = SignInState::EnvVarFound; + *self.sign_in_state.write().unwrap() = SignInState::EnvVarFound; } else { - self.sign_in_state = SignInState::EnvVarMissing; + *self.sign_in_state.write().unwrap() = SignInState::EnvVarMissing; } - - self.event_tx.send(AppEvent::RequestRedraw); + self.request_frame.schedule_frame(); } } -async fn spawn_completion_poller( - child: codex_login::LoginServer, - event_tx: AppEventSender, -) -> tokio::task::JoinHandle<()> { - tokio::spawn(async move { - if let Ok(()) = child.block_until_done().await { - event_tx.send(AppEvent::OnboardingAuthComplete(Ok(()))); - } else { - event_tx.send(AppEvent::OnboardingAuthComplete(Err( - "login failed".to_string() - ))); - } - }) -} - impl StepStateProvider for AuthModeWidget { fn get_step_state(&self) -> StepState { - match &self.sign_in_state { + let sign_in_state = self.sign_in_state.read().unwrap(); + match &*sign_in_state { SignInState::PickMode | SignInState::EnvVarMissing | SignInState::ChatGptContinueInBrowser(_) @@ -391,7 +398,8 @@ impl StepStateProvider for AuthModeWidget { impl WidgetRef for AuthModeWidget { fn render_ref(&self, area: Rect, buf: &mut Buffer) { - match self.sign_in_state { + let sign_in_state = self.sign_in_state.read().unwrap(); + match &*sign_in_state { SignInState::PickMode => { self.render_pick_mode(area, buf); } diff --git a/codex-rs/tui/src/onboarding/continue_to_chat.rs b/codex-rs/tui/src/onboarding/continue_to_chat.rs deleted file mode 100644 index 01e31d900a..0000000000 --- a/codex-rs/tui/src/onboarding/continue_to_chat.rs +++ /dev/null @@ -1,34 +0,0 @@ -use ratatui::buffer::Buffer; -use ratatui::layout::Rect; -use ratatui::widgets::WidgetRef; - -use crate::app::ChatWidgetArgs; -use crate::app_event::AppEvent; -use crate::app_event_sender::AppEventSender; -use crate::onboarding::onboarding_screen::StepStateProvider; - -use super::onboarding_screen::StepState; -use std::sync::Arc; -use std::sync::Mutex; - -/// This doesn't render anything explicitly but serves as a signal that we made it to the end and -/// we should continue to the chat. -pub(crate) struct ContinueToChatWidget { - pub event_tx: AppEventSender, - pub chat_widget_args: Arc>, -} - -impl StepStateProvider for ContinueToChatWidget { - fn get_step_state(&self) -> StepState { - StepState::Complete - } -} - -impl WidgetRef for &ContinueToChatWidget { - fn render_ref(&self, _area: Rect, _buf: &mut Buffer) { - if let Ok(args) = self.chat_widget_args.lock() { - self.event_tx - .send(AppEvent::OnboardingComplete(args.clone())); - } - } -} diff --git a/codex-rs/tui/src/onboarding/mod.rs b/codex-rs/tui/src/onboarding/mod.rs index c116936851..d4cfd6d1f4 100644 --- a/codex-rs/tui/src/onboarding/mod.rs +++ b/codex-rs/tui/src/onboarding/mod.rs @@ -1,5 +1,5 @@ mod auth; -mod continue_to_chat; pub mod onboarding_screen; mod trust_directory; +pub use trust_directory::TrustDirectorySelection; mod welcome; diff --git a/codex-rs/tui/src/onboarding/onboarding_screen.rs b/codex-rs/tui/src/onboarding/onboarding_screen.rs index ea473512d0..5721430cd0 100644 --- a/codex-rs/tui/src/onboarding/onboarding_screen.rs +++ b/codex-rs/tui/src/onboarding/onboarding_screen.rs @@ -1,5 +1,7 @@ use codex_core::util::is_inside_git_repo; +use crossterm::event::KeyCode; use crossterm::event::KeyEvent; +use crossterm::event::KeyEventKind; use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::prelude::Widget; @@ -9,25 +11,24 @@ use ratatui::widgets::WidgetRef; use codex_login::AuthMode; use crate::LoginStatus; -use crate::app::ChatWidgetArgs; -use crate::app_event::AppEvent; -use crate::app_event_sender::AppEventSender; use crate::onboarding::auth::AuthModeWidget; use crate::onboarding::auth::SignInState; -use crate::onboarding::continue_to_chat::ContinueToChatWidget; use crate::onboarding::trust_directory::TrustDirectorySelection; use crate::onboarding::trust_directory::TrustDirectoryWidget; use crate::onboarding::welcome::WelcomeWidget; +use crate::tui::FrameRequester; +use crate::tui::Tui; +use crate::tui::TuiEvent; +use color_eyre::eyre::Result; use std::path::PathBuf; use std::sync::Arc; -use std::sync::Mutex; +use std::sync::RwLock; #[allow(clippy::large_enum_variant)] enum Step { Welcome(WelcomeWidget), Auth(AuthModeWidget), TrustDirectory(TrustDirectoryWidget), - ContinueToChat(ContinueToChatWidget), } pub(crate) trait KeyboardHandler { @@ -45,43 +46,42 @@ pub(crate) trait StepStateProvider { } pub(crate) struct OnboardingScreen { - event_tx: AppEventSender, + request_frame: FrameRequester, steps: Vec, + is_done: bool, } pub(crate) struct OnboardingScreenArgs { - pub event_tx: AppEventSender, - pub chat_widget_args: ChatWidgetArgs, pub codex_home: PathBuf, pub cwd: PathBuf, pub show_trust_screen: bool, pub show_login_screen: bool, pub login_status: LoginStatus, + pub preferred_auth_method: AuthMode, } impl OnboardingScreen { - pub(crate) fn new(args: OnboardingScreenArgs) -> Self { + pub(crate) fn new(tui: &mut Tui, args: OnboardingScreenArgs) -> Self { let OnboardingScreenArgs { - event_tx, - chat_widget_args, codex_home, cwd, show_trust_screen, show_login_screen, login_status, + preferred_auth_method, } = args; let mut steps: Vec = vec![Step::Welcome(WelcomeWidget { is_logged_in: !matches!(login_status, LoginStatus::NotAuthenticated), })]; if show_login_screen { steps.push(Step::Auth(AuthModeWidget { - event_tx: event_tx.clone(), + request_frame: tui.frame_requester(), highlighted_mode: AuthMode::ChatGPT, error: None, - sign_in_state: SignInState::PickMode, + sign_in_state: Arc::new(RwLock::new(SignInState::PickMode)), codex_home: codex_home.clone(), login_status, - preferred_auth_method: chat_widget_args.config.preferred_auth_method, + preferred_auth_method, })) } let is_git_repo = is_inside_git_repo(&cwd); @@ -91,9 +91,6 @@ impl OnboardingScreen { // Default to not trusting the directory if it's not a git repo. TrustDirectorySelection::DontTrust }; - // Share ChatWidgetArgs between steps so changes in the TrustDirectory step - // are reflected when continuing to chat. - let shared_chat_args = Arc::new(Mutex::new(chat_widget_args)); if show_trust_screen { steps.push(Step::TrustDirectory(TrustDirectoryWidget { cwd, @@ -102,39 +99,13 @@ impl OnboardingScreen { selection: None, highlighted, error: None, - chat_widget_args: shared_chat_args.clone(), })) } - steps.push(Step::ContinueToChat(ContinueToChatWidget { - event_tx: event_tx.clone(), - chat_widget_args: shared_chat_args, - })); // TODO: add git warning. - Self { event_tx, steps } - } - - pub(crate) fn on_auth_complete(&mut self, result: Result<(), String>) { - let current_step = self.current_step_mut(); - if let Some(Step::Auth(state)) = current_step { - match result { - Ok(()) => { - state.sign_in_state = SignInState::ChatGptSuccessMessage; - self.event_tx.send(AppEvent::RequestRedraw); - let tx1 = self.event_tx.clone(); - let tx2 = self.event_tx.clone(); - std::thread::spawn(move || { - std::thread::sleep(std::time::Duration::from_millis(150)); - tx1.send(AppEvent::RequestRedraw); - std::thread::sleep(std::time::Duration::from_millis(200)); - tx2.send(AppEvent::RequestRedraw); - }); - } - Err(e) => { - state.sign_in_state = SignInState::PickMode; - state.error = Some(e); - self.event_tx.send(AppEvent::RequestRedraw); - } - } + Self { + request_frame: tui.frame_requester(), + steps, + is_done: false, } } @@ -168,19 +139,57 @@ impl OnboardingScreen { out } - fn current_step_mut(&mut self) -> Option<&mut Step> { + pub(crate) fn is_done(&self) -> bool { + self.is_done + || !self + .steps + .iter() + .any(|step| matches!(step.get_step_state(), StepState::InProgress)) + } + + pub fn directory_trust_decision(&self) -> Option { self.steps - .iter_mut() - .find(|step| matches!(step.get_step_state(), StepState::InProgress)) + .iter() + .find_map(|step| { + if let Step::TrustDirectory(TrustDirectoryWidget { selection, .. }) = step { + Some(*selection) + } else { + None + } + }) + .flatten() } } impl KeyboardHandler for OnboardingScreen { fn handle_key_event(&mut self, key_event: KeyEvent) { - if let Some(active_step) = self.current_steps_mut().into_iter().last() { - active_step.handle_key_event(key_event); - } - self.event_tx.send(AppEvent::RequestRedraw); + match key_event { + KeyEvent { + code: KeyCode::Char('d'), + modifiers: crossterm::event::KeyModifiers::CONTROL, + kind: KeyEventKind::Press, + .. + } + | KeyEvent { + code: KeyCode::Char('c'), + modifiers: crossterm::event::KeyModifiers::CONTROL, + kind: KeyEventKind::Press, + .. + } + | KeyEvent { + code: KeyCode::Char('q'), + kind: KeyEventKind::Press, + .. + } => { + self.is_done = true; + } + _ => { + if let Some(active_step) = self.current_steps_mut().into_iter().last() { + active_step.handle_key_event(key_event); + } + } + }; + self.request_frame.schedule_frame(); } } @@ -246,7 +255,7 @@ impl WidgetRef for &OnboardingScreen { impl KeyboardHandler for Step { fn handle_key_event(&mut self, key_event: KeyEvent) { match self { - Step::Welcome(_) | Step::ContinueToChat(_) => (), + Step::Welcome(_) => (), Step::Auth(widget) => widget.handle_key_event(key_event), Step::TrustDirectory(widget) => widget.handle_key_event(key_event), } @@ -259,7 +268,6 @@ impl StepStateProvider for Step { Step::Welcome(w) => w.get_step_state(), Step::Auth(w) => w.get_step_state(), Step::TrustDirectory(w) => w.get_step_state(), - Step::ContinueToChat(w) => w.get_step_state(), } } } @@ -276,9 +284,39 @@ impl WidgetRef for Step { Step::TrustDirectory(widget) => { widget.render_ref(area, buf); } - Step::ContinueToChat(widget) => { - widget.render_ref(area, buf); - } } } } + +pub(crate) async fn run_onboarding_app( + args: OnboardingScreenArgs, + tui: &mut Tui, +) -> Result> { + use tokio_stream::StreamExt; + + let mut onboarding_screen = OnboardingScreen::new(tui, args); + + tui.draw(u16::MAX, |frame| { + frame.render_widget_ref(&onboarding_screen, frame.area()); + })?; + + let tui_events = tui.event_stream(); + tokio::pin!(tui_events); + + while !onboarding_screen.is_done() { + if let Some(event) = tui_events.next().await { + match event { + TuiEvent::Key(key_event) => { + onboarding_screen.handle_key_event(key_event); + } + TuiEvent::Draw => { + let _ = tui.draw(u16::MAX, |frame| { + frame.render_widget_ref(&onboarding_screen, frame.area()); + }); + } + _ => {} + } + } + } + Ok(onboarding_screen.directory_trust_decision()) +} diff --git a/codex-rs/tui/src/onboarding/trust_directory.rs b/codex-rs/tui/src/onboarding/trust_directory.rs index 84f7aef8eb..7e41ae0506 100644 --- a/codex-rs/tui/src/onboarding/trust_directory.rs +++ b/codex-rs/tui/src/onboarding/trust_directory.rs @@ -1,8 +1,6 @@ use std::path::PathBuf; use codex_core::config::set_project_trusted; -use codex_core::protocol::AskForApproval; -use codex_core::protocol::SandboxPolicy; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use ratatui::buffer::Buffer; @@ -22,9 +20,6 @@ use crate::onboarding::onboarding_screen::KeyboardHandler; use crate::onboarding::onboarding_screen::StepStateProvider; use super::onboarding_screen::StepState; -use crate::app::ChatWidgetArgs; -use std::sync::Arc; -use std::sync::Mutex; pub(crate) struct TrustDirectoryWidget { pub codex_home: PathBuf, @@ -33,11 +28,10 @@ pub(crate) struct TrustDirectoryWidget { pub selection: Option, pub highlighted: TrustDirectorySelection, pub error: Option, - pub chat_widget_args: Arc>, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(crate) enum TrustDirectorySelection { +pub enum TrustDirectorySelection { Trust, DontTrust, } @@ -156,13 +150,6 @@ impl TrustDirectoryWidget { // self.error = Some("Failed to set project trusted".to_string()); } - // Update the in-memory chat config for this session to a more permissive - // policy suitable for a trusted workspace. - if let Ok(mut args) = self.chat_widget_args.lock() { - args.config.approval_policy = AskForApproval::OnRequest; - args.config.sandbox_policy = SandboxPolicy::new_workspace_write_policy(); - } - self.selection = Some(TrustDirectorySelection::Trust); } diff --git a/codex-rs/tui/src/session_log.rs b/codex-rs/tui/src/session_log.rs index e94f58436f..03334f7ffe 100644 --- a/codex-rs/tui/src/session_log.rs +++ b/codex-rs/tui/src/session_log.rs @@ -132,24 +132,7 @@ pub(crate) fn log_inbound_app_event(event: &AppEvent) { AppEvent::CodexEvent(ev) => { write_record("to_tui", "codex_event", ev); } - AppEvent::KeyEvent(k) => { - let value = json!({ - "ts": now_ts(), - "dir": "to_tui", - "kind": "key_event", - "event": format!("{:?}", k), - }); - LOGGER.write_json_line(value); - } - AppEvent::Paste(s) => { - let value = json!({ - "ts": now_ts(), - "dir": "to_tui", - "kind": "paste", - "text": s, - }); - LOGGER.write_json_line(value); - } + AppEvent::DispatchCommand(cmd) => { let value = json!({ "ts": now_ts(), diff --git a/codex-rs/tui/src/status_indicator_widget.rs b/codex-rs/tui/src/status_indicator_widget.rs index 70dd2ed0b0..2b261941f9 100644 --- a/codex-rs/tui/src/status_indicator_widget.rs +++ b/codex-rs/tui/src/status_indicator_widget.rs @@ -19,6 +19,7 @@ use unicode_width::UnicodeWidthStr; use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; use crate::shimmer::shimmer_spans; +use crate::tui::FrameRequester; // We render the live text using markdown so it visually matches the history // cells. Before rendering we strip any ANSI escape sequences to avoid writing @@ -39,10 +40,11 @@ pub(crate) struct StatusIndicatorWidget { reveal_len_at_base: usize, start_time: Instant, app_event_tx: AppEventSender, + frame_requester: FrameRequester, } impl StatusIndicatorWidget { - pub(crate) fn new(app_event_tx: AppEventSender) -> Self { + pub(crate) fn new(app_event_tx: AppEventSender, frame_requester: FrameRequester) -> Self { Self { text: String::from("waiting for model"), last_target_len: 0, @@ -51,6 +53,7 @@ impl StatusIndicatorWidget { start_time: Instant::now(), app_event_tx, + frame_requester, } } @@ -143,8 +146,8 @@ impl WidgetRef for StatusIndicatorWidget { } // Schedule next animation frame. - self.app_event_tx - .send(AppEvent::ScheduleFrameIn(Duration::from_millis(100))); + self.frame_requester + .schedule_frame_in(Duration::from_millis(100)); let idx = self.current_frame(); let elapsed = self.start_time.elapsed().as_secs(); let shown_now = self.current_shown_len(idx); @@ -219,7 +222,7 @@ mod tests { fn renders_without_left_border_or_padding() { let (tx_raw, _rx) = unbounded_channel::(); let tx = AppEventSender::new(tx_raw); - let mut w = StatusIndicatorWidget::new(tx); + let mut w = StatusIndicatorWidget::new(tx, crate::tui::FrameRequester::test_dummy()); w.restart_with_text("Hello".to_string()); let area = ratatui::layout::Rect::new(0, 0, 30, 1); @@ -237,7 +240,7 @@ mod tests { fn working_header_is_present_on_last_line() { let (tx_raw, _rx) = unbounded_channel::(); let tx = AppEventSender::new(tx_raw); - let mut w = StatusIndicatorWidget::new(tx); + let mut w = StatusIndicatorWidget::new(tx, crate::tui::FrameRequester::test_dummy()); w.restart_with_text("Hi".to_string()); // Ensure some frames elapse so we get a stable state. std::thread::sleep(std::time::Duration::from_millis(120)); @@ -258,7 +261,7 @@ mod tests { fn header_starts_at_expected_position() { let (tx_raw, _rx) = unbounded_channel::(); let tx = AppEventSender::new(tx_raw); - let mut w = StatusIndicatorWidget::new(tx); + let mut w = StatusIndicatorWidget::new(tx, crate::tui::FrameRequester::test_dummy()); w.restart_with_text("Hello".to_string()); std::thread::sleep(std::time::Duration::from_millis(120)); diff --git a/codex-rs/tui/src/tui.rs b/codex-rs/tui/src/tui.rs index 0447e32ae9..6af0d391b6 100644 --- a/codex-rs/tui/src/tui.rs +++ b/codex-rs/tui/src/tui.rs @@ -1,28 +1,39 @@ use std::io::Result; use std::io::Stdout; use std::io::stdout; +use std::pin::Pin; +use std::time::Duration; +use std::time::Instant; -use codex_core::config::Config; +use crossterm::SynchronizedUpdate; use crossterm::cursor::MoveTo; use crossterm::event::DisableBracketedPaste; use crossterm::event::EnableBracketedPaste; +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyEventKind; use crossterm::event::KeyboardEnhancementFlags; use crossterm::event::PopKeyboardEnhancementFlags; use crossterm::event::PushKeyboardEnhancementFlags; use crossterm::terminal::Clear; use crossterm::terminal::ClearType; +use ratatui::backend::Backend; use ratatui::backend::CrosstermBackend; use ratatui::crossterm::execute; use ratatui::crossterm::terminal::disable_raw_mode; use ratatui::crossterm::terminal::enable_raw_mode; +use ratatui::layout::Offset; +use ratatui::text::Line; -use crate::custom_terminal::Terminal; +use crate::custom_terminal; +use crate::custom_terminal::Terminal as CustomTerminal; +use tokio::select; +use tokio_stream::Stream; /// A type alias for the terminal type used in this application -pub type Tui = Terminal>; +pub type Terminal = CustomTerminal>; -/// Initialize the terminal (inline viewport; history stays in normal scrollback) -pub fn init(_config: &Config) -> Result { +pub fn set_modes() -> Result<()> { execute!(stdout(), EnableBracketedPaste)?; enable_raw_mode()?; @@ -40,13 +51,31 @@ pub fn init(_config: &Config) -> Result { | KeyboardEnhancementFlags::REPORT_ALTERNATE_KEYS ) ); + Ok(()) +} + +/// Restore the terminal to its original state. +/// Inverse of `set_modes`. +pub fn restore() -> Result<()> { + // Pop may fail on platforms that didn't support the push; ignore errors. + let _ = execute!(stdout(), PopKeyboardEnhancementFlags); + execute!(stdout(), DisableBracketedPaste)?; + disable_raw_mode()?; + let _ = execute!(stdout(), crossterm::cursor::Show); + Ok(()) +} + +/// Initialize the terminal (inline viewport; history stays in normal scrollback) +pub fn init() -> Result { + set_modes()?; + set_panic_hook(); // Clear screen and move cursor to top-left before drawing UI execute!(stdout(), Clear(ClearType::All), MoveTo(0, 0))?; let backend = CrosstermBackend::new(stdout()); - let tui = Terminal::with_options(backend)?; + let tui = CustomTerminal::with_options(backend)?; Ok(tui) } @@ -58,11 +87,223 @@ fn set_panic_hook() { })); } -/// Restore the terminal to its original state -pub fn restore() -> Result<()> { - // Pop may fail on platforms that didn't support the push; ignore errors. - let _ = execute!(stdout(), PopKeyboardEnhancementFlags); - execute!(stdout(), DisableBracketedPaste)?; - disable_raw_mode()?; - Ok(()) +#[derive(Debug)] +pub enum TuiEvent { + Key(KeyEvent), + Paste(String), + Draw, + #[cfg(unix)] + ResumeFromSuspend, +} + +pub struct Tui { + frame_schedule_tx: tokio::sync::mpsc::UnboundedSender, + draw_tx: tokio::sync::broadcast::Sender<()>, + pub(crate) terminal: Terminal, + pending_history_lines: Vec>, +} + +#[derive(Clone, Debug)] +pub struct FrameRequester { + frame_schedule_tx: tokio::sync::mpsc::UnboundedSender, +} +impl FrameRequester { + pub fn schedule_frame(&self) { + let _ = self.frame_schedule_tx.send(Instant::now()); + } + pub fn schedule_frame_in(&self, dur: Duration) { + let _ = self.frame_schedule_tx.send(Instant::now() + dur); + } +} + +#[cfg(test)] +impl FrameRequester { + /// Create a no-op frame requester for tests. + pub(crate) fn test_dummy() -> Self { + let (tx, _rx) = tokio::sync::mpsc::unbounded_channel(); + FrameRequester { + frame_schedule_tx: tx, + } + } +} + +impl Tui { + 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); + + // Spawn background scheduler to coalesce frame requests and emit draws at deadlines. + let draw_tx_clone = draw_tx.clone(); + tokio::spawn(async move { + use tokio::select; + use tokio::time::Instant as TokioInstant; + use tokio::time::sleep_until; + + let mut rx = frame_schedule_rx; + let mut next_deadline: Option = None; + + loop { + let target = next_deadline + .unwrap_or_else(|| Instant::now() + Duration::from_secs(60 * 60 * 24 * 365)); + let sleep_fut = sleep_until(TokioInstant::from_std(target)); + tokio::pin!(sleep_fut); + + select! { + recv = rx.recv() => { + match recv { + Some(at) => { + if next_deadline.is_none_or(|cur| at < cur) { + next_deadline = Some(at); + } + if at <= Instant::now() { + next_deadline = None; + let _ = draw_tx_clone.send(()); + } + } + None => break, + } + } + _ = &mut sleep_fut => { + if next_deadline.is_some() { + next_deadline = None; + let _ = draw_tx_clone.send(()); + } + } + } + } + }); + + Self { + frame_schedule_tx, + draw_tx, + terminal, + pending_history_lines: vec![], + } + } + + pub fn frame_requester(&self) -> FrameRequester { + FrameRequester { + frame_schedule_tx: self.frame_schedule_tx.clone(), + } + } + + pub fn event_stream(&self) -> Pin + Send + 'static>> { + use tokio_stream::StreamExt; + let mut crossterm_events = crossterm::event::EventStream::new(); + let mut draw_rx = self.draw_tx.subscribe(); + let event_stream = async_stream::stream! { + loop { + select! { + Some(Ok(event)) = crossterm_events.next() => { + match event { + crossterm::event::Event::Key(KeyEvent { + code: KeyCode::Char('z'), + modifiers: crossterm::event::KeyModifiers::CONTROL, + kind: KeyEventKind::Press, + .. + }) => { + #[cfg(unix)] + { + let _ = Tui::suspend(); + yield TuiEvent::ResumeFromSuspend; + yield TuiEvent::Draw; + } + } + crossterm::event::Event::Key(key_event) => { + yield TuiEvent::Key(key_event); + } + crossterm::event::Event::Resize(_, _) => { + yield TuiEvent::Draw; + } + crossterm::event::Event::Paste(pasted) => { + yield TuiEvent::Paste(pasted); + } + _ => {} + } + } + result = draw_rx.recv() => { + match result { + Ok(_) => { + yield TuiEvent::Draw; + } + Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => { + // We dropped one or more draw notifications; coalesce to a single draw. + yield TuiEvent::Draw; + } + Err(tokio::sync::broadcast::error::RecvError::Closed) => { + // Sender dropped; stop emitting draws from this source. + } + } + } + } + } + }; + Box::pin(event_stream) + } + + #[cfg(unix)] + fn suspend() -> Result<()> { + restore()?; + unsafe { libc::kill(0, libc::SIGTSTP) }; + set_modes()?; + Ok(()) + } + + pub fn insert_history_lines(&mut self, lines: Vec>) { + self.pending_history_lines.extend(lines); + self.frame_requester().schedule_frame(); + } + + pub fn draw( + &mut self, + height: u16, + draw_fn: impl FnOnce(&mut custom_terminal::Frame), + ) -> Result<()> { + std::io::stdout().sync_update(|_| { + let terminal = &mut self.terminal; + let screen_size = terminal.size()?; + let last_known_screen_size = terminal.last_known_screen_size; + if screen_size != last_known_screen_size { + let cursor_pos = terminal.get_cursor_position()?; + let last_known_cursor_pos = terminal.last_known_cursor_pos; + if cursor_pos.y != last_known_cursor_pos.y { + let cursor_delta = cursor_pos.y as i32 - last_known_cursor_pos.y as i32; + + let new_viewport_area = terminal.viewport_area.offset(Offset { + x: 0, + y: cursor_delta, + }); + terminal.set_viewport_area(new_viewport_area); + terminal.clear()?; + } + } + + let size = terminal.size()?; + + let mut area = terminal.viewport_area; + area.height = height.min(size.height); + area.width = size.width; + if area.bottom() > size.height { + terminal + .backend_mut() + .scroll_region_up(0..area.top(), area.bottom() - size.height)?; + area.y = size.height - area.height; + } + if area != terminal.viewport_area { + terminal.clear()?; + terminal.set_viewport_area(area); + } + if !self.pending_history_lines.is_empty() { + crate::insert_history::insert_history_lines( + terminal, + self.pending_history_lines.clone(), + ); + self.pending_history_lines.clear(); + } + terminal.draw(|frame| { + draw_fn(frame); + })?; + Ok(()) + })? + } } diff --git a/codex-rs/tui/src/user_approval_widget.rs b/codex-rs/tui/src/user_approval_widget.rs index d317ff0d8a..f55ebba70c 100644 --- a/codex-rs/tui/src/user_approval_widget.rs +++ b/codex-rs/tui/src/user_approval_widget.rs @@ -95,11 +95,11 @@ static PATCH_SELECT_OPTIONS: LazyLock> = LazyLock::new(|| { }); /// A modal prompting the user to approve or deny the pending request. -pub(crate) struct UserApprovalWidget<'a> { +pub(crate) struct UserApprovalWidget { approval_request: ApprovalRequest, app_event_tx: AppEventSender, - confirmation_prompt: Paragraph<'a>, - select_options: &'a Vec, + confirmation_prompt: Paragraph<'static>, + select_options: &'static Vec, /// Currently selected index in *select* mode. selected_option: usize, @@ -137,7 +137,7 @@ fn to_command_display<'a>( lines } -impl UserApprovalWidget<'_> { +impl UserApprovalWidget { pub(crate) fn new(approval_request: ApprovalRequest, app_event_tx: AppEventSender) -> Self { let confirmation_prompt = match &approval_request { ApprovalRequest::Exec { @@ -356,7 +356,7 @@ impl UserApprovalWidget<'_> { } } -impl WidgetRef for &UserApprovalWidget<'_> { +impl WidgetRef for &UserApprovalWidget { fn render_ref(&self, area: Rect, buf: &mut Buffer) { let prompt_height = self.get_confirmation_prompt_height(area.width); let [prompt_chunk, response_chunk] = Layout::default() From c579ae41aee48e7d2a0c9171bb6bd655d61736d0 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Wed, 20 Aug 2025 14:05:20 -0700 Subject: [PATCH 07/27] Fix login for internal employees (#2528) This PR: - fixes for internal employee because we currently want to prefer SIWC for them. - fixes retrying forever on unauthorized access. we need to break eventually on max retries. --- codex-rs/core/src/client.rs | 15 ++++++++------- codex-rs/login/src/lib.rs | 2 +- codex-rs/login/src/token_data.rs | 17 ++++++++++++++++- 3 files changed, 25 insertions(+), 9 deletions(-) diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index 9552a53d7f..5534e11f36 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -248,12 +248,10 @@ impl ModelClient { .and_then(|v| v.to_str().ok()) .and_then(|s| s.parse::().ok()); - if status == StatusCode::UNAUTHORIZED { - if let Some(a) = auth.as_ref() { - let _ = a.refresh_token().await; - } - // Retry immediately with refreshed credentials. - continue; + if status == StatusCode::UNAUTHORIZED + && let Some(a) = auth.as_ref() + { + let _ = a.refresh_token().await; } // The OpenAI Responses endpoint returns structured JSON bodies even for 4xx/5xx @@ -263,7 +261,10 @@ impl ModelClient { // exact error message (e.g. "Unknown parameter: 'input[0].metadata'"). The body is // small and this branch only runs on error paths so the extra allocation is // negligible. - if !(status == StatusCode::TOO_MANY_REQUESTS || status.is_server_error()) { + if !(status == StatusCode::TOO_MANY_REQUESTS + || status == StatusCode::UNAUTHORIZED + || status.is_server_error()) + { // Surface the error body to callers. Use `unwrap_or_default` per Clippy. let body = res.text().await.unwrap_or_default(); return Err(CodexErr::UnexpectedStatus(status, body)); diff --git a/codex-rs/login/src/lib.rs b/codex-rs/login/src/lib.rs index d6fa4aef9a..8c9a5cf37d 100644 --- a/codex-rs/login/src/lib.rs +++ b/codex-rs/login/src/lib.rs @@ -242,7 +242,7 @@ fn load_auth( // "refreshable" even if we are using the API key for auth? match &tokens { Some(tokens) => { - if tokens.should_use_api_key(preferred_auth_method) { + if tokens.should_use_api_key(preferred_auth_method, tokens.is_openai_email()) { return Ok(Some(CodexAuth::from_api_key(api_key))); } else { // Ignore the API key and fall through to ChatGPT auth. diff --git a/codex-rs/login/src/token_data.rs b/codex-rs/login/src/token_data.rs index 59e6d97269..f6d04f16a6 100644 --- a/codex-rs/login/src/token_data.rs +++ b/codex-rs/login/src/token_data.rs @@ -25,16 +25,31 @@ pub struct TokenData { impl TokenData { /// Returns true if this is a plan that should use the traditional /// "metered" billing via an API key. - pub(crate) fn should_use_api_key(&self, preferred_auth_method: AuthMode) -> bool { + pub(crate) fn should_use_api_key( + &self, + preferred_auth_method: AuthMode, + is_openai_email: bool, + ) -> bool { if preferred_auth_method == AuthMode::ApiKey { return true; } + // If the email is an OpenAI email, use AuthMode::ChatGPT unless preferred_auth_method is AuthMode::ApiKey. + if is_openai_email { + return false; + } self.id_token .chatgpt_plan_type .as_ref() .is_none_or(|plan| plan.is_plan_that_should_use_api_key()) } + + pub fn is_openai_email(&self) -> bool { + self.id_token + .email + .as_deref() + .is_some_and(|email| email.trim().to_ascii_lowercase().ends_with("@openai.com")) + } } /// Flat subset of useful claims in id_token from auth.json. From 250ae37c8492f475b5f2af3f7a9f3e96973b2657 Mon Sep 17 00:00:00 2001 From: ae Date: Wed, 20 Aug 2025 14:58:04 -0700 Subject: [PATCH 08/27] tui: link docs when no MCP servers configured (#2516) --- codex-rs/tui/src/history_cell.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 3583aaa397..4062393517 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -642,6 +642,15 @@ pub(crate) fn empty_mcp_output() -> PlainHistoryCell { Line::from(vec!["🔌 ".into(), "MCP Tools".bold()]), Line::from(""), Line::from(" • No MCP servers configured.".italic()), + Line::from(vec![ + " See the ".into(), + Span::styled( + "\u{1b}]8;;https://github.com/openai/codex/blob/main/codex-rs/config.md#mcp_servers\u{7}MCP docs\u{1b}]8;;\u{7}", + Style::default().add_modifier(Modifier::UNDERLINED), + ), + " to configure them.".into(), + ]) + .style(Style::default().add_modifier(Modifier::DIM)), Line::from(""), ]; From 5ab30c73f3d2e711f40d75a7105083753e6d3675 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Wed, 20 Aug 2025 15:57:33 -0700 Subject: [PATCH 09/27] fix: update build cache key in .github/workflows/codex.yml (#2534) Change to match `.github/workflows/rust-ci.yml`, which was updated in https://github.com/openai/codex/pull/2242: https://github.com/openai/codex/blob/250ae37c8492f475b5f2af3f7a9f3e96973b2657/.github/workflows/rust-ci.yml#L120-L128 --- .github/workflows/codex.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/codex.yml b/.github/workflows/codex.yml index 367c53e947..58e852336d 100644 --- a/.github/workflows/codex.yml +++ b/.github/workflows/codex.yml @@ -52,7 +52,7 @@ jobs: ~/.cargo/registry/cache/ ~/.cargo/git/db/ ${{ github.workspace }}/codex-rs/target/ - key: cargo-ubuntu-24.04-x86_64-unknown-linux-gnu-${{ hashFiles('**/Cargo.lock') }} + key: cargo-ubuntu-24.04-x86_64-unknown-linux-gnu-dev-${{ hashFiles('**/Cargo.lock') }} # Note it is possible that the `verify` step internal to Run Codex will # fail, in which case the work to setup the repo was worthless :( From 050b9baeb6e0e5b49d4e5a578653954bb1857d75 Mon Sep 17 00:00:00 2001 From: eddy-win Date: Wed, 20 Aug 2025 16:30:34 -0700 Subject: [PATCH 10/27] Bridge command generation to powershell when on Windows (#2319) ## What? Why? How? - When running on Windows, codex often tries to invoke bash commands, which commonly fail (unless WSL is installed) - Fix: Detect if powershell is available and, if so, route commands to it - Also add a shell_name property to environmental context for codex to default to powershell commands when running in that environment ## Testing - Tested within WSL and powershell (e.g. get top 5 largest files within a folder and validated that commands generated were powershell commands) - Tested within Zsh - Updated unit tests --------- Co-authored-by: Eddy Escardo --- codex-rs/Cargo.lock | 19 +++ codex-rs/core/Cargo.toml | 3 + codex-rs/core/src/codex.rs | 20 ++- codex-rs/core/src/environment_context.rs | 7 + codex-rs/core/src/shell.rs | 205 ++++++++++++++++++++++- codex-rs/core/tests/prompt_caching.rs | 20 ++- 6 files changed, 259 insertions(+), 15 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index d6b98fcd1b..825b2a485f 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -737,6 +737,7 @@ dependencies = [ "tree-sitter-bash", "uuid", "walkdir", + "which", "whoami", "wildmatch", "wiremock", @@ -5599,6 +5600,18 @@ version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a751b3277700db47d3e574514de2eced5e54dc8a5436a3bf7a0b248b2cee16f3" +[[package]] +name = "which" +version = "6.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ee928febd44d98f2f459a4a79bd4d928591333a494a10a868418ac1b39cf1f" +dependencies = [ + "either", + "home", + "rustix 0.38.44", + "winsafe", +] + [[package]] name = "whoami" version = "1.6.0" @@ -6011,6 +6024,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "winsafe" +version = "0.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904" + [[package]] name = "wiremock" version = "0.6.4" diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index 52c731ae66..56815ba03c 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -71,6 +71,9 @@ openssl-sys = { version = "*", features = ["vendored"] } [target.aarch64-unknown-linux-musl.dependencies] openssl-sys = { version = "*", features = ["vendored"] } +[target.'cfg(target_os = "windows")'.dependencies] +which = "6" + [dev-dependencies] assert_cmd = "2" core_test_support = { path = "tests/common" } diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 57719f5269..dfa10398fe 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -511,6 +511,7 @@ impl Session { turn_context.cwd.to_path_buf(), turn_context.approval_policy, turn_context.sandbox_policy.clone(), + sess.user_shell.clone(), ))); sess.record_conversation_items(&conversation_items).await; @@ -1070,6 +1071,7 @@ async fn submission_loop( new_cwd, new_approval_policy, new_sandbox_policy, + sess.user_shell.clone(), ))]) .await; } @@ -2051,18 +2053,20 @@ pub struct ExecInvokeArgs<'a> { pub stdout_stream: Option, } -fn maybe_run_with_user_profile( +fn maybe_translate_shell_command( params: ExecParams, sess: &Session, turn_context: &TurnContext, ) -> ExecParams { - if turn_context.shell_environment_policy.use_profile { - let command = sess + let should_translate = matches!(sess.user_shell, crate::shell::Shell::PowerShell(_)) + || turn_context.shell_environment_policy.use_profile; + + if should_translate + && let Some(command) = sess .user_shell - .format_default_shell_invocation(params.command.clone()); - if let Some(command) = command { - return ExecParams { command, ..params }; - } + .format_default_shell_invocation(params.command.clone()) + { + return ExecParams { command, ..params }; } params } @@ -2227,7 +2231,7 @@ async fn handle_container_exec_with_params( ), }; - let params = maybe_run_with_user_profile(params, sess, turn_context); + let params = maybe_translate_shell_command(params, sess, turn_context); let output_result = sess .run_exec_with_events( turn_diff_tracker, diff --git a/codex-rs/core/src/environment_context.rs b/codex-rs/core/src/environment_context.rs index b37645a32b..a5a9b85589 100644 --- a/codex-rs/core/src/environment_context.rs +++ b/codex-rs/core/src/environment_context.rs @@ -6,6 +6,7 @@ use crate::models::ContentItem; use crate::models::ResponseItem; use crate::protocol::AskForApproval; use crate::protocol::SandboxPolicy; +use crate::shell::Shell; use codex_protocol::config_types::SandboxMode; use std::fmt::Display; use std::path::PathBuf; @@ -28,6 +29,7 @@ pub(crate) struct EnvironmentContext { pub approval_policy: AskForApproval, pub sandbox_mode: SandboxMode, pub network_access: NetworkAccess, + pub shell: Shell, } impl EnvironmentContext { @@ -35,6 +37,7 @@ impl EnvironmentContext { cwd: PathBuf, approval_policy: AskForApproval, sandbox_policy: SandboxPolicy, + shell: Shell, ) -> Self { Self { cwd, @@ -55,6 +58,7 @@ impl EnvironmentContext { } } }, + shell, } } } @@ -69,6 +73,9 @@ impl Display for EnvironmentContext { writeln!(f, "Approval policy: {}", self.approval_policy)?; writeln!(f, "Sandbox mode: {}", self.sandbox_mode)?; writeln!(f, "Network access: {}", self.network_access)?; + if let Some(shell_name) = self.shell.name() { + writeln!(f, "Shell: {shell_name}")?; + } Ok(()) } } diff --git a/codex-rs/core/src/shell.rs b/codex-rs/core/src/shell.rs index c269b87ef2..3a69874eb2 100644 --- a/codex-rs/core/src/shell.rs +++ b/codex-rs/core/src/shell.rs @@ -1,14 +1,24 @@ +use serde::Deserialize; +use serde::Serialize; use shlex; +use std::path::PathBuf; -#[derive(Debug, PartialEq, Eq)] +#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)] pub struct ZshShell { shell_path: String, zshrc_path: String, } -#[derive(Debug, PartialEq, Eq)] +#[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. +} + +#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)] pub enum Shell { Zsh(ZshShell), + PowerShell(PowerShellConfig), Unknown, } @@ -33,6 +43,61 @@ impl Shell { } Some(result) } + Shell::PowerShell(ps) => { + // If model generated a bash command, prefer a detected bash fallback + if let Some(script) = strip_bash_lc(&command) { + return match &ps.bash_exe_fallback { + Some(bash) => Some(vec![ + bash.to_string_lossy().to_string(), + "-lc".to_string(), + script, + ]), + + // No bash fallback → run the script under PowerShell. + // It will likely fail (except for some simple commands), but the error + // should give a clue to the model to fix upon retry that it's running under PowerShell. + None => Some(vec![ + ps.exe.clone(), + "-NoProfile".to_string(), + "-Command".to_string(), + script, + ]), + }; + } + + // Not a bash command. If model did not generate a PowerShell command, + // turn it into a PowerShell command. + let first = command.first().map(String::as_str); + if first != Some(ps.exe.as_str()) { + // TODO (CODEX_2900): Handle escaping newlines. + if command.iter().any(|a| a.contains('\n') || a.contains('\r')) { + return Some(command); + } + + let joined = shlex::try_join(command.iter().map(|s| s.as_str())).ok(); + return joined.map(|arg| { + vec![ + ps.exe.clone(), + "-NoProfile".to_string(), + "-Command".to_string(), + arg, + ] + }); + } + + // Model generated a PowerShell command. Run it. + Some(command) + } + Shell::Unknown => None, + } + } + + pub fn name(&self) -> Option { + match self { + Shell::Zsh(zsh) => std::path::Path::new(&zsh.shell_path) + .file_name() + .map(|s| s.to_string_lossy().to_string()), + Shell::PowerShell(ps) => Some(ps.exe.clone()), Shell::Unknown => None, } } @@ -86,11 +151,51 @@ pub async fn default_user_shell() -> Shell { } } -#[cfg(not(target_os = "macos"))] +#[cfg(all(not(target_os = "macos"), not(target_os = "windows")))] pub async fn default_user_shell() -> Shell { Shell::Unknown } +#[cfg(target_os = "windows")] +pub async fn default_user_shell() -> Shell { + use tokio::process::Command; + + // Prefer PowerShell 7+ (`pwsh`) if available, otherwise fall back to Windows PowerShell. + let has_pwsh = Command::new("pwsh") + .arg("-NoLogo") + .arg("-NoProfile") + .arg("-Command") + .arg("$PSVersionTable.PSVersion.Major") + .output() + .await + .map(|o| o.status.success()) + .unwrap_or(false); + let bash_exe = if Command::new("bash.exe") + .arg("--version") + .output() + .await + .ok() + .map(|o| o.status.success()) + .unwrap_or(false) + { + which::which("bash.exe").ok() + } else { + None + }; + + if has_pwsh { + Shell::PowerShell(PowerShellConfig { + exe: "pwsh.exe".to_string(), + bash_exe_fallback: bash_exe, + }) + } else { + Shell::PowerShell(PowerShellConfig { + exe: "powershell.exe".to_string(), + bash_exe_fallback: bash_exe, + }) + } +} + #[cfg(test)] #[cfg(target_os = "macos")] mod tests { @@ -231,3 +336,97 @@ mod tests { } } } + +#[cfg(test)] +#[cfg(target_os = "windows")] +mod tests_windows { + use super::*; + + #[test] + fn test_format_default_shell_invocation_powershell() { + let cases = vec![ + ( + Shell::PowerShell(PowerShellConfig { + exe: "pwsh.exe".to_string(), + bash_exe_fallback: None, + }), + vec!["bash", "-lc", "echo hello"], + vec!["pwsh.exe", "-NoProfile", "-Command", "echo hello"], + ), + ( + Shell::PowerShell(PowerShellConfig { + exe: "powershell.exe".to_string(), + bash_exe_fallback: None, + }), + vec!["bash", "-lc", "echo hello"], + vec!["powershell.exe", "-NoProfile", "-Command", "echo hello"], + ), + ( + Shell::PowerShell(PowerShellConfig { + exe: "pwsh.exe".to_string(), + bash_exe_fallback: Some(PathBuf::from("bash.exe")), + }), + vec!["bash", "-lc", "echo hello"], + vec!["bash.exe", "-lc", "echo hello"], + ), + ( + Shell::PowerShell(PowerShellConfig { + exe: "pwsh.exe".to_string(), + bash_exe_fallback: Some(PathBuf::from("bash.exe")), + }), + vec![ + "bash", + "-lc", + "apply_patch <<'EOF'\n*** Begin Patch\n*** Update File: destination_file.txt\n-original content\n+modified content\n*** End Patch\nEOF", + ], + vec![ + "bash.exe", + "-lc", + "apply_patch <<'EOF'\n*** Begin Patch\n*** Update File: destination_file.txt\n-original content\n+modified content\n*** End Patch\nEOF", + ], + ), + ( + Shell::PowerShell(PowerShellConfig { + exe: "pwsh.exe".to_string(), + bash_exe_fallback: Some(PathBuf::from("bash.exe")), + }), + vec!["echo", "hello"], + vec!["pwsh.exe", "-NoProfile", "-Command", "echo hello"], + ), + ( + Shell::PowerShell(PowerShellConfig { + exe: "pwsh.exe".to_string(), + bash_exe_fallback: Some(PathBuf::from("bash.exe")), + }), + vec!["pwsh.exe", "-NoProfile", "-Command", "echo hello"], + vec!["pwsh.exe", "-NoProfile", "-Command", "echo hello"], + ), + ( + // TODO (CODEX_2900): Handle escaping newlines for powershell invocation. + Shell::PowerShell(PowerShellConfig { + exe: "powershell.exe".to_string(), + bash_exe_fallback: Some(PathBuf::from("bash.exe")), + }), + vec![ + "codex-mcp-server.exe", + "--codex-run-as-apply-patch", + "*** Begin Patch\n*** Update File: C:\\Users\\person\\destination_file.txt\n-original content\n+modified content\n*** End Patch", + ], + vec![ + "codex-mcp-server.exe", + "--codex-run-as-apply-patch", + "*** Begin Patch\n*** Update File: C:\\Users\\person\\destination_file.txt\n-original content\n+modified content\n*** End Patch", + ], + ), + ]; + + for (shell, input, expected_cmd) in cases { + let actual_cmd = shell + .format_default_shell_invocation(input.iter().map(|s| s.to_string()).collect()); + assert_eq!( + actual_cmd, + Some(expected_cmd.iter().map(|s| s.to_string()).collect()) + ); + } + } +} diff --git a/codex-rs/core/tests/prompt_caching.rs b/codex-rs/core/tests/prompt_caching.rs index 9f5829e113..75b7691ba4 100644 --- a/codex-rs/core/tests/prompt_caching.rs +++ b/codex-rs/core/tests/prompt_caching.rs @@ -8,6 +8,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::default_user_shell; use codex_login::CodexAuth; use core_test_support::load_default_config_for_test; use core_test_support::load_sse_fixture_with_id; @@ -85,9 +86,15 @@ async fn prefixes_context_and_instructions_once_and_consistently_across_requests let requests = server.received_requests().await.unwrap(); assert_eq!(requests.len(), 2, "expected two POST requests"); + let shell = default_user_shell().await; + let expected_env_text = format!( - "\nCurrent working directory: {}\nApproval policy: on-request\nSandbox mode: read-only\nNetwork access: restricted\n", - cwd.path().to_string_lossy() + "\nCurrent working directory: {}\nApproval policy: on-request\nSandbox mode: read-only\nNetwork access: restricted\n{}", + cwd.path().to_string_lossy(), + match shell.name() { + Some(name) => format!("Shell: {name}\n"), + None => String::new(), + } ); let expected_ui_text = "\n\nbe consistent and helpful\n\n"; @@ -237,9 +244,14 @@ async fn overrides_turn_context_but_keeps_cached_prefix_and_key_constant() { }); // After overriding the turn context, the environment context should be emitted again // reflecting the new cwd, approval policy and sandbox settings. + let shell = default_user_shell().await; let expected_env_text_2 = format!( - "\nCurrent working directory: {}\nApproval policy: never\nSandbox mode: workspace-write\nNetwork access: enabled\n", - new_cwd.path().to_string_lossy() + "\nCurrent working directory: {}\nApproval policy: never\nSandbox mode: workspace-write\nNetwork access: enabled\n{}", + new_cwd.path().to_string_lossy(), + match shell.name() { + Some(name) => format!("Shell: {name}\n"), + None => String::new(), + } ); let expected_env_msg_2 = serde_json::json!({ "type": "message", From 2ec5a28528c3174ec45f4814e154c096d687253f Mon Sep 17 00:00:00 2001 From: Jeremy Rose <172423086+nornagon-openai@users.noreply.github.com> Date: Wed, 20 Aug 2025 16:57:35 -0700 Subject: [PATCH 11/27] add transcript mode (#2525) this adds a new 'transcript mode' that shows the full event history in a "pager"-style interface. https://github.com/user-attachments/assets/52df7a14-adb2-4ea7-a0f9-7f5eb8235182 --- codex-rs/tui/src/app.rs | 18 ++- codex-rs/tui/src/lib.rs | 1 + codex-rs/tui/src/transcript_app.rs | 230 +++++++++++++++++++++++++++++ 3 files changed, 247 insertions(+), 2 deletions(-) create mode 100644 codex-rs/tui/src/transcript_app.rs diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 399acd401c..a58070c04b 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -4,6 +4,7 @@ use crate::chatwidget::ChatWidget; use crate::file_search::FileSearchManager; use crate::get_git_diff::get_git_diff; use crate::slash_command::SlashCommand; +use crate::transcript_app::run_transcript_app; use crate::tui; use crate::tui::TuiEvent; use codex_core::ConversationManager; @@ -16,6 +17,7 @@ use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyEventKind; use crossterm::terminal::supports_keyboard_enhancement; +use ratatui::text::Line; use std::path::PathBuf; use std::sync::Arc; use std::sync::atomic::AtomicBool; @@ -35,6 +37,8 @@ pub(crate) struct App { file_search: FileSearchManager, + transcript_lines: Vec>, + enhanced_keys_supported: bool, /// Controls the animation thread that sends CommitTick events. @@ -75,6 +79,7 @@ impl App { config, file_search, enhanced_keys_supported, + transcript_lines: Vec::new(), commit_anim_running: Arc::new(AtomicBool::new(false)), }; @@ -102,7 +107,7 @@ impl App { ) -> Result { match event { TuiEvent::Key(key_event) => { - self.handle_key_event(key_event).await; + self.handle_key_event(tui, key_event).await; } TuiEvent::Paste(pasted) => { // Many terminals convert newlines to \r when pasting (e.g., iTerm2), @@ -136,6 +141,7 @@ impl App { fn handle_event(&mut self, tui: &mut tui::Tui, event: AppEvent) -> Result { match event { AppEvent::InsertHistory(lines) => { + self.transcript_lines.extend(lines.clone()); tui.insert_history_lines(lines); } AppEvent::StartCommitAnimation => { @@ -303,7 +309,7 @@ impl App { self.chat_widget.token_usage().clone() } - async fn handle_key_event(&mut self, key_event: KeyEvent) { + async fn handle_key_event(&mut self, tui: &mut tui::Tui, key_event: KeyEvent) { match key_event { KeyEvent { code: KeyCode::Char('c'), @@ -321,6 +327,14 @@ impl App { } if self.chat_widget.composer_is_empty() => { self.app_event_tx.send(AppEvent::ExitRequest); } + KeyEvent { + code: KeyCode::Char('t'), + modifiers: crossterm::event::KeyModifiers::CONTROL, + kind: KeyEventKind::Press, + .. + } => { + run_transcript_app(tui, self.transcript_lines.clone()).await; + } KeyEvent { kind: KeyEventKind::Press | KeyEventKind::Repeat, .. diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index ac463be060..52b1c7dab2 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -49,6 +49,7 @@ mod slash_command; mod status_indicator_widget; mod streaming; mod text_formatting; +mod transcript_app; mod tui; mod user_approval_widget; diff --git a/codex-rs/tui/src/transcript_app.rs b/codex-rs/tui/src/transcript_app.rs new file mode 100644 index 0000000000..a9b9d5d8f5 --- /dev/null +++ b/codex-rs/tui/src/transcript_app.rs @@ -0,0 +1,230 @@ +use crate::insert_history; +use crate::tui; +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyEventKind; +use crossterm::execute; +use crossterm::terminal::EnterAlternateScreen; +use crossterm::terminal::LeaveAlternateScreen; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::Color; +use ratatui::style::Style; +use ratatui::style::Styled; +use ratatui::style::Stylize; +use ratatui::text::Line; +use ratatui::text::Span; +use ratatui::widgets::Paragraph; +use ratatui::widgets::WidgetRef; +use tokio::select; + +pub async fn run_transcript_app(tui: &mut tui::Tui, transcript_lines: Vec>) { + use tokio_stream::StreamExt; + let _ = execute!(tui.terminal.backend_mut(), EnterAlternateScreen); + #[allow(clippy::unwrap_used)] + let size = tui.terminal.size().unwrap(); + let old_viewport_area = tui.terminal.viewport_area; + tui.terminal + .set_viewport_area(Rect::new(0, 0, size.width, size.height)); + let _ = tui.terminal.clear(); + + let tui_events = tui.event_stream(); + tokio::pin!(tui_events); + + tui.frame_requester().schedule_frame(); + + let mut app = TranscriptApp { + transcript_lines, + scroll_offset: usize::MAX, + is_done: false, + }; + + while !app.is_done { + select! { + Some(event) = tui_events.next() => { + match event { + crate::tui::TuiEvent::Key(key_event) => { + app.handle_key_event(tui, key_event); + tui.frame_requester().schedule_frame(); + } + crate::tui::TuiEvent::Draw => { + let _ = tui.draw(u16::MAX, |frame| { + app.render(frame.area(), frame.buffer); + }); + } + _ => {} + } + } + } + } + + let _ = execute!(tui.terminal.backend_mut(), LeaveAlternateScreen); + + tui.terminal.set_viewport_area(old_viewport_area); +} + +pub(crate) struct TranscriptApp { + pub(crate) transcript_lines: Vec>, + pub(crate) scroll_offset: usize, + pub(crate) is_done: bool, +} + +impl TranscriptApp { + pub(crate) fn handle_key_event(&mut self, tui: &mut tui::Tui, key_event: KeyEvent) { + match key_event { + KeyEvent { + code: KeyCode::Char('q'), + kind: KeyEventKind::Press, + .. + } + | KeyEvent { + code: KeyCode::Char('t'), + modifiers: crossterm::event::KeyModifiers::CONTROL, + kind: KeyEventKind::Press, + .. + } + | KeyEvent { + code: KeyCode::Char('c'), + modifiers: crossterm::event::KeyModifiers::CONTROL, + kind: KeyEventKind::Press, + .. + } => { + self.is_done = true; + } + KeyEvent { + code: KeyCode::Up, + kind: KeyEventKind::Press | KeyEventKind::Repeat, + .. + } => { + self.scroll_offset = self.scroll_offset.saturating_sub(1); + } + KeyEvent { + code: KeyCode::Down, + kind: KeyEventKind::Press | KeyEventKind::Repeat, + .. + } => { + self.scroll_offset = self.scroll_offset.saturating_add(1); + } + KeyEvent { + code: KeyCode::PageUp, + kind: KeyEventKind::Press | KeyEventKind::Repeat, + .. + } => { + let area = self.scroll_area(tui.terminal.viewport_area); + self.scroll_offset = self.scroll_offset.saturating_sub(area.height as usize); + } + KeyEvent { + code: KeyCode::PageDown, + kind: KeyEventKind::Press | KeyEventKind::Repeat, + .. + } => { + let area = self.scroll_area(tui.terminal.viewport_area); + self.scroll_offset = self.scroll_offset.saturating_add(area.height as usize); + } + KeyEvent { + code: KeyCode::Home, + kind: KeyEventKind::Press | KeyEventKind::Repeat, + .. + } => { + self.scroll_offset = 0; + } + KeyEvent { + code: KeyCode::End, + kind: KeyEventKind::Press | KeyEventKind::Repeat, + .. + } => { + self.scroll_offset = usize::MAX; + } + _ => {} + } + } + + fn scroll_area(&self, area: Rect) -> Rect { + let mut area = area; + // Reserve 1 line for the header and 4 lines for the bottom status section. This matches the chat composer. + area.y = area.y.saturating_add(1); + area.height = area.height.saturating_sub(5); + area + } + + pub(crate) fn render(&mut self, area: Rect, buf: &mut Buffer) { + Span::from("/ ".repeat(area.width as usize / 2)) + .dim() + .render_ref(area, buf); + Span::from("/ T R A N S C R I P T") + .dim() + .render_ref(area, buf); + + // Main content area (excludes header and bottom status section) + let content_area = self.scroll_area(area); + let wrapped = insert_history::word_wrap_lines(&self.transcript_lines, content_area.width); + + // Clamp scroll offset to valid range + 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 page = &wrapped[start..end]; + Paragraph::new(page.to_vec()).render_ref(content_area, buf); + + // Fill remaining visible lines (if any) with a leading '~' in the first column. + let visible = (end - start) as u16; + if content_area.height > visible { + let extra = content_area.height - visible; + for i in 0..extra { + let y = content_area.y.saturating_add(visible + i); + Span::from("~") + .dim() + .render_ref(Rect::new(content_area.x, y, 1, 1), buf); + } + } + + // Bottom status section (4 lines): separator with % scrolled, then key hints (styled like chat composer) + let sep_y = content_area.bottom(); + let sep_rect = Rect::new(area.x, sep_y, area.width, 1); + let hints_rect = Rect::new(area.x, sep_y + 1, area.width, 2); + + // Separator line (dim) + Span::from("─".repeat(sep_rect.width as usize)) + .dim() + .render_ref(sep_rect, buf); + + // Scroll percentage (0-100%) aligned near the right edge + let max_scroll = wrapped.len().saturating_sub(content_area.height as usize); + let percent: u8 = if max_scroll == 0 { + 100 + } else { + (((self.scroll_offset.min(max_scroll)) as f32 / max_scroll as f32) * 100.0).round() + as u8 + }; + let pct_text = format!(" {percent}% "); + let pct_w = pct_text.chars().count() as u16; + let pct_x = sep_rect.x + sep_rect.width - pct_w - 1; + Span::from(pct_text) + .dim() + .render_ref(Rect::new(pct_x, sep_rect.y, pct_w, 1), buf); + + let key_hint_style = Style::default().fg(Color::Cyan); + + let hints1 = vec![ + " ".into(), + "↑".set_style(key_hint_style), + "/".into(), + "↓".set_style(key_hint_style), + " scroll ".into(), + "PgUp".set_style(key_hint_style), + "/".into(), + "PgDn".set_style(key_hint_style), + " page ".into(), + "Home".set_style(key_hint_style), + "/".into(), + "End".set_style(key_hint_style), + " jump".into(), + ]; + + let hints2 = vec![" ".into(), "q".set_style(key_hint_style), " quit".into()]; + Paragraph::new(vec![Line::from(hints1).dim(), Line::from(hints2).dim()]) + .render_ref(hints_rect, buf); + } +} From e95cad19466716763658961cbc9525d0af8be148 Mon Sep 17 00:00:00 2001 From: Jeremy Rose <172423086+nornagon-openai@users.noreply.github.com> Date: Wed, 20 Aug 2025 16:58:56 -0700 Subject: [PATCH 12/27] hide CoT by default; show headers in status indicator (#2316) Plan is for full CoT summaries to be visible in a "transcript view" when we implement that, but for now they're hidden. https://github.com/user-attachments/assets/e8a1b0ef-8f2a-48ff-9625-9c3c67d92cdb --- .../tui/src/bottom_pane/bottom_pane_view.rs | 5 + codex-rs/tui/src/bottom_pane/mod.rs | 11 ++ .../src/bottom_pane/status_indicator_view.rs | 8 + codex-rs/tui/src/chatwidget.rs | 85 ++++++--- ...e_final_message_are_rendered_snapshot.snap | 4 - ...n_message_without_deltas_are_rendered.snap | 3 - codex-rs/tui/src/chatwidget/tests.rs | 75 ++++++-- codex-rs/tui/src/markdown_stream.rs | 16 -- codex-rs/tui/src/shimmer.rs | 2 +- codex-rs/tui/src/status_indicator_widget.rs | 14 +- codex-rs/tui/src/streaming/controller.rs | 171 +++++------------- codex-rs/tui/src/streaming/mod.rs | 94 ++-------- .../tests/fixtures/ideal-binary-response.txt | 104 +---------- 13 files changed, 231 insertions(+), 361 deletions(-) diff --git a/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs b/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs index c98ed43e30..6a6d2aa89e 100644 --- a/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs +++ b/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs @@ -28,6 +28,11 @@ pub(crate) trait BottomPaneView { /// Render the view: this will be displayed in place of the composer. fn render(&self, area: Rect, buf: &mut Buffer); + /// Update the status indicator animated header. Default no-op. + fn update_status_header(&mut self, _header: String) { + // no-op + } + /// Called when task completes to check if the view should be hidden. fn should_hide_when_task_is_done(&mut self) -> bool { false diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index b6d71198e7..48ad3f02b5 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -182,6 +182,17 @@ impl BottomPane { self.request_redraw(); } + /// Update the animated header shown to the left of the brackets in the + /// status indicator (defaults to "Working"). This will update the active + /// StatusIndicatorView if present; otherwise, if a live overlay is active, + /// it will update that. If neither is present, this call is a no-op. + pub(crate) fn update_status_header(&mut self, header: String) { + if let Some(view) = self.active_view.as_mut() { + view.update_status_header(header.clone()); + self.request_redraw(); + } + } + pub(crate) fn show_ctrl_c_quit_hint(&mut self) { self.ctrl_c_quit_hint = true; self.composer diff --git a/codex-rs/tui/src/bottom_pane/status_indicator_view.rs b/codex-rs/tui/src/bottom_pane/status_indicator_view.rs index dcd1fc0b9f..7aeade1bc8 100644 --- a/codex-rs/tui/src/bottom_pane/status_indicator_view.rs +++ b/codex-rs/tui/src/bottom_pane/status_indicator_view.rs @@ -24,9 +24,17 @@ impl StatusIndicatorView { pub fn update_text(&mut self, text: String) { self.view.update_text(text); } + + pub fn update_header(&mut self, header: String) { + self.view.update_header(header); + } } impl BottomPaneView for StatusIndicatorView { + fn update_status_header(&mut self, header: String) { + self.update_header(header); + } + fn should_hide_when_task_is_done(&mut self) -> bool { true } diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index d10f6e3cbf..ca166d7b43 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -89,8 +89,6 @@ pub(crate) struct ChatWidget { last_token_usage: TokenUsage, // Stream lifecycle controller stream: StreamController, - // Track the most recently active stream kind in the current turn - last_stream_kind: Option, running_commands: HashMap, pending_exec_completions: Vec<(Vec, Vec, CommandOutput)>, task_complete_pending: bool, @@ -98,6 +96,8 @@ pub(crate) struct ChatWidget { interrupts: InterruptManager, // Whether a redraw is needed after handling the current event needs_redraw: bool, + // Accumulates the current reasoning block text to extract a header + reasoning_buffer: String, session_id: Option, frame_requester: FrameRequester, } @@ -107,8 +107,6 @@ struct UserMessage { image_paths: Vec, } -use crate::streaming::StreamKind; - impl From for UserMessage { fn from(text: String) -> Self { Self { @@ -133,7 +131,7 @@ impl ChatWidget { } fn flush_answer_stream_with_separator(&mut self) { let sink = AppEventHistorySink(self.app_event_tx.clone()); - let _ = self.stream.finalize(StreamKind::Answer, true, &sink); + let _ = self.stream.finalize(true, &sink); } // --- Small event handlers --- fn on_session_configured(&mut self, event: codex_core::protocol::SessionConfiguredEvent) { @@ -150,30 +148,38 @@ impl ChatWidget { fn on_agent_message(&mut self, message: String) { let sink = AppEventHistorySink(self.app_event_tx.clone()); let finished = self.stream.apply_final_answer(&message, &sink); - self.last_stream_kind = Some(StreamKind::Answer); self.handle_if_stream_finished(finished); self.mark_needs_redraw(); } fn on_agent_message_delta(&mut self, delta: String) { - self.handle_streaming_delta(StreamKind::Answer, delta); + self.handle_streaming_delta(delta); } fn on_agent_reasoning_delta(&mut self, delta: String) { - self.handle_streaming_delta(StreamKind::Reasoning, delta); + // For reasoning deltas, do not stream to history. Accumulate the + // current reasoning block and extract the first bold element + // (between **/**) as the chunk header. Show this header as status. + self.reasoning_buffer.push_str(&delta); + + if let Some(header) = extract_first_bold(&self.reasoning_buffer) { + // Update the shimmer header to the extracted reasoning chunk header. + self.bottom_pane.update_status_header(header); + } else { + // Fallback while we don't yet have a bold header: leave existing header as-is. + } + self.mark_needs_redraw(); } - fn on_agent_reasoning_final(&mut self, text: String) { - let sink = AppEventHistorySink(self.app_event_tx.clone()); - let finished = self.stream.apply_final_reasoning(&text, &sink); - self.last_stream_kind = Some(StreamKind::Reasoning); - self.handle_if_stream_finished(finished); + fn on_agent_reasoning_final(&mut self) { + // Clear the reasoning buffer at the end of a reasoning block. + self.reasoning_buffer.clear(); self.mark_needs_redraw(); } fn on_reasoning_section_break(&mut self) { - let sink = AppEventHistorySink(self.app_event_tx.clone()); - self.stream.insert_reasoning_section_break(&sink); + // Start a new reasoning block for header extraction. + self.reasoning_buffer.clear(); } // Raw reasoning uses the same flow as summarized reasoning @@ -182,7 +188,7 @@ impl ChatWidget { self.bottom_pane.clear_ctrl_c_quit_hint(); self.bottom_pane.set_task_running(true); self.stream.reset_headers_for_new_turn(); - self.last_stream_kind = None; + self.reasoning_buffer.clear(); self.mark_needs_redraw(); } @@ -191,9 +197,7 @@ impl ChatWidget { // without emitting stray headers for other streams. if self.stream.is_write_cycle_active() { let sink = AppEventHistorySink(self.app_event_tx.clone()); - if let Some(kind) = self.last_stream_kind { - let _ = self.stream.finalize(kind, true, &sink); - } + let _ = self.stream.finalize(true, &sink); } // Mark task stopped and request redraw now that all content is in history. self.bottom_pane.set_task_running(false); @@ -355,10 +359,9 @@ impl ChatWidget { } #[inline] - fn handle_streaming_delta(&mut self, kind: StreamKind, delta: String) { + fn handle_streaming_delta(&mut self, delta: String) { let sink = AppEventHistorySink(self.app_event_tx.clone()); - self.stream.begin(kind, &sink); - self.last_stream_kind = Some(kind); + self.stream.begin(&sink); self.stream.push_and_maybe_commit(&delta, &sink); self.mark_needs_redraw(); } @@ -532,12 +535,12 @@ impl ChatWidget { total_token_usage: TokenUsage::default(), last_token_usage: TokenUsage::default(), stream: StreamController::new(config), - last_stream_kind: None, running_commands: HashMap::new(), pending_exec_completions: Vec::new(), task_complete_pending: false, interrupts: InterruptManager::new(), needs_redraw: false, + reasoning_buffer: String::new(), session_id: None, } } @@ -641,9 +644,9 @@ impl ChatWidget { | EventMsg::AgentReasoningRawContentDelta(AgentReasoningRawContentDeltaEvent { delta, }) => self.on_agent_reasoning_delta(delta), - EventMsg::AgentReasoning(AgentReasoningEvent { text }) - | EventMsg::AgentReasoningRawContent(AgentReasoningRawContentEvent { text }) => { - self.on_agent_reasoning_final(text) + EventMsg::AgentReasoning(AgentReasoningEvent { .. }) + | EventMsg::AgentReasoningRawContent(AgentReasoningRawContentEvent { .. }) => { + self.on_agent_reasoning_final() } EventMsg::AgentReasoningSectionBreak(_) => self.on_reasoning_section_break(), EventMsg::TaskStarted => self.on_task_started(), @@ -932,5 +935,35 @@ fn add_token_usage(current_usage: &TokenUsage, new_usage: &TokenUsage) -> TokenU } } +// Extract the first bold (Markdown) element in the form **...** from `s`. +// Returns the inner text if found; otherwise `None`. +fn extract_first_bold(s: &str) -> Option { + let bytes = s.as_bytes(); + let mut i = 0usize; + while i + 1 < bytes.len() { + if bytes[i] == b'*' && bytes[i + 1] == b'*' { + let start = i + 2; + let mut j = start; + while j + 1 < bytes.len() { + if bytes[j] == b'*' && bytes[j + 1] == b'*' { + // Found closing ** + let inner = &s[start..j]; + let trimmed = inner.trim(); + if !trimmed.is_empty() { + return Some(trimmed.to_string()); + } else { + return None; + } + } + j += 1; + } + // No closing; stop searching (wait for more deltas) + return None; + } + i += 1; + } + None +} + #[cfg(test)] mod tests; diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__deltas_then_same_final_message_are_rendered_snapshot.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__deltas_then_same_final_message_are_rendered_snapshot.snap index 8258ea0b28..2bc5c75e05 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__deltas_then_same_final_message_are_rendered_snapshot.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__deltas_then_same_final_message_are_rendered_snapshot.snap @@ -1,10 +1,6 @@ --- source: tui/src/chatwidget/tests.rs -assertion_line: 886 expression: combined --- -thinking -I will first analyze the request. - codex Here is the result. diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__final_reasoning_then_message_without_deltas_are_rendered.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__final_reasoning_then_message_without_deltas_are_rendered.snap index c90ec2733d..2bc5c75e05 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__final_reasoning_then_message_without_deltas_are_rendered.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__final_reasoning_then_message_without_deltas_are_rendered.snap @@ -2,8 +2,5 @@ source: tui/src/chatwidget/tests.rs expression: combined --- -thinking -I will first analyze the request. - codex Here is the result. diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index fab64c8864..a7959b1f24 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -144,12 +144,12 @@ fn make_chatwidget_manual() -> ( total_token_usage: TokenUsage::default(), last_token_usage: TokenUsage::default(), stream: StreamController::new(cfg), - last_stream_kind: None, running_commands: HashMap::new(), pending_exec_completions: Vec::new(), task_complete_pending: false, interrupts: InterruptManager::new(), needs_redraw: false, + reasoning_buffer: String::new(), session_id: None, frame_requester: crate::tui::FrameRequester::test_dummy(), }; @@ -375,6 +375,11 @@ async fn binary_size_transcript_matches_ideal_fixture() { .expect("read ideal-binary-response.txt"); // Normalize line endings for Windows vs. Unix checkouts let ideal = ideal.replace("\r\n", "\n"); + let ideal_first_line = ideal + .lines() + .find(|l| !l.trim().is_empty()) + .unwrap_or("") + .to_string(); // Build the final VT100 visual by parsing the ANSI stream. Trim trailing spaces per line // and drop trailing empty lines so the shape matches the ideal fixture exactly. @@ -400,22 +405,68 @@ async fn binary_size_transcript_matches_ideal_fixture() { while lines.last().is_some_and(|l| l.is_empty()) { lines.pop(); } - // Compare only after the last session banner marker, and start at the next 'thinking' line. + // Compare only after the last session banner marker. Skip the transient + // 'thinking' header if present, and start from the first non-empty line + // of content that follows. const MARKER_PREFIX: &str = ">_ You are using OpenAI Codex in "; let last_marker_line_idx = lines .iter() .rposition(|l| l.starts_with(MARKER_PREFIX)) .expect("marker not found in visible output"); - let thinking_line_idx = (last_marker_line_idx + 1..lines.len()) - .find(|&idx| lines[idx].trim_start() == "thinking") - .expect("no 'thinking' line found after marker"); + // Anchor to the first ideal line if present; otherwise use heuristics. + let start_idx = (last_marker_line_idx + 1..lines.len()) + .find(|&idx| lines[idx].trim_start() == ideal_first_line) + .or_else(|| { + // Prefer the first assistant content line (blockquote '>' prefix) after the marker. + (last_marker_line_idx + 1..lines.len()) + .find(|&idx| lines[idx].trim_start().starts_with('>')) + }) + .unwrap_or_else(|| { + // Fallback: first non-empty, non-'thinking' line + (last_marker_line_idx + 1..lines.len()) + .find(|&idx| { + let t = lines[idx].trim_start(); + !t.is_empty() && t != "thinking" + }) + .expect("no content line found after marker") + }); let mut compare_lines: Vec = Vec::new(); - // Ensure the first line is exactly 'thinking' without leading spaces to match the fixture - compare_lines.push(lines[thinking_line_idx].trim_start().to_string()); - compare_lines.extend(lines[(thinking_line_idx + 1)..].iter().cloned()); + // Ensure the first line is trimmed-left to match the fixture shape. + compare_lines.push(lines[start_idx].trim_start().to_string()); + compare_lines.extend(lines[(start_idx + 1)..].iter().cloned()); let visible_after = compare_lines.join("\n"); + // Normalize: drop a leading 'thinking' line if present in either side to + // avoid coupling to whether the reasoning header is rendered in history. + fn drop_leading_thinking(s: &str) -> String { + let mut it = s.lines(); + let first = it.next(); + let rest = it.collect::>().join("\n"); + if first.is_some_and(|l| l.trim() == "thinking") { + rest + } else { + s.to_string() + } + } + let visible_after = drop_leading_thinking(&visible_after); + let ideal = drop_leading_thinking(&ideal); + + // Normalize: strip leading Markdown blockquote markers ('>' or '> ') which + // may be present in rendered transcript lines but not in the ideal text. + fn strip_blockquotes(s: &str) -> String { + s.lines() + .map(|l| { + l.strip_prefix("> ") + .or_else(|| l.strip_prefix('>')) + .unwrap_or(l) + }) + .collect::>() + .join("\n") + } + let visible_after = strip_blockquotes(&visible_after); + let ideal = strip_blockquotes(&ideal); + // Optionally update the fixture when env var is set if std::env::var("UPDATE_IDEAL").as_deref() == Ok("1") { let mut p = PathBuf::from(env!("CARGO_MANIFEST_DIR")); @@ -746,7 +797,7 @@ fn plan_update_renders_history_cell() { } #[test] -fn headers_emitted_on_stream_begin_for_answer_and_reasoning() { +fn headers_emitted_on_stream_begin_for_answer_and_not_for_reasoning() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); // Answer: no header until a newline commit @@ -804,7 +855,7 @@ fn headers_emitted_on_stream_begin_for_answer_and_reasoning() { "expected 'codex' header to be emitted after first newline commit" ); - // Reasoning: header immediately + // Reasoning: do NOT emit a history header; status text is updated instead let (mut chat2, mut rx2, _op_rx2) = make_chatwidget_manual(); chat2.handle_codex_event(Event { id: "sub-b".into(), @@ -828,8 +879,8 @@ fn headers_emitted_on_stream_begin_for_answer_and_reasoning() { } } assert!( - saw_thinking, - "expected 'thinking' header to be emitted at stream start" + !saw_thinking, + "reasoning deltas should not emit history headers" ); } diff --git a/codex-rs/tui/src/markdown_stream.rs b/codex-rs/tui/src/markdown_stream.rs index 29fcbc8c4d..af928cf8e1 100644 --- a/codex-rs/tui/src/markdown_stream.rs +++ b/codex-rs/tui/src/markdown_stream.rs @@ -45,22 +45,6 @@ impl MarkdownStreamCollector { self.buffer.push_str(delta); } - /// Insert a paragraph/section separator if one is not already present at the - /// end of the buffer. Ensures the next content starts after a blank line. - pub fn insert_section_break(&mut self) { - if self.buffer.is_empty() { - return; - } - if self.buffer.ends_with("\n\n") { - return; - } - if self.buffer.ends_with('\n') { - self.buffer.push('\n'); - } else { - self.buffer.push_str("\n\n"); - } - } - /// Render the full buffer and return only the newly completed logical lines /// since the last commit. When the buffer does not end with a newline, the /// final rendered line is considered incomplete and is not emitted. diff --git a/codex-rs/tui/src/shimmer.rs b/codex-rs/tui/src/shimmer.rs index 2523f6eccf..24373ceefb 100644 --- a/codex-rs/tui/src/shimmer.rs +++ b/codex-rs/tui/src/shimmer.rs @@ -22,7 +22,7 @@ pub(crate) fn shimmer_spans(text: &str) -> Vec> { // Use time-based sweep synchronized to process start. let padding = 10usize; let period = chars.len() + padding * 2; - let sweep_seconds = 2.5f32; + let sweep_seconds = 2.0f32; let pos_f = (elapsed_since_start().as_secs_f32() % sweep_seconds) / sweep_seconds * (period as f32); let pos = pos_f as usize; diff --git a/codex-rs/tui/src/status_indicator_widget.rs b/codex-rs/tui/src/status_indicator_widget.rs index 2b261941f9..df4910fe91 100644 --- a/codex-rs/tui/src/status_indicator_widget.rs +++ b/codex-rs/tui/src/status_indicator_widget.rs @@ -30,6 +30,8 @@ pub(crate) struct StatusIndicatorWidget { /// Latest text to display (truncated to the available width at render /// time). text: String, + /// Animated header text (defaults to "Working"). + header: String, /// Animation state: reveal target `text` progressively like a typewriter. /// We compute the currently visible prefix length based on the current @@ -47,6 +49,7 @@ impl StatusIndicatorWidget { pub(crate) fn new(app_event_tx: AppEventSender, frame_requester: FrameRequester) -> Self { Self { text: String::from("waiting for model"), + header: String::from("Working"), last_target_len: 0, base_frame: 0, reveal_len_at_base: 0, @@ -95,6 +98,13 @@ impl StatusIndicatorWidget { self.app_event_tx.send(AppEvent::CodexOp(Op::Interrupt)); } + /// Update the animated header label (left of the brackets). + pub(crate) fn update_header(&mut self, header: String) { + if self.header != header { + self.header = header; + } + } + /// Reset the animation and start revealing `text` from the beginning. #[cfg(test)] pub(crate) fn restart_with_text(&mut self, text: String) { @@ -147,12 +157,12 @@ impl WidgetRef for StatusIndicatorWidget { // Schedule next animation frame. self.frame_requester - .schedule_frame_in(Duration::from_millis(100)); + .schedule_frame_in(Duration::from_millis(32)); let idx = self.current_frame(); let elapsed = self.start_time.elapsed().as_secs(); let shown_now = self.current_shown_len(idx); let status_prefix: String = self.text.chars().take(shown_now).collect(); - let animated_spans = shimmer_spans("Working"); + let animated_spans = shimmer_spans(&self.header); // Plain rendering: no borders or padding so the live cell is visually indistinguishable from terminal scrollback. let inner_width = area.width as usize; diff --git a/codex-rs/tui/src/streaming/controller.rs b/codex-rs/tui/src/streaming/controller.rs index 5eae2a91f6..050e6154b9 100644 --- a/codex-rs/tui/src/streaming/controller.rs +++ b/codex-rs/tui/src/streaming/controller.rs @@ -2,7 +2,6 @@ use codex_core::config::Config; use ratatui::text::Line; use super::HeaderEmitter; -use super::StreamKind; use super::StreamState; /// Sink for history insertions and animation control. @@ -36,8 +35,8 @@ type Lines = Vec>; pub(crate) struct StreamController { config: Config, header: HeaderEmitter, - states: [StreamState; 2], - current_stream: Option, + state: StreamState, + active: bool, finishing_after_drain: bool, } @@ -46,8 +45,8 @@ impl StreamController { Self { config, header: HeaderEmitter::new(), - states: [StreamState::new(), StreamState::new()], - current_stream: None, + state: StreamState::new(), + active: false, finishing_after_drain: false, } } @@ -57,29 +56,18 @@ impl StreamController { } pub(crate) fn is_write_cycle_active(&self) -> bool { - self.current_stream.is_some() + self.active } pub(crate) fn clear_all(&mut self) { - self.states.iter_mut().for_each(|s| s.clear()); - self.current_stream = None; + self.state.clear(); + self.active = false; self.finishing_after_drain = false; // leave header state unchanged; caller decides when to reset } - #[inline] - fn idx(kind: StreamKind) -> usize { - kind as usize - } - fn state(&self, kind: StreamKind) -> &StreamState { - &self.states[Self::idx(kind)] - } - fn state_mut(&mut self, kind: StreamKind) -> &mut StreamState { - &mut self.states[Self::idx(kind)] - } - - fn emit_header_if_needed(&mut self, kind: StreamKind, out_lines: &mut Lines) -> bool { - self.header.maybe_emit(kind, out_lines) + fn emit_header_if_needed(&mut self, out_lines: &mut Lines) -> bool { + self.header.maybe_emit(out_lines) } #[inline] @@ -93,56 +81,23 @@ impl StreamController { } } - /// Begin a stream, flushing previously completed lines from any other - /// active stream to maintain ordering. - pub(crate) fn begin(&mut self, kind: StreamKind, sink: &impl HistorySink) { - if let Some(current) = self.current_stream - && current != kind - { - // Synchronously flush completed lines from previous stream. - let cfg = self.config.clone(); - let prev_state = self.state_mut(current); - let newly_completed = prev_state.collector.commit_complete_lines(&cfg); - if !newly_completed.is_empty() { - prev_state.enqueue(newly_completed); - } - let step = prev_state.drain_all(); - if !step.history.is_empty() { - let mut lines: Lines = Vec::new(); - self.emit_header_if_needed(current, &mut lines); - lines.extend(step.history); - // Ensure at most one trailing blank after the flushed block. - Self::ensure_single_trailing_blank(&mut lines); - sink.insert_history(lines); - } - self.current_stream = None; - } - - if self.current_stream != Some(kind) { - let prev = self.current_stream; - self.current_stream = Some(kind); - // Starting a new stream cancels any pending finish-from-previous-stream animation. - self.finishing_after_drain = false; - if prev.is_some() { - self.header.reset_for_stream(kind); - } - // Emit header immediately for reasoning; for answers, defer to first commit. - if matches!(kind, StreamKind::Reasoning) { - let mut header_lines = Vec::new(); - if self.emit_header_if_needed(kind, &mut header_lines) { - sink.insert_history(header_lines); - } - } + /// Begin an answer stream. Does not emit header yet; it is emitted on first commit. + pub(crate) fn begin(&mut self, _sink: &impl HistorySink) { + // Starting a new stream cancels any pending finish-from-previous-stream animation. + if !self.active { + self.header.reset_for_stream(); } + self.finishing_after_drain = false; + self.active = true; } /// Push a delta; if it contains a newline, commit completed lines and start animation. pub(crate) fn push_and_maybe_commit(&mut self, delta: &str, sink: &impl HistorySink) { - let Some(kind) = self.current_stream else { + if !self.active { return; - }; + } let cfg = self.config.clone(); - let state = self.state_mut(kind); + let state = &mut self.state; // Record that at least one delta was received for this stream if !delta.is_empty() { state.has_seen_delta = true; @@ -157,42 +112,22 @@ impl StreamController { } } - /// Insert a reasoning section break and commit any newly completed lines. - pub(crate) fn insert_reasoning_section_break(&mut self, sink: &impl HistorySink) { - if self.current_stream != Some(StreamKind::Reasoning) { - self.begin(StreamKind::Reasoning, sink); - } - let cfg = self.config.clone(); - let state = self.state_mut(StreamKind::Reasoning); - state.collector.insert_section_break(); - let newly_completed = state.collector.commit_complete_lines(&cfg); - if !newly_completed.is_empty() { - state.enqueue(newly_completed); - sink.start_commit_animation(); - } - } - /// Finalize the active stream. If `flush_immediately` is true, drain and emit now. - pub(crate) fn finalize( - &mut self, - kind: StreamKind, - flush_immediately: bool, - sink: &impl HistorySink, - ) -> bool { - if self.current_stream != Some(kind) { + pub(crate) fn finalize(&mut self, flush_immediately: bool, sink: &impl HistorySink) -> bool { + if !self.active { return false; } let cfg = self.config.clone(); // Finalize collector first. let remaining = { - let state = self.state_mut(kind); + let state = &mut self.state; state.collector.finalize_and_drain(&cfg) }; if flush_immediately { // Collect all output first to avoid emitting headers when there is no content. let mut out_lines: Lines = Vec::new(); { - let state = self.state_mut(kind); + let state = &mut self.state; if !remaining.is_empty() { state.enqueue(remaining); } @@ -201,28 +136,28 @@ impl StreamController { } if !out_lines.is_empty() { let mut lines_with_header: Lines = Vec::new(); - self.emit_header_if_needed(kind, &mut lines_with_header); + self.emit_header_if_needed(&mut lines_with_header); lines_with_header.extend(out_lines); Self::ensure_single_trailing_blank(&mut lines_with_header); sink.insert_history(lines_with_header); } // Cleanup - self.state_mut(kind).clear(); - // Allow a subsequent block of the same kind in this turn to emit its header. - self.header.allow_reemit_for_same_kind_in_turn(kind); + self.state.clear(); + // Allow a subsequent block in this turn to emit its header. + self.header.allow_reemit_in_turn(); // Also clear the per-stream emitted flag so the header can render again. - self.header.reset_for_stream(kind); - self.current_stream = None; + self.header.reset_for_stream(); + self.active = false; self.finishing_after_drain = false; true } else { if !remaining.is_empty() { - let state = self.state_mut(kind); + let state = &mut self.state; state.enqueue(remaining); } // Spacer animated out - self.state_mut(kind).enqueue(vec![Line::from("")]); + self.state.enqueue(vec![Line::from("")]); self.finishing_after_drain = true; sink.start_commit_animation(); false @@ -231,32 +166,29 @@ impl StreamController { /// Step animation: commit at most one queued line and handle end-of-drain cleanup. pub(crate) fn on_commit_tick(&mut self, sink: &impl HistorySink) -> bool { - let Some(kind) = self.current_stream else { + if !self.active { return false; - }; - let step = { - let state = self.state_mut(kind); - state.step() - }; + } + let step = { self.state.step() }; if !step.history.is_empty() { let mut lines: Lines = Vec::new(); - self.emit_header_if_needed(kind, &mut lines); + self.emit_header_if_needed(&mut lines); let mut out = lines; out.extend(step.history); sink.insert_history(out); } - let is_idle = self.state(kind).is_idle(); + let is_idle = self.state.is_idle(); if is_idle { sink.stop_commit_animation(); if self.finishing_after_drain { // Reset and notify - self.state_mut(kind).clear(); - // Allow a subsequent block of the same kind in this turn to emit its header. - self.header.allow_reemit_for_same_kind_in_turn(kind); + self.state.clear(); + // Allow a subsequent block in this turn to emit its header. + self.header.allow_reemit_in_turn(); // Also clear the per-stream emitted flag so the header can render again. - self.header.reset_for_stream(kind); - self.current_stream = None; + self.header.reset_for_stream(); + self.active = false; self.finishing_after_drain = false; return true; } @@ -267,24 +199,14 @@ impl StreamController { /// Apply a full final answer: replace queued content with only the remaining tail, /// then finalize immediately and notify completion. pub(crate) fn apply_final_answer(&mut self, message: &str, sink: &impl HistorySink) -> bool { - self.apply_full_final(StreamKind::Answer, message, true, sink) + self.apply_full_final(message, sink) } - pub(crate) fn apply_final_reasoning(&mut self, message: &str, sink: &impl HistorySink) -> bool { - self.apply_full_final(StreamKind::Reasoning, message, false, sink) - } - - fn apply_full_final( - &mut self, - kind: StreamKind, - message: &str, - immediate: bool, - sink: &impl HistorySink, - ) -> bool { - self.begin(kind, sink); + fn apply_full_final(&mut self, message: &str, sink: &impl HistorySink) -> bool { + self.begin(sink); { - let state = self.state_mut(kind); + let state = &mut self.state; // Only inject the final full message if we have not seen any deltas for this stream. // If deltas were received, rely on the collector's existing buffer to avoid duplication. if !state.has_seen_delta && !message.is_empty() { @@ -301,7 +223,6 @@ impl StreamController { .replace_with_and_mark_committed(&msg, committed); } } - - self.finalize(kind, immediate, sink) + self.finalize(true, sink) } } diff --git a/codex-rs/tui/src/streaming/mod.rs b/codex-rs/tui/src/streaming/mod.rs index bb4fdcb6ff..1e670738f1 100644 --- a/codex-rs/tui/src/streaming/mod.rs +++ b/codex-rs/tui/src/streaming/mod.rs @@ -2,12 +2,6 @@ use crate::markdown_stream::AnimatedLineStreamer; use crate::markdown_stream::MarkdownStreamCollector; pub(crate) mod controller; -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum StreamKind { - Answer, - Reasoning, -} - pub(crate) struct StreamState { pub(crate) collector: MarkdownStreamCollector, pub(crate) streamer: AnimatedLineStreamer, @@ -42,92 +36,44 @@ impl StreamState { } pub(crate) struct HeaderEmitter { - reasoning_emitted_this_turn: bool, - answer_emitted_this_turn: bool, - reasoning_emitted_in_stream: bool, - answer_emitted_in_stream: bool, + emitted_this_turn: bool, + emitted_in_stream: bool, } impl HeaderEmitter { pub(crate) fn new() -> Self { Self { - reasoning_emitted_this_turn: false, - answer_emitted_this_turn: false, - reasoning_emitted_in_stream: false, - answer_emitted_in_stream: false, + emitted_this_turn: false, + emitted_in_stream: false, } } pub(crate) fn reset_for_new_turn(&mut self) { - self.reasoning_emitted_this_turn = false; - self.answer_emitted_this_turn = false; - self.reasoning_emitted_in_stream = false; - self.answer_emitted_in_stream = false; + self.emitted_this_turn = false; + self.emitted_in_stream = false; } - pub(crate) fn reset_for_stream(&mut self, kind: StreamKind) { - match kind { - StreamKind::Reasoning => self.reasoning_emitted_in_stream = false, - StreamKind::Answer => self.answer_emitted_in_stream = false, - } + pub(crate) fn reset_for_stream(&mut self) { + self.emitted_in_stream = false; } - pub(crate) fn has_emitted_for_stream(&self, kind: StreamKind) -> bool { - match kind { - StreamKind::Reasoning => self.reasoning_emitted_in_stream, - StreamKind::Answer => self.answer_emitted_in_stream, - } + /// Allow emitting the header again within the current turn after a finalize. + pub(crate) fn allow_reemit_in_turn(&mut self) { + self.emitted_this_turn = false; } - /// Allow emitting the header again for the same kind within the current turn. - /// - /// This is used when a stream (e.g., Answer) is finalized and a subsequent - /// block of the same kind is started within the same turn. Without this, - /// only the first block would render a header. - pub(crate) fn allow_reemit_for_same_kind_in_turn(&mut self, kind: StreamKind) { - match kind { - StreamKind::Reasoning => self.reasoning_emitted_this_turn = false, - StreamKind::Answer => self.answer_emitted_this_turn = false, - } - } - - pub(crate) fn maybe_emit( - &mut self, - kind: StreamKind, - out_lines: &mut Vec>, - ) -> bool { - let already_emitted_this_turn = match kind { - StreamKind::Reasoning => self.reasoning_emitted_this_turn, - StreamKind::Answer => self.answer_emitted_this_turn, - }; - let already_emitted_in_stream = self.has_emitted_for_stream(kind); - if !already_emitted_in_stream && !already_emitted_this_turn { - out_lines.push(render_header_line(kind)); - match kind { - StreamKind::Reasoning => { - self.reasoning_emitted_in_stream = true; - self.reasoning_emitted_this_turn = true; - // Reset opposite header so it may be emitted again this turn - self.answer_emitted_this_turn = false; - } - StreamKind::Answer => { - self.answer_emitted_in_stream = true; - self.answer_emitted_this_turn = true; - // Reset opposite header so it may be emitted again this turn - self.reasoning_emitted_this_turn = false; - } - } - true - } else { - false + pub(crate) fn maybe_emit(&mut self, out_lines: &mut Vec>) -> bool { + if !self.emitted_in_stream && !self.emitted_this_turn { + out_lines.push(render_header_line()); + self.emitted_in_stream = true; + self.emitted_this_turn = true; + return true; } + false } } -fn render_header_line(kind: StreamKind) -> ratatui::text::Line<'static> { +fn render_header_line() -> ratatui::text::Line<'static> { use ratatui::style::Stylize; - match kind { - StreamKind::Reasoning => ratatui::text::Line::from("thinking".magenta().italic()), - StreamKind::Answer => ratatui::text::Line::from("codex".magenta().bold()), - } + ratatui::text::Line::from("codex".magenta().bold()) } diff --git a/codex-rs/tui/tests/fixtures/ideal-binary-response.txt b/codex-rs/tui/tests/fixtures/ideal-binary-response.txt index bc1bb5e56d..80aae5d0e4 100644 --- a/codex-rs/tui/tests/fixtures/ideal-binary-response.txt +++ b/codex-rs/tui/tests/fixtures/ideal-binary-response.txt @@ -1,12 +1,9 @@ -thinking -Investigating binary sizes +To get started, describe a task or try one of these commands: -I need to check the codex-rs repository to explain why the project's binaries -are large. The user is likely seeking specifics about the setup: are Rust builds -static, what features are enabled, and is debug information included? It could -be due to static linking, included OpenSSL, or how panic handling is set up. I -should look into the Cargo.toml file to confirm features and profiles without -needing to edit any code. Let's get started on this! + /init - create an AGENTS.md file with instructions for Codex + /status - show current session configuration and token usage + /approvals - choose what Codex can do without approval + /model - choose what model and reasoning effort to use codex I’m going to scan the workspace and Cargo manifests to see build profiles and @@ -37,16 +34,6 @@ dependencies that impact binary size. Then I’ll summarize the main causes. # ratatui = { path = "../../ratatui" } ratatui = { git = "https://github.com/nornagon/ratatui", branch = "nornagon-v0.29.0-patch" } -Optimizing Rust Release Profile - -I'm reviewing the workspace's release profile, which has settings like lto=fat, -strip=symbols, and codegen-units=1 to reduce binary size. However, it seems that -"strip = symbols" only removes symbols, not debug information. In Rust 1.73+, -both "symbols" and "debuginfo" stripping options are available. I should check -whether using these results in a smaller binary. I’ll also skim through crates -for heavy dependencies in Cargo.toml, including cli, core, exec, linux-sandbox, -tui, login, ollama, and mcp. - ⚡ Running for d in ansi-escape apply-patch arg0 cli common core exec execpolicy file-search linux-sandbox login mcp-client mcp-server mcp-types ollama tui; do echo "--- $d/Cargo.toml"; sed -n '1,200p' $d/Cargo.toml; echo; done @@ -181,85 +168,6 @@ echo "--- $d/Cargo.toml"; sed -n '1,200p' $d/Cargo.toml; echo; done [dev-dependencies] tempfile = "3" -thinking -Considering binary size impacts - -I’m evaluating how binary size is influenced by various factors. There are many -binaries, such as codex and codex-exec, which include dependencies by default, -pulling in features like Tokio and hyper. For instance, reqwest includes -default features like TLS through rustls. If default features remain enabled, -it inflates the size with many additional components. I notice that for musl -targets, using vendored builds of OpenSSL could significantly increase binary -size. This is quite the intricate web of dependencies! - -Analyzing musl and binary size - -I’m considering the implications of building for musl with static linking. -In this case, OpenSSL might come into play through some crates, but reqwest -defaults to using rustls, although openssl-sys vendored could increase binary -size. It’s interesting to think about whether they’re targeting musl, especially -since it seems they might often be building for Mac and Linux with dynamic -linking instead. - -I also realize there’s a difference between debug and release builds — release -should be smaller due to optimizations, but if they’re defaulting to debug -builds, that could lead to much larger binaries. - -Assessing crate impacts on size - -I need to be precise when analyzing binary sizes from various crates. Some -crates, like ratatui-image, include heavy features such as libjpeg decoders, -which can really bulk things up. Then there are the tree-sitter crates that -contain large static grammar tables, adding significant size. The starlark crate -and tracing-subscriber also contribute to size increases. - -With static linking per binary being standard in Rust, it leads to multiple -copies of the same dependencies unless cdylibs are used. That makes the compile -times longer, but using LTO helps reduce bloat somewhat through deduplication. - -Evaluating binary size factors - -Having multiple binaries indeed increases the overall size significantly. -I'm considering options like upgrading to "opt-level = 'z'" or using -"strip=debuginfo" for reducing size. It's interesting that while the profile -only sets symbols for stripping, not debuginfo, this could inflate sizes if -retained in release builds. - -The distinction between "symbols" and "debuginfo" is critical; "symbols" keeps -debug information which may not align with the goal of minimizing size. I’d like -to verify with the Rust documentation, but my network access is restricted. - -Explaining large binary sizes - -I’m analyzing why binaries in this project are so large. The primary reasons -include monolithic static linking, where each binary includes the full -dependency graph without shared libraries. Heavy dependencies such as reqwest, -hyper, and TLS components also contribute significantly to size, along with -image decoders and other libraries. - -Using MUSL targets means vendored OpenSSL can drastically increase the size due -to static linking. Furthermore, if the release profile keeps debug information -due to using "strip = symbols," that can lead to larger binaries as well. It’s a -mix of several factors, including panic strategies and platform-specific linking -behavior. - -Identifying binary size contributors - -I’m considering why binaries are still large even without static linking. The -tracing-subscriber crate potentially adds weight because it includes regex, -though it can sometimes use a lighter version. References from files like "core/ -Cargo.toml" show dependencies like reqwest and tokio, while the tui uses the -image crate. - -To present this clearly, I’ll format the final answer with two main sections: -"Main Causes" and "Build-Mode Notes." I can also include brief suggestions for -reducing size, but I want to stay focused on answering the user's question. - - - - - - codex Here’s what’s driving size in this workspace’s binaries. @@ -295,4 +203,4 @@ assertions—outputs are much larger than cargo build --release. If you want, I can outline targeted trims (e.g., strip = "debuginfo", opt-level = "z", panic abort, tighter tokio/reqwest features) and estimate impact per -binary. \ No newline at end of file +binary. From 9193eb6b53173962b58886b2d5fb33fe282d9aca Mon Sep 17 00:00:00 2001 From: Jeremy Rose <172423086+nornagon-openai@users.noreply.github.com> Date: Wed, 20 Aug 2025 17:09:46 -0700 Subject: [PATCH 13/27] show thinking in transcript (#2538) record the full reasoning trace and show it in transcript mode --- codex-rs/tui/src/app.rs | 9 +++- codex-rs/tui/src/app_event.rs | 5 +- codex-rs/tui/src/chatwidget.rs | 59 +++++++++++++-------- codex-rs/tui/src/chatwidget/tests.rs | 65 +++++++++++++++++------- codex-rs/tui/src/history_cell.rs | 38 +++++++++++++- codex-rs/tui/src/session_log.rs | 11 +++- codex-rs/tui/src/streaming/controller.rs | 2 +- codex-rs/tui/src/user_approval_widget.rs | 2 +- 8 files changed, 145 insertions(+), 46 deletions(-) diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index a58070c04b..2585c6c29f 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -140,10 +140,17 @@ impl App { fn handle_event(&mut self, tui: &mut tui::Tui, event: AppEvent) -> Result { match event { - AppEvent::InsertHistory(lines) => { + AppEvent::InsertHistoryLines(lines) => { self.transcript_lines.extend(lines.clone()); tui.insert_history_lines(lines); } + AppEvent::InsertHistoryCell(cell) => { + self.transcript_lines.extend(cell.transcript_lines()); + let display = cell.display_lines(); + if !display.is_empty() { + tui.insert_history_lines(display); + } + } AppEvent::StartCommitAnimation => { if self .commit_anim_running diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 325b2d5b6d..31ac032b0e 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -2,6 +2,8 @@ use codex_core::protocol::Event; use codex_file_search::FileMatch; use ratatui::text::Line; +use crate::history_cell::HistoryCell; + use crate::slash_command::SlashCommand; use codex_core::protocol::AskForApproval; use codex_core::protocol::SandboxPolicy; @@ -39,7 +41,8 @@ pub(crate) enum AppEvent { /// Result of computing a `/diff` command. DiffResult(String), - InsertHistory(Vec>), + InsertHistoryLines(Vec>), + InsertHistoryCell(Box), StartCommitAnimation, StopCommitAnimation, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index ca166d7b43..b39cf7aa3a 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -98,6 +98,8 @@ pub(crate) struct ChatWidget { needs_redraw: bool, // Accumulates the current reasoning block text to extract a header reasoning_buffer: String, + // Accumulates full reasoning content for transcript-only recording + full_reasoning_buffer: String, session_id: Option, frame_requester: FrameRequester, } @@ -138,7 +140,7 @@ impl ChatWidget { self.bottom_pane .set_history_metadata(event.history_log_id, event.history_entry_count); self.session_id = Some(event.session_id); - self.add_to_history(&history_cell::new_session_info(&self.config, event, true)); + self.add_to_history(history_cell::new_session_info(&self.config, event, true)); if let Some(user_message) = self.initial_user_message.take() { self.submit_user_message(user_message); } @@ -172,13 +174,23 @@ impl ChatWidget { } fn on_agent_reasoning_final(&mut self) { - // Clear the reasoning buffer at the end of a reasoning block. + // 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() { + self.add_to_history(history_cell::new_reasoning_block( + self.full_reasoning_buffer.clone(), + &self.config, + )); + } self.reasoning_buffer.clear(); + self.full_reasoning_buffer.clear(); self.mark_needs_redraw(); } fn on_reasoning_section_break(&mut self) { - // Start a new reasoning block for header extraction. + // Start a new reasoning block for header extraction and accumulate transcript. + self.full_reasoning_buffer.push_str(&self.reasoning_buffer); + self.full_reasoning_buffer.push_str("\n\n"); self.reasoning_buffer.clear(); } @@ -188,6 +200,7 @@ impl ChatWidget { self.bottom_pane.clear_ctrl_c_quit_hint(); self.bottom_pane.set_task_running(true); self.stream.reset_headers_for_new_turn(); + self.full_reasoning_buffer.clear(); self.reasoning_buffer.clear(); self.mark_needs_redraw(); } @@ -216,7 +229,7 @@ impl ChatWidget { } fn on_error(&mut self, message: String) { - self.add_to_history(&history_cell::new_error_event(message)); + self.add_to_history(history_cell::new_error_event(message)); self.bottom_pane.set_task_running(false); self.running_commands.clear(); self.stream.clear_all(); @@ -224,7 +237,7 @@ impl ChatWidget { } fn on_plan_update(&mut self, update: codex_core::plan_tool::UpdatePlanArgs) { - self.add_to_history(&history_cell::new_plan_update(update)); + self.add_to_history(history_cell::new_plan_update(update)); } fn on_exec_approval_request(&mut self, id: String, ev: ExecApprovalRequestEvent) { @@ -259,7 +272,7 @@ impl ChatWidget { } fn on_patch_apply_begin(&mut self, event: PatchApplyBeginEvent) { - self.add_to_history(&history_cell::new_patch_event( + self.add_to_history(history_cell::new_patch_event( PatchEventType::ApplyBegin { auto_approved: event.auto_approved, }, @@ -386,7 +399,7 @@ impl ChatWidget { self.active_exec_cell = None; let pending = std::mem::take(&mut self.pending_exec_completions); for (command, parsed, output) in pending { - self.add_to_history(&history_cell::new_completed_exec_command( + self.add_to_history(history_cell::new_completed_exec_command( command, parsed, output, )); } @@ -398,9 +411,9 @@ impl ChatWidget { event: codex_core::protocol::PatchApplyEndEvent, ) { if event.success { - self.add_to_history(&history_cell::new_patch_apply_success(event.stdout)); + self.add_to_history(history_cell::new_patch_apply_success(event.stdout)); } else { - self.add_to_history(&history_cell::new_patch_apply_failure(event.stderr)); + self.add_to_history(history_cell::new_patch_apply_failure(event.stderr)); } } @@ -422,7 +435,7 @@ impl ChatWidget { ev: ApplyPatchApprovalRequestEvent, ) { self.flush_answer_stream_with_separator(); - self.add_to_history(&history_cell::new_patch_event( + self.add_to_history(history_cell::new_patch_event( PatchEventType::ApprovalRequest, ev.changes.clone(), )); @@ -464,11 +477,11 @@ impl ChatWidget { pub(crate) fn handle_mcp_begin_now(&mut self, ev: McpToolCallBeginEvent) { self.flush_answer_stream_with_separator(); - self.add_to_history(&history_cell::new_active_mcp_tool_call(ev.invocation)); + self.add_to_history(history_cell::new_active_mcp_tool_call(ev.invocation)); } pub(crate) fn handle_mcp_end_now(&mut self, ev: McpToolCallEndEvent) { self.flush_answer_stream_with_separator(); - self.add_to_history(&*history_cell::new_completed_mcp_tool_call( + self.add_boxed_history(history_cell::new_completed_mcp_tool_call( 80, ev.invocation, ev.duration, @@ -541,6 +554,7 @@ impl ChatWidget { interrupts: InterruptManager::new(), needs_redraw: false, reasoning_buffer: String::new(), + full_reasoning_buffer: String::new(), session_id: None, } } @@ -573,14 +587,19 @@ impl ChatWidget { fn flush_active_exec_cell(&mut self) { if let Some(active) = self.active_exec_cell.take() { self.app_event_tx - .send(AppEvent::InsertHistory(active.display_lines())); + .send(AppEvent::InsertHistoryCell(Box::new(active))); } } - fn add_to_history(&mut self, cell: &dyn HistoryCell) { + fn add_to_history(&mut self, cell: impl HistoryCell + 'static) { self.flush_active_exec_cell(); self.app_event_tx - .send(AppEvent::InsertHistory(cell.display_lines())); + .send(AppEvent::InsertHistoryCell(Box::new(cell))); + } + + fn add_boxed_history(&mut self, cell: Box) { + self.flush_active_exec_cell(); + self.app_event_tx.send(AppEvent::InsertHistoryCell(cell)); } fn submit_user_message(&mut self, user_message: UserMessage) { @@ -616,7 +635,7 @@ impl ChatWidget { // Only show the text portion in conversation history. if !text.is_empty() { - self.add_to_history(&history_cell::new_user_prompt(text.clone())); + self.add_to_history(history_cell::new_user_prompt(text.clone())); } } @@ -692,12 +711,12 @@ impl ChatWidget { pub(crate) fn add_diff_output(&mut self, diff_output: String) { self.bottom_pane.set_task_running(false); - self.add_to_history(&history_cell::new_diff_output(diff_output)); + self.add_to_history(history_cell::new_diff_output(diff_output)); self.mark_needs_redraw(); } pub(crate) fn add_status_output(&mut self) { - self.add_to_history(&history_cell::new_status_output( + self.add_to_history(history_cell::new_status_output( &self.config, &self.total_token_usage, &self.session_id, @@ -808,7 +827,7 @@ impl ChatWidget { pub(crate) fn add_mcp_output(&mut self) { if self.config.mcp_servers.is_empty() { - self.add_to_history(&history_cell::empty_mcp_output()); + self.add_to_history(history_cell::empty_mcp_output()); } else { self.submit_op(Op::ListMcpTools); } @@ -856,7 +875,7 @@ impl ChatWidget { } fn on_list_mcp_tools(&mut self, ev: McpListToolsResponseEvent) { - self.add_to_history(&history_cell::new_mcp_tools_output(&self.config, ev.tools)); + self.add_to_history(history_cell::new_mcp_tools_output(&self.config, ev.tools)); } /// Programmatically submit a user text message as if typed in the diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index a7959b1f24..42dcddeb3c 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -150,6 +150,7 @@ fn make_chatwidget_manual() -> ( interrupts: InterruptManager::new(), needs_redraw: false, reasoning_buffer: String::new(), + full_reasoning_buffer: String::new(), session_id: None, frame_requester: crate::tui::FrameRequester::test_dummy(), }; @@ -161,8 +162,10 @@ fn drain_insert_history( ) -> Vec>> { let mut out = Vec::new(); while let Ok(ev) = rx.try_recv() { - if let AppEvent::InsertHistory(lines) = ev { - out.push(lines); + match ev { + AppEvent::InsertHistoryLines(lines) => out.push(lines), + AppEvent::InsertHistoryCell(cell) => out.push(cell.display_lines()), + _ => {} } } out @@ -336,13 +339,25 @@ async fn binary_size_transcript_matches_ideal_fixture() { let ev: Event = serde_json::from_value(payload.clone()).expect("parse"); chat.handle_codex_event(ev); while let Ok(app_ev) = rx.try_recv() { - if let AppEvent::InsertHistory(lines) = app_ev { - transcript.push_str(&lines_to_single_string(&lines)); - crate::insert_history::insert_history_lines_to_writer( - &mut terminal, - &mut ansi, - lines, - ); + match app_ev { + AppEvent::InsertHistoryLines(lines) => { + transcript.push_str(&lines_to_single_string(&lines)); + crate::insert_history::insert_history_lines_to_writer( + &mut terminal, + &mut ansi, + lines, + ); + } + AppEvent::InsertHistoryCell(cell) => { + let lines = cell.display_lines(); + transcript.push_str(&lines_to_single_string(&lines)); + crate::insert_history::insert_history_lines_to_writer( + &mut terminal, + &mut ansi, + lines, + ); + } + _ => {} } } } @@ -353,13 +368,25 @@ async fn binary_size_transcript_matches_ideal_fixture() { { chat.on_commit_tick(); while let Ok(app_ev) = rx.try_recv() { - if let AppEvent::InsertHistory(lines) = app_ev { - transcript.push_str(&lines_to_single_string(&lines)); - crate::insert_history::insert_history_lines_to_writer( - &mut terminal, - &mut ansi, - lines, - ); + match app_ev { + AppEvent::InsertHistoryLines(lines) => { + transcript.push_str(&lines_to_single_string(&lines)); + crate::insert_history::insert_history_lines_to_writer( + &mut terminal, + &mut ansi, + lines, + ); + } + AppEvent::InsertHistoryCell(cell) => { + let lines = cell.display_lines(); + transcript.push_str(&lines_to_single_string(&lines)); + crate::insert_history::insert_history_lines_to_writer( + &mut terminal, + &mut ansi, + lines, + ); + } + _ => {} } } } @@ -809,7 +836,7 @@ fn headers_emitted_on_stream_begin_for_answer_and_not_for_reasoning() { }); let mut saw_codex_pre = false; while let Ok(ev) = rx.try_recv() { - if let AppEvent::InsertHistory(lines) = ev { + if let AppEvent::InsertHistoryLines(lines) = ev { let s = lines .iter() .flat_map(|l| l.spans.iter()) @@ -837,7 +864,7 @@ fn headers_emitted_on_stream_begin_for_answer_and_not_for_reasoning() { chat.on_commit_tick(); let mut saw_codex_post = false; while let Ok(ev) = rx.try_recv() { - if let AppEvent::InsertHistory(lines) = ev { + if let AppEvent::InsertHistoryLines(lines) = ev { let s = lines .iter() .flat_map(|l| l.spans.iter()) @@ -865,7 +892,7 @@ fn headers_emitted_on_stream_begin_for_answer_and_not_for_reasoning() { }); let mut saw_thinking = false; while let Ok(ev) = rx2.try_recv() { - if let AppEvent::InsertHistory(lines) = ev { + if let AppEvent::InsertHistoryLines(lines) = ev { let s = lines .iter() .flat_map(|l| l.spans.iter()) diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 4062393517..8e3a5fb943 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -1,6 +1,7 @@ use crate::diff_render::create_diff_summary; use crate::exec_command::relativize_to_home; use crate::exec_command::strip_bash_lc_and_escape; +use crate::markdown::append_markdown; use crate::slash_command::SlashCommand; use crate::text_formatting::format_and_truncate_tool_result; use base64::Engine; @@ -39,7 +40,7 @@ use std::time::Instant; use tracing::error; use uuid::Uuid; -#[derive(Clone)] +#[derive(Clone, Debug)] pub(crate) struct CommandOutput { pub(crate) exit_code: i32, pub(crate) stdout: String, @@ -54,9 +55,13 @@ 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 { +pub(crate) trait HistoryCell: std::fmt::Debug + Send + Sync { fn display_lines(&self) -> Vec>; + fn transcript_lines(&self) -> Vec> { + self.display_lines() + } + fn desired_height(&self, width: u16) -> u16 { Paragraph::new(Text::from(self.display_lines())) .wrap(Wrap { trim: false }) @@ -66,6 +71,7 @@ pub(crate) trait HistoryCell { } } +#[derive(Debug)] pub(crate) struct PlainHistoryCell { lines: Vec>, } @@ -76,6 +82,22 @@ impl HistoryCell for PlainHistoryCell { } } +#[derive(Debug)] +pub(crate) struct TranscriptOnlyHistoryCell { + lines: Vec>, +} + +impl HistoryCell for TranscriptOnlyHistoryCell { + fn display_lines(&self) -> Vec> { + Vec::new() + } + + fn transcript_lines(&self) -> Vec> { + self.lines.clone() + } +} + +#[derive(Debug)] pub(crate) struct ExecCell { pub(crate) command: Vec, pub(crate) parsed: Vec, @@ -101,6 +123,7 @@ impl WidgetRef for &ExecCell { } } +#[derive(Debug)] struct CompletedMcpToolCallWithImageOutput { _image: DynamicImage, } @@ -930,6 +953,17 @@ pub(crate) fn new_patch_apply_success(stdout: String) -> PlainHistoryCell { PlainHistoryCell { lines } } +pub(crate) fn new_reasoning_block( + full_reasoning_buffer: String, + config: &Config, +) -> TranscriptOnlyHistoryCell { + let mut lines: Vec> = Vec::new(); + lines.push(Line::from("thinking".magenta().italic())); + append_markdown(&full_reasoning_buffer, &mut lines, config); + lines.push(Line::from("")); + TranscriptOnlyHistoryCell { lines } +} + fn output_lines( output: Option<&CommandOutput>, only_err: bool, diff --git a/codex-rs/tui/src/session_log.rs b/codex-rs/tui/src/session_log.rs index 03334f7ffe..d1dc453243 100644 --- a/codex-rs/tui/src/session_log.rs +++ b/codex-rs/tui/src/session_log.rs @@ -143,7 +143,7 @@ pub(crate) fn log_inbound_app_event(event: &AppEvent) { LOGGER.write_json_line(value); } // Internal UI events; still log for fidelity, but avoid heavy payloads. - AppEvent::InsertHistory(lines) => { + AppEvent::InsertHistoryLines(lines) => { let value = json!({ "ts": now_ts(), "dir": "to_tui", @@ -152,6 +152,15 @@ pub(crate) fn log_inbound_app_event(event: &AppEvent) { }); LOGGER.write_json_line(value); } + AppEvent::InsertHistoryCell(cell) => { + let value = json!({ + "ts": now_ts(), + "dir": "to_tui", + "kind": "insert_history_cell", + "lines": cell.transcript_lines().len(), + }); + LOGGER.write_json_line(value); + } AppEvent::StartFileSearch(query) => { let value = json!({ "ts": now_ts(), diff --git a/codex-rs/tui/src/streaming/controller.rs b/codex-rs/tui/src/streaming/controller.rs index 050e6154b9..5fb60e78a5 100644 --- a/codex-rs/tui/src/streaming/controller.rs +++ b/codex-rs/tui/src/streaming/controller.rs @@ -17,7 +17,7 @@ pub(crate) struct AppEventHistorySink(pub(crate) crate::app_event_sender::AppEve impl HistorySink for AppEventHistorySink { fn insert_history(&self, lines: Vec>) { self.0 - .send(crate::app_event::AppEvent::InsertHistory(lines)) + .send(crate::app_event::AppEvent::InsertHistoryLines(lines)) } fn start_commit_animation(&self) { self.0 diff --git a/codex-rs/tui/src/user_approval_widget.rs b/codex-rs/tui/src/user_approval_widget.rs index f55ebba70c..deb2c8ff16 100644 --- a/codex-rs/tui/src/user_approval_widget.rs +++ b/codex-rs/tui/src/user_approval_widget.rs @@ -328,7 +328,7 @@ impl UserApprovalWidget { } } lines.push(Line::from("")); - self.app_event_tx.send(AppEvent::InsertHistory(lines)); + self.app_event_tx.send(AppEvent::InsertHistoryLines(lines)); let op = match &self.approval_request { ApprovalRequest::Exec { id, .. } => Op::ExecApproval { From 697b4ce100dbfb7fa15438a5f3756fbec798ee43 Mon Sep 17 00:00:00 2001 From: Jeremy Rose <172423086+nornagon-openai@users.noreply.github.com> Date: Wed, 20 Aug 2025 19:41:49 -0700 Subject: [PATCH 14/27] tui: show upgrade banner in history (#2537) previously the upgrade banner was disappearing into scrollback when we cleared the screen to start the tui. --- codex-rs/tui/src/lib.rs | 80 ++++++++++++++++++++++++----------------- 1 file changed, 48 insertions(+), 32 deletions(-) diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 52b1c7dab2..454a7f3ead 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -220,38 +220,6 @@ pub async fn run_main( let _ = tracing_subscriber::registry().with(file_layer).try_init(); - #[allow(clippy::print_stderr)] - #[cfg(not(debug_assertions))] - if let Some(latest_version) = updates::get_upgrade_version(&config) { - let current_version = env!("CARGO_PKG_VERSION"); - let exe = std::env::current_exe()?; - let managed_by_npm = std::env::var_os("CODEX_MANAGED_BY_NPM").is_some(); - - eprintln!( - "{} {current_version} -> {latest_version}.", - "✨⬆️ Update available!".bold().cyan() - ); - - if managed_by_npm { - let npm_cmd = "npm install -g @openai/codex@latest"; - eprintln!("Run {} to update.", npm_cmd.cyan().on_black()); - } else if cfg!(target_os = "macos") - && (exe.starts_with("/opt/homebrew") || exe.starts_with("/usr/local")) - { - let brew_cmd = "brew upgrade codex"; - eprintln!("Run {} to update.", brew_cmd.cyan().on_black()); - } else { - eprintln!( - "See {} for the latest releases and installation options.", - "https://github.com/openai/codex/releases/latest" - .cyan() - .on_black() - ); - } - - eprintln!(""); - } - run_ratatui_app(cli, config, should_show_trust_screen) .await .map_err(|err| std::io::Error::other(err.to_string())) @@ -276,8 +244,56 @@ async fn run_ratatui_app( })); let mut terminal = tui::init()?; terminal.clear()?; + let mut tui = Tui::new(terminal); + // Show update banner in terminal history (instead of stderr) so it is visible + // within the TUI scrollback. Building spans keeps styling consistent. + #[cfg(not(debug_assertions))] + if let Some(latest_version) = updates::get_upgrade_version(&config) { + use ratatui::style::Stylize as _; + use ratatui::text::Line; + use ratatui::text::Span; + + let current_version = env!("CARGO_PKG_VERSION"); + let exe = std::env::current_exe()?; + let managed_by_npm = std::env::var_os("CODEX_MANAGED_BY_NPM").is_some(); + + let mut lines: Vec> = Vec::new(); + lines.push(Line::from(vec![ + "✨⬆️ Update available!".bold().cyan(), + Span::raw(" "), + Span::raw(format!("{current_version} -> {latest_version}.")), + ])); + + if managed_by_npm { + let npm_cmd = "npm install -g @openai/codex@latest"; + lines.push(Line::from(vec![ + Span::raw("Run "), + npm_cmd.cyan(), + Span::raw(" to update."), + ])); + } else if cfg!(target_os = "macos") + && (exe.starts_with("/opt/homebrew") || exe.starts_with("/usr/local")) + { + let brew_cmd = "brew upgrade codex"; + lines.push(Line::from(vec![ + Span::raw("Run "), + brew_cmd.cyan(), + Span::raw(" to update."), + ])); + } else { + lines.push(Line::from(vec![ + Span::raw("See "), + "https://github.com/openai/codex/releases/latest".cyan(), + Span::raw(" for the latest releases and installation options."), + ])); + } + + lines.push(Line::from("")); + tui.insert_history_lines(lines); + } + // Initialize high-fidelity session event logging if enabled. session_log::maybe_init(&config); From dacff9675a3cc5f6d7c60f88be6d2bbe9241b048 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Wed, 20 Aug 2025 20:36:34 -0700 Subject: [PATCH 15/27] Added new auth-related methods and events to mcp server (#2496) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR adds the following: * A getAuthStatus method on the mcp server. This returns the auth method currently in use (chatgpt or apikey) or none if the user is not authenticated. It also returns the "preferred auth method" which reflects the `preferred_auth_method` value in the config. * A logout method on the mcp server. If called, it logs out the user and deletes the `auth.json` file — the same behavior in the cli's `/logout` command. * An `authStatusChange` event notification that is sent when the auth status changes due to successful login or logout operations. * Logic to pass command-line config overrides to the mcp server at startup time. This allows use cases like `codex mcp -c preferred_auth_method=apikey`. --- codex-rs/Cargo.lock | 1 + codex-rs/cli/src/main.rs | 2 +- codex-rs/login/Cargo.toml | 1 + codex-rs/login/src/lib.rs | 8 +- codex-rs/mcp-server/Cargo.toml | 2 +- .../mcp-server/src/codex_message_processor.rs | 119 +++++++++++++++--- codex-rs/mcp-server/src/lib.rs | 29 ++++- codex-rs/mcp-server/src/main.rs | 3 +- codex-rs/mcp-server/src/message_processor.rs | 8 +- codex-rs/mcp-server/src/outgoing_message.rs | 12 ++ codex-rs/protocol-ts/src/lib.rs | 1 + codex-rs/protocol/src/mcp_protocol.rs | 80 ++++++++++-- 12 files changed, 219 insertions(+), 47 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 825b2a485f..34e7932053 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -823,6 +823,7 @@ version = "0.0.0" dependencies = [ "base64 0.22.1", "chrono", + "codex-protocol", "pretty_assertions", "rand 0.8.5", "reqwest", diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index d237fe6729..2acc3d84c5 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -159,7 +159,7 @@ async fn cli_main(codex_linux_sandbox_exe: Option) -> anyhow::Result<() codex_exec::run_main(exec_cli, codex_linux_sandbox_exe).await?; } Some(Subcommand::Mcp) => { - codex_mcp_server::run_main(codex_linux_sandbox_exe).await?; + codex_mcp_server::run_main(codex_linux_sandbox_exe, cli.config_overrides).await?; } Some(Subcommand::Login(mut login_cli)) => { prepend_config_flags(&mut login_cli.config_overrides, cli.config_overrides); diff --git a/codex-rs/login/Cargo.toml b/codex-rs/login/Cargo.toml index c1e21ca627..bf04a8e3ff 100644 --- a/codex-rs/login/Cargo.toml +++ b/codex-rs/login/Cargo.toml @@ -9,6 +9,7 @@ workspace = true [dependencies] base64 = "0.22" chrono = { version = "0.4", features = ["serde"] } +codex-protocol = { path = "../protocol" } rand = "0.8" reqwest = { version = "0.12", features = ["json", "blocking"] } serde = { version = "1", features = ["derive"] } diff --git a/codex-rs/login/src/lib.rs b/codex-rs/login/src/lib.rs index 8c9a5cf37d..1f11882302 100644 --- a/codex-rs/login/src/lib.rs +++ b/codex-rs/login/src/lib.rs @@ -29,13 +29,7 @@ mod token_data; pub const CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann"; pub const OPENAI_API_KEY_ENV_VAR: &str = "OPENAI_API_KEY"; - -#[derive(Clone, Debug, PartialEq, Copy, Eq, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum AuthMode { - ApiKey, - ChatGPT, -} +pub use codex_protocol::mcp_protocol::AuthMode; #[derive(Debug, Clone)] pub struct CodexAuth { diff --git a/codex-rs/mcp-server/Cargo.toml b/codex-rs/mcp-server/Cargo.toml index 44a3907159..cddf4cf3e4 100644 --- a/codex-rs/mcp-server/Cargo.toml +++ b/codex-rs/mcp-server/Cargo.toml @@ -17,7 +17,7 @@ workspace = true [dependencies] anyhow = "1" codex-arg0 = { path = "../arg0" } -codex-common = { path = "../common" } +codex-common = { path = "../common", features = ["cli"] } codex-core = { path = "../core" } codex-login = { path = "../login" } codex-protocol = { path = "../protocol" } diff --git a/codex-rs/mcp-server/src/codex_message_processor.rs b/codex-rs/mcp-server/src/codex_message_processor.rs index 07e06d66eb..657cda25ce 100644 --- a/codex-rs/mcp-server/src/codex_message_processor.rs +++ b/codex-rs/mcp-server/src/codex_message_processor.rs @@ -14,6 +14,7 @@ use codex_core::protocol::Event; use codex_core::protocol::EventMsg; use codex_core::protocol::ExecApprovalRequestEvent; use codex_core::protocol::ReviewDecision; +use codex_protocol::mcp_protocol::AuthMode; use codex_protocol::mcp_protocol::GitDiffToRemoteResponse; use mcp_types::JSONRPCErrorError; use mcp_types::RequestId; @@ -30,14 +31,17 @@ use crate::outgoing_message::OutgoingNotification; use codex_core::protocol::InputItem as CoreInputItem; use codex_core::protocol::Op; use codex_login::CLIENT_ID; +use codex_login::CodexAuth; use codex_login::ServerOptions as LoginServerOptions; use codex_login::ShutdownHandle; +use codex_login::logout; use codex_login::run_login_server; use codex_protocol::mcp_protocol::APPLY_PATCH_APPROVAL_METHOD; use codex_protocol::mcp_protocol::AddConversationListenerParams; use codex_protocol::mcp_protocol::AddConversationSubscriptionResponse; use codex_protocol::mcp_protocol::ApplyPatchApprovalParams; use codex_protocol::mcp_protocol::ApplyPatchApprovalResponse; +use codex_protocol::mcp_protocol::AuthStatusChangeNotification; use codex_protocol::mcp_protocol::ClientRequest; use codex_protocol::mcp_protocol::ConversationId; use codex_protocol::mcp_protocol::EXEC_COMMAND_APPROVAL_METHOD; @@ -46,7 +50,6 @@ use codex_protocol::mcp_protocol::ExecCommandApprovalResponse; use codex_protocol::mcp_protocol::InputItem as WireInputItem; use codex_protocol::mcp_protocol::InterruptConversationParams; use codex_protocol::mcp_protocol::InterruptConversationResponse; -use codex_protocol::mcp_protocol::LOGIN_CHATGPT_COMPLETE_EVENT; use codex_protocol::mcp_protocol::LoginChatGptCompleteNotification; use codex_protocol::mcp_protocol::LoginChatGptResponse; use codex_protocol::mcp_protocol::NewConversationParams; @@ -57,6 +60,7 @@ use codex_protocol::mcp_protocol::SendUserMessageParams; use codex_protocol::mcp_protocol::SendUserMessageResponse; use codex_protocol::mcp_protocol::SendUserTurnParams; use codex_protocol::mcp_protocol::SendUserTurnResponse; +use codex_protocol::mcp_protocol::ServerNotification; // Duration before a ChatGPT login attempt is abandoned. const LOGIN_CHATGPT_TIMEOUT: Duration = Duration::from_secs(10 * 60); @@ -77,6 +81,7 @@ pub(crate) struct CodexMessageProcessor { conversation_manager: Arc, outgoing: Arc, codex_linux_sandbox_exe: Option, + config: Arc, conversation_listeners: HashMap>, active_login: Arc>>, // Queue of pending interrupt requests per conversation. We reply when TurnAborted arrives. @@ -88,11 +93,13 @@ impl CodexMessageProcessor { conversation_manager: Arc, outgoing: Arc, codex_linux_sandbox_exe: Option, + config: Arc, ) -> Self { Self { conversation_manager, outgoing, codex_linux_sandbox_exe, + config, conversation_listeners: HashMap::new(), active_login: Arc::new(Mutex::new(None)), pending_interrupts: Arc::new(Mutex::new(HashMap::new())), @@ -128,6 +135,12 @@ impl CodexMessageProcessor { ClientRequest::CancelLoginChatGpt { request_id, params } => { self.cancel_login_chatgpt(request_id, params.login_id).await; } + ClientRequest::LogoutChatGpt { request_id } => { + self.logout_chatgpt(request_id).await; + } + ClientRequest::GetAuthStatus { request_id } => { + self.get_auth_status(request_id).await; + } ClientRequest::GitDiffToRemote { request_id, params } => { self.git_diff_to_origin(request_id, params.cwd).await; } @@ -135,19 +148,7 @@ impl CodexMessageProcessor { } async fn login_chatgpt(&mut self, request_id: RequestId) { - let config = - match Config::load_with_cli_overrides(Default::default(), ConfigOverrides::default()) { - Ok(cfg) => cfg, - Err(err) => { - let error = JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: format!("error loading config for login: {err}"), - data: None, - }; - self.outgoing.send_error(request_id, error).await; - return; - } - }; + let config = self.config.as_ref(); let opts = LoginServerOptions { open_browser: false, @@ -199,19 +200,25 @@ impl CodexMessageProcessor { (false, Some("Login timed out".to_string())) } }; - let notification = LoginChatGptCompleteNotification { + let payload = LoginChatGptCompleteNotification { login_id, success, error: error_msg, }; - let params = serde_json::to_value(¬ification).ok(); outgoing_clone - .send_notification(OutgoingNotification { - method: LOGIN_CHATGPT_COMPLETE_EVENT.to_string(), - params, - }) + .send_server_notification(ServerNotification::LoginChatGptComplete(payload)) .await; + // Send an auth status change notification. + if success { + let payload = AuthStatusChangeNotification { + auth_method: Some(AuthMode::ChatGPT), + }; + outgoing_clone + .send_server_notification(ServerNotification::AuthStatusChange(payload)) + .await; + } + // Clear the active login if it matches this attempt. It may have been replaced or cancelled. let mut guard = active_login.lock().await; if guard.as_ref().map(|l| l.login_id) == Some(login_id) { @@ -260,6 +267,78 @@ impl CodexMessageProcessor { } } + async fn logout_chatgpt(&mut self, request_id: RequestId) { + { + // Cancel any active login attempt. + let mut guard = self.active_login.lock().await; + if let Some(active) = guard.take() { + active.drop(); + } + } + + // Load config to locate codex_home for persistent logout. + let config = self.config.as_ref(); + + if let Err(err) = logout(&config.codex_home) { + let error = JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + message: format!("logout failed: {err}"), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + return; + } + + self.outgoing + .send_response( + request_id, + codex_protocol::mcp_protocol::LogoutChatGptResponse {}, + ) + .await; + + // Send auth status change notification. + let payload = AuthStatusChangeNotification { auth_method: None }; + self.outgoing + .send_server_notification(ServerNotification::AuthStatusChange(payload)) + .await; + } + + async fn get_auth_status(&self, request_id: RequestId) { + // Load config to determine codex_home and preferred auth method. + let config = self.config.as_ref(); + + let preferred_auth_method: AuthMode = config.preferred_auth_method; + let response = + match CodexAuth::from_codex_home(&config.codex_home, config.preferred_auth_method) { + Ok(Some(auth)) => { + // Verify that the current auth mode has a valid, non-empty token. + // If token acquisition fails or is empty, treat as unauthenticated. + let reported_auth_method = match auth.get_token().await { + Ok(token) if !token.is_empty() => Some(auth.mode), + Ok(_) => None, // Empty token + Err(err) => { + tracing::warn!("failed to get token for auth status: {err}"); + None + } + }; + codex_protocol::mcp_protocol::GetAuthStatusResponse { + auth_method: reported_auth_method, + preferred_auth_method, + } + } + Ok(None) => codex_protocol::mcp_protocol::GetAuthStatusResponse { + auth_method: None, + preferred_auth_method, + }, + Err(_) => codex_protocol::mcp_protocol::GetAuthStatusResponse { + auth_method: None, + preferred_auth_method, + }, + }; + + self.outgoing.send_response(request_id, response).await; + } + async fn process_new_conversation(&self, request_id: RequestId, params: NewConversationParams) { let config = match derive_config_from_params(params, self.codex_linux_sandbox_exe.clone()) { Ok(config) => config, diff --git a/codex-rs/mcp-server/src/lib.rs b/codex-rs/mcp-server/src/lib.rs index e22df1f9ef..aaf3e31441 100644 --- a/codex-rs/mcp-server/src/lib.rs +++ b/codex-rs/mcp-server/src/lib.rs @@ -1,9 +1,14 @@ //! Prototype MCP server. #![deny(clippy::print_stdout, clippy::print_stderr)] +use std::io::ErrorKind; use std::io::Result as IoResult; use std::path::PathBuf; +use codex_common::CliConfigOverrides; +use codex_core::config::Config; +use codex_core::config::ConfigOverrides; + use mcp_types::JSONRPCMessage; use tokio::io::AsyncBufReadExt; use tokio::io::AsyncWriteExt; @@ -41,7 +46,10 @@ pub use crate::patch_approval::PatchApprovalResponse; /// plenty for an interactive CLI. const CHANNEL_CAPACITY: usize = 128; -pub async fn run_main(codex_linux_sandbox_exe: Option) -> IoResult<()> { +pub async fn run_main( + codex_linux_sandbox_exe: Option, + cli_config_overrides: CliConfigOverrides, +) -> IoResult<()> { // Install a simple subscriber so `tracing` output is visible. Users can // control the log level with `RUST_LOG`. tracing_subscriber::fmt() @@ -77,10 +85,27 @@ pub async fn run_main(codex_linux_sandbox_exe: Option) -> IoResult<()> } }); + // Parse CLI overrides once and derive the base Config eagerly so later + // components do not need to work with raw TOML values. + let cli_kv_overrides = cli_config_overrides.parse_overrides().map_err(|e| { + std::io::Error::new( + ErrorKind::InvalidInput, + format!("error parsing -c overrides: {e}"), + ) + })?; + let config = Config::load_with_cli_overrides(cli_kv_overrides, ConfigOverrides::default()) + .map_err(|e| { + std::io::Error::new(ErrorKind::InvalidData, format!("error loading config: {e}")) + })?; + // Task: process incoming messages. let processor_handle = tokio::spawn({ let outgoing_message_sender = OutgoingMessageSender::new(outgoing_tx); - let mut processor = MessageProcessor::new(outgoing_message_sender, codex_linux_sandbox_exe); + let mut processor = MessageProcessor::new( + outgoing_message_sender, + codex_linux_sandbox_exe, + std::sync::Arc::new(config), + ); async move { while let Some(msg) = incoming_rx.recv().await { match msg { diff --git a/codex-rs/mcp-server/src/main.rs b/codex-rs/mcp-server/src/main.rs index 60ddeeab41..314944fab5 100644 --- a/codex-rs/mcp-server/src/main.rs +++ b/codex-rs/mcp-server/src/main.rs @@ -1,9 +1,10 @@ use codex_arg0::arg0_dispatch_or_else; +use codex_common::CliConfigOverrides; use codex_mcp_server::run_main; fn main() -> anyhow::Result<()> { arg0_dispatch_or_else(|codex_linux_sandbox_exe| async move { - run_main(codex_linux_sandbox_exe).await?; + run_main(codex_linux_sandbox_exe, CliConfigOverrides::default()).await?; Ok(()) }) } diff --git a/codex-rs/mcp-server/src/message_processor.rs b/codex-rs/mcp-server/src/message_processor.rs index 1ddcc6bc28..a22f9c5b4e 100644 --- a/codex-rs/mcp-server/src/message_processor.rs +++ b/codex-rs/mcp-server/src/message_processor.rs @@ -1,6 +1,5 @@ use std::collections::HashMap; use std::path::PathBuf; -use std::sync::Arc; use crate::codex_message_processor::CodexMessageProcessor; use crate::codex_tool_config::CodexToolCallParam; @@ -12,7 +11,7 @@ use crate::outgoing_message::OutgoingMessageSender; use codex_protocol::mcp_protocol::ClientRequest; use codex_core::ConversationManager; -use codex_core::config::Config as CodexConfig; +use codex_core::config::Config; use codex_core::protocol::Submission; use mcp_types::CallToolRequestParams; use mcp_types::CallToolResult; @@ -30,6 +29,7 @@ use mcp_types::ServerCapabilitiesTools; use mcp_types::ServerNotification; use mcp_types::TextContent; use serde_json::json; +use std::sync::Arc; use tokio::sync::Mutex; use tokio::task; use uuid::Uuid; @@ -49,6 +49,7 @@ impl MessageProcessor { pub(crate) fn new( outgoing: OutgoingMessageSender, codex_linux_sandbox_exe: Option, + config: Arc, ) -> Self { let outgoing = Arc::new(outgoing); let conversation_manager = Arc::new(ConversationManager::default()); @@ -56,6 +57,7 @@ impl MessageProcessor { conversation_manager.clone(), outgoing.clone(), codex_linux_sandbox_exe.clone(), + config, ); Self { codex_message_processor, @@ -344,7 +346,7 @@ impl MessageProcessor { } } async fn handle_tool_call_codex(&self, id: RequestId, arguments: Option) { - let (initial_prompt, config): (String, CodexConfig) = match arguments { + let (initial_prompt, config): (String, Config) = match arguments { Some(json_val) => match serde_json::from_value::(json_val) { Ok(tool_cfg) => match tool_cfg.into_config(self.codex_linux_sandbox_exe.clone()) { Ok(cfg) => cfg, diff --git a/codex-rs/mcp-server/src/outgoing_message.rs b/codex-rs/mcp-server/src/outgoing_message.rs index c5e51a3494..16241a0899 100644 --- a/codex-rs/mcp-server/src/outgoing_message.rs +++ b/codex-rs/mcp-server/src/outgoing_message.rs @@ -3,6 +3,7 @@ use std::sync::atomic::AtomicI64; use std::sync::atomic::Ordering; use codex_core::protocol::Event; +use codex_protocol::mcp_protocol::ServerNotification; use mcp_types::JSONRPC_VERSION; use mcp_types::JSONRPCError; use mcp_types::JSONRPCErrorError; @@ -121,6 +122,17 @@ impl OutgoingMessageSender { .await; } + pub(crate) async fn send_server_notification(&self, notification: ServerNotification) { + let method = format!("codex/event/{}", notification); + let params = match serde_json::to_value(¬ification) { + Ok(serde_json::Value::Object(mut map)) => map.remove("data"), + _ => None, + }; + let outgoing_message = + OutgoingMessage::Notification(OutgoingNotification { method, params }); + let _ = self.sender.send(outgoing_message).await; + } + pub(crate) async fn send_notification(&self, notification: OutgoingNotification) { let outgoing_message = OutgoingMessage::Notification(notification); let _ = self.sender.send(outgoing_message).await; diff --git a/codex-rs/protocol-ts/src/lib.rs b/codex-rs/protocol-ts/src/lib.rs index 6bbc926988..2366ae862d 100644 --- a/codex-rs/protocol-ts/src/lib.rs +++ b/codex-rs/protocol-ts/src/lib.rs @@ -41,6 +41,7 @@ pub fn generate_ts(out_dir: &Path, prettier: Option<&Path>) -> Result<()> { codex_protocol::mcp_protocol::ApplyPatchApprovalResponse::export_all_to(out_dir)?; codex_protocol::mcp_protocol::ExecCommandApprovalParams::export_all_to(out_dir)?; codex_protocol::mcp_protocol::ExecCommandApprovalResponse::export_all_to(out_dir)?; + codex_protocol::mcp_protocol::ServerNotification::export_all_to(out_dir)?; // Prepend header to each generated .ts file let ts_files = ts_files_in(out_dir)?; diff --git a/codex-rs/protocol/src/mcp_protocol.rs b/codex-rs/protocol/src/mcp_protocol.rs index 68f5c01d07..7cb38e15e2 100644 --- a/codex-rs/protocol/src/mcp_protocol.rs +++ b/codex-rs/protocol/src/mcp_protocol.rs @@ -13,6 +13,7 @@ use crate::protocol::TurnAbortReason; use mcp_types::RequestId; use serde::Deserialize; use serde::Serialize; +use strum_macros::Display; use ts_rs::TS; use uuid::Uuid; @@ -36,6 +37,13 @@ impl GitSha { } } +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, TS)] +#[serde(rename_all = "lowercase")] +pub enum AuthMode { + ApiKey, + ChatGPT, +} + /// Request from the client to the server. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] #[serde(tag = "method", rename_all = "camelCase")] @@ -79,6 +87,14 @@ pub enum ClientRequest { request_id: RequestId, params: CancelLoginChatGptParams, }, + LogoutChatGpt { + #[serde(rename = "id")] + request_id: RequestId, + }, + GetAuthStatus { + #[serde(rename = "id")] + request_id: RequestId, + }, GitDiffToRemote { #[serde(rename = "id")] request_id: RequestId, @@ -161,18 +177,6 @@ pub struct GitDiffToRemoteResponse { pub diff: String, } -// Event name for notifying client of login completion or failure. -pub const LOGIN_CHATGPT_COMPLETE_EVENT: &str = "codex/event/login_chatgpt_complete"; - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] -#[serde(rename_all = "camelCase")] -pub struct LoginChatGptCompleteNotification { - pub login_id: Uuid, - pub success: bool, - #[serde(skip_serializing_if = "Option::is_none")] - pub error: Option, -} - #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] #[serde(rename_all = "camelCase")] pub struct CancelLoginChatGptParams { @@ -189,6 +193,30 @@ pub struct GitDiffToRemoteParams { #[serde(rename_all = "camelCase")] pub struct CancelLoginChatGptResponse {} +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[serde(rename_all = "camelCase")] +pub struct LogoutChatGptParams { + pub login_id: Uuid, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[serde(rename_all = "camelCase")] +pub struct LogoutChatGptResponse {} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[serde(rename_all = "camelCase")] +pub struct GetAuthStatusParams { + pub login_id: Uuid, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[serde(rename_all = "camelCase")] +pub struct GetAuthStatusResponse { + #[serde(skip_serializing_if = "Option::is_none")] + pub auth_method: Option, + pub preferred_auth_method: AuthMode, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] #[serde(rename_all = "camelCase")] pub struct SendUserMessageParams { @@ -321,6 +349,34 @@ pub struct ApplyPatchApprovalResponse { pub decision: ReviewDecision, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[serde(rename_all = "camelCase")] +pub struct LoginChatGptCompleteNotification { + pub login_id: Uuid, + pub success: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[serde(rename_all = "camelCase")] +pub struct AuthStatusChangeNotification { + /// Current authentication method; omitted if signed out. + #[serde(skip_serializing_if = "Option::is_none")] + pub auth_method: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS, Display)] +#[serde(tag = "type", content = "data", rename_all = "snake_case")] +#[strum(serialize_all = "snake_case")] +pub enum ServerNotification { + /// Authentication status changed + AuthStatusChange(AuthStatusChangeNotification), + + /// ChatGPT login flow completed + LoginChatGptComplete(LoginChatGptCompleteNotification), +} + #[cfg(test)] mod tests { use super::*; From 74683bab91e46ece256558e51240ccb241c51240 Mon Sep 17 00:00:00 2001 From: Gabriel Peal Date: Wed, 20 Aug 2025 22:34:46 -0700 Subject: [PATCH 16/27] Add a serde tag to ParsedItem (#2546) --- codex-rs/protocol/src/parse_command.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/codex-rs/protocol/src/parse_command.rs b/codex-rs/protocol/src/parse_command.rs index 495562a765..63399acc65 100644 --- a/codex-rs/protocol/src/parse_command.rs +++ b/codex-rs/protocol/src/parse_command.rs @@ -2,6 +2,7 @@ use serde::Deserialize; use serde::Serialize; #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[serde(tag = "type", rename_all = "snake_case")] pub enum ParsedCommand { Read { cmd: String, From d2b2a6d13ae4169a5806e8dc98c1333f188d894a Mon Sep 17 00:00:00 2001 From: Dylan Date: Wed, 20 Aug 2025 23:45:16 -0700 Subject: [PATCH 17/27] [prompt] xml-format EnvironmentContext (#2272) ## Summary Before we land #2243, let's start printing environment_context in our preferred format. This struct will evolve over time with new information, xml gives us a balance of human readable without too much parsing, llm readable, and extensible. Also moves us over to an Option-based struct, so we can easily provide diffs to the model. ## Testing - [x] Updated tests to reflect new format --- codex-rs/core/src/codex.rs | 17 +++-- codex-rs/core/src/environment_context.rs | 94 +++++++++++++++--------- codex-rs/core/tests/prompt_caching.rs | 31 ++++---- 3 files changed, 85 insertions(+), 57 deletions(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index dfa10398fe..a692e6fa8d 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -508,10 +508,10 @@ impl Session { conversation_items.push(Prompt::format_user_instructions_message(user_instructions)); } conversation_items.push(ResponseItem::from(EnvironmentContext::new( - turn_context.cwd.to_path_buf(), - turn_context.approval_policy, - turn_context.sandbox_policy.clone(), - sess.user_shell.clone(), + Some(turn_context.cwd.clone()), + Some(turn_context.approval_policy), + Some(turn_context.sandbox_policy.clone()), + Some(sess.user_shell.clone()), ))); sess.record_conversation_items(&conversation_items).await; @@ -1068,10 +1068,11 @@ async fn submission_loop( turn_context = Arc::new(new_turn_context); if cwd.is_some() || approval_policy.is_some() || sandbox_policy.is_some() { sess.record_conversation_items(&[ResponseItem::from(EnvironmentContext::new( - new_cwd, - new_approval_policy, - new_sandbox_policy, - sess.user_shell.clone(), + cwd, + approval_policy, + sandbox_policy, + // Shell is not configurable from turn to turn + None, ))]) .await; } diff --git a/codex-rs/core/src/environment_context.rs b/codex-rs/core/src/environment_context.rs index a5a9b85589..c89d7ca6c7 100644 --- a/codex-rs/core/src/environment_context.rs +++ b/codex-rs/core/src/environment_context.rs @@ -8,11 +8,10 @@ use crate::protocol::AskForApproval; use crate::protocol::SandboxPolicy; use crate::shell::Shell; use codex_protocol::config_types::SandboxMode; -use std::fmt::Display; use std::path::PathBuf; /// wraps environment context message in a tag for the model to parse more easily. -pub(crate) const ENVIRONMENT_CONTEXT_START: &str = "\n"; +pub(crate) const ENVIRONMENT_CONTEXT_START: &str = ""; pub(crate) const ENVIRONMENT_CONTEXT_END: &str = ""; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, DeriveDisplay)] @@ -25,58 +24,87 @@ pub enum NetworkAccess { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename = "environment_context", rename_all = "snake_case")] pub(crate) struct EnvironmentContext { - pub cwd: PathBuf, - pub approval_policy: AskForApproval, - pub sandbox_mode: SandboxMode, - pub network_access: NetworkAccess, - pub shell: Shell, + pub cwd: Option, + pub approval_policy: Option, + pub sandbox_mode: Option, + pub network_access: Option, + pub shell: Option, } impl EnvironmentContext { pub fn new( - cwd: PathBuf, - approval_policy: AskForApproval, - sandbox_policy: SandboxPolicy, - shell: Shell, + cwd: Option, + approval_policy: Option, + sandbox_policy: Option, + shell: Option, ) -> Self { Self { cwd, approval_policy, sandbox_mode: match sandbox_policy { - SandboxPolicy::DangerFullAccess => SandboxMode::DangerFullAccess, - SandboxPolicy::ReadOnly => SandboxMode::ReadOnly, - SandboxPolicy::WorkspaceWrite { .. } => SandboxMode::WorkspaceWrite, + Some(SandboxPolicy::DangerFullAccess) => Some(SandboxMode::DangerFullAccess), + Some(SandboxPolicy::ReadOnly) => Some(SandboxMode::ReadOnly), + Some(SandboxPolicy::WorkspaceWrite { .. }) => Some(SandboxMode::WorkspaceWrite), + None => None, }, network_access: match sandbox_policy { - SandboxPolicy::DangerFullAccess => NetworkAccess::Enabled, - SandboxPolicy::ReadOnly => NetworkAccess::Restricted, - SandboxPolicy::WorkspaceWrite { network_access, .. } => { + Some(SandboxPolicy::DangerFullAccess) => Some(NetworkAccess::Enabled), + Some(SandboxPolicy::ReadOnly) => Some(NetworkAccess::Restricted), + Some(SandboxPolicy::WorkspaceWrite { network_access, .. }) => { if network_access { - NetworkAccess::Enabled + Some(NetworkAccess::Enabled) } else { - NetworkAccess::Restricted + Some(NetworkAccess::Restricted) } } + None => None, }, shell, } } } -impl Display for EnvironmentContext { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - writeln!( - f, - "Current working directory: {}", - self.cwd.to_string_lossy() - )?; - writeln!(f, "Approval policy: {}", self.approval_policy)?; - writeln!(f, "Sandbox mode: {}", self.sandbox_mode)?; - writeln!(f, "Network access: {}", self.network_access)?; - if let Some(shell_name) = self.shell.name() { - writeln!(f, "Shell: {shell_name}")?; +impl EnvironmentContext { + /// Serializes the environment context to XML. Libraries like `quick-xml` + /// require custom macros to handle Enums with newtypes, so we just do it + /// manually, to keep things simple. Output looks like: + /// + /// ```xml + /// + /// ... + /// ... + /// ... + /// ... + /// ... + /// + /// ``` + pub fn serialize_to_xml(self) -> String { + let mut lines = vec![ENVIRONMENT_CONTEXT_START.to_string()]; + if let Some(cwd) = self.cwd { + lines.push(format!(" {}", cwd.to_string_lossy())); } - Ok(()) + if let Some(approval_policy) = self.approval_policy { + lines.push(format!( + " {}", + approval_policy + )); + } + if let Some(sandbox_mode) = self.sandbox_mode { + lines.push(format!(" {}", sandbox_mode)); + } + if let Some(network_access) = self.network_access { + lines.push(format!( + " {}", + network_access + )); + } + if let Some(shell) = self.shell + && let Some(shell_name) = shell.name() + { + lines.push(format!(" {}", shell_name)); + } + lines.push(ENVIRONMENT_CONTEXT_END.to_string()); + lines.join("\n") } } @@ -86,7 +114,7 @@ impl From for ResponseItem { id: None, role: "user".to_string(), content: vec![ContentItem::InputText { - text: format!("{ENVIRONMENT_CONTEXT_START}{ec}{ENVIRONMENT_CONTEXT_END}"), + text: ec.serialize_to_xml(), }], } } diff --git a/codex-rs/core/tests/prompt_caching.rs b/codex-rs/core/tests/prompt_caching.rs index 75b7691ba4..bf49262a88 100644 --- a/codex-rs/core/tests/prompt_caching.rs +++ b/codex-rs/core/tests/prompt_caching.rs @@ -89,10 +89,15 @@ async fn prefixes_context_and_instructions_once_and_consistently_across_requests let shell = default_user_shell().await; let expected_env_text = format!( - "\nCurrent working directory: {}\nApproval policy: on-request\nSandbox mode: read-only\nNetwork access: restricted\n{}", + r#" + {} + on-request + read-only + restricted +{}"#, cwd.path().to_string_lossy(), match shell.name() { - Some(name) => format!("Shell: {name}\n"), + Some(name) => format!(" {}\n", name), None => String::new(), } ); @@ -190,12 +195,10 @@ async fn overrides_turn_context_but_keeps_cached_prefix_and_key_constant() { .unwrap(); wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await; - // Change everything about the turn context. - let new_cwd = TempDir::new().unwrap(); let writable = TempDir::new().unwrap(); codex .submit(Op::OverrideTurnContext { - cwd: Some(new_cwd.path().to_path_buf()), + cwd: None, approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(SandboxPolicy::WorkspaceWrite { writable_roots: vec![writable.path().to_path_buf()], @@ -227,7 +230,6 @@ async fn overrides_turn_context_but_keeps_cached_prefix_and_key_constant() { let body1 = requests[0].body_json::().unwrap(); let body2 = requests[1].body_json::().unwrap(); - // prompt_cache_key should remain constant across overrides assert_eq!( body1["prompt_cache_key"], body2["prompt_cache_key"], @@ -243,16 +245,13 @@ async fn overrides_turn_context_but_keeps_cached_prefix_and_key_constant() { "content": [ { "type": "input_text", "text": "hello 2" } ] }); // After overriding the turn context, the environment context should be emitted again - // reflecting the new cwd, approval policy and sandbox settings. - let shell = default_user_shell().await; - let expected_env_text_2 = format!( - "\nCurrent working directory: {}\nApproval policy: never\nSandbox mode: workspace-write\nNetwork access: enabled\n{}", - new_cwd.path().to_string_lossy(), - match shell.name() { - Some(name) => format!("Shell: {name}\n"), - None => String::new(), - } - ); + // reflecting the new approval policy and sandbox settings. Omit cwd because it did + // not change. + let expected_env_text_2 = r#" + never + workspace-write + enabled +"#; let expected_env_msg_2 = serde_json::json!({ "type": "message", "id": serde_json::Value::Null, From 8ad56be06e8ddbc97edf5e7d16456dd2a6f8f589 Mon Sep 17 00:00:00 2001 From: easong-openai Date: Thu, 21 Aug 2025 01:15:24 -0700 Subject: [PATCH 18/27] Parse and expose stream errors (#2540) --- codex-rs/core/src/codex.rs | 15 ++++++++++++-- .../src/event_processor_with_human_output.rs | 4 ++++ codex-rs/mcp-server/src/codex_tool_runner.rs | 1 + codex-rs/protocol/src/protocol.rs | 9 +++++++++ codex-rs/tui/src/chatwidget.rs | 8 ++++++++ codex-rs/tui/src/chatwidget/tests.rs | 20 +++++++++++++++++++ codex-rs/tui/src/history_cell.rs | 6 ++++++ 7 files changed, 61 insertions(+), 2 deletions(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index a692e6fa8d..7d616f96e0 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -94,6 +94,7 @@ use crate::protocol::PatchApplyEndEvent; use crate::protocol::ReviewDecision; use crate::protocol::SandboxPolicy; use crate::protocol::SessionConfiguredEvent; +use crate::protocol::StreamErrorEvent; use crate::protocol::Submission; use crate::protocol::TaskCompleteEvent; use crate::protocol::TurnDiffEvent; @@ -815,6 +816,16 @@ impl Session { let _ = self.tx_event.send(event).await; } + async fn notify_stream_error(&self, sub_id: &str, message: impl Into) { + let event = Event { + id: sub_id.to_string(), + msg: EventMsg::StreamError(StreamErrorEvent { + message: message.into(), + }), + }; + let _ = self.tx_event.send(event).await; + } + /// 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 { @@ -1523,7 +1534,7 @@ async fn run_turn( // Surface retry information to any UI/front‑end so the // user understands what is happening instead of staring // at a seemingly frozen screen. - sess.notify_background_event( + sess.notify_stream_error( &sub_id, format!( "stream error: {e}; retrying {retries}/{max_retries} in {delay:?}…" @@ -1758,7 +1769,7 @@ async fn run_compact_task( if retries < max_retries { retries += 1; let delay = backoff(retries); - sess.notify_background_event( + sess.notify_stream_error( &sub_id, format!( "stream error: {e}; retrying {retries}/{max_retries} in {delay:?}…" 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 498b1d29a4..9a562cbd4d 100644 --- a/codex-rs/exec/src/event_processor_with_human_output.rs +++ b/codex-rs/exec/src/event_processor_with_human_output.rs @@ -20,6 +20,7 @@ use codex_core::protocol::McpToolCallEndEvent; use codex_core::protocol::PatchApplyBeginEvent; use codex_core::protocol::PatchApplyEndEvent; use codex_core::protocol::SessionConfiguredEvent; +use codex_core::protocol::StreamErrorEvent; use codex_core::protocol::TaskCompleteEvent; use codex_core::protocol::TurnAbortReason; use codex_core::protocol::TurnDiffEvent; @@ -174,6 +175,9 @@ impl EventProcessor for EventProcessorWithHumanOutput { EventMsg::BackgroundEvent(BackgroundEventEvent { message }) => { ts_println!(self, "{}", message.style(self.dimmed)); } + EventMsg::StreamError(StreamErrorEvent { message }) => { + ts_println!(self, "{}", message.style(self.dimmed)); + } EventMsg::TaskStarted => { // Ignore. } diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index e1dfd08c8e..36845d895c 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -268,6 +268,7 @@ async fn run_codex_tool_session_inner( | EventMsg::ExecCommandOutputDelta(_) | EventMsg::ExecCommandEnd(_) | EventMsg::BackgroundEvent(_) + | EventMsg::StreamError(_) | EventMsg::PatchApplyBegin(_) | EventMsg::PatchApplyEnd(_) | EventMsg::TurnDiff(_) diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 65ef30aa6b..fbe052bf95 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -446,6 +446,10 @@ pub enum EventMsg { BackgroundEvent(BackgroundEventEvent), + /// Notification that a model stream experienced an error or disconnect + /// and the system is handling it (e.g., retrying with backoff). + StreamError(StreamErrorEvent), + /// Notification that the agent is about to apply a code patch. Mirrors /// `ExecCommandBegin` so front‑ends can show progress indicators. PatchApplyBegin(PatchApplyBeginEvent), @@ -721,6 +725,11 @@ pub struct BackgroundEventEvent { pub message: String, } +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct StreamErrorEvent { + pub message: String, +} + #[derive(Debug, Clone, Deserialize, Serialize)] pub struct PatchApplyBeginEvent { /// Identifier so this can be paired with the PatchApplyEnd event. diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index b39cf7aa3a..3386133faa 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -23,6 +23,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::StreamErrorEvent; use codex_core::protocol::TaskCompleteEvent; use codex_core::protocol::TokenUsage; use codex_core::protocol::TurnDiffEvent; @@ -327,6 +328,12 @@ impl ChatWidget { fn on_background_event(&mut self, message: String) { debug!("BackgroundEvent: {message}"); } + + fn on_stream_error(&mut self, message: String) { + // Show stream errors in the transcript so users see retry/backoff info. + self.add_to_history(history_cell::new_stream_error_event(message)); + self.mark_needs_redraw(); + } /// Periodic tick to commit at most one queued line to history with a small delay, /// animating the output. pub(crate) fn on_commit_tick(&mut self) { @@ -690,6 +697,7 @@ impl ChatWidget { EventMsg::BackgroundEvent(BackgroundEventEvent { message }) => { self.on_background_event(message) } + EventMsg::StreamError(StreamErrorEvent { message }) => self.on_stream_error(message), } // Coalesce redraws: issue at most one after handling the event if self.needs_redraw { diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 42dcddeb3c..d981f06df2 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -19,6 +19,7 @@ use codex_core::protocol::ExecCommandEndEvent; use codex_core::protocol::FileChange; use codex_core::protocol::PatchApplyBeginEvent; use codex_core::protocol::PatchApplyEndEvent; +use codex_core::protocol::StreamErrorEvent; use codex_core::protocol::TaskCompleteEvent; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; @@ -823,6 +824,25 @@ fn plan_update_renders_history_cell() { assert!(blob.contains("Write tests")); } +#[test] +fn stream_error_is_rendered_to_history() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); + let msg = "stream error: stream disconnected before completion: idle timeout waiting for SSE; retrying 1/5 in 211ms…"; + chat.handle_codex_event(Event { + id: "sub-1".into(), + msg: EventMsg::StreamError(StreamErrorEvent { + message: msg.to_string(), + }), + }); + + let cells = drain_insert_history(&mut rx); + assert!(!cells.is_empty(), "expected a history cell for StreamError"); + let blob = lines_to_single_string(cells.last().unwrap()); + assert!(blob.contains("⚠ ")); + assert!(blob.contains("stream error:")); + assert!(blob.contains("idle timeout waiting for SSE")); +} + #[test] fn headers_emitted_on_stream_begin_for_answer_and_not_for_reasoning() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 8e3a5fb943..4948d31df7 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -751,6 +751,12 @@ pub(crate) fn new_error_event(message: String) -> PlainHistoryCell { PlainHistoryCell { lines } } +pub(crate) fn new_stream_error_event(message: String) -> PlainHistoryCell { + let lines: Vec> = + vec![vec!["⚠ ".magenta().bold(), message.dim()].into(), "".into()]; + PlainHistoryCell { lines } +} + /// Render a user‑friendly plan update styled like a checkbox todo list. pub(crate) fn new_plan_update(update: UpdatePlanArgs) -> PlainHistoryCell { let UpdatePlanArgs { explanation, plan } = update; From 5f6e1af1a50ecab35cf9ce58041600622187af35 Mon Sep 17 00:00:00 2001 From: Jeremy Rose <172423086+nornagon-openai@users.noreply.github.com> Date: Thu, 21 Aug 2025 08:51:26 -0700 Subject: [PATCH 19/27] scroll instead of clear on boot (#2535) this actually works fine already in iterm without this change, but Terminal.app adds a bunch of excess whitespace when we clear all. https://github.com/user-attachments/assets/c5bd1809-c2ed-4daa-a148-944d2df52876 --- codex-rs/tui/src/tui.rs | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/codex-rs/tui/src/tui.rs b/codex-rs/tui/src/tui.rs index 6af0d391b6..4ec17ea8af 100644 --- a/codex-rs/tui/src/tui.rs +++ b/codex-rs/tui/src/tui.rs @@ -6,6 +6,7 @@ use std::time::Duration; use std::time::Instant; use crossterm::SynchronizedUpdate; +use crossterm::cursor; use crossterm::cursor::MoveTo; use crossterm::event::DisableBracketedPaste; use crossterm::event::EnableBracketedPaste; @@ -15,8 +16,7 @@ use crossterm::event::KeyEventKind; use crossterm::event::KeyboardEnhancementFlags; use crossterm::event::PopKeyboardEnhancementFlags; use crossterm::event::PushKeyboardEnhancementFlags; -use crossterm::terminal::Clear; -use crossterm::terminal::ClearType; +use crossterm::terminal::ScrollUp; use ratatui::backend::Backend; use ratatui::backend::CrosstermBackend; use ratatui::crossterm::execute; @@ -71,8 +71,14 @@ pub fn init() -> Result { set_panic_hook(); - // Clear screen and move cursor to top-left before drawing UI - execute!(stdout(), Clear(ClearType::All), MoveTo(0, 0))?; + // Instead of clearing the screen (which can drop scrollback in some terminals), + // scroll existing lines up until the cursor reaches the top, then start at (0, 0). + if let Ok((_x, y)) = cursor::position() + && y > 0 + { + execute!(stdout(), ScrollUp(y))?; + } + execute!(stdout(), MoveTo(0, 0))?; let backend = CrosstermBackend::new(stdout()); let tui = CustomTerminal::with_options(backend)?; From db934e438eba734feb86f70788e2baef9580b20b Mon Sep 17 00:00:00 2001 From: Jeremy Rose <172423086+nornagon-openai@users.noreply.github.com> Date: Thu, 21 Aug 2025 08:52:17 -0700 Subject: [PATCH 20/27] read all AGENTS.md up to git root (#2532) This updates our logic for AGENTS.md to match documented behavior, which is to read all AGENTS.md files from cwd up to git root. --- codex-rs/core/src/lib.rs | 2 +- codex-rs/core/src/project_doc.rs | 219 ++++++++++++++++++++----------- codex-rs/tui/src/history_cell.rs | 49 +++++++ 3 files changed, 194 insertions(+), 76 deletions(-) diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index ad3947aa94..40ccbf6769 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -43,7 +43,7 @@ mod models; mod openai_model_info; mod openai_tools; pub mod plan_tool; -mod project_doc; +pub mod project_doc; mod rollout; pub(crate) mod safety; pub mod seatbelt; diff --git a/codex-rs/core/src/project_doc.rs b/codex-rs/core/src/project_doc.rs index 3591b5b1d8..a869b40cb3 100644 --- a/codex-rs/core/src/project_doc.rs +++ b/codex-rs/core/src/project_doc.rs @@ -1,18 +1,19 @@ //! Project-level documentation discovery. //! -//! Project-level documentation can be stored in a file named `AGENTS.md`. -//! Currently, we include only the contents of the first file found as follows: +//! Project-level documentation can be stored in files named `AGENTS.md`. +//! We include the concatenation of all files found along the path from the +//! repository root to the current working directory as follows: //! -//! 1. Look for the doc file in the current working directory (as determined -//! by the `Config`). -//! 2. If not found, walk *upwards* until the Git repository root is reached -//! (detected by the presence of a `.git` directory/file), or failing that, -//! the filesystem root. -//! 3. If the Git root is encountered, look for the doc file there. If it -//! exists, the search stops – we do **not** walk past the Git root. +//! 1. Determine the Git repository root by walking upwards from the current +//! working directory until a `.git` directory or file is found. If no Git +//! root is found, only the current working directory is considered. +//! 2. Collect every `AGENTS.md` found from the repository root down to the +//! current working directory (inclusive) and concatenate their contents in +//! that order. +//! 3. We do **not** walk past the Git root. use crate::config::Config; -use std::path::Path; +use std::path::PathBuf; use tokio::io::AsyncReadExt; use tracing::error; @@ -26,7 +27,7 @@ const PROJECT_DOC_SEPARATOR: &str = "\n\n--- project-doc ---\n\n"; /// Combines `Config::instructions` and `AGENTS.md` (if present) into a single /// string of instructions. pub(crate) async fn get_user_instructions(config: &Config) -> Option { - match find_project_doc(config).await { + match read_project_docs(config).await { Ok(Some(project_doc)) => match &config.user_instructions { Some(original_instructions) => Some(format!( "{original_instructions}{PROJECT_DOC_SEPARATOR}{project_doc}" @@ -41,95 +42,135 @@ pub(crate) async fn get_user_instructions(config: &Config) -> Option { } } -/// Attempt to locate and load the project documentation. Currently, the search -/// starts from `Config::cwd`, but if we may want to consider other directories -/// in the future, e.g., additional writable directories in the `SandboxPolicy`. +/// Attempt to locate and load the project documentation. /// -/// On success returns `Ok(Some(contents))`. If no documentation file is found -/// the function returns `Ok(None)`. Unexpected I/O failures bubble up as -/// `Err` so callers can decide how to handle them. -async fn find_project_doc(config: &Config) -> std::io::Result> { - let max_bytes = config.project_doc_max_bytes; +/// On success returns `Ok(Some(contents))` where `contents` is the +/// concatenation of all discovered docs. If no documentation file is found the +/// function returns `Ok(None)`. Unexpected I/O failures bubble up as `Err` so +/// callers can decide how to handle them. +pub async fn read_project_docs(config: &Config) -> std::io::Result> { + let max_total = config.project_doc_max_bytes; - // Attempt to load from the working directory first. - if let Some(doc) = load_first_candidate(&config.cwd, CANDIDATE_FILENAMES, max_bytes).await? { - return Ok(Some(doc)); + if max_total == 0 { + return Ok(None); } - // Walk up towards the filesystem root, stopping once we encounter the Git - // repository root. The presence of **either** a `.git` *file* or - // *directory* counts. - let mut dir = config.cwd.clone(); + let paths = discover_project_doc_paths(config)?; + if paths.is_empty() { + return Ok(None); + } - // Canonicalize the path so that we do not end up in an infinite loop when - // `cwd` contains `..` components. + let mut remaining: u64 = max_total as u64; + let mut parts: Vec = Vec::new(); + + for p in paths { + if remaining == 0 { + break; + } + + let file = match tokio::fs::File::open(&p).await { + Ok(f) => f, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue, + Err(e) => return Err(e), + }; + + let size = file.metadata().await?.len(); + let mut reader = tokio::io::BufReader::new(file).take(remaining); + let mut data: Vec = Vec::new(); + reader.read_to_end(&mut data).await?; + + if size > remaining { + tracing::warn!( + "Project doc `{}` exceeds remaining budget ({} bytes) - truncating.", + p.display(), + remaining, + ); + } + + let text = String::from_utf8_lossy(&data).to_string(); + if !text.trim().is_empty() { + parts.push(text); + remaining = remaining.saturating_sub(data.len() as u64); + } + } + + if parts.is_empty() { + Ok(None) + } else { + Ok(Some(parts.join("\n\n"))) + } +} + +/// Discover the list of AGENTS.md files using the same search rules as +/// `read_project_docs`, but return the file paths instead of concatenated +/// contents. The list is ordered from repository root to the current working +/// directory (inclusive). Symlinks are allowed. When `project_doc_max_bytes` +/// is zero, returns an empty list. +pub fn discover_project_doc_paths(config: &Config) -> std::io::Result> { + let mut dir = config.cwd.clone(); if let Ok(canon) = dir.canonicalize() { dir = canon; } - while let Some(parent) = dir.parent() { - // `.git` can be a *file* (for worktrees or submodules) or a *dir*. - let git_marker = dir.join(".git"); - let git_exists = match tokio::fs::metadata(&git_marker).await { + // Build chain from cwd upwards and detect git root. + let mut chain: Vec = vec![dir.clone()]; + let mut git_root: Option = None; + let mut cursor = dir.clone(); + while let Some(parent) = cursor.parent() { + let git_marker = cursor.join(".git"); + let git_exists = match std::fs::metadata(&git_marker) { Ok(_) => true, Err(e) if e.kind() == std::io::ErrorKind::NotFound => false, Err(e) => return Err(e), }; if git_exists { - // We are at the repo root – attempt one final load. - if let Some(doc) = load_first_candidate(&dir, CANDIDATE_FILENAMES, max_bytes).await? { - return Ok(Some(doc)); - } + git_root = Some(cursor.clone()); break; } - dir = parent.to_path_buf(); + chain.push(parent.to_path_buf()); + cursor = parent.to_path_buf(); } - Ok(None) -} - -/// Attempt to load the first candidate file found in `dir`. Returns the file -/// contents (truncated if it exceeds `max_bytes`) when successful. -async fn load_first_candidate( - dir: &Path, - names: &[&str], - max_bytes: usize, -) -> std::io::Result> { - for name in names { - let candidate = dir.join(name); - - let file = match tokio::fs::File::open(&candidate).await { - Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue, - Err(e) => return Err(e), - Ok(f) => f, - }; - - let size = file.metadata().await?.len(); - - let reader = tokio::io::BufReader::new(file); - let mut data = Vec::with_capacity(std::cmp::min(size as usize, max_bytes)); - let mut limited = reader.take(max_bytes as u64); - limited.read_to_end(&mut data).await?; - - if size as usize > max_bytes { - tracing::warn!( - "Project doc `{}` exceeds {max_bytes} bytes - truncating.", - candidate.display(), - ); + let search_dirs: Vec = if let Some(root) = git_root { + let mut dirs: Vec = Vec::new(); + let mut saw_root = false; + for p in chain.iter().rev() { + if !saw_root { + if p == &root { + saw_root = true; + } else { + continue; + } + } + dirs.push(p.clone()); } + dirs + } else { + vec![config.cwd.clone()] + }; - let contents = String::from_utf8_lossy(&data).to_string(); - if contents.trim().is_empty() { - // Empty file – treat as not found. - continue; + let mut found: Vec = Vec::new(); + for d in search_dirs { + for name in CANDIDATE_FILENAMES { + let candidate = d.join(name); + match std::fs::symlink_metadata(&candidate) { + Ok(md) => { + let ft = md.file_type(); + // Allow regular files and symlinks; opening will later fail for dangling links. + if ft.is_file() || ft.is_symlink() { + found.push(candidate); + break; + } + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue, + Err(e) => return Err(e), + } } - - return Ok(Some(contents)); } - Ok(None) + Ok(found) } #[cfg(test)] @@ -278,4 +319,32 @@ mod tests { assert_eq!(res, Some(INSTRUCTIONS.to_string())); } + + /// When both the repository root and the working directory contain + /// AGENTS.md files, their contents are concatenated from root to cwd. + #[tokio::test] + async fn concatenates_root_and_cwd_docs() { + let repo = tempfile::tempdir().expect("tempdir"); + + // Simulate a git repository. + std::fs::write( + repo.path().join(".git"), + "gitdir: /path/to/actual/git/dir\n", + ) + .unwrap(); + + // Repo root doc. + fs::write(repo.path().join("AGENTS.md"), "root doc").unwrap(); + + // Nested working directory with its own doc. + let nested = repo.path().join("workspace/crate_a"); + std::fs::create_dir_all(&nested).unwrap(); + fs::write(nested.join("AGENTS.md"), "crate doc").unwrap(); + + let mut cfg = make_config(&repo, 4096, None); + cfg.cwd = nested; + + let res = get_user_instructions(&cfg).await.expect("doc expected"); + assert_eq!(res, "root doc\n\ncrate doc"); + } } diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 4948d31df7..9f236d78d1 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -12,6 +12,7 @@ use codex_core::config::Config; use codex_core::plan_tool::PlanItemArg; use codex_core::plan_tool::StepStatus; use codex_core::plan_tool::UpdatePlanArgs; +use codex_core::project_doc::discover_project_doc_paths; use codex_core::protocol::FileChange; use codex_core::protocol::McpInvocation; use codex_core::protocol::SandboxPolicy; @@ -561,6 +562,54 @@ pub(crate) fn new_status_output( sandbox_name.into(), ])); + // AGENTS.md files discovered via core's project_doc logic + let agents_list = { + match discover_project_doc_paths(config) { + Ok(paths) => { + let mut rels: Vec = Vec::new(); + for p in paths { + let display = if let Some(parent) = p.parent() { + if parent == config.cwd { + "AGENTS.md".to_string() + } else { + let mut cur = config.cwd.as_path(); + let mut ups = 0usize; + let mut reached = false; + while let Some(c) = cur.parent() { + if cur == parent { + reached = true; + break; + } + cur = c; + ups += 1; + } + if reached { + format!("{}AGENTS.md", "../".repeat(ups)) + } else if let Ok(stripped) = p.strip_prefix(&config.cwd) { + stripped.display().to_string() + } else { + p.display().to_string() + } + } + } else { + p.display().to_string() + }; + rels.push(display); + } + rels + } + Err(_) => Vec::new(), + } + }; + if agents_list.is_empty() { + lines.push(Line::from(" • AGENTS files: (none)")); + } else { + lines.push(Line::from(vec![ + " • AGENTS files: ".into(), + agents_list.join(", ").into(), + ])); + } + lines.push(Line::from("")); // 👤 Account (only if ChatGPT tokens exist), shown under the first block From 96046716786e254f19d61f42171caa53a61cd1ab Mon Sep 17 00:00:00 2001 From: Jeremy Rose <172423086+nornagon-openai@users.noreply.github.com> Date: Thu, 21 Aug 2025 08:54:11 -0700 Subject: [PATCH 21/27] tui: show diff hunk headers to separate sections (#2488) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Screenshot 2025-08-20 at 2 38 29 PM --- codex-rs/tui/src/diff_render.rs | 97 +++++++++++++++++-- ...iff_render__tests__blank_context_line.snap | 14 +++ ...tests__single_line_replacement_counts.snap | 12 +++ ...er__tests__update_details_with_rename.snap | 1 - ...ests__vertical_ellipsis_between_hunks.snap | 20 ++++ ...f_render__tests__wrap_behavior_insert.snap | 5 +- 6 files changed, 136 insertions(+), 13 deletions(-) create mode 100644 codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__blank_context_line.snap create mode 100644 codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__single_line_replacement_counts.snap create mode 100644 codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__vertical_ellipsis_between_hunks.snap diff --git a/codex-rs/tui/src/diff_render.rs b/codex-rs/tui/src/diff_render.rs index aaa7617d91..48a1665957 100644 --- a/codex-rs/tui/src/diff_render.rs +++ b/codex-rs/tui/src/diff_render.rs @@ -212,7 +212,18 @@ fn render_patch_details(changes: &HashMap) -> Vec { if let Ok(patch) = diffy::Patch::from_str(unified_diff) { + let mut is_first_hunk = true; for h in patch.hunks() { + // Render a simple separator between non-contiguous hunks + // instead of diff-style @@ headers. + if !is_first_hunk { + out.push(RtLine::from(vec![ + RtSpan::raw(" "), + RtSpan::styled("⋮", style_dim()), + ])); + } + is_first_hunk = false; + let mut old_ln = h.old_range().start(); let mut new_ln = h.new_range().start(); for l in h.lines() { @@ -276,8 +287,7 @@ fn push_wrapped_diff_line( // ("+"/"-" for inserts/deletes, or a space for context lines) so alignment // stays consistent across all diff lines. let gap_after_ln = SPACES_AFTER_LINE_NUMBER.saturating_sub(ln_str.len()); - let first_prefix_cols = indent.len() + ln_str.len() + gap_after_ln; - let cont_prefix_cols = indent.len() + ln_str.len() + gap_after_ln; + let prefix_cols = indent.len() + ln_str.len() + gap_after_ln; let mut first = true; let (sign_opt, line_style) = match kind { @@ -286,16 +296,14 @@ fn push_wrapped_diff_line( DiffLineType::Context => (None, None), }; let mut lines: Vec> = Vec::new(); - while !remaining_text.is_empty() { - let prefix_cols = if first { - first_prefix_cols - } else { - cont_prefix_cols - }; + + loop { // Fit the content for the current terminal row: // compute how many columns are available after the prefix, then split // at a UTF-8 character boundary so this row's chunk fits exactly. - let available_content_cols = term_cols.saturating_sub(prefix_cols).max(1); + let available_content_cols = term_cols + .saturating_sub(if first { prefix_cols + 1 } else { prefix_cols }) + .max(1); let split_at_byte_index = remaining_text .char_indices() .nth(available_content_cols) @@ -341,6 +349,9 @@ fn push_wrapped_diff_line( } lines.push(line); } + if remaining_text.is_empty() { + break; + } } lines } @@ -430,4 +441,72 @@ mod tests { // Render into a small terminal to capture the visual layout snapshot_lines("wrap_behavior_insert", lines, DEFAULT_WRAP_COLS + 10, 8); } + + #[test] + fn ui_snapshot_single_line_replacement_counts() { + // Reproduce: one deleted line replaced by one inserted line, no extra context + let original = "# Codex CLI (Rust Implementation)\n"; + let modified = "# Codex CLI (Rust Implementation) banana\n"; + let patch = diffy::create_patch(original, modified).to_string(); + + let mut changes: HashMap = HashMap::new(); + changes.insert( + PathBuf::from("README.md"), + FileChange::Update { + unified_diff: patch, + move_path: None, + }, + ); + + let lines = + create_diff_summary("proposed patch", &changes, PatchEventType::ApprovalRequest); + + snapshot_lines("single_line_replacement_counts", lines, 80, 8); + } + + #[test] + fn ui_snapshot_blank_context_line() { + // Ensure a hunk that includes a blank context line at the beginning is rendered visibly + let original = "\nY\n"; + let modified = "\nY changed\n"; + let patch = diffy::create_patch(original, modified).to_string(); + + let mut changes: HashMap = HashMap::new(); + changes.insert( + PathBuf::from("example.txt"), + FileChange::Update { + unified_diff: patch, + move_path: None, + }, + ); + + let lines = + create_diff_summary("proposed patch", &changes, PatchEventType::ApprovalRequest); + + snapshot_lines("blank_context_line", lines, 80, 10); + } + + #[test] + fn ui_snapshot_vertical_ellipsis_between_hunks() { + // Create a patch with two separate hunks to ensure we render the vertical ellipsis (⋮) + let original = + "line 1\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7\nline 8\nline 9\nline 10\n"; + let modified = "line 1\nline two changed\nline 3\nline 4\nline 5\nline 6\nline 7\nline 8\nline nine changed\nline 10\n"; + let patch = diffy::create_patch(original, modified).to_string(); + + let mut changes: HashMap = HashMap::new(); + changes.insert( + PathBuf::from("example.txt"), + FileChange::Update { + unified_diff: patch, + move_path: None, + }, + ); + + let lines = + create_diff_summary("proposed patch", &changes, PatchEventType::ApprovalRequest); + + // Height is large enough to show both hunks and the separator + snapshot_lines("vertical_ellipsis_between_hunks", lines, 80, 16); + } } diff --git a/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__blank_context_line.snap b/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__blank_context_line.snap new file mode 100644 index 0000000000..b38704f9ad --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__blank_context_line.snap @@ -0,0 +1,14 @@ +--- +source: tui/src/diff_render.rs +expression: terminal.backend() +--- +"proposed patch to 1 file (+1 -1) " +" └ example.txt " +" 1 " +" 2 -Y " +" 2 +Y changed " +" " +" " +" " +" " +" " diff --git a/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__single_line_replacement_counts.snap b/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__single_line_replacement_counts.snap new file mode 100644 index 0000000000..b92dcbd646 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__single_line_replacement_counts.snap @@ -0,0 +1,12 @@ +--- +source: tui/src/diff_render.rs +expression: terminal.backend() +--- +"proposed patch to 1 file (+1 -1) " +" └ README.md " +" 1 -# Codex CLI (Rust Implementation) " +" 1 +# Codex CLI (Rust Implementation) banana " +" " +" " +" " +" " diff --git a/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__update_details_with_rename.snap b/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__update_details_with_rename.snap index 9a73c0c32b..459791e024 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__update_details_with_rename.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__update_details_with_rename.snap @@ -1,6 +1,5 @@ --- source: tui/src/diff_render.rs -assertion_line: 380 expression: terminal.backend() --- "proposed patch to 1 file (+1 -1) " diff --git a/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__vertical_ellipsis_between_hunks.snap b/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__vertical_ellipsis_between_hunks.snap new file mode 100644 index 0000000000..0f4bfd5aed --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__vertical_ellipsis_between_hunks.snap @@ -0,0 +1,20 @@ +--- +source: tui/src/diff_render.rs +expression: terminal.backend() +--- +"proposed patch to 1 file (+2 -2) " +" └ example.txt " +" 1 line 1 " +" 2 -line 2 " +" 2 +line two changed " +" 3 line 3 " +" 4 line 4 " +" 5 line 5 " +" ⋮ " +" 6 line 6 " +" 7 line 7 " +" 8 line 8 " +" 9 -line 9 " +" 9 +line nine changed " +" 10 line 10 " +" " diff --git a/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__wrap_behavior_insert.snap b/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__wrap_behavior_insert.snap index 14ff468e38..b14dafaa3c 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__wrap_behavior_insert.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__wrap_behavior_insert.snap @@ -1,10 +1,9 @@ --- source: tui/src/diff_render.rs -assertion_line: 380 expression: terminal.backend() --- -" 1 +this is a very long line that should wrap across multiple terminal col " -" umns and continue " +" 1 +this is a very long line that should wrap across multiple terminal co " +" lumns and continue " " " " " " " From 16d16a4ddc10710b47cb45ae54ddc86de7b18633 Mon Sep 17 00:00:00 2001 From: Jeremy Rose <172423086+nornagon-openai@users.noreply.github.com> Date: Thu, 21 Aug 2025 10:36:58 -0700 Subject: [PATCH 22/27] refactor: move slash command handling into chatwidget (#2536) no functional change, just moving the code that handles /foo into chatwidget, since most commands just do things with chatwidget. --- codex-rs/tui/src/app.rs | 121 ++---------------- codex-rs/tui/src/app_event.rs | 8 +- codex-rs/tui/src/bottom_pane/chat_composer.rs | 53 +++----- codex-rs/tui/src/chatwidget.rs | 101 +++++++++++++++ codex-rs/tui/src/session_log.rs | 6 +- 5 files changed, 137 insertions(+), 152 deletions(-) diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 2585c6c29f..971d1d16c5 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -2,15 +2,11 @@ use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; use crate::chatwidget::ChatWidget; use crate::file_search::FileSearchManager; -use crate::get_git_diff::get_git_diff; -use crate::slash_command::SlashCommand; use crate::transcript_app::run_transcript_app; use crate::tui; use crate::tui::TuiEvent; use codex_core::ConversationManager; use codex_core::config::Config; -use codex_core::protocol::Event; -use codex_core::protocol::Op; use codex_core::protocol::TokenUsage; use color_eyre::eyre::Result; use crossterm::event::KeyCode; @@ -140,6 +136,18 @@ impl App { fn handle_event(&mut self, tui: &mut tui::Tui, event: AppEvent) -> Result { match event { + AppEvent::NewSession => { + self.chat_widget = ChatWidget::new( + self.config.clone(), + self.server.clone(), + tui.frame_requester(), + self.app_event_tx.clone(), + None, + Vec::new(), + self.enhanced_keys_supported, + ); + tui.frame_requester().schedule_frame(); + } AppEvent::InsertHistoryLines(lines) => { self.transcript_lines.extend(lines.clone()); tui.insert_history_lines(lines); @@ -183,111 +191,6 @@ impl App { AppEvent::DiffResult(text) => { self.chat_widget.add_diff_output(text); } - AppEvent::DispatchCommand(command) => match command { - SlashCommand::New => { - // User accepted – switch to chat view. - let new_widget = ChatWidget::new( - self.config.clone(), - self.server.clone(), - tui.frame_requester(), - self.app_event_tx.clone(), - None, - Vec::new(), - self.enhanced_keys_supported, - ); - self.chat_widget = new_widget; - tui.frame_requester().schedule_frame(); - } - SlashCommand::Init => { - // Guard: do not run if a task is active. - const INIT_PROMPT: &str = include_str!("../prompt_for_init_command.md"); - self.chat_widget - .submit_text_message(INIT_PROMPT.to_string()); - } - SlashCommand::Compact => { - self.chat_widget.clear_token_usage(); - self.app_event_tx.send(AppEvent::CodexOp(Op::Compact)); - } - SlashCommand::Model => { - self.chat_widget.open_model_popup(); - } - SlashCommand::Approvals => { - self.chat_widget.open_approvals_popup(); - } - SlashCommand::Quit => { - return Ok(false); - } - SlashCommand::Logout => { - if let Err(e) = codex_login::logout(&self.config.codex_home) { - tracing::error!("failed to logout: {e}"); - } - return Ok(false); - } - SlashCommand::Diff => { - self.chat_widget.add_diff_in_progress(); - let tx = self.app_event_tx.clone(); - tokio::spawn(async move { - let text = match get_git_diff().await { - Ok((is_git_repo, diff_text)) => { - if is_git_repo { - diff_text - } else { - "`/diff` — _not inside a git repository_".to_string() - } - } - Err(e) => format!("Failed to compute diff: {e}"), - }; - tx.send(AppEvent::DiffResult(text)); - }); - } - SlashCommand::Mention => { - self.chat_widget.insert_str("@"); - } - SlashCommand::Status => { - self.chat_widget.add_status_output(); - } - SlashCommand::Mcp => { - self.chat_widget.add_mcp_output(); - } - #[cfg(debug_assertions)] - SlashCommand::TestApproval => { - use codex_core::protocol::EventMsg; - use std::collections::HashMap; - - use codex_core::protocol::ApplyPatchApprovalRequestEvent; - use codex_core::protocol::FileChange; - - self.app_event_tx.send(AppEvent::CodexEvent(Event { - id: "1".to_string(), - // msg: EventMsg::ExecApprovalRequest(ExecApprovalRequestEvent { - // call_id: "1".to_string(), - // command: vec!["git".into(), "apply".into()], - // cwd: self.config.cwd.clone(), - // reason: Some("test".to_string()), - // }), - msg: EventMsg::ApplyPatchApprovalRequest(ApplyPatchApprovalRequestEvent { - call_id: "1".to_string(), - changes: HashMap::from([ - ( - PathBuf::from("/tmp/test.txt"), - FileChange::Add { - content: "test".to_string(), - }, - ), - ( - PathBuf::from("/tmp/test2.txt"), - FileChange::Update { - unified_diff: "+test\n-test2".to_string(), - move_path: None, - }, - ), - ]), - reason: None, - grant_root: Some(PathBuf::from("/tmp")), - }), - })); - } - }, AppEvent::StartFileSearch(query) => { if !query.is_empty() { self.file_search.on_user_query(query); diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 31ac032b0e..a961f19e34 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -4,7 +4,6 @@ use ratatui::text::Line; use crate::history_cell::HistoryCell; -use crate::slash_command::SlashCommand; use codex_core::protocol::AskForApproval; use codex_core::protocol::SandboxPolicy; use codex_core::protocol_config_types::ReasoningEffort; @@ -14,6 +13,9 @@ use codex_core::protocol_config_types::ReasoningEffort; pub(crate) enum AppEvent { CodexEvent(Event), + /// Start a new session. + NewSession, + /// Request to exit the application gracefully. ExitRequest, @@ -21,10 +23,6 @@ pub(crate) enum AppEvent { /// bubbling channels through layers of widgets. CodexOp(codex_core::protocol::Op), - /// Dispatch a recognized slash command from the UI (composer) to the app - /// layer so it can be handled centrally. - DispatchCommand(SlashCommand), - /// Kick off an asynchronous file search for the given query (text after /// the `@`). Previous searches may be cancelled by the app layer so there /// is at most one in-flight search. diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 675d2292d5..0cc34542c5 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -23,6 +23,7 @@ use ratatui::widgets::WidgetRef; use super::chat_composer_history::ChatComposerHistory; use super::command_popup::CommandPopup; use super::file_search_popup::FileSearchPopup; +use crate::slash_command::SlashCommand; use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; @@ -38,6 +39,7 @@ const LARGE_PASTE_CHAR_THRESHOLD: usize = 1000; /// Result returned when the user interacts with the text area. pub enum InputResult { Submitted(String), + Command(SlashCommand), None, } @@ -289,15 +291,15 @@ impl ChatComposer { .. } => { if let Some(cmd) = popup.selected_command() { - // Send command to the app layer. - self.app_event_tx.send(AppEvent::DispatchCommand(*cmd)); - // Clear textarea so no residual text remains. self.textarea.set_text(""); + let result = (InputResult::Command(*cmd), true); + // Hide popup since the command has been dispatched. self.active_popup = ActivePopup::None; - return (InputResult::None, true); + + return result; } // Fallback to default newline handling if no command selected. self.handle_key_event_without_popup(key_event) @@ -1039,9 +1041,8 @@ mod tests { use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyModifiers; - use tokio::sync::mpsc::error::TryRecvError; - let (tx, mut rx) = unbounded_channel::(); + let (tx, _rx) = unbounded_channel::(); let sender = AppEventSender::new(tx); let mut composer = ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string()); @@ -1057,25 +1058,18 @@ mod tests { let (result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - // When a slash command is dispatched, the composer should not submit - // literal text and should clear its textarea. + // When a slash command is dispatched, the composer should return a + // Command result (not submit literal text) and clear its textarea. match result { - InputResult::None => {} + InputResult::Command(cmd) => { + assert_eq!(cmd.command(), "init"); + } InputResult::Submitted(text) => { panic!("expected command dispatch, but composer submitted literal text: {text}") } + InputResult::None => panic!("expected Command result for '/init'"), } assert!(composer.textarea.is_empty(), "composer should be cleared"); - - // Verify a DispatchCommand event for the "init" command was sent. - match rx.try_recv() { - Ok(AppEvent::DispatchCommand(cmd)) => { - assert_eq!(cmd.command(), "init"); - } - Ok(_other) => panic!("unexpected app event"), - Err(TryRecvError::Empty) => panic!("expected a DispatchCommand event for '/init'"), - Err(TryRecvError::Disconnected) => panic!("app event channel disconnected"), - } } #[test] @@ -1105,9 +1099,8 @@ mod tests { use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyModifiers; - use tokio::sync::mpsc::error::TryRecvError; - let (tx, mut rx) = unbounded_channel::(); + let (tx, _rx) = unbounded_channel::(); let sender = AppEventSender::new(tx); let mut composer = ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string()); @@ -1120,24 +1113,16 @@ mod tests { composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); match result { - InputResult::None => {} + InputResult::Command(cmd) => { + assert_eq!(cmd.command(), "mention"); + } InputResult::Submitted(text) => { panic!("expected command dispatch, but composer submitted literal text: {text}") } + InputResult::None => panic!("expected Command result for '/mention'"), } assert!(composer.textarea.is_empty(), "composer should be cleared"); - - match rx.try_recv() { - Ok(AppEvent::DispatchCommand(cmd)) => { - assert_eq!(cmd.command(), "mention"); - composer.insert_str("@"); - } - Ok(_other) => panic!("unexpected app event"), - Err(TryRecvError::Empty) => panic!("expected a DispatchCommand event for '/mention'"), - Err(TryRecvError::Disconnected) => { - panic!("app event channel disconnected") - } - } + composer.insert_str("@"); assert_eq!(composer.textarea.text(), "@"); } diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 3386133faa..fa18342b53 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -48,11 +48,13 @@ use crate::bottom_pane::CancellationEvent; use crate::bottom_pane::InputResult; use crate::bottom_pane::SelectionAction; use crate::bottom_pane::SelectionItem; +use crate::get_git_diff::get_git_diff; use crate::history_cell; use crate::history_cell::CommandOutput; use crate::history_cell::ExecCell; use crate::history_cell::HistoryCell; use crate::history_cell::PatchEventType; +use crate::slash_command::SlashCommand; use crate::tui::FrameRequester; // streaming internals are provided by crate::streaming and crate::markdown_stream use crate::user_approval_widget::ApprovalRequest; @@ -583,10 +585,109 @@ impl ChatWidget { InputResult::Submitted(text) => { self.submit_user_message(text.into()); } + InputResult::Command(cmd) => { + self.dispatch_command(cmd); + } InputResult::None => {} } } + fn dispatch_command(&mut self, cmd: SlashCommand) { + match cmd { + SlashCommand::New => { + self.app_event_tx.send(AppEvent::NewSession); + } + SlashCommand::Init => { + // Guard: do not run if a task is active. + const INIT_PROMPT: &str = include_str!("../prompt_for_init_command.md"); + self.submit_text_message(INIT_PROMPT.to_string()); + } + SlashCommand::Compact => { + self.clear_token_usage(); + self.app_event_tx.send(AppEvent::CodexOp(Op::Compact)); + } + SlashCommand::Model => { + self.open_model_popup(); + } + SlashCommand::Approvals => { + self.open_approvals_popup(); + } + SlashCommand::Quit => { + self.app_event_tx.send(AppEvent::ExitRequest); + } + SlashCommand::Logout => { + if let Err(e) = codex_login::logout(&self.config.codex_home) { + tracing::error!("failed to logout: {e}"); + } + self.app_event_tx.send(AppEvent::ExitRequest); + } + SlashCommand::Diff => { + self.add_diff_in_progress(); + let tx = self.app_event_tx.clone(); + tokio::spawn(async move { + let text = match get_git_diff().await { + Ok((is_git_repo, diff_text)) => { + if is_git_repo { + diff_text + } else { + "`/diff` — _not inside a git repository_".to_string() + } + } + Err(e) => format!("Failed to compute diff: {e}"), + }; + tx.send(AppEvent::DiffResult(text)); + }); + } + SlashCommand::Mention => { + self.insert_str("@"); + } + SlashCommand::Status => { + self.add_status_output(); + } + SlashCommand::Mcp => { + self.add_mcp_output(); + } + #[cfg(debug_assertions)] + SlashCommand::TestApproval => { + use codex_core::protocol::EventMsg; + use std::collections::HashMap; + + use codex_core::protocol::ApplyPatchApprovalRequestEvent; + use codex_core::protocol::FileChange; + + self.app_event_tx.send(AppEvent::CodexEvent(Event { + id: "1".to_string(), + // msg: EventMsg::ExecApprovalRequest(ExecApprovalRequestEvent { + // call_id: "1".to_string(), + // command: vec!["git".into(), "apply".into()], + // cwd: self.config.cwd.clone(), + // reason: Some("test".to_string()), + // }), + msg: EventMsg::ApplyPatchApprovalRequest(ApplyPatchApprovalRequestEvent { + call_id: "1".to_string(), + changes: HashMap::from([ + ( + PathBuf::from("/tmp/test.txt"), + FileChange::Add { + content: "test".to_string(), + }, + ), + ( + PathBuf::from("/tmp/test2.txt"), + FileChange::Update { + unified_diff: "+test\n-test2".to_string(), + move_path: None, + }, + ), + ]), + reason: None, + grant_root: Some(PathBuf::from("/tmp")), + }), + })); + } + } + } + pub(crate) fn handle_paste(&mut self, text: String) { self.bottom_pane.handle_paste(text); } diff --git a/codex-rs/tui/src/session_log.rs b/codex-rs/tui/src/session_log.rs index d1dc453243..4937be8b5f 100644 --- a/codex-rs/tui/src/session_log.rs +++ b/codex-rs/tui/src/session_log.rs @@ -132,13 +132,11 @@ pub(crate) fn log_inbound_app_event(event: &AppEvent) { AppEvent::CodexEvent(ev) => { write_record("to_tui", "codex_event", ev); } - - AppEvent::DispatchCommand(cmd) => { + AppEvent::NewSession => { let value = json!({ "ts": now_ts(), "dir": "to_tui", - "kind": "slash_command", - "command": format!("{:?}", cmd), + "kind": "new_session", }); LOGGER.write_json_line(value); } From 4b4aa2a77496e0a0f5229b4502c80c82aea6c543 Mon Sep 17 00:00:00 2001 From: Jeremy Rose <172423086+nornagon-openai@users.noreply.github.com> Date: Thu, 21 Aug 2025 11:17:29 -0700 Subject: [PATCH 23/27] tui: transcript mode updates live (#2562) moves TranscriptApp to be an "overlay", and continue to pump AppEvents while the transcript is active, but forward all tui handling to the transcript screen. --- codex-rs/tui/src/app.rs | 121 +++++++++++++++++++++-------- codex-rs/tui/src/transcript_app.rs | 81 +++++++------------ 2 files changed, 120 insertions(+), 82 deletions(-) diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 971d1d16c5..e706fa723b 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -2,7 +2,7 @@ use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; use crate::chatwidget::ChatWidget; use crate::file_search::FileSearchManager; -use crate::transcript_app::run_transcript_app; +use crate::transcript_app::TranscriptApp; use crate::tui; use crate::tui::TuiEvent; use codex_core::ConversationManager; @@ -12,7 +12,11 @@ use color_eyre::eyre::Result; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyEventKind; +use crossterm::execute; +use crossterm::terminal::EnterAlternateScreen; +use crossterm::terminal::LeaveAlternateScreen; use crossterm::terminal::supports_keyboard_enhancement; +use ratatui::layout::Rect; use ratatui::text::Line; use std::path::PathBuf; use std::sync::Arc; @@ -35,6 +39,11 @@ pub(crate) struct App { transcript_lines: Vec>, + // Transcript overlay state + transcript_overlay: Option, + deferred_history_lines: Vec>, + transcript_saved_viewport: Option, + enhanced_keys_supported: bool, /// Controls the animation thread that sends CommitTick events. @@ -76,6 +85,9 @@ impl App { file_search, enhanced_keys_supported, transcript_lines: Vec::new(), + transcript_overlay: None, + deferred_history_lines: Vec::new(), + transcript_saved_viewport: None, commit_anim_running: Arc::new(AtomicBool::new(false)), }; @@ -101,34 +113,55 @@ impl App { tui: &mut tui::Tui, event: TuiEvent, ) -> Result { - match event { - TuiEvent::Key(key_event) => { - self.handle_key_event(tui, key_event).await; + if let Some(overlay) = &mut self.transcript_overlay { + overlay.handle_event(tui, event)?; + if overlay.is_done { + // Exit alternate screen and restore viewport. + let _ = execute!(tui.terminal.backend_mut(), LeaveAlternateScreen); + if let Some(saved) = self.transcript_saved_viewport.take() { + tui.terminal.set_viewport_area(saved); + } + if !self.deferred_history_lines.is_empty() { + let lines = std::mem::take(&mut self.deferred_history_lines); + tui.insert_history_lines(lines); + } + self.transcript_overlay = None; } - TuiEvent::Paste(pasted) => { - // Many terminals convert newlines to \r when pasting (e.g., iTerm2), - // but tui-textarea expects \n. Normalize CR to LF. - // [tui-textarea]: https://github.com/rhysd/tui-textarea/blob/4d18622eeac13b309e0ff6a55a46ac6706da68cf/src/textarea.rs#L782-L783 - // [iTerm2]: https://github.com/gnachman/iTerm2/blob/5d0c0d9f68523cbd0494dad5422998964a2ecd8d/sources/iTermPasteHelper.m#L206-L216 - let pasted = pasted.replace("\r", "\n"); - self.chat_widget.handle_paste(pasted); - } - TuiEvent::Draw => { - tui.draw( - self.chat_widget.desired_height(tui.terminal.size()?.width), - |frame| { - frame.render_widget_ref(&self.chat_widget, frame.area()); - if let Some((x, y)) = self.chat_widget.cursor_pos(frame.area()) { - frame.set_cursor_position((x, y)); - } - }, - )?; - } - #[cfg(unix)] - TuiEvent::ResumeFromSuspend => { - let cursor_pos = tui.terminal.get_cursor_position()?; - tui.terminal - .set_viewport_area(ratatui::layout::Rect::new(0, cursor_pos.y, 0, 0)); + tui.frame_requester().schedule_frame(); + } else { + match event { + TuiEvent::Key(key_event) => { + self.handle_key_event(tui, key_event).await; + } + TuiEvent::Paste(pasted) => { + // Many terminals convert newlines to \r when pasting (e.g., iTerm2), + // but tui-textarea expects \n. Normalize CR to LF. + // [tui-textarea]: https://github.com/rhysd/tui-textarea/blob/4d18622eeac13b309e0ff6a55a46ac6706da68cf/src/textarea.rs#L782-L783 + // [iTerm2]: https://github.com/gnachman/iTerm2/blob/5d0c0d9f68523cbd0494dad5422998964a2ecd8d/sources/iTermPasteHelper.m#L206-L216 + let pasted = pasted.replace("\r", "\n"); + self.chat_widget.handle_paste(pasted); + } + TuiEvent::Draw => { + tui.draw( + self.chat_widget.desired_height(tui.terminal.size()?.width), + |frame| { + frame.render_widget_ref(&self.chat_widget, frame.area()); + if let Some((x, y)) = self.chat_widget.cursor_pos(frame.area()) { + frame.set_cursor_position((x, y)); + } + }, + )?; + } + #[cfg(unix)] + TuiEvent::ResumeFromSuspend => { + let cursor_pos = tui.terminal.get_cursor_position()?; + tui.terminal.set_viewport_area(ratatui::layout::Rect::new( + 0, + cursor_pos.y, + 0, + 0, + )); + } } } Ok(true) @@ -149,14 +182,30 @@ impl App { tui.frame_requester().schedule_frame(); } AppEvent::InsertHistoryLines(lines) => { + if let Some(overlay) = &mut self.transcript_overlay { + overlay.insert_lines(lines.clone()); + tui.frame_requester().schedule_frame(); + } self.transcript_lines.extend(lines.clone()); - tui.insert_history_lines(lines); + if self.transcript_overlay.is_some() { + self.deferred_history_lines.extend(lines); + } else { + tui.insert_history_lines(lines); + } } AppEvent::InsertHistoryCell(cell) => { + if let Some(overlay) = &mut self.transcript_overlay { + overlay.insert_lines(cell.transcript_lines()); + tui.frame_requester().schedule_frame(); + } self.transcript_lines.extend(cell.transcript_lines()); let display = cell.display_lines(); if !display.is_empty() { - tui.insert_history_lines(display); + if self.transcript_overlay.is_some() { + self.deferred_history_lines.extend(display); + } else { + tui.insert_history_lines(display); + } } } AppEvent::StartCommitAnimation => { @@ -243,7 +292,17 @@ impl App { kind: KeyEventKind::Press, .. } => { - run_transcript_app(tui, self.transcript_lines.clone()).await; + // Enter alternate screen and set viewport to full size. + let _ = execute!(tui.terminal.backend_mut(), EnterAlternateScreen); + if let Ok(size) = tui.terminal.size() { + self.transcript_saved_viewport = Some(tui.terminal.viewport_area); + tui.terminal + .set_viewport_area(Rect::new(0, 0, size.width, size.height)); + let _ = tui.terminal.clear(); + } + + self.transcript_overlay = Some(TranscriptApp::new(self.transcript_lines.clone())); + tui.frame_requester().schedule_frame(); } KeyEvent { kind: KeyEventKind::Press | KeyEventKind::Repeat, diff --git a/codex-rs/tui/src/transcript_app.rs b/codex-rs/tui/src/transcript_app.rs index a9b9d5d8f5..059e2a07c8 100644 --- a/codex-rs/tui/src/transcript_app.rs +++ b/codex-rs/tui/src/transcript_app.rs @@ -1,11 +1,11 @@ +use std::io::Result; + use crate::insert_history; use crate::tui; +use crate::tui::TuiEvent; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyEventKind; -use crossterm::execute; -use crossterm::terminal::EnterAlternateScreen; -use crossterm::terminal::LeaveAlternateScreen; use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::style::Color; @@ -16,52 +16,6 @@ use ratatui::text::Line; use ratatui::text::Span; use ratatui::widgets::Paragraph; use ratatui::widgets::WidgetRef; -use tokio::select; - -pub async fn run_transcript_app(tui: &mut tui::Tui, transcript_lines: Vec>) { - use tokio_stream::StreamExt; - let _ = execute!(tui.terminal.backend_mut(), EnterAlternateScreen); - #[allow(clippy::unwrap_used)] - let size = tui.terminal.size().unwrap(); - let old_viewport_area = tui.terminal.viewport_area; - tui.terminal - .set_viewport_area(Rect::new(0, 0, size.width, size.height)); - let _ = tui.terminal.clear(); - - let tui_events = tui.event_stream(); - tokio::pin!(tui_events); - - tui.frame_requester().schedule_frame(); - - let mut app = TranscriptApp { - transcript_lines, - scroll_offset: usize::MAX, - is_done: false, - }; - - while !app.is_done { - select! { - Some(event) = tui_events.next() => { - match event { - crate::tui::TuiEvent::Key(key_event) => { - app.handle_key_event(tui, key_event); - tui.frame_requester().schedule_frame(); - } - crate::tui::TuiEvent::Draw => { - let _ = tui.draw(u16::MAX, |frame| { - app.render(frame.area(), frame.buffer); - }); - } - _ => {} - } - } - } - } - - let _ = execute!(tui.terminal.backend_mut(), LeaveAlternateScreen); - - tui.terminal.set_viewport_area(old_viewport_area); -} pub(crate) struct TranscriptApp { pub(crate) transcript_lines: Vec>, @@ -70,7 +24,32 @@ pub(crate) struct TranscriptApp { } impl TranscriptApp { - pub(crate) fn handle_key_event(&mut self, tui: &mut tui::Tui, key_event: KeyEvent) { + pub(crate) fn new(transcript_lines: Vec>) -> Self { + Self { + transcript_lines, + scroll_offset: 0, + is_done: false, + } + } + + pub(crate) fn handle_event(&mut self, tui: &mut tui::Tui, event: TuiEvent) -> Result<()> { + match event { + TuiEvent::Key(key_event) => self.handle_key_event(tui, key_event), + TuiEvent::Draw => { + tui.draw(u16::MAX, |frame| { + self.render(frame.area(), frame.buffer); + })?; + } + _ => {} + } + Ok(()) + } + + pub(crate) fn insert_lines(&mut self, lines: Vec>) { + self.transcript_lines.extend(lines); + } + + fn handle_key_event(&mut self, tui: &mut tui::Tui, key_event: KeyEvent) { match key_event { KeyEvent { code: KeyCode::Char('q'), @@ -147,7 +126,7 @@ impl TranscriptApp { area } - pub(crate) fn render(&mut self, area: Rect, buf: &mut Buffer) { + fn render(&mut self, area: Rect, buf: &mut Buffer) { Span::from("/ ".repeat(area.width as usize / 2)) .dim() .render_ref(area, buf); From 24c7be7da05824d8e4ae2da5522f214fae8deab2 Mon Sep 17 00:00:00 2001 From: khai-oai Date: Thu, 21 Aug 2025 11:26:37 -0700 Subject: [PATCH 24/27] Update README.md (#2564) Adding some notes about MCP tool calls are not running within the sandbox --- README.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/README.md b/README.md index f7428dc35e..2ff4c87eb7 100644 --- a/README.md +++ b/README.md @@ -383,6 +383,13 @@ base_url = "http://my-ollama.example.com:11434/v1" ### Platform sandboxing details +By default, Codex CLI runs code and shell commands inside a restricted sandbox to protect your system. + +> [!IMPORTANT] +> Not all tool calls are sandboxed. Specifically, **trusted Model Context Protocol (MCP) tool calls** are executed outside of the sandbox. +> This is intentional: MCP tools are explicitly configured and trusted by you, and they often need to connect to **external applications or services** (e.g. issue trackers, databases, messaging systems). +> Running them outside the sandbox allows Codex to integrate with these external systems without being blocked by sandbox restrictions. + The mechanism Codex uses to implement the sandbox policy depends on your OS: - **macOS 12+** uses **Apple Seatbelt** and runs commands using `sandbox-exec` with a profile (`-p`) that corresponds to the `--sandbox` that was specified. From 5fac7b25665fea8dc27bfb5edf9d2f7911549c7a Mon Sep 17 00:00:00 2001 From: Jeremy Rose <172423086+nornagon-openai@users.noreply.github.com> Date: Thu, 21 Aug 2025 11:44:18 -0700 Subject: [PATCH 25/27] tweak thresholds for shimmer on non-true-color terminals (#2533) https://github.com/user-attachments/assets/dc7bf820-eeec-4b78-aba9-231e1337921c --- codex-rs/tui/src/shimmer.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/codex-rs/tui/src/shimmer.rs b/codex-rs/tui/src/shimmer.rs index 24373ceefb..b5752fc3d8 100644 --- a/codex-rs/tui/src/shimmer.rs +++ b/codex-rs/tui/src/shimmer.rs @@ -63,9 +63,11 @@ pub(crate) fn shimmer_spans(text: &str) -> Vec> { } fn color_for_level(level: u8) -> Style { - if level < 144 { + // Tune thresholds so the edges of the shimmer band appear dim + // in fallback mode (no true color support). + if level < 160 { Style::default().add_modifier(Modifier::DIM) - } else if level < 208 { + } else if level < 224 { Style::default() } else { Style::default().add_modifier(Modifier::BOLD) From 750ca9e21d269fc065923319a3b57a6677ecbf97 Mon Sep 17 00:00:00 2001 From: Jeremy Rose <172423086+nornagon-openai@users.noreply.github.com> Date: Thu, 21 Aug 2025 13:20:36 -0700 Subject: [PATCH 26/27] core: write explicit [projects] tables for trusted projects (#2523) all of my trust_level settings in my ~/.codex/config.toml were on one line. --- codex-rs/core/src/config.rs | 141 +++++++++++++++++++++++++++++++++++- 1 file changed, 138 insertions(+), 3 deletions(-) diff --git a/codex-rs/core/src/config.rs b/codex-rs/core/src/config.rs index 4b6f8ac9ef..5b6a1ed265 100644 --- a/codex-rs/core/src/config.rs +++ b/codex-rs/core/src/config.rs @@ -259,10 +259,53 @@ pub fn set_project_trusted(codex_home: &Path, project_path: &Path) -> anyhow::Re Err(e) => return Err(e.into()), }; - // Mark the project as trusted. toml_edit is very good at handling - // missing properties + // Ensure we render a human-friendly structure: + // + // [projects] + // [projects."/path/to/project"] + // trust_level = "trusted" + // + // rather than inline tables like: + // + // [projects] + // "/path/to/project" = { trust_level = "trusted" } let project_key = project_path.to_string_lossy().to_string(); - doc["projects"][project_key.as_str()]["trust_level"] = toml_edit::value("trusted"); + + // Ensure top-level `projects` exists as a non-inline, explicit table. If it + // exists but was previously represented as a non-table (e.g., inline), + // replace it with an explicit table. + { + let root = doc.as_table_mut(); + let needs_table = !root.contains_key("projects") + || root.get("projects").and_then(|i| i.as_table()).is_none(); + if needs_table { + root.insert("projects", toml_edit::table()); + } + } + let Some(projects_tbl) = doc["projects"].as_table_mut() else { + return Err(anyhow::anyhow!( + "projects table missing after initialization" + )); + }; + + // Ensure the per-project entry is its own explicit table. If it exists but + // is not a table (e.g., an inline table), replace it with an explicit table. + let needs_proj_table = !projects_tbl.contains_key(project_key.as_str()) + || projects_tbl + .get(project_key.as_str()) + .and_then(|i| i.as_table()) + .is_none(); + if needs_proj_table { + projects_tbl.insert(project_key.as_str(), toml_edit::table()); + } + let Some(proj_tbl) = projects_tbl + .get_mut(project_key.as_str()) + .and_then(|i| i.as_table_mut()) + else { + return Err(anyhow::anyhow!("project table missing for {}", project_key)); + }; + proj_tbl.set_implicit(false); + proj_tbl["trust_level"] = toml_edit::value("trusted"); // ensure codex_home exists std::fs::create_dir_all(codex_home)?; @@ -1178,4 +1221,96 @@ disable_response_storage = true Ok(()) } + + #[test] + fn test_set_project_trusted_writes_explicit_tables() -> anyhow::Result<()> { + let codex_home = TempDir::new().unwrap(); + let project_dir = TempDir::new().unwrap(); + + // Call the function under test + set_project_trusted(codex_home.path(), project_dir.path())?; + + // Read back the generated config.toml + let config_path = codex_home.path().join(CONFIG_TOML_FILE); + let contents = std::fs::read_to_string(&config_path)?; + + // Verify it does not use inline tables for the project entry + assert!( + !contents.contains("{ trust_level"), + "config.toml should not use inline tables:\n{}", + contents + ); + + // Verify the explicit table for the project exists. toml_edit may choose + // either basic (double-quoted) or literal (single-quoted) strings for keys + // containing backslashes (e.g., on Windows). Accept both forms. + let path_str = project_dir.path().to_string_lossy(); + let project_key_double = format!("[projects.\"{}\"]", path_str); + let project_key_single = format!("[projects.'{}']", path_str); + assert!( + contents.contains(&project_key_double) || contents.contains(&project_key_single), + "missing explicit project table header: expected to find `{}` or `{}` in:\n{}", + project_key_double, + project_key_single, + contents + ); + + // Verify the trust_level entry + assert!( + contents.contains("trust_level = \"trusted\""), + "missing trust_level entry in:\n{}", + contents + ); + + Ok(()) + } + + #[test] + fn test_set_project_trusted_converts_inline_to_explicit() -> anyhow::Result<()> { + let codex_home = TempDir::new().unwrap(); + let project_dir = TempDir::new().unwrap(); + + // Seed config.toml with an inline project entry under [projects] + let config_path = codex_home.path().join(CONFIG_TOML_FILE); + let path_str = project_dir.path().to_string_lossy(); + // Use a literal-quoted key so Windows backslashes don't require escaping + let initial = format!( + "[projects]\n'{}' = {{ trust_level = \"untrusted\" }}\n", + path_str + ); + std::fs::create_dir_all(codex_home.path())?; + std::fs::write(&config_path, initial)?; + + // Run the function; it should convert to explicit tables and set trusted + set_project_trusted(codex_home.path(), project_dir.path())?; + + let contents = std::fs::read_to_string(&config_path)?; + + // Should not contain inline table representation anymore (accept both quote styles) + let inline_double = format!("\"{}\" = {{ trust_level = \"trusted\" }}", path_str); + let inline_single = format!("'{}' = {{ trust_level = \"trusted\" }}", path_str); + assert!( + !contents.contains(&inline_double) && !contents.contains(&inline_single), + "config.toml should not contain inline project table anymore:\n{}", + contents + ); + + // And explicit child table header for the project + let project_key_double = format!("[projects.\"{}\"]", path_str); + let project_key_single = format!("[projects.'{}']", path_str); + assert!( + contents.contains(&project_key_double) || contents.contains(&project_key_single), + "missing explicit project table header: expected to find `{}` or `{}` in:\n{}", + project_key_double, + project_key_single, + contents + ); + + // And the trust level value + assert!(contents.contains("trust_level = \"trusted\"")); + + Ok(()) + } + + // No test enforcing the presence of a standalone [projects] header. } From 7f0d2f427cb25d22479d37fe6acb9695879af38e Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Thu, 21 Aug 2025 19:42:22 -0700 Subject: [PATCH 27/27] feat: StreamableShell with exec_command and write_stdin tools --- codex-rs/Cargo.lock | 75 +++++ codex-rs/core/Cargo.toml | 1 + codex-rs/core/src/codex.rs | 43 +++ codex-rs/core/src/config.rs | 10 + .../src/exec_command/exec_command_params.rs | 57 ++++ .../src/exec_command/exec_command_session.rs | 54 ++++ codex-rs/core/src/exec_command/mod.rs | 13 + .../core/src/exec_command/responses_api.rs | 97 ++++++ codex-rs/core/src/exec_command/session_id.rs | 5 + .../core/src/exec_command/session_manager.rs | 302 ++++++++++++++++++ codex-rs/core/src/lib.rs | 1 + codex-rs/core/src/openai_tools.rs | 21 +- 12 files changed, 678 insertions(+), 1 deletion(-) create mode 100644 codex-rs/core/src/exec_command/exec_command_params.rs create mode 100644 codex-rs/core/src/exec_command/exec_command_session.rs create mode 100644 codex-rs/core/src/exec_command/mod.rs create mode 100644 codex-rs/core/src/exec_command/responses_api.rs create mode 100644 codex-rs/core/src/exec_command/session_id.rs create mode 100644 codex-rs/core/src/exec_command/session_manager.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 34e7932053..18a4d04ad1 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -711,6 +711,7 @@ dependencies = [ "mime_guess", "openssl-sys", "os_info", + "portable-pty", "predicates", "pretty_assertions", "rand 0.9.2", @@ -1443,6 +1444,12 @@ version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + [[package]] name = "dupe" version = "0.9.1" @@ -1688,6 +1695,17 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror 1.0.69", + "winapi", +] + [[package]] name = "fixedbitset" version = "0.4.2" @@ -3345,6 +3363,27 @@ dependencies = [ "portable-atomic", ] +[[package]] +name = "portable-pty" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4a596a2b3d2752d94f51fac2d4a96737b8705dddd311a32b9af47211f08671e" +dependencies = [ + "anyhow", + "bitflags 1.3.2", + "downcast-rs", + "filedescriptor", + "lazy_static", + "libc", + "log", + "nix", + "serial2", + "shared_library", + "shell-words", + "winapi", + "winreg", +] + [[package]] name = "potential_utf" version = "0.1.2" @@ -4272,6 +4311,17 @@ dependencies = [ "syn 2.0.104", ] +[[package]] +name = "serial2" +version = "0.2.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26e1e5956803a69ddd72ce2de337b577898801528749565def03515f82bad5bb" +dependencies = [ + "cfg-if", + "libc", + "winapi", +] + [[package]] name = "sha1" version = "0.10.6" @@ -4303,6 +4353,22 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shared_library" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a9e7e0f2bfae24d8a5b5a66c5b257a83c7412304311512a0c054cd5e619da11" +dependencies = [ + "lazy_static", + "libc", +] + +[[package]] +name = "shell-words" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24188a676b6ae68c3b2cb3a01be17fbf7240ce009799bb56d5b1409051e78fde" + [[package]] name = "shlex" version = "1.3.0" @@ -6025,6 +6091,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "winreg" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" +dependencies = [ + "winapi", +] + [[package]] name = "winsafe" version = "0.0.19" diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index 56815ba03c..2f2fa7cbad 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -28,6 +28,7 @@ libc = "0.2.175" mcp-types = { path = "../mcp-types" } mime_guess = "2.0" os_info = "3.12.0" +portable-pty = "0.9.0" rand = "0.9" regex-lite = "0.1.6" reqwest = { version = "0.12", features = ["json", "stream"] } diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 7d616f96e0..01acd5c6e5 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -52,6 +52,11 @@ use crate::exec::SandboxType; use crate::exec::StdoutStream; use crate::exec::StreamOutput; use crate::exec::process_exec_tool_call; +use crate::exec_command::EXEC_COMMAND_TOOL_NAME; +use crate::exec_command::ExecCommandParams; +use crate::exec_command::SESSION_MANAGER; +use crate::exec_command::WRITE_STDIN_TOOL_NAME; +use crate::exec_command::WriteStdinParams; use crate::exec_env::create_env; use crate::mcp_connection_manager::McpConnectionManager; use crate::mcp_tool_call::handle_mcp_tool_call; @@ -481,6 +486,7 @@ impl Session { sandbox_policy.clone(), config.include_plan_tool, config.include_apply_patch_tool, + config.use_experimental_streamable_shell_tool, ), user_instructions, base_instructions, @@ -1061,6 +1067,7 @@ async fn submission_loop( new_sandbox_policy.clone(), config.include_plan_tool, config.include_apply_patch_tool, + config.use_experimental_streamable_shell_tool, ); let new_turn_context = TurnContext { @@ -1139,6 +1146,7 @@ async fn submission_loop( sandbox_policy.clone(), config.include_plan_tool, config.include_apply_patch_tool, + config.use_experimental_streamable_shell_tool, ), user_instructions: turn_context.user_instructions.clone(), base_instructions: turn_context.base_instructions.clone(), @@ -1999,6 +2007,41 @@ async fn handle_function_call( .await } "update_plan" => handle_update_plan(sess, arguments, sub_id, call_id).await, + EXEC_COMMAND_TOOL_NAME => { + // TODO(mbolin): Sandbox check. + let exec_params = match serde_json::from_str::(&arguments) { + Ok(params) => params, + Err(e) => { + return ResponseInputItem::FunctionCallOutput { + call_id, + output: FunctionCallOutputPayload { + content: format!("failed to parse function arguments: {e}"), + success: Some(false), + }, + }; + } + }; + SESSION_MANAGER + .handle_exec_command_request(call_id, exec_params) + .await + } + WRITE_STDIN_TOOL_NAME => { + let write_stdin_params = match serde_json::from_str::(&arguments) { + Ok(params) => params, + Err(e) => { + return ResponseInputItem::FunctionCallOutput { + call_id, + output: FunctionCallOutputPayload { + content: format!("failed to parse function arguments: {e}"), + success: Some(false), + }, + }; + } + }; + SESSION_MANAGER + .handle_write_stdin_request(call_id, write_stdin_params) + .await + } _ => { match sess.mcp_connection_manager.parse_tool_name(&name) { Some((server, tool_name)) => { diff --git a/codex-rs/core/src/config.rs b/codex-rs/core/src/config.rs index 5b6a1ed265..241f80eb7b 100644 --- a/codex-rs/core/src/config.rs +++ b/codex-rs/core/src/config.rs @@ -169,6 +169,8 @@ pub struct Config { /// If set to `true`, the API key will be signed with the `originator` header. pub preferred_auth_method: AuthMode, + + pub use_experimental_streamable_shell_tool: bool, } impl Config { @@ -454,6 +456,8 @@ pub struct ConfigToml { /// Experimental path to a file whose contents replace the built-in BASE_INSTRUCTIONS. pub experimental_instructions_file: Option, + pub experimental_use_exec_command_tool: Option, + /// The value for the `originator` header included with Responses API requests. pub responses_originator_header_internal_override: Option, @@ -729,6 +733,9 @@ impl Config { include_apply_patch_tool: include_apply_patch_tool_val, responses_originator_header, preferred_auth_method: cfg.preferred_auth_method.unwrap_or(AuthMode::ChatGPT), + use_experimental_streamable_shell_tool: cfg + .experimental_use_exec_command_tool + .unwrap_or(false), }; Ok(config) } @@ -1094,6 +1101,7 @@ disable_response_storage = true include_apply_patch_tool: false, responses_originator_header: "codex_cli_rs".to_string(), preferred_auth_method: AuthMode::ChatGPT, + use_experimental_streamable_shell_tool: false, }, o3_profile_config ); @@ -1147,6 +1155,7 @@ disable_response_storage = true include_apply_patch_tool: false, responses_originator_header: "codex_cli_rs".to_string(), preferred_auth_method: AuthMode::ChatGPT, + use_experimental_streamable_shell_tool: false, }; assert_eq!(expected_gpt3_profile_config, gpt3_profile_config); @@ -1215,6 +1224,7 @@ disable_response_storage = true include_apply_patch_tool: false, responses_originator_header: "codex_cli_rs".to_string(), preferred_auth_method: AuthMode::ChatGPT, + use_experimental_streamable_shell_tool: false, }; assert_eq!(expected_zdr_profile_config, zdr_profile_config); diff --git a/codex-rs/core/src/exec_command/exec_command_params.rs b/codex-rs/core/src/exec_command/exec_command_params.rs new file mode 100644 index 0000000000..11a3fd4596 --- /dev/null +++ b/codex-rs/core/src/exec_command/exec_command_params.rs @@ -0,0 +1,57 @@ +use serde::Deserialize; +use serde::Serialize; + +use crate::exec_command::session_id::SessionId; + +#[derive(Debug, Clone, Deserialize)] +pub struct ExecCommandParams { + pub(crate) cmd: String, + + #[serde(default = "default_yield_time")] + pub(crate) yield_time_ms: u64, + + #[serde(default = "max_output_tokens")] + pub(crate) max_output_tokens: u64, + + #[serde(default = "default_shell")] + pub(crate) shell: String, + + #[serde(default = "default_login")] + pub(crate) login: bool, +} + +fn default_yield_time() -> u64 { + 10_000 +} + +fn max_output_tokens() -> u64 { + 10_000 +} + +fn default_login() -> bool { + true +} + +fn default_shell() -> String { + "/bin/bash".to_string() +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct WriteStdinParams { + pub(crate) session_id: SessionId, + pub(crate) chars: String, + + #[serde(default = "write_stdin_default_yield_time_ms")] + pub(crate) yield_time_ms: u64, + + #[serde(default = "write_stdin_default_max_output_tokens")] + pub(crate) max_output_tokens: u64, +} + +fn write_stdin_default_yield_time_ms() -> u64 { + 250 +} + +fn write_stdin_default_max_output_tokens() -> u64 { + 10_000 +} diff --git a/codex-rs/core/src/exec_command/exec_command_session.rs b/codex-rs/core/src/exec_command/exec_command_session.rs new file mode 100644 index 0000000000..131ab84d96 --- /dev/null +++ b/codex-rs/core/src/exec_command/exec_command_session.rs @@ -0,0 +1,54 @@ +use std::sync::Arc; + +use tokio::sync::Mutex; +use tokio::sync::mpsc; + +use crate::exec_command::session_id::SessionId; + +#[allow(dead_code)] +#[derive(Debug)] +pub(crate) struct ExecCommandSession { + pub(crate) id: SessionId, + /// Queue for writing bytes to the process stdin (PTY master write side). + writer_tx: mpsc::Sender>, + /// Stream of output chunks read from the PTY. Wrapped in Mutex so callers can + /// `await` receiving without needing `&mut self`. + output_rx: Arc>>>, +} + +#[allow(dead_code)] +impl ExecCommandSession { + pub(crate) fn new( + id: SessionId, + writer_tx: mpsc::Sender>, + output_rx: mpsc::Receiver>, + ) -> Self { + Self { + id, + writer_tx, + output_rx: Arc::new(Mutex::new(output_rx)), + } + } + + /// Enqueue bytes to be written to the process stdin (PTY master). + pub(crate) async fn write_stdin(&self, bytes: impl AsRef<[u8]>) -> anyhow::Result<()> { + self.writer_tx + .send(bytes.as_ref().to_vec()) + .await + .map_err(|e| anyhow::anyhow!("failed to send to writer: {e}")) + } + + /// Receive the next chunk of output from the process. Returns `None` when the + /// output stream is closed (process exited or reader finished). + pub(crate) async fn recv_output_chunk(&self) -> Option> { + self.output_rx.lock().await.recv().await + } + + pub(crate) fn writer_sender(&self) -> mpsc::Sender> { + self.writer_tx.clone() + } + + pub(crate) fn output_receiver(&self) -> Arc>>> { + self.output_rx.clone() + } +} diff --git a/codex-rs/core/src/exec_command/mod.rs b/codex-rs/core/src/exec_command/mod.rs new file mode 100644 index 0000000000..d48748f633 --- /dev/null +++ b/codex-rs/core/src/exec_command/mod.rs @@ -0,0 +1,13 @@ +mod exec_command_params; +mod exec_command_session; +mod responses_api; +mod session_id; +mod session_manager; + +pub use exec_command_params::ExecCommandParams; +pub use exec_command_params::WriteStdinParams; +pub use responses_api::EXEC_COMMAND_TOOL_NAME; +pub use responses_api::WRITE_STDIN_TOOL_NAME; +pub use responses_api::create_exec_command_tool_for_responses_api; +pub use responses_api::create_write_stdin_tool_for_responses_api; +pub use session_manager::SESSION_MANAGER; diff --git a/codex-rs/core/src/exec_command/responses_api.rs b/codex-rs/core/src/exec_command/responses_api.rs new file mode 100644 index 0000000000..c07684c326 --- /dev/null +++ b/codex-rs/core/src/exec_command/responses_api.rs @@ -0,0 +1,97 @@ +use std::collections::BTreeMap; + +use crate::openai_tools::JsonSchema; +use crate::openai_tools::ResponsesApiTool; + +pub const EXEC_COMMAND_TOOL_NAME: &str = "exec_command"; +pub const WRITE_STDIN_TOOL_NAME: &str = "write_stdin"; + +pub fn create_exec_command_tool_for_responses_api() -> ResponsesApiTool { + let mut properties = BTreeMap::::new(); + properties.insert( + "cmd".to_string(), + JsonSchema::String { + description: Some("The shell command to execute.".to_string()), + }, + ); + properties.insert( + "yield_time_ms".to_string(), + JsonSchema::Number { + description: Some("The maximum time in milliseconds to wait for output.".to_string()), + }, + ); + properties.insert( + "max_output_tokens".to_string(), + JsonSchema::Number { + description: Some("The maximum number of tokens to output.".to_string()), + }, + ); + properties.insert( + "shell".to_string(), + JsonSchema::String { + description: Some("The shell to use. Defaults to \"/bin/bash\".".to_string()), + }, + ); + properties.insert( + "login".to_string(), + JsonSchema::Boolean { + description: Some( + "Whether to run the command as a login shell. Defaults to true.".to_string(), + ), + }, + ); + + ResponsesApiTool { + name: EXEC_COMMAND_TOOL_NAME.to_owned(), + description: r#"Execute shell commands on the local machine with streaming output."# + .to_string(), + strict: false, + parameters: JsonSchema::Object { + properties, + required: Some(vec!["cmd".to_string()]), + additional_properties: Some(false), + }, + } +} + +pub fn create_write_stdin_tool_for_responses_api() -> ResponsesApiTool { + let mut properties = BTreeMap::::new(); + properties.insert( + "session_id".to_string(), + JsonSchema::String { + description: Some("The ID of the exec_command session.".to_string()), + }, + ); + properties.insert( + "chars".to_string(), + JsonSchema::String { + description: Some("The characters to write to stdin.".to_string()), + }, + ); + properties.insert( + "yield_time_ms".to_string(), + JsonSchema::Number { + description: Some( + "The maximum time in milliseconds to wait for output after writing.".to_string(), + ), + }, + ); + properties.insert( + "max_output_tokens".to_string(), + JsonSchema::Number { + description: Some("The maximum number of tokens to output.".to_string()), + }, + ); + + ResponsesApiTool { + name: WRITE_STDIN_TOOL_NAME.to_owned(), + description: r#"Write characters to the stdin of an existing exec_command session."# + .to_string(), + strict: false, + parameters: JsonSchema::Object { + properties, + required: Some(vec!["session_id".to_string(), "chars".to_string()]), + additional_properties: Some(false), + }, + } +} diff --git a/codex-rs/core/src/exec_command/session_id.rs b/codex-rs/core/src/exec_command/session_id.rs new file mode 100644 index 0000000000..c97c5d5440 --- /dev/null +++ b/codex-rs/core/src/exec_command/session_id.rs @@ -0,0 +1,5 @@ +use serde::Deserialize; +use serde::Serialize; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub(crate) struct SessionId(pub u32); diff --git a/codex-rs/core/src/exec_command/session_manager.rs b/codex-rs/core/src/exec_command/session_manager.rs new file mode 100644 index 0000000000..60f0d284ad --- /dev/null +++ b/codex-rs/core/src/exec_command/session_manager.rs @@ -0,0 +1,302 @@ +use std::collections::HashMap; +use std::io::Read; +use std::sync::Arc; +use std::sync::LazyLock; +use std::sync::Mutex as StdMutex; +use std::sync::atomic::AtomicU32; + +use portable_pty::CommandBuilder; +use portable_pty::PtySize; +use portable_pty::native_pty_system; +use serde_json::json; +use tokio::sync::Mutex; +use tokio::sync::mpsc; +use tokio::sync::oneshot; +use tokio::time::Duration; +use tokio::time::Instant; +use tokio::time::timeout; + +use crate::exec_command::exec_command_params::ExecCommandParams; +use crate::exec_command::exec_command_params::WriteStdinParams; +use crate::exec_command::exec_command_session::ExecCommandSession; +use crate::exec_command::session_id::SessionId; +use crate::models::FunctionCallOutputPayload; +use crate::models::ResponseInputItem; + +pub static SESSION_MANAGER: LazyLock = LazyLock::new(SessionManager::default); + +#[derive(Debug, Default)] +pub struct SessionManager { + next_session_id: AtomicU32, + sessions: Mutex>, +} + +impl SessionManager { + /// Processes the request and is required to send a response via `outgoing`. + pub async fn handle_exec_command_request( + &self, + call_id: String, + params: ExecCommandParams, + ) -> ResponseInputItem { + // Allocate a session id. + let session_id = SessionId( + self.next_session_id + .fetch_add(1, std::sync::atomic::Ordering::SeqCst), + ); + + let result = create_exec_command_session(session_id, params.clone()).await; + + match result { + Ok((session, mut exit_rx)) => { + // Insert into session map. + let output_receiver = session.output_receiver(); + self.sessions.lock().await.insert(session_id, session); + + // Collect output until either timeout expires or process exits. + // Cap by assuming 4 bytes per token (TODO: use a real tokenizer). + let cap_bytes_u64 = params.max_output_tokens.saturating_mul(4); + let cap_bytes: usize = cap_bytes_u64.min(usize::MAX as u64) as usize; + let cap_hint = cap_bytes.clamp(1024, 8192); + let mut collected: Vec = Vec::with_capacity(cap_hint); + + let deadline = Instant::now() + Duration::from_millis(params.yield_time_ms); + let mut exit_code: Option = None; + + loop { + if Instant::now() >= deadline { + break; + } + let remaining = deadline.saturating_duration_since(Instant::now()); + tokio::select! { + biased; + exit = &mut exit_rx => { + exit_code = exit.ok(); + // Small grace period to pull remaining buffered output + let grace_deadline = Instant::now() + Duration::from_millis(25); + while Instant::now() < grace_deadline { + let recv_next = async { + let mut rx = output_receiver.lock().await; + rx.recv().await + }; + if let Ok(Some(chunk)) = timeout(Duration::from_millis(1), recv_next).await { + let available = cap_bytes.saturating_sub(collected.len()); + if available == 0 { break; } + let take = available.min(chunk.len()); + collected.extend_from_slice(&chunk[..take]); + } else { + break; + } + } + break; + } + chunk = timeout(remaining, async { + let mut rx = output_receiver.lock().await; + rx.recv().await + }) => { + match chunk { + Ok(Some(chunk)) => { + let available = cap_bytes.saturating_sub(collected.len()); + if available == 0 { /* keep draining, but don't store */ } + else { + let take = available.min(chunk.len()); + collected.extend_from_slice(&chunk[..take]); + } + } + Ok(None) => { break; } + Err(_) => { break; } + } + } + } + } + + let text = String::from_utf8_lossy(&collected).to_string(); + let mut structured = json!({ "sessionId": session_id }); + if let Some(code) = exit_code { + structured["exitCode"] = json!(code); + } + + ResponseInputItem::FunctionCallOutput { + call_id, + output: FunctionCallOutputPayload { + content: text, + success: Some(true), + }, + } + } + Err(err) => ResponseInputItem::FunctionCallOutput { + call_id, + output: FunctionCallOutputPayload { + content: format!("failed to start exec session: {err}"), + success: Some(false), + }, + }, + } + } + + /// Write characters to a session's stdin and collect combined output for up to `yield_time_ms`. + pub async fn handle_write_stdin_request( + &self, + call_id: String, + params: WriteStdinParams, + ) -> ResponseInputItem { + let WriteStdinParams { + session_id, + chars, + yield_time_ms, + max_output_tokens, + } = params; + + // Grab handles without holding the sessions lock across await points. + let (writer_tx, output_rx) = { + let sessions = self.sessions.lock().await; + match sessions.get(&session_id) { + Some(session) => (session.writer_sender(), session.output_receiver()), + None => { + return ResponseInputItem::FunctionCallOutput { + call_id, + output: FunctionCallOutputPayload { + content: format!("unknown session id {}", session_id.0), + success: Some(false), + }, + }; + } + } + }; + + // Write stdin if provided. + if !chars.is_empty() && writer_tx.send(chars.into_bytes()).await.is_err() { + return ResponseInputItem::FunctionCallOutput { + call_id, + output: FunctionCallOutputPayload { + content: "failed to write to stdin".to_string(), + success: Some(false), + }, + }; + } + + // Collect output up to yield_time_ms, truncating to max_output_tokens bytes. + let mut collected: Vec = Vec::with_capacity(4096); + let deadline = Instant::now() + Duration::from_millis(yield_time_ms); + loop { + let now = Instant::now(); + if now >= deadline { + break; + } + let remaining = deadline - now; + match timeout(remaining, output_rx.lock().await.recv()).await { + Ok(Some(chunk)) => { + // Respect token/byte limit; keep draining but drop once full. + let available = + max_output_tokens.saturating_sub(collected.len() as u64) as usize; + if available > 0 { + let take = available.min(chunk.len()); + collected.extend_from_slice(&chunk[..take]); + } + // Continue loop to drain further within time. + } + Ok(None) => break, // channel closed + Err(_) => break, // timeout + } + } + + // Return text output as a CallToolResult + let text = String::from_utf8_lossy(&collected).to_string(); + ResponseInputItem::FunctionCallOutput { + call_id, + output: FunctionCallOutputPayload { + content: text, + success: Some(true), + }, + } + } +} + +/// Spawn PTY and child process per spawn_exec_command_session logic. +async fn create_exec_command_session( + session_id: SessionId, + params: ExecCommandParams, +) -> anyhow::Result<(ExecCommandSession, oneshot::Receiver)> { + let ExecCommandParams { + cmd, + yield_time_ms: _, + max_output_tokens: _, + shell, + login, + } = params; + + // Use the native pty implementation for the system + let pty_system = native_pty_system(); + + // Create a new pty + let pair = pty_system.openpty(PtySize { + rows: 24, + cols: 80, + pixel_width: 0, + pixel_height: 0, + })?; + + // Spawn a shell into the pty + let mut command_builder = CommandBuilder::new(shell); + let shell_mode_opt = if login { "-lc" } else { "-c" }; + command_builder.arg(shell_mode_opt); + command_builder.arg(cmd); + + let mut child = pair.slave.spawn_command(command_builder)?; + + // Channel to forward write requests to the PTY writer. + let (writer_tx, mut writer_rx) = mpsc::channel::>(128); + // Channel for streaming PTY output to readers. + let (output_tx, output_rx) = mpsc::channel::>(256); + + // Reader task: drain PTY and forward chunks to output channel. + let mut reader = pair.master.try_clone_reader()?; + let output_tx_clone = output_tx.clone(); + tokio::task::spawn_blocking(move || { + let mut buf = [0u8; 8192]; + loop { + match reader.read(&mut buf) { + Ok(0) => break, // EOF + Ok(n) => { + // Forward; block if receiver is slow to avoid dropping output. + let _ = output_tx_clone.blocking_send(buf[..n].to_vec()); + } + Err(_) => break, + } + } + }); + + // Writer task: apply stdin writes to the PTY writer. + let writer = pair.master.take_writer()?; + let writer = Arc::new(StdMutex::new(writer)); + tokio::spawn({ + let writer = writer.clone(); + async move { + while let Some(bytes) = writer_rx.recv().await { + let writer = writer.clone(); + // Perform blocking write on a blocking thread. + let _ = tokio::task::spawn_blocking(move || { + if let Ok(mut guard) = writer.lock() { + use std::io::Write; + let _ = guard.write_all(&bytes); + let _ = guard.flush(); + } + }) + .await; + } + } + }); + + // Keep the child alive until it exits, then signal exit code. + let (exit_tx, exit_rx) = oneshot::channel::(); + tokio::task::spawn_blocking(move || { + let code = match child.wait() { + Ok(status) => status.exit_code() as i32, + Err(_) => -1, + }; + let _ = exit_tx.send(code); + }); + + // Create and store the session with channels. + let session = ExecCommandSession::new(session_id, writer_tx, output_rx); + Ok((session, exit_rx)) +} diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 40ccbf6769..ef066c1a0a 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -20,6 +20,7 @@ mod conversation_history; mod environment_context; pub mod error; pub mod exec; +mod exec_command; pub mod exec_env; mod flags; pub mod git_info; diff --git a/codex-rs/core/src/openai_tools.rs b/codex-rs/core/src/openai_tools.rs index dd5eb12516..95e21f7685 100644 --- a/codex-rs/core/src/openai_tools.rs +++ b/codex-rs/core/src/openai_tools.rs @@ -37,6 +37,7 @@ pub enum ConfigShellToolType { DefaultShell, ShellWithRequest { sandbox_policy: SandboxPolicy }, LocalShell, + StreamableShell, } #[derive(Debug, Clone)] @@ -53,8 +54,11 @@ impl ToolsConfig { sandbox_policy: SandboxPolicy, include_plan_tool: bool, include_apply_patch_tool: bool, + use_streamable_shell_tool: bool, ) -> Self { - let mut shell_type = if model_family.uses_local_shell_tool { + let mut shell_type = if use_streamable_shell_tool { + ConfigShellToolType::StreamableShell + } else if model_family.uses_local_shell_tool { ConfigShellToolType::LocalShell } else { ConfigShellToolType::DefaultShell @@ -533,6 +537,14 @@ pub(crate) fn get_openai_tools( ConfigShellToolType::LocalShell => { tools.push(OpenAiTool::LocalShell {}); } + ConfigShellToolType::StreamableShell => { + tools.push(OpenAiTool::Function( + crate::exec_command::create_exec_command_tool_for_responses_api(), + )); + tools.push(OpenAiTool::Function( + crate::exec_command::create_write_stdin_tool_for_responses_api(), + )); + } } if config.plan_tool { @@ -597,6 +609,7 @@ mod tests { SandboxPolicy::ReadOnly, true, model_family.uses_apply_patch_tool, + /*use_experimental_streamable_shell_tool*/ false, ); let tools = get_openai_tools(&config, Some(HashMap::new())); @@ -612,6 +625,7 @@ mod tests { SandboxPolicy::ReadOnly, true, model_family.uses_apply_patch_tool, + /*use_experimental_streamable_shell_tool*/ false, ); let tools = get_openai_tools(&config, Some(HashMap::new())); @@ -627,6 +641,7 @@ mod tests { SandboxPolicy::ReadOnly, false, model_family.uses_apply_patch_tool, + /*use_experimental_streamable_shell_tool*/ false, ); let tools = get_openai_tools( &config, @@ -721,6 +736,7 @@ mod tests { SandboxPolicy::ReadOnly, false, model_family.uses_apply_patch_tool, + /*use_experimental_streamable_shell_tool*/ false, ); let tools = get_openai_tools( @@ -777,6 +793,7 @@ mod tests { SandboxPolicy::ReadOnly, false, model_family.uses_apply_patch_tool, + /*use_experimental_streamable_shell_tool*/ false, ); let tools = get_openai_tools( @@ -828,6 +845,7 @@ mod tests { SandboxPolicy::ReadOnly, false, model_family.uses_apply_patch_tool, + /*use_experimental_streamable_shell_tool*/ false, ); let tools = get_openai_tools( @@ -882,6 +900,7 @@ mod tests { SandboxPolicy::ReadOnly, false, model_family.uses_apply_patch_tool, + /*use_experimental_streamable_shell_tool*/ false, ); let tools = get_openai_tools(