From 096bca2fa26d9155c61b3327ff8ff65090687a1b Mon Sep 17 00:00:00 2001 From: ae Date: Tue, 19 Aug 2025 08:20:32 -0700 Subject: [PATCH 01/27] fix: exclude sysprompt etc from context left % (#2446) - Prevents the % left indicator from immediately decrementing to ~97%. - Tested by prompting "hi" and noting it only decremented to 99%. And by adding a bunch of debug logs and observing numbers. --- codex-rs/protocol/src/protocol.rs | 27 +++++++++++++++++++ codex-rs/tui/src/bottom_pane/chat_composer.rs | 25 +++++++++++++---- 2 files changed, 47 insertions(+), 5 deletions(-) diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 23ef4668f1..5d32b8c335 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -508,6 +508,33 @@ impl TokenUsage { self.total_tokens .saturating_sub(self.reasoning_output_tokens.unwrap_or(0)) } + + /// Estimate the remaining user-controllable percentage of the model's context window. + /// + /// `context_window` is the total size of the model's context window. + /// `baseline_used_tokens` should capture tokens that are always present in + /// the context (e.g., system prompt and fixed tool instructions) so that + /// the percentage reflects the portion the user can influence. + /// + /// This normalizes both the numerator and denominator by subtracting the + /// baseline, so immediately after the first prompt the UI shows 100% left + /// and trends toward 0% as the user fills the effective window. + pub fn percent_of_context_window_remaining( + &self, + context_window: u64, + baseline_used_tokens: u64, + ) -> u8 { + if context_window <= baseline_used_tokens { + return 0; + } + + let effective_window = context_window - baseline_used_tokens; + let used = self + .tokens_in_context_window() + .saturating_sub(baseline_used_tokens); + let remaining = effective_window.saturating_sub(used); + ((remaining as f32 / effective_window as f32) * 100.0).clamp(0.0, 100.0) as u8 + } } #[derive(Debug, Clone, Deserialize, Serialize)] diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index c801d6fd2a..71483f0495 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -45,6 +45,15 @@ struct TokenUsageInfo { total_token_usage: TokenUsage, last_token_usage: TokenUsage, model_context_window: Option, + /// Baseline token count present in the context before the user's first + /// message content is considered. This is used to normalize the + /// "context left" percentage so it reflects the portion the user can + /// influence rather than fixed prompt overhead (system prompt, tool + /// instructions, etc.). + /// + /// Preferred source is `cached_input_tokens` from the first turn (when + /// available), otherwise we fall back to 0. + initial_prompt_tokens: u64, } pub(crate) struct ChatComposer { @@ -134,10 +143,17 @@ impl ChatComposer { last_token_usage: TokenUsage, model_context_window: Option, ) { + let initial_prompt_tokens = self + .token_usage_info + .as_ref() + .map(|info| info.initial_prompt_tokens) + .unwrap_or_else(|| last_token_usage.cached_input_tokens.unwrap_or(0)); + self.token_usage_info = Some(TokenUsageInfo { total_token_usage, last_token_usage, model_context_window, + initial_prompt_tokens, }); } @@ -673,11 +689,10 @@ impl WidgetRef for &ChatComposer { let last_token_usage = &token_usage_info.last_token_usage; if let Some(context_window) = token_usage_info.model_context_window { let percent_remaining: u8 = if context_window > 0 { - let percent = 100.0 - - (last_token_usage.tokens_in_context_window() as f32 - / context_window as f32 - * 100.0); - percent.clamp(0.0, 100.0) as u8 + last_token_usage.percent_of_context_window_remaining( + context_window, + token_usage_info.initial_prompt_tokens, + ) } else { 100 }; From e7e5fe91c8e4d80935d1f554cf100c4417b0f03d Mon Sep 17 00:00:00 2001 From: Dylan Date: Tue, 19 Aug 2025 09:00:31 -0700 Subject: [PATCH 02/27] [tui] Support /mcp command (#2430) ## Summary Adds a `/mcp` command to list active tools. We can extend this command to allow configuration of MCP tools, but for now a simple list command will help debug if your config.toml and your tools are working as expected. --- codex-rs/core/src/codex.rs | 16 ++++ .../src/event_processor_with_human_output.rs | 3 + codex-rs/mcp-server/src/codex_tool_runner.rs | 1 + codex-rs/protocol/src/protocol.rs | 15 ++++ codex-rs/tui/src/app.rs | 5 ++ codex-rs/tui/src/chatwidget.rs | 14 ++++ codex-rs/tui/src/history_cell.rs | 81 +++++++++++++++++++ codex-rs/tui/src/slash_command.rs | 2 + 8 files changed, 137 insertions(+) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 397246a7de..6423fb44ef 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -1199,6 +1199,22 @@ async fn submission_loop( } }); } + Op::ListMcpTools => { + let tx_event = sess.tx_event.clone(); + let sub_id = sub.id.clone(); + + // This is a cheap lookup from the connection manager's cache. + let tools = sess.mcp_connection_manager.list_all_tools(); + let event = Event { + id: sub_id, + msg: EventMsg::McpListToolsResponse( + crate::protocol::McpListToolsResponseEvent { tools }, + ), + }; + if let Err(e) = tx_event.send(event).await { + warn!("failed to send McpListToolsResponse event: {e}"); + } + } Op::Compact => { // Create a summarization request as user input const SUMMARIZATION_PROMPT: &str = include_str!("prompt_for_compact_command.md"); 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 98d7a1fb6a..498b1d29a4 100644 --- a/codex-rs/exec/src/event_processor_with_human_output.rs +++ b/codex-rs/exec/src/event_processor_with_human_output.rs @@ -523,6 +523,9 @@ impl EventProcessor for EventProcessorWithHumanOutput { EventMsg::GetHistoryEntryResponse(_) => { // Currently ignored in exec output. } + EventMsg::McpListToolsResponse(_) => { + // Currently ignored in exec output. + } EventMsg::TurnAborted(abort_reason) => match abort_reason.reason { TurnAbortReason::Interrupted => { ts_println!(self, "task interrupted"); diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index c0d14ecedc..e1dfd08c8e 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -263,6 +263,7 @@ async fn run_codex_tool_session_inner( | EventMsg::AgentReasoningSectionBreak(_) | EventMsg::McpToolCallBegin(_) | EventMsg::McpToolCallEnd(_) + | EventMsg::McpListToolsResponse(_) | EventMsg::ExecCommandBegin(_) | EventMsg::ExecCommandOutputDelta(_) | EventMsg::ExecCommandEnd(_) diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 5d32b8c335..65ef30aa6b 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -11,6 +11,7 @@ use std::str::FromStr; use std::time::Duration; use mcp_types::CallToolResult; +use mcp_types::Tool as McpTool; use serde::Deserialize; use serde::Serialize; use serde_bytes::ByteBuf; @@ -136,6 +137,10 @@ pub enum Op { /// Request a single history entry identified by `log_id` + `offset`. GetHistoryEntryRequest { offset: usize, log_id: u64 }, + /// Request the list of MCP tools available across all configured servers. + /// Reply is delivered via `EventMsg::McpListToolsResponse`. + ListMcpTools, + /// Request the agent to summarize the current conversation context. /// The agent will use its existing context (either conversation history or previous response id) /// to generate a summary which will be returned as an AgentMessage event. @@ -453,6 +458,9 @@ pub enum EventMsg { /// Response to GetHistoryEntryRequest. GetHistoryEntryResponse(GetHistoryEntryResponseEvent), + /// List of MCP tools available to the agent. + McpListToolsResponse(McpListToolsResponseEvent), + PlanUpdate(UpdatePlanArgs), TurnAborted(TurnAbortedEvent), @@ -749,6 +757,13 @@ pub struct GetHistoryEntryResponseEvent { pub entry: Option, } +/// Response payload for `Op::ListMcpTools`. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct McpListToolsResponseEvent { + /// Fully qualified tool name -> tool definition. + pub tools: std::collections::HashMap, +} + #[derive(Debug, Default, Clone, Deserialize, Serialize)] pub struct SessionConfiguredEvent { /// Unique id for this session. diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index d12eb76e8d..810d6cb13c 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -421,6 +421,11 @@ impl App<'_> { 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; diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 88b816d976..bbb99f52e2 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -18,6 +18,7 @@ use codex_core::protocol::ExecApprovalRequestEvent; use codex_core::protocol::ExecCommandBeginEvent; use codex_core::protocol::ExecCommandEndEvent; use codex_core::protocol::InputItem; +use codex_core::protocol::McpListToolsResponseEvent; use codex_core::protocol::McpToolCallBeginEvent; use codex_core::protocol::McpToolCallEndEvent; use codex_core::protocol::Op; @@ -647,6 +648,7 @@ impl ChatWidget<'_> { EventMsg::McpToolCallBegin(ev) => self.on_mcp_tool_call_begin(ev), EventMsg::McpToolCallEnd(ev) => self.on_mcp_tool_call_end(ev), EventMsg::GetHistoryEntryResponse(ev) => self.on_get_history_entry_response(ev), + EventMsg::McpListToolsResponse(ev) => self.on_list_mcp_tools(ev), EventMsg::ShutdownComplete => self.on_shutdown_complete(), EventMsg::TurnDiff(TurnDiffEvent { unified_diff }) => self.on_turn_diff(unified_diff), EventMsg::BackgroundEvent(BackgroundEventEvent { message }) => { @@ -685,6 +687,14 @@ 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()); + } else { + self.submit_op(Op::ListMcpTools); + } + } + /// Forward file-search results to the bottom pane. pub(crate) fn apply_file_search_result(&mut self, query: String, matches: Vec) { self.bottom_pane.on_file_search_result(query, matches); @@ -726,6 +736,10 @@ 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)); + } + /// Programmatically submit a user text message as if typed in the /// composer. The text will be added to conversation history and sent to /// the agent. diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 555226dddd..d9ce9ad5e5 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -634,6 +634,87 @@ pub(crate) fn new_status_output( PlainHistoryCell { lines } } +/// Render a summary of configured MCP servers from the current `Config`. +pub(crate) fn empty_mcp_output() -> PlainHistoryCell { + let lines: Vec> = vec![ + Line::from("/mcp".magenta()), + Line::from(""), + Line::from(vec!["🔌 ".into(), "MCP Tools".bold()]), + Line::from(""), + Line::from(" • No MCP servers configured.".italic()), + Line::from(""), + ]; + + PlainHistoryCell { lines } +} + +/// Render MCP tools grouped by connection using the fully-qualified tool names. +pub(crate) fn new_mcp_tools_output( + config: &Config, + tools: std::collections::HashMap, +) -> PlainHistoryCell { + let mut lines: Vec> = vec![ + Line::from("/mcp".magenta()), + Line::from(""), + Line::from(vec!["🔌 ".into(), "MCP Tools".bold()]), + Line::from(""), + ]; + + if tools.is_empty() { + lines.push(Line::from(" • No MCP tools available.".italic())); + lines.push(Line::from("")); + return PlainHistoryCell { lines }; + } + + for (server, cfg) in config.mcp_servers.iter() { + let prefix = format!("{server}__"); + let mut names: Vec = tools + .keys() + .filter(|k| k.starts_with(&prefix)) + .map(|k| k[prefix.len()..].to_string()) + .collect(); + names.sort(); + + lines.push(Line::from(vec![ + " • Server: ".into(), + server.clone().into(), + ])); + + if !cfg.command.is_empty() { + let cmd_display = format!("{} {}", cfg.command, cfg.args.join(" ")); + + lines.push(Line::from(vec![ + " • Command: ".into(), + cmd_display.into(), + ])); + } + + if let Some(env) = cfg.env.as_ref() { + if !env.is_empty() { + let mut env_pairs: Vec = + env.iter().map(|(k, v)| format!("{k}={v}")).collect(); + env_pairs.sort(); + lines.push(Line::from(vec![ + " • Env: ".into(), + env_pairs.join(" ").into(), + ])); + } + } + + if names.is_empty() { + lines.push(Line::from(" • Tools: (none)")); + } else { + lines.push(Line::from(vec![ + " • Tools: ".into(), + names.join(", ").into(), + ])); + } + lines.push(Line::from("")); + } + + PlainHistoryCell { lines } +} + pub(crate) fn new_error_event(message: String) -> PlainHistoryCell { let lines: Vec> = vec![vec!["🖐 ".red().bold(), message.into()].into(), "".into()]; PlainHistoryCell { lines } diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index 71d60237cf..56a6c316b5 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -18,6 +18,7 @@ pub enum SlashCommand { Diff, Mention, Status, + Mcp, Logout, Quit, #[cfg(debug_assertions)] @@ -35,6 +36,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::Mcp => "list configured MCP tools", SlashCommand::Logout => "log out of Codex", #[cfg(debug_assertions)] SlashCommand::TestApproval => "test approval request", From 16f11a89d8c08105f54fa35b3915e9543b9a728f Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Wed, 20 Aug 2025 01:34:52 +0900 Subject: [PATCH 03/27] Fix #2429 Tweak the cursor position after tab completion (#2442) This pull request resolves #2429; I was also feeling that this is not great dev experience, so we should fix. --- codex-rs/tui/src/bottom_pane/chat_composer.rs | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 71483f0495..5d555aa083 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -273,6 +273,7 @@ impl ChatComposer { if !starts_with_cmd { self.textarea.set_text(&format!("/{} ", cmd.command())); + self.textarea.set_cursor(self.textarea.text().len()); } } (InputResult::None, true) @@ -1071,6 +1072,28 @@ mod tests { } } + #[test] + fn slash_tab_completion_moves_cursor_to_end() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = std::sync::mpsc::channel(); + let sender = AppEventSender::new(tx); + let mut composer = + ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string()); + + for ch in ['/', 'c'] { + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)); + } + + let (_result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); + + assert_eq!(composer.textarea.text(), "/compact "); + assert_eq!(composer.textarea.cursor(), composer.textarea.text().len()); + } + #[test] fn slash_mention_dispatches_command_and_inserts_at() { use crossterm::event::KeyCode; From 7b4313bf311e221b8b52d7168d51928cca8b931b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hans=20Kn=C3=B6chel?= Date: Tue, 19 Aug 2025 19:27:26 +0200 Subject: [PATCH 04/27] fix: fix missing spacing in review decision response (#2457) This PR fixes a display issue inside the user approval widget: Screenshot 2025-08-19 at 16 42 13 The other review decision responses are already properly formatted. Thanks! --- codex-rs/tui/src/user_approval_widget.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/codex-rs/tui/src/user_approval_widget.rs b/codex-rs/tui/src/user_approval_widget.rs index 34cac5b4cd..c2d7f70af0 100644 --- a/codex-rs/tui/src/user_approval_widget.rs +++ b/codex-rs/tui/src/user_approval_widget.rs @@ -285,7 +285,7 @@ impl UserApprovalWidget<'_> { "✔ ".fg(Color::Green), "You ".into(), "approved".bold(), - "codex to run ".into(), + " codex to run ".into(), ], cmd, vec![" every time this session".bold()], From 9d3124c6b7745a4ee3e81157e3a7c7de028c08d2 Mon Sep 17 00:00:00 2001 From: Evan Verma Date: Tue, 19 Aug 2025 10:27:50 -0700 Subject: [PATCH 05/27] tui: support Ghostty Ctrl-b/Ctrl-f fallback (#2427) Ensure Emacs-style Ctrl-b/Ctrl-f work when terminals send bare control chars. - Map ^B (U+0002) to move left when no CONTROL modifier is reported. - Map ^F (U+0006) to move right when no CONTROL modifier is reported. - Preserve existing Ctrl-b/Ctrl-f and Alt-b/Alt-f behavior. - Add unit test covering the fallback path. Background: Ghostty (and some tmux/terminal configs) can emit bare control characters for Ctrl-b/Ctrl-f. Previously these could be treated as literal input; with this change both styles behave identically. --- codex-rs/tui/src/bottom_pane/textarea.rs | 29 ++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/codex-rs/tui/src/bottom_pane/textarea.rs b/codex-rs/tui/src/bottom_pane/textarea.rs index 029ffc8590..d2a3916e55 100644 --- a/codex-rs/tui/src/bottom_pane/textarea.rs +++ b/codex-rs/tui/src/bottom_pane/textarea.rs @@ -204,6 +204,16 @@ impl TextArea { pub fn input(&mut self, event: KeyEvent) { match event { + // Some terminals (or configurations) send Control key chords as + // C0 control characters without reporting the CONTROL modifier. + // Handle common fallbacks for Ctrl-B/Ctrl-F here so they don't get + // inserted as literal control bytes. + KeyEvent { code: KeyCode::Char('\u{0002}'), modifiers: KeyModifiers::NONE, .. } /* ^B */ => { + self.move_cursor_left(); + } + KeyEvent { code: KeyCode::Char('\u{0006}'), modifiers: KeyModifiers::NONE, .. } /* ^F */ => { + self.move_cursor_right(); + } KeyEvent { code: KeyCode::Char(c), // Insert plain characters (and Shift-modified). Do NOT insert when ALT is held, @@ -1142,6 +1152,25 @@ mod tests { assert_eq!(t.cursor(), 1); } + #[test] + fn control_b_f_fallback_control_chars_move_cursor() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let mut t = ta_with("abcd"); + t.set_cursor(2); + + // Simulate terminals that send C0 control chars without CONTROL modifier. + // ^B (U+0002) should move left + t.input(KeyEvent::new(KeyCode::Char('\u{0002}'), KeyModifiers::NONE)); + assert_eq!(t.cursor(), 1); + + // ^F (U+0006) should move right + t.input(KeyEvent::new(KeyCode::Char('\u{0006}'), KeyModifiers::NONE)); + assert_eq!(t.cursor(), 2); + } + #[test] fn cursor_vertical_movement_across_lines_and_bounds() { let mut t = ta_with("short\nloooooooooong\nmid"); From 8f544153a7d1aa2fa8580810f58ed98db91cf24e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 19 Aug 2025 10:28:52 -0700 Subject: [PATCH 06/27] chore(deps): bump actions/checkout from 4 to 5 (#2407) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 5.
Release notes

Sourced from actions/checkout's releases.

v5.0.0

What's Changed

⚠️ Minimum Compatible Runner Version

v2.327.1
Release Notes

Make sure your runner is updated to this version or newer to use this release.

Full Changelog: https://github.com/actions/checkout/compare/v4...v5.0.0

v4.3.0

What's Changed

New Contributors

Full Changelog: https://github.com/actions/checkout/compare/v4...v4.3.0

v4.2.2

What's Changed

Full Changelog: https://github.com/actions/checkout/compare/v4.2.1...v4.2.2

v4.2.1

What's Changed

New Contributors

Full Changelog: https://github.com/actions/checkout/compare/v4.2.0...v4.2.1

... (truncated)

Changelog

Sourced from actions/checkout's changelog.

Changelog

V5.0.0

V4.3.0

v4.2.2

v4.2.1

v4.2.0

v4.1.7

v4.1.6

v4.1.5

v4.1.4

v4.1.3

... (truncated)

Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=actions/checkout&package-manager=github_actions&previous-version=4&new-version=5)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 2 +- .github/workflows/codespell.yml | 2 +- .github/workflows/codex.yml | 2 +- .github/workflows/rust-ci.yml | 6 +++--- .github/workflows/rust-release.yml | 6 +++--- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 32fb070924..e62d7bca56 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,7 +12,7 @@ jobs: NODE_OPTIONS: --max-old-space-size=4096 steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Setup Node.js uses: actions/setup-node@v4 diff --git a/.github/workflows/codespell.yml b/.github/workflows/codespell.yml index 5737a6bca8..ce03a430fc 100644 --- a/.github/workflows/codespell.yml +++ b/.github/workflows/codespell.yml @@ -18,7 +18,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Annotate locations with typos uses: codespell-project/codespell-problem-matcher@b80729f885d32f78a716c2f107b4db1025001c42 # v1 - name: Codespell diff --git a/.github/workflows/codex.yml b/.github/workflows/codex.yml index 6ca0d57f46..cb31275534 100644 --- a/.github/workflows/codex.yml +++ b/.github/workflows/codex.yml @@ -37,7 +37,7 @@ jobs: # Codex is not going to run. - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 - uses: dtolnay/rust-toolchain@1.88 with: diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml index fd8340a083..29733a46f6 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/rust-ci.yml @@ -17,7 +17,7 @@ jobs: codex: ${{ steps.detect.outputs.codex }} workflows: ${{ steps.detect.outputs.workflows }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: fetch-depth: 0 - name: Detect changed paths (no external action) @@ -56,7 +56,7 @@ jobs: run: working-directory: codex-rs steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - uses: dtolnay/rust-toolchain@1.88 with: components: rustfmt @@ -111,7 +111,7 @@ jobs: profile: release steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - uses: dtolnay/rust-toolchain@1.88 with: targets: ${{ matrix.target }} diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index c30ffdf61c..c00a7a5e03 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -19,7 +19,7 @@ jobs: tag-check: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Validate tag matches Cargo.toml version shell: bash @@ -74,7 +74,7 @@ jobs: target: x86_64-pc-windows-msvc steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - uses: dtolnay/rust-toolchain@1.88 with: targets: ${{ matrix.target }} @@ -163,7 +163,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 - uses: actions/download-artifact@v4 with: From e91c3d6d1cc050a9ca85d3460799f22c7d0541b9 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Tue, 19 Aug 2025 10:55:07 -0700 Subject: [PATCH 07/27] Support changing reasoning effort (#2435) https://github.com/user-attachments/assets/50198ee8-5915-47a3-bb71-69af65add1ef Building up on #2431 #2428 --- codex-rs/Cargo.lock | 1 + codex-rs/common/src/lib.rs | 2 + codex-rs/common/src/model_presets.rs | 54 ++++ codex-rs/config.md | 9 +- codex-rs/core/src/config.rs | 4 +- codex-rs/mcp-server/Cargo.toml | 1 + codex-rs/protocol/src/config_types.rs | 7 +- codex-rs/tui/src/app.rs | 15 ++ codex-rs/tui/src/app_event.rs | 7 + codex-rs/tui/src/bottom_pane/command_popup.rs | 5 +- .../tui/src/bottom_pane/file_search_popup.rs | 4 +- .../src/bottom_pane/list_selection_view.rs | 250 ++++++++++++++++++ codex-rs/tui/src/bottom_pane/mod.rs | 23 ++ .../src/bottom_pane/selection_popup_common.rs | 5 +- codex-rs/tui/src/chatwidget.rs | 56 ++++ codex-rs/tui/src/slash_command.rs | 2 + 16 files changed, 428 insertions(+), 17 deletions(-) create mode 100644 codex-rs/common/src/model_presets.rs create mode 100644 codex-rs/tui/src/bottom_pane/list_selection_view.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 265857533f..80809434e3 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -858,6 +858,7 @@ dependencies = [ "anyhow", "assert_cmd", "codex-arg0", + "codex-common", "codex-core", "codex-login", "codex-protocol", diff --git a/codex-rs/common/src/lib.rs b/codex-rs/common/src/lib.rs index 8595262cc0..dc684c21d8 100644 --- a/codex-rs/common/src/lib.rs +++ b/codex-rs/common/src/lib.rs @@ -29,3 +29,5 @@ mod config_summary; pub use config_summary::create_config_summary_entries; // Shared fuzzy matcher (used by TUI selection popups and other UI filtering) pub mod fuzzy_match; +// Shared model presets used by TUI and MCP server +pub mod model_presets; diff --git a/codex-rs/common/src/model_presets.rs b/codex-rs/common/src/model_presets.rs new file mode 100644 index 0000000000..16ec9be199 --- /dev/null +++ b/codex-rs/common/src/model_presets.rs @@ -0,0 +1,54 @@ +use codex_core::protocol_config_types::ReasoningEffort; + +/// A simple preset pairing a model slug with a reasoning effort. +#[derive(Debug, Clone, Copy)] +pub struct ModelPreset { + /// Stable identifier for the preset. + pub id: &'static str, + /// Display label shown in UIs. + pub label: &'static str, + /// Short human description shown next to the label in UIs. + pub description: &'static str, + /// Model slug (e.g., "gpt-5"). + pub model: &'static str, + /// Reasoning effort to apply for this preset. + pub effort: ReasoningEffort, +} + +/// Built-in list of model presets that pair a model with a reasoning effort. +/// +/// Keep this UI-agnostic so it can be reused by both TUI and MCP server. +pub fn builtin_model_presets() -> &'static [ModelPreset] { + // Order reflects effort from minimal to high. + const PRESETS: &[ModelPreset] = &[ + ModelPreset { + id: "gpt-5-minimal", + label: "gpt-5 minimal", + description: "— Fastest responses with very 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.", + 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.", + model: "gpt-5", + effort: ReasoningEffort::Medium, + }, + ModelPreset { + id: "gpt-5-high", + label: "gpt-5 high", + description: "— Maximizes reasoning depth for complex or ambiguous problems.", + model: "gpt-5", + effort: ReasoningEffort::High, + }, + ]; + PRESETS +} diff --git a/codex-rs/config.md b/codex-rs/config.md index 0d5df17cc8..80ef1466c4 100644 --- a/codex-rs/config.md +++ b/codex-rs/config.md @@ -217,17 +217,14 @@ Users can specify config values at multiple levels. Order of precedence is as fo ## model_reasoning_effort -If the model name starts with `"o"` (as in `"o3"` or `"o4-mini"`) or `"codex"`, reasoning is enabled by default when using the Responses API. As explained in the [OpenAI Platform documentation](https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning), this can be set to: +If the selected model is known to support reasoning (for example: `o3`, `o4-mini`, `codex-*`, `gpt-5`), reasoning is enabled by default when using the Responses API. As explained in the [OpenAI Platform documentation](https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning), this can be set to: +- `"minimal"` - `"low"` - `"medium"` (default) - `"high"` -To disable reasoning, set `model_reasoning_effort` to `"none"` in your config: - -```toml -model_reasoning_effort = "none" # disable reasoning -``` +Note: to minimize reasoning, choose `"minimal"`. ## model_reasoning_summary diff --git a/codex-rs/core/src/config.rs b/codex-rs/core/src/config.rs index f20912b22e..760729d827 100644 --- a/codex-rs/core/src/config.rs +++ b/codex-rs/core/src/config.rs @@ -140,8 +140,8 @@ pub struct Config { /// When this program is invoked, arg0 will be set to `codex-linux-sandbox`. pub codex_linux_sandbox_exe: Option, - /// If not "none", the value to use for `reasoning.effort` when making a - /// request using the Responses API. + /// Value to use for `reasoning.effort` when making a request using the + /// Responses API. pub model_reasoning_effort: ReasoningEffort, /// If not "none", the value to use for `reasoning.summary` when making a diff --git a/codex-rs/mcp-server/Cargo.toml b/codex-rs/mcp-server/Cargo.toml index 3f5c2a0fa2..44a3907159 100644 --- a/codex-rs/mcp-server/Cargo.toml +++ b/codex-rs/mcp-server/Cargo.toml @@ -17,6 +17,7 @@ workspace = true [dependencies] anyhow = "1" codex-arg0 = { path = "../arg0" } +codex-common = { path = "../common" } codex-core = { path = "../core" } codex-login = { path = "../login" } codex-protocol = { path = "../protocol" } diff --git a/codex-rs/protocol/src/config_types.rs b/codex-rs/protocol/src/config_types.rs index 1c88e9cbdd..3e543358bb 100644 --- a/codex-rs/protocol/src/config_types.rs +++ b/codex-rs/protocol/src/config_types.rs @@ -1,10 +1,13 @@ use serde::Deserialize; use serde::Serialize; use strum_macros::Display; +use strum_macros::EnumIter; use ts_rs::TS; /// See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning -#[derive(Debug, Serialize, Deserialize, Default, Clone, Copy, PartialEq, Eq, Display, TS)] +#[derive( + Debug, Serialize, Deserialize, Default, Clone, Copy, PartialEq, Eq, Display, TS, EnumIter, +)] #[serde(rename_all = "lowercase")] #[strum(serialize_all = "lowercase")] pub enum ReasoningEffort { @@ -13,8 +16,6 @@ pub enum ReasoningEffort { #[default] Medium, High, - /// Option to disable reasoning. - None, } /// A summary of the reasoning performed by the model. This can be useful for diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 810d6cb13c..ffb3d7cacb 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -382,6 +382,11 @@ impl App<'_> { 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::Quit => { break; } @@ -499,6 +504,16 @@ impl App<'_> { widget.apply_file_search_result(query, matches); } } + AppEvent::UpdateReasoningEffort(effort) => { + if let AppState::Chat { widget } = &mut self.app_state { + widget.set_reasoning_effort(effort); + } + } + AppEvent::UpdateModel(model) => { + if let AppState::Chat { widget } = &mut self.app_state { + widget.set_model(model); + } + } } } terminal.clear()?; diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 1afffd756a..1780dbc710 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -6,6 +6,7 @@ use std::time::Duration; use crate::app::ChatWidgetArgs; use crate::slash_command::SlashCommand; +use codex_core::protocol_config_types::ReasoningEffort; #[allow(clippy::large_enum_variant)] #[derive(Debug)] @@ -63,4 +64,10 @@ pub(crate) enum AppEvent { /// Onboarding: result of login_with_chatgpt. OnboardingAuthComplete(Result<(), String>), OnboardingComplete(ChatWidgetArgs), + + /// Update the current reasoning effort in the running app and widget. + UpdateReasoningEffort(ReasoningEffort), + + /// Update the current model slug in the running app and widget. + UpdateModel(String), } diff --git a/codex-rs/tui/src/bottom_pane/command_popup.rs b/codex-rs/tui/src/bottom_pane/command_popup.rs index b7a203e9fd..9ae7ada81a 100644 --- a/codex-rs/tui/src/bottom_pane/command_popup.rs +++ b/codex-rs/tui/src/bottom_pane/command_popup.rs @@ -71,6 +71,8 @@ impl CommandPopup { for (_, cmd) in self.all_commands.iter() { out.push((cmd, None, 0)); } + // Keep the original presentation order when no filter is applied. + return out; } else { for (_, cmd) in self.all_commands.iter() { if let Some((indices, score)) = fuzzy_match(cmd.command(), filter) { @@ -78,6 +80,7 @@ impl CommandPopup { } } } + // When filtering, sort by ascending score and then by command for stability. out.sort_by(|a, b| a.2.cmp(&b.2).then_with(|| a.0.command().cmp(b.0.command()))); out } @@ -128,7 +131,7 @@ impl WidgetRef for CommandPopup { }) .collect() }; - render_rows(area, buf, &rows_all, &self.state, MAX_POPUP_ROWS); + render_rows(area, buf, &rows_all, &self.state, MAX_POPUP_ROWS, false); } } diff --git a/codex-rs/tui/src/bottom_pane/file_search_popup.rs b/codex-rs/tui/src/bottom_pane/file_search_popup.rs index a811a22a8c..f046a2f144 100644 --- a/codex-rs/tui/src/bottom_pane/file_search_popup.rs +++ b/codex-rs/tui/src/bottom_pane/file_search_popup.rs @@ -134,9 +134,9 @@ impl WidgetRef for &FileSearchPopup { if self.waiting && rows_all.is_empty() { // Render a minimal waiting stub using the shared renderer (no rows -> "no matches"). - render_rows(area, buf, &[], &self.state, MAX_POPUP_ROWS); + render_rows(area, buf, &[], &self.state, MAX_POPUP_ROWS, false); } else { - render_rows(area, buf, &rows_all, &self.state, MAX_POPUP_ROWS); + render_rows(area, buf, &rows_all, &self.state, MAX_POPUP_ROWS, false); } } } diff --git a/codex-rs/tui/src/bottom_pane/list_selection_view.rs b/codex-rs/tui/src/bottom_pane/list_selection_view.rs new file mode 100644 index 0000000000..3b03eb9c03 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/list_selection_view.rs @@ -0,0 +1,250 @@ +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyModifiers; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::Modifier; +use ratatui::style::Style; +use ratatui::text::Line; +use ratatui::text::Span; +use ratatui::widgets::Paragraph; +use ratatui::widgets::Widget; + +use crate::app_event_sender::AppEventSender; + +use super::BottomPane; +use super::CancellationEvent; +use super::bottom_pane_view::BottomPaneView; +use super::popup_consts::MAX_POPUP_ROWS; +use super::scroll_state::ScrollState; +use super::selection_popup_common::GenericDisplayRow; +use super::selection_popup_common::render_rows; + +/// One selectable item in the generic selection list. +pub(crate) type SelectionAction = Box; + +pub(crate) struct SelectionItem { + pub name: String, + pub description: Option, + pub is_current: bool, + pub actions: Vec, +} + +pub(crate) struct ListSelectionView { + title: String, + subtitle: Option, + footer_hint: Option, + items: Vec, + state: ScrollState, + complete: bool, + app_event_tx: AppEventSender, +} + +impl ListSelectionView { + fn dim_prefix_span() -> Span<'static> { + Span::styled("▌ ", Style::default().add_modifier(Modifier::DIM)) + } + + fn render_dim_prefix_line(area: Rect, buf: &mut Buffer) { + let para = Paragraph::new(Line::from(Self::dim_prefix_span())); + para.render(area, buf); + } + pub fn new( + title: String, + subtitle: Option, + footer_hint: Option, + items: Vec, + app_event_tx: AppEventSender, + ) -> Self { + let mut s = Self { + title, + subtitle, + footer_hint, + items, + state: ScrollState::new(), + complete: false, + app_event_tx, + }; + let len = s.items.len(); + if let Some(idx) = s.items.iter().position(|it| it.is_current) { + s.state.selected_idx = Some(idx); + } + s.state.clamp_selection(len); + s.state.ensure_visible(len, MAX_POPUP_ROWS.min(len)); + s + } + + fn move_up(&mut self) { + let len = self.items.len(); + self.state.move_up_wrap(len); + self.state.ensure_visible(len, MAX_POPUP_ROWS.min(len)); + } + + fn move_down(&mut self) { + let len = self.items.len(); + self.state.move_down_wrap(len); + self.state.ensure_visible(len, MAX_POPUP_ROWS.min(len)); + } + + fn accept(&mut self) { + if let Some(idx) = self.state.selected_idx { + if let Some(item) = self.items.get(idx) { + for act in &item.actions { + act(&self.app_event_tx); + } + self.complete = true; + } + } else { + self.complete = true; + } + } + + fn cancel(&mut self) { + // Close the popup without performing any actions. + self.complete = true; + } +} + +impl BottomPaneView<'_> for ListSelectionView { + fn handle_key_event(&mut self, _pane: &mut BottomPane<'_>, key_event: KeyEvent) { + match key_event { + KeyEvent { + code: KeyCode::Up, .. + } => self.move_up(), + KeyEvent { + code: KeyCode::Down, + .. + } => self.move_down(), + KeyEvent { + code: KeyCode::Esc, .. + } => self.cancel(), + KeyEvent { + code: KeyCode::Enter, + modifiers: KeyModifiers::NONE, + .. + } => self.accept(), + _ => {} + } + } + + fn is_complete(&self) -> bool { + self.complete + } + + fn on_ctrl_c(&mut self, _pane: &mut BottomPane<'_>) -> CancellationEvent { + self.complete = true; + CancellationEvent::Handled + } + + fn desired_height(&self, _width: u16) -> u16 { + let rows = (self.items.len()).clamp(1, MAX_POPUP_ROWS); + // +1 for the title row, +1 for optional subtitle, +1 for optional footer + let mut height = rows as u16 + 1; + if self.subtitle.is_some() { + // +1 for subtitle, +1 for a blank spacer line beneath it + height = height.saturating_add(2); + } + if self.footer_hint.is_some() { + height = height.saturating_add(2); + } + height + } + + fn render(&self, area: Rect, buf: &mut Buffer) { + if area.height == 0 || area.width == 0 { + return; + } + + let title_area = Rect { + x: area.x, + y: area.y, + width: area.width, + height: 1, + }; + + let title_spans: Vec> = vec![ + Self::dim_prefix_span(), + Span::styled( + self.title.clone(), + Style::default().add_modifier(Modifier::BOLD), + ), + ]; + let title_para = Paragraph::new(Line::from(title_spans)); + title_para.render(title_area, buf); + + let mut next_y = area.y.saturating_add(1); + if let Some(sub) = &self.subtitle { + let subtitle_area = Rect { + x: area.x, + y: next_y, + width: area.width, + height: 1, + }; + let subtitle_spans: Vec> = vec![ + Self::dim_prefix_span(), + Span::styled(sub.clone(), Style::default().add_modifier(Modifier::DIM)), + ]; + let subtitle_para = Paragraph::new(Line::from(subtitle_spans)); + subtitle_para.render(subtitle_area, buf); + // Render the extra spacer line with the dimmed prefix to align with title/subtitle + let spacer_area = Rect { + x: area.x, + y: next_y.saturating_add(1), + width: area.width, + height: 1, + }; + Self::render_dim_prefix_line(spacer_area, buf); + next_y = next_y.saturating_add(2); + } + + let footer_reserved = if self.footer_hint.is_some() { 2 } else { 0 }; + let rows_area = Rect { + x: area.x, + y: next_y, + width: area.width, + height: area + .height + .saturating_sub(next_y.saturating_sub(area.y)) + .saturating_sub(footer_reserved), + }; + + let rows: Vec = self + .items + .iter() + .enumerate() + .map(|(i, it)| { + let is_selected = self.state.selected_idx == Some(i); + let prefix = if is_selected { '>' } else { ' ' }; + let name_with_marker = if it.is_current { + format!("{} (current)", it.name) + } else { + it.name.clone() + }; + let display_name = format!("{} {}. {}", prefix, i + 1, name_with_marker); + GenericDisplayRow { + name: display_name, + match_indices: None, + is_current: it.is_current, + description: it.description.clone(), + } + }) + .collect(); + if rows_area.height > 0 { + render_rows(rows_area, buf, &rows, &self.state, MAX_POPUP_ROWS, true); + } + + if let Some(hint) = &self.footer_hint { + let footer_area = Rect { + x: area.x, + y: area.y + area.height - 1, + width: area.width, + height: 1, + }; + let footer_para = Paragraph::new(Line::from(Span::styled( + hint.clone(), + Style::default().add_modifier(Modifier::DIM), + ))); + footer_para.render(footer_area, buf); + } + } +} diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index c05e9e9437..b27ea6e945 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -17,6 +17,7 @@ mod chat_composer; mod chat_composer_history; mod command_popup; mod file_search_popup; +mod list_selection_view; mod popup_consts; mod scroll_state; mod selection_popup_common; @@ -33,6 +34,8 @@ pub(crate) use chat_composer::ChatComposer; pub(crate) use chat_composer::InputResult; use approval_modal_view::ApprovalModalView; +pub(crate) use list_selection_view::SelectionAction; +pub(crate) use list_selection_view::SelectionItem; use status_indicator_view::StatusIndicatorView; /// Pane displayed in the lower half of the chat UI. @@ -212,6 +215,26 @@ impl BottomPane<'_> { } } + /// Show a generic list selection view with the provided items. + pub(crate) fn show_selection_view( + &mut self, + title: String, + subtitle: Option, + footer_hint: Option, + items: Vec, + ) { + let view = list_selection_view::ListSelectionView::new( + title, + subtitle, + footer_hint, + items, + self.app_event_tx.clone(), + ); + self.active_view = Some(Box::new(view)); + self.status_view_active = false; + self.request_redraw(); + } + /// Update the live status text shown while a task is running. /// If a modal view is active (i.e., not the status indicator), this is a no‑op. pub(crate) fn update_status_text(&mut self, text: String) { diff --git a/codex-rs/tui/src/bottom_pane/selection_popup_common.rs b/codex-rs/tui/src/bottom_pane/selection_popup_common.rs index 6098a957da..a83ec15182 100644 --- a/codex-rs/tui/src/bottom_pane/selection_popup_common.rs +++ b/codex-rs/tui/src/bottom_pane/selection_popup_common.rs @@ -34,6 +34,7 @@ pub(crate) fn render_rows( rows_all: &[GenericDisplayRow], state: &ScrollState, max_results: usize, + _dim_non_selected: bool, ) { let mut rows: Vec = Vec::new(); if rows_all.is_empty() { @@ -69,7 +70,7 @@ pub(crate) fn render_rows( let GenericDisplayRow { name, match_indices, - is_current, + is_current: _is_current, description, } = row; @@ -104,8 +105,6 @@ pub(crate) fn render_rows( .fg(Color::Cyan) .add_modifier(Modifier::BOLD), ); - } else if *is_current { - cell = cell.style(Style::default().fg(Color::Cyan)); } rows.push(Row::new(vec![cell])); } diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index bbb99f52e2..360a9f8ef1 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -45,6 +45,8 @@ use crate::bottom_pane::BottomPane; use crate::bottom_pane::BottomPaneParams; use crate::bottom_pane::CancellationEvent; use crate::bottom_pane::InputResult; +use crate::bottom_pane::SelectionAction; +use crate::bottom_pane::SelectionItem; use crate::history_cell; use crate::history_cell::CommandOutput; use crate::history_cell::ExecCell; @@ -58,7 +60,10 @@ mod agent; use self::agent::spawn_agent; use crate::streaming::controller::AppEventHistorySink; use crate::streaming::controller::StreamController; +use codex_common::model_presets::ModelPreset; +use codex_common::model_presets::builtin_model_presets; use codex_core::ConversationManager; +use codex_core::protocol_config_types::ReasoningEffort as ReasoningEffortConfig; use codex_file_search::FileMatch; use uuid::Uuid; @@ -687,6 +692,57 @@ impl ChatWidget<'_> { )); } + /// Open a popup to choose the model preset (model + reasoning effort). + pub(crate) fn open_model_popup(&mut self) { + let current_model = self.config.model.clone(); + let current_effort = self.config.model_reasoning_effort; + let presets: &[ModelPreset] = builtin_model_presets(); + + let mut items: Vec = Vec::new(); + for preset in presets.iter() { + let name = preset.label.to_string(); + let description = Some(preset.description.to_string()); + let is_current = preset.model == current_model && preset.effort == current_effort; + let model_slug = preset.model.to_string(); + let effort = preset.effort; + let actions: Vec = vec![Box::new(move |tx| { + tx.send(AppEvent::CodexOp(Op::OverrideTurnContext { + cwd: None, + approval_policy: None, + sandbox_policy: None, + model: Some(model_slug.clone()), + effort: Some(effort), + summary: None, + })); + tx.send(AppEvent::UpdateModel(model_slug.clone())); + tx.send(AppEvent::UpdateReasoningEffort(effort)); + })]; + items.push(SelectionItem { + name, + description, + is_current, + actions, + }); + } + + self.bottom_pane.show_selection_view( + "Select model and reasoning level".to_string(), + Some("Switch between OpenAI models for this and future Codex CLI session".to_string()), + Some("Press Enter to confirm or Esc to go back".to_string()), + items, + ); + } + + /// Set the reasoning effort in the widget's config copy. + pub(crate) fn set_reasoning_effort(&mut self, effort: ReasoningEffortConfig) { + self.config.model_reasoning_effort = effort; + } + + /// Set the model in the widget's config copy. + pub(crate) fn set_model(&mut self, model: String) { + self.config.model = model; + } + pub(crate) fn add_mcp_output(&mut self) { if self.config.mcp_servers.is_empty() { self.add_to_history(&history_cell::empty_mcp_output()); diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index 56a6c316b5..d572895abb 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -12,6 +12,7 @@ use strum_macros::IntoStaticStr; pub enum SlashCommand { // DO NOT ALPHA-SORT! Enum order is presentation order in the popup, so // more frequently used commands should be listed first. + Model, New, Init, Compact, @@ -36,6 +37,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::Mcp => "list configured MCP tools", SlashCommand::Logout => "log out of Codex", #[cfg(debug_assertions)] From 783654e218c75ca00b43081c4d6a54a66d4c742f Mon Sep 17 00:00:00 2001 From: ae Date: Tue, 19 Aug 2025 11:27:05 -0700 Subject: [PATCH 08/27] feat: move session ID bullet in /status (#2462) ## Summary - just want to declutter the top level workspace section ## Testing - `just fmt` - `just fix` *(fails: error[E0658] let expressions in this position are unstable in codex-protocol)* - `cargo test -p codex-tui` *(fails: error[E0658] let expressions in this position are unstable)* ------ https://chatgpt.com/codex/tasks/task_i_68a4a7311dbc832caf14f52e0fbaf9c2 --- codex-rs/tui/src/history_cell.rs | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index d9ce9ad5e5..58677c724a 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -537,13 +537,6 @@ pub(crate) fn new_status_output( sandbox_name.into(), ])); - if let Some(session_id) = session_id { - lines.push(Line::from(vec![ - " • Session ID: ".into(), - session_id.to_string().into(), - ])); - } - lines.push(Line::from("")); // 👤 Account (only if ChatGPT tokens exist), shown under the first block @@ -608,6 +601,12 @@ pub(crate) fn new_status_output( // 📊 Token Usage lines.push(Line::from(vec!["📊 ".into(), "Token Usage".bold()])); + if let Some(session_id) = session_id { + lines.push(Line::from(vec![ + " • Session ID: ".into(), + session_id.to_string().into(), + ])); + } // Input: [+ cached] let mut input_line_spans: Vec> = vec![ " • Input: ".into(), From 1f5638b0f315b1fc1ad523105b975a1851b37b28 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Tue, 19 Aug 2025 11:39:31 -0700 Subject: [PATCH 09/27] docs: document writable_roots for sandbox_workspace_write (#2464) As discovered on https://github.com/openai/codex/issues/2444, this was missing from the docs. --- codex-rs/config.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/codex-rs/config.md b/codex-rs/config.md index 80ef1466c4..8c2a7831dc 100644 --- a/codex-rs/config.md +++ b/codex-rs/config.md @@ -149,6 +149,7 @@ approval_policy = "untrusted" ``` If you want to be notified whenever a command fails, use "on-failure": + ```toml # If the command fails when run in the sandbox, Codex asks for permission to # retry the command outside the sandbox. @@ -156,12 +157,14 @@ approval_policy = "on-failure" ``` If you want the model to run until it decides that it needs to ask you for escalated permissions, use "on-request": + ```toml # The model decides when to escalate approval_policy = "on-request" ``` Alternatively, you can have the model run until it is done, and never ask to run a command with escalated permissions: + ```toml # User is never prompted: if the command fails, Codex will automatically try # something out. Note the `exec` subcommand always uses this mode. @@ -278,6 +281,9 @@ sandbox_mode = "workspace-write" exclude_tmpdir_env_var = false exclude_slash_tmp = false +# Optional list of _additional_ writable roots beyond $TMPDIR and /tmp. +writable_roots = ["/Users/YOU/.pyenv/shims"] + # Allow the command being run inside the sandbox to make outbound network # requests. Disabled by default. network_access = false @@ -495,10 +501,12 @@ hide_agent_reasoning = true # defaults to false Surfaces the model’s raw chain-of-thought ("raw reasoning content") when available. Notes: + - Only takes effect if the selected model/provider actually emits raw reasoning content. Many models do not. When unsupported, this option has no visible effect. - Raw reasoning may include intermediate thoughts or sensitive context. Enable only if acceptable for your workflow. Example: + ```toml show_raw_agent_reasoning = true # defaults to false ``` From aafa00dbe087fa5e83e45643c1d8785c4fd4fcf0 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Tue, 19 Aug 2025 12:57:31 -0700 Subject: [PATCH 10/27] fix: prefer `cargo check` to `cargo build` to save time and space (#2466) The `ubuntu-24.04 - x86_64-unknown-linux-gnu` build is failing with `No space left on device` on #2465, so let's get this in first, which should help. Note that `cargo check` should be faster and use less disk than `cargo build` because it does not write out the object files. --- .github/workflows/rust-ci.yml | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml index 29733a46f6..fca1a02288 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/rust-ci.yml @@ -130,7 +130,7 @@ jobs: - if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl'}} name: Install musl build tools run: | - sudo apt install -y musl-tools pkg-config + sudo apt install -y musl-tools pkg-config && sudo rm -rf /var/lib/apt/lists/* - name: cargo clippy id: clippy @@ -139,15 +139,15 @@ jobs: # Running `cargo build` from the workspace root builds the workspace using # the union of all features from third-party crates. This can mask errors # where individual crates have underspecified features. To avoid this, we - # run `cargo build` for each crate individually, though because this is + # run `cargo check` for each crate individually, though because this is # slower, we only do this for the x86_64-unknown-linux-gnu target. - - name: cargo build individual crates - id: build + - name: cargo check individual crates + id: cargo_check_all_crates if: ${{ matrix.target == 'x86_64-unknown-linux-gnu' && matrix.profile != 'release' }} continue-on-error: true run: | find . -name Cargo.toml -mindepth 2 -maxdepth 2 -print0 \ - | xargs -0 -n1 -I{} bash -c 'cd "$(dirname "{}")" && cargo build --profile ${{ matrix.profile }}' + | xargs -0 -n1 -I{} bash -c 'cd "$(dirname "{}")" && cargo check --profile ${{ matrix.profile }}' - name: cargo test id: test @@ -162,10 +162,10 @@ jobs: - name: verify all steps passed if: | steps.clippy.outcome == 'failure' || - steps.build.outcome == 'failure' || + steps.cargo_check_all_crates.outcome == 'failure' || steps.test.outcome == 'failure' run: | - echo "One or more checks failed (clippy, build, or test). See logs for details." + echo "One or more checks failed (clippy, cargo_check_all_crates, or test). See logs for details." exit 1 # --- Gatherer job that you mark as the ONLY required status ----------------- From 50c48e88f5f1385300e74689f4f066d71990ad05 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Tue, 19 Aug 2025 13:22:02 -0700 Subject: [PATCH 11/27] chore: upgrade to Rust 1.89 (#2465) Codex created this PR from the following prompt: > upgrade this entire repo to Rust 1.89. Note that this requires updating codex-rs/rust-toolchain.toml as well as the workflows in .github/. Make sure that things are "clippy clean" as this change will likely uncover new Clippy errors. `just fmt` and `cargo clippy --tests` are sufficient to check for correctness Note this modifies a lot of lines because it folds nested `if` statements using `&&`. --- [//]: # (BEGIN SAPLING FOOTER) Stack created with [Sapling](https://sapling-scm.com). Best reviewed with [ReviewStack](https://reviewstack.dev/openai/codex/pull/2465). * #2467 * __->__ #2465 --- .github/workflows/codex.yml | 2 +- .github/workflows/rust-ci.yml | 4 +- .github/workflows/rust-release.yml | 2 +- codex-rs/apply-patch/src/lib.rs | 36 +-- codex-rs/arg0/src/lib.rs | 8 +- codex-rs/cli/src/login.rs | 12 +- codex-rs/core/src/chat_completions.rs | 60 ++-- codex-rs/core/src/codex.rs | 32 +-- codex-rs/core/src/config.rs | 8 +- codex-rs/core/src/git_info.rs | 37 ++- codex-rs/core/src/is_safe_command.rs | 25 +- codex-rs/core/src/model_provider_info.rs | 8 +- codex-rs/core/src/openai_tools.rs | 34 +-- codex-rs/core/src/parse_command.rs | 265 +++++++++--------- codex-rs/core/src/safety.rs | 8 +- codex-rs/core/src/shell.rs | 14 +- codex-rs/core/tests/cli_stream.rs | 26 +- codex-rs/exec/src/event_processor.rs | 8 +- codex-rs/execpolicy/src/execv_checker.rs | 19 +- codex-rs/execpolicy/src/policy.rs | 20 +- codex-rs/execpolicy/src/sed_command.rs | 12 +- codex-rs/file-search/src/lib.rs | 20 +- codex-rs/login/src/server.rs | 8 +- codex-rs/ollama/src/client.rs | 5 +- codex-rs/protocol-ts/src/lib.rs | 22 +- codex-rs/rust-toolchain.toml | 2 +- .../src/bottom_pane/approval_modal_view.rs | 8 +- codex-rs/tui/src/bottom_pane/textarea.rs | 72 ++--- codex-rs/tui/src/chatwidget/tests.rs | 24 +- codex-rs/tui/src/custom_terminal.rs | 10 +- codex-rs/tui/src/exec_command.rs | 8 +- codex-rs/tui/src/file_search.rs | 22 +- codex-rs/tui/src/history_cell.rs | 77 +++-- codex-rs/tui/src/onboarding/auth.rs | 46 +-- codex-rs/tui/src/streaming/controller.rs | 38 +-- codex-rs/tui/src/text_formatting.rs | 12 +- codex-rs/tui/tests/vt100_history.rs | 11 +- 37 files changed, 504 insertions(+), 521 deletions(-) diff --git a/.github/workflows/codex.yml b/.github/workflows/codex.yml index cb31275534..367c53e947 100644 --- a/.github/workflows/codex.yml +++ b/.github/workflows/codex.yml @@ -39,7 +39,7 @@ jobs: - name: Checkout repository uses: actions/checkout@v5 - - uses: dtolnay/rust-toolchain@1.88 + - uses: dtolnay/rust-toolchain@1.89 with: targets: x86_64-unknown-linux-gnu components: clippy diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml index fca1a02288..fa9f1cd15f 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/rust-ci.yml @@ -57,7 +57,7 @@ jobs: working-directory: codex-rs steps: - uses: actions/checkout@v5 - - uses: dtolnay/rust-toolchain@1.88 + - uses: dtolnay/rust-toolchain@1.89 with: components: rustfmt - name: cargo fmt @@ -112,7 +112,7 @@ jobs: steps: - uses: actions/checkout@v5 - - uses: dtolnay/rust-toolchain@1.88 + - uses: dtolnay/rust-toolchain@1.89 with: targets: ${{ matrix.target }} components: clippy diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index c00a7a5e03..0044b864c7 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -75,7 +75,7 @@ jobs: steps: - uses: actions/checkout@v5 - - uses: dtolnay/rust-toolchain@1.88 + - uses: dtolnay/rust-toolchain@1.89 with: targets: ${{ matrix.target }} diff --git a/codex-rs/apply-patch/src/lib.rs b/codex-rs/apply-patch/src/lib.rs index 87df66dd23..1aae106682 100644 --- a/codex-rs/apply-patch/src/lib.rs +++ b/codex-rs/apply-patch/src/lib.rs @@ -415,12 +415,12 @@ fn apply_hunks_to_files(hunks: &[Hunk]) -> anyhow::Result { for hunk in hunks { match hunk { Hunk::AddFile { path, contents } => { - if let Some(parent) = path.parent() { - if !parent.as_os_str().is_empty() { - std::fs::create_dir_all(parent).with_context(|| { - format!("Failed to create parent directories for {}", path.display()) - })?; - } + if let Some(parent) = path.parent() + && !parent.as_os_str().is_empty() + { + std::fs::create_dir_all(parent).with_context(|| { + format!("Failed to create parent directories for {}", path.display()) + })?; } std::fs::write(path, contents) .with_context(|| format!("Failed to write file {}", path.display()))?; @@ -439,15 +439,12 @@ fn apply_hunks_to_files(hunks: &[Hunk]) -> anyhow::Result { let AppliedPatch { new_contents, .. } = derive_new_contents_from_chunks(path, chunks)?; if let Some(dest) = move_path { - if let Some(parent) = dest.parent() { - if !parent.as_os_str().is_empty() { - std::fs::create_dir_all(parent).with_context(|| { - format!( - "Failed to create parent directories for {}", - dest.display() - ) - })?; - } + if let Some(parent) = dest.parent() + && !parent.as_os_str().is_empty() + { + std::fs::create_dir_all(parent).with_context(|| { + format!("Failed to create parent directories for {}", dest.display()) + })?; } std::fs::write(dest, new_contents) .with_context(|| format!("Failed to write file {}", dest.display()))?; @@ -529,9 +526,12 @@ fn compute_replacements( // If a chunk has a `change_context`, we use seek_sequence to find it, then // adjust our `line_index` to continue from there. if let Some(ctx_line) = &chunk.change_context { - if let Some(idx) = - seek_sequence::seek_sequence(original_lines, &[ctx_line.clone()], line_index, false) - { + if let Some(idx) = seek_sequence::seek_sequence( + original_lines, + std::slice::from_ref(ctx_line), + line_index, + false, + ) { line_index = idx + 1; } else { return Err(ApplyPatchError::ComputeReplacements(format!( diff --git a/codex-rs/arg0/src/lib.rs b/codex-rs/arg0/src/lib.rs index b7766fe70a..216a0437d1 100644 --- a/codex-rs/arg0/src/lib.rs +++ b/codex-rs/arg0/src/lib.rs @@ -89,10 +89,10 @@ const ILLEGAL_ENV_VAR_PREFIX: &str = "CODEX_"; /// Security: Do not allow `.env` files to create or modify any variables /// with names starting with `CODEX_`. fn load_dotenv() { - if let Ok(codex_home) = codex_core::config::find_codex_home() { - if let Ok(iter) = dotenvy::from_path_iter(codex_home.join(".env")) { - set_filtered(iter); - } + if let Ok(codex_home) = codex_core::config::find_codex_home() + && let Ok(iter) = dotenvy::from_path_iter(codex_home.join(".env")) + { + set_filtered(iter); } if let Ok(iter) = dotenvy::dotenv_iter() { diff --git a/codex-rs/cli/src/login.rs b/codex-rs/cli/src/login.rs index 72eb7b4faf..959bf46f18 100644 --- a/codex-rs/cli/src/login.rs +++ b/codex-rs/cli/src/login.rs @@ -66,12 +66,12 @@ pub async fn run_login_status(cli_config_overrides: CliConfigOverrides) -> ! { Ok(api_key) => { eprintln!("Logged in using an API key - {}", safe_format_key(&api_key)); - if let Ok(env_api_key) = env::var(OPENAI_API_KEY_ENV_VAR) { - if env_api_key == api_key { - eprintln!( - " API loaded from OPENAI_API_KEY environment variable or .env file" - ); - } + if let Ok(env_api_key) = env::var(OPENAI_API_KEY_ENV_VAR) + && env_api_key == api_key + { + eprintln!( + " API loaded from OPENAI_API_KEY environment variable or .env file" + ); } std::process::exit(0); } diff --git a/codex-rs/core/src/chat_completions.rs b/codex-rs/core/src/chat_completions.rs index fc0ceca5ad..f3ed34c6dc 100644 --- a/codex-rs/core/src/chat_completions.rs +++ b/codex-rs/core/src/chat_completions.rs @@ -290,13 +290,12 @@ async fn process_chat_sse( .get("delta") .and_then(|d| d.get("content")) .and_then(|c| c.as_str()) + && !content.is_empty() { - if !content.is_empty() { - assistant_text.push_str(content); - let _ = tx_event - .send(Ok(ResponseEvent::OutputTextDelta(content.to_string()))) - .await; - } + assistant_text.push_str(content); + let _ = tx_event + .send(Ok(ResponseEvent::OutputTextDelta(content.to_string()))) + .await; } // Forward any reasoning/thinking deltas if present. @@ -333,27 +332,25 @@ async fn process_chat_sse( .get("delta") .and_then(|d| d.get("tool_calls")) .and_then(|tc| tc.as_array()) + && let Some(tool_call) = tool_calls.first() { - if let Some(tool_call) = tool_calls.first() { - // Mark that we have an active function call in progress. - fn_call_state.active = true; + // Mark that we have an active function call in progress. + fn_call_state.active = true; - // Extract call_id if present. - if let Some(id) = tool_call.get("id").and_then(|v| v.as_str()) { - fn_call_state.call_id.get_or_insert_with(|| id.to_string()); + // Extract call_id if present. + if let Some(id) = tool_call.get("id").and_then(|v| v.as_str()) { + fn_call_state.call_id.get_or_insert_with(|| id.to_string()); + } + + // Extract function details if present. + if let Some(function) = tool_call.get("function") { + if let Some(name) = function.get("name").and_then(|n| n.as_str()) { + fn_call_state.name.get_or_insert_with(|| name.to_string()); } - // Extract function details if present. - if let Some(function) = tool_call.get("function") { - if let Some(name) = function.get("name").and_then(|n| n.as_str()) { - fn_call_state.name.get_or_insert_with(|| name.to_string()); - } - - if let Some(args_fragment) = - function.get("arguments").and_then(|a| a.as_str()) - { - fn_call_state.arguments.push_str(args_fragment); - } + if let Some(args_fragment) = function.get("arguments").and_then(|a| a.as_str()) + { + fn_call_state.arguments.push_str(args_fragment); } } } @@ -491,15 +488,14 @@ where // Only use the final assistant message if we have not // seen any deltas; otherwise, deltas already built the // cumulative text and this would duplicate it. - if this.cumulative.is_empty() { - if let crate::models::ResponseItem::Message { content, .. } = &item { - if let Some(text) = content.iter().find_map(|c| match c { - crate::models::ContentItem::OutputText { text } => Some(text), - _ => None, - }) { - this.cumulative.push_str(text); - } - } + if this.cumulative.is_empty() + && let crate::models::ResponseItem::Message { content, .. } = &item + && let Some(text) = content.iter().find_map(|c| match c { + crate::models::ContentItem::OutputText { text } => Some(text), + _ => None, + }) + { + this.cumulative.push_str(text); } // Swallow assistant message here; emit on Completed. diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 6423fb44ef..57719f5269 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -544,10 +544,10 @@ impl Session { pub fn remove_task(&self, sub_id: &str) { let mut state = self.state.lock_unchecked(); - if let Some(task) = &state.current_task { - if task.sub_id == sub_id { - state.current_task.take(); - } + if let Some(task) = &state.current_task + && task.sub_id == sub_id + { + state.current_task.take(); } } @@ -1239,18 +1239,18 @@ async fn submission_loop( // Gracefully flush and shutdown rollout recorder on session end so tests // that inspect the rollout file do not race with the background writer. let recorder_opt = sess.rollout.lock_unchecked().take(); - if let Some(rec) = recorder_opt { - if let Err(e) = rec.shutdown().await { - warn!("failed to shutdown rollout recorder: {e}"); - let event = Event { - id: sub.id.clone(), - msg: EventMsg::Error(ErrorEvent { - message: "Failed to shutdown rollout recorder".to_string(), - }), - }; - if let Err(e) = sess.tx_event.send(event).await { - warn!("failed to send error message: {e:?}"); - } + if let Some(rec) = recorder_opt + && let Err(e) = rec.shutdown().await + { + warn!("failed to shutdown rollout recorder: {e}"); + let event = Event { + id: sub.id.clone(), + msg: EventMsg::Error(ErrorEvent { + message: "Failed to shutdown rollout recorder".to_string(), + }), + }; + if let Err(e) = sess.tx_event.send(event).await { + warn!("failed to send error message: {e:?}"); } } diff --git a/codex-rs/core/src/config.rs b/codex-rs/core/src/config.rs index 760729d827..e0ff411f15 100644 --- a/codex-rs/core/src/config.rs +++ b/codex-rs/core/src/config.rs @@ -759,10 +759,10 @@ fn default_model() -> String { pub fn find_codex_home() -> std::io::Result { // Honor the `CODEX_HOME` environment variable when it is set to allow users // (and tests) to override the default location. - if let Ok(val) = std::env::var("CODEX_HOME") { - if !val.is_empty() { - return PathBuf::from(val).canonicalize(); - } + if let Ok(val) = std::env::var("CODEX_HOME") + && !val.is_empty() + { + return PathBuf::from(val).canonicalize(); } let mut p = home_dir().ok_or_else(|| { diff --git a/codex-rs/core/src/git_info.rs b/codex-rs/core/src/git_info.rs index 09a59b7b8c..ccb43ae55a 100644 --- a/codex-rs/core/src/git_info.rs +++ b/codex-rs/core/src/git_info.rs @@ -51,33 +51,30 @@ pub async fn collect_git_info(cwd: &Path) -> Option { }; // Process commit hash - if let Some(output) = commit_result { - if output.status.success() { - if let Ok(hash) = String::from_utf8(output.stdout) { - git_info.commit_hash = Some(hash.trim().to_string()); - } - } + if let Some(output) = commit_result + && output.status.success() + && let Ok(hash) = String::from_utf8(output.stdout) + { + git_info.commit_hash = Some(hash.trim().to_string()); } // Process branch name - if let Some(output) = branch_result { - if output.status.success() { - if let Ok(branch) = String::from_utf8(output.stdout) { - let branch = branch.trim(); - if branch != "HEAD" { - git_info.branch = Some(branch.to_string()); - } - } + if let Some(output) = branch_result + && output.status.success() + && let Ok(branch) = String::from_utf8(output.stdout) + { + let branch = branch.trim(); + if branch != "HEAD" { + git_info.branch = Some(branch.to_string()); } } // Process repository URL - if let Some(output) = url_result { - if output.status.success() { - if let Ok(url) = String::from_utf8(output.stdout) { - git_info.repository_url = Some(url.trim().to_string()); - } - } + if let Some(output) = url_result + && output.status.success() + && let Ok(url) = String::from_utf8(output.stdout) + { + git_info.repository_url = Some(url.trim().to_string()); } Some(git_info) diff --git a/codex-rs/core/src/is_safe_command.rs b/codex-rs/core/src/is_safe_command.rs index 38dd9562f8..f54e247fd8 100644 --- a/codex-rs/core/src/is_safe_command.rs +++ b/codex-rs/core/src/is_safe_command.rs @@ -12,20 +12,17 @@ pub fn is_known_safe_command(command: &[String]) -> bool { // introduce side effects ( "&&", "||", ";", and "|" ). If every // individual command in the script is itself a known‑safe command, then // the composite expression is considered safe. - if let [bash, flag, script] = command { - if bash == "bash" && flag == "-lc" { - if let Some(tree) = try_parse_bash(script) { - if let Some(all_commands) = try_parse_word_only_commands_sequence(&tree, script) { - if !all_commands.is_empty() - && all_commands - .iter() - .all(|cmd| is_safe_to_call_with_exec(cmd)) - { - return true; - } - } - } - } + if let [bash, flag, script] = command + && bash == "bash" + && flag == "-lc" + && let Some(tree) = try_parse_bash(script) + && let Some(all_commands) = try_parse_word_only_commands_sequence(&tree, script) + && !all_commands.is_empty() + && all_commands + .iter() + .all(|cmd| is_safe_to_call_with_exec(cmd)) + { + return true; } false diff --git a/codex-rs/core/src/model_provider_info.rs b/codex-rs/core/src/model_provider_info.rs index 00eabc539c..0102591d2c 100644 --- a/codex-rs/core/src/model_provider_info.rs +++ b/codex-rs/core/src/model_provider_info.rs @@ -167,10 +167,10 @@ impl ModelProviderInfo { if let Some(env_headers) = &self.env_http_headers { for (header, env_var) in env_headers { - if let Ok(val) = std::env::var(env_var) { - if !val.trim().is_empty() { - builder = builder.header(header, val); - } + if let Ok(val) = std::env::var(env_var) + && !val.trim().is_empty() + { + builder = builder.header(header, val); } } } diff --git a/codex-rs/core/src/openai_tools.rs b/codex-rs/core/src/openai_tools.rs index 32ead20e7d..dd5eb12516 100644 --- a/codex-rs/core/src/openai_tools.rs +++ b/codex-rs/core/src/openai_tools.rs @@ -420,11 +420,11 @@ fn sanitize_json_schema(value: &mut JsonValue) { } JsonValue::Object(map) => { // First, recursively sanitize known nested schema holders - if let Some(props) = map.get_mut("properties") { - if let Some(props_map) = props.as_object_mut() { - for (_k, v) in props_map.iter_mut() { - sanitize_json_schema(v); - } + if let Some(props) = map.get_mut("properties") + && let Some(props_map) = props.as_object_mut() + { + for (_k, v) in props_map.iter_mut() { + sanitize_json_schema(v); } } if let Some(items) = map.get_mut("items") { @@ -444,18 +444,18 @@ fn sanitize_json_schema(value: &mut JsonValue) { .map(|s| s.to_string()); // If type is an array (union), pick first supported; else leave to inference - if ty.is_none() { - if let Some(JsonValue::Array(types)) = map.get("type") { - for t in types { - if let Some(tt) = t.as_str() { - if matches!( - tt, - "object" | "array" | "string" | "number" | "integer" | "boolean" - ) { - ty = Some(tt.to_string()); - break; - } - } + if ty.is_none() + && let Some(JsonValue::Array(types)) = map.get("type") + { + for t in types { + if let Some(tt) = t.as_str() + && matches!( + tt, + "object" | "array" | "string" | "number" | "integer" | "boolean" + ) + { + ty = Some(tt.to_string()); + break; } } } diff --git a/codex-rs/core/src/parse_command.rs b/codex-rs/core/src/parse_command.rs index 6ea0626814..7348af43ac 100644 --- a/codex-rs/core/src/parse_command.rs +++ b/codex-rs/core/src/parse_command.rs @@ -1196,10 +1196,10 @@ fn simplify_once(commands: &[ParsedCommand]) -> Option> { } // echo ... && ...rest => ...rest - if let ParsedCommand::Unknown { cmd } = &commands[0] { - if shlex_split(cmd).is_some_and(|t| t.first().map(|s| s.as_str()) == Some("echo")) { - return Some(commands[1..].to_vec()); - } + if let ParsedCommand::Unknown { cmd } = &commands[0] + && shlex_split(cmd).is_some_and(|t| t.first().map(|s| s.as_str()) == Some("echo")) + { + return Some(commands[1..].to_vec()); } // cd foo && [any Test command] => [any Test command] @@ -1208,17 +1208,15 @@ fn simplify_once(commands: &[ParsedCommand]) -> Option> { shlex_split(cmd).is_some_and(|t| t.first().map(|s| s.as_str()) == Some("cd")) } _ => false, - }) { - if commands - .iter() - .skip(idx + 1) - .any(|pc| matches!(pc, ParsedCommand::Test { .. })) - { - let mut out = Vec::with_capacity(commands.len() - 1); - out.extend_from_slice(&commands[..idx]); - out.extend_from_slice(&commands[idx + 1..]); - return Some(out); - } + }) && commands + .iter() + .skip(idx + 1) + .any(|pc| matches!(pc, ParsedCommand::Test { .. })) + { + let mut out = Vec::with_capacity(commands.len() - 1); + out.extend_from_slice(&commands[..idx]); + out.extend_from_slice(&commands[idx + 1..]); + return Some(out); } // cmd || true => cmd @@ -1564,127 +1562,124 @@ fn parse_bash_lc_commands(original: &[String]) -> Option> { if bash != "bash" || flag != "-lc" { return None; } - if let Some(tree) = try_parse_bash(script) { - if let Some(all_commands) = try_parse_word_only_commands_sequence(&tree, script) { - if !all_commands.is_empty() { - let script_tokens = shlex_split(script) - .unwrap_or_else(|| vec!["bash".to_string(), flag.clone(), script.clone()]); - // Strip small formatting helpers (e.g., head/tail/awk/wc/etc) so we - // bias toward the primary command when pipelines are present. - // First, drop obvious small formatting helpers (e.g., wc/awk/etc). - let had_multiple_commands = all_commands.len() > 1; - // The bash AST walker yields commands in right-to-left order for - // connector/pipeline sequences. Reverse to reflect actual execution order. - let mut filtered_commands = drop_small_formatting_commands(all_commands); - filtered_commands.reverse(); - if filtered_commands.is_empty() { - return Some(vec![ParsedCommand::Unknown { - cmd: script.clone(), - }]); - } - let mut commands: Vec = filtered_commands - .into_iter() - .map(|tokens| summarize_main_tokens(&tokens)) - .collect(); - if commands.len() > 1 { - commands.retain(|pc| !matches!(pc, ParsedCommand::Noop { .. })); - } - if commands.len() == 1 { - // If we reduced to a single command, attribute the full original script - // for clearer UX in file-reading and listing scenarios, or when there were - // no connectors in the original script. For search commands that came from - // a pipeline (e.g. `rg --files | sed -n`), keep only the primary command. - let had_connectors = had_multiple_commands - || script_tokens - .iter() - .any(|t| t == "|" || t == "&&" || t == "||" || t == ";"); - commands = commands - .into_iter() - .map(|pc| match pc { - ParsedCommand::Read { name, cmd, .. } => { - if had_connectors { - let has_pipe = script_tokens.iter().any(|t| t == "|"); - let has_sed_n = script_tokens.windows(2).any(|w| { - w.first().map(|s| s.as_str()) == Some("sed") - && w.get(1).map(|s| s.as_str()) == Some("-n") - }); - if has_pipe && has_sed_n { - ParsedCommand::Read { - cmd: script.clone(), - name, - } - } else { - ParsedCommand::Read { - cmd: cmd.clone(), - name, - } - } - } else { - ParsedCommand::Read { - cmd: shlex_join(&script_tokens), - name, - } - } - } - ParsedCommand::ListFiles { path, cmd, .. } => { - if had_connectors { - ParsedCommand::ListFiles { - cmd: cmd.clone(), - path, - } - } else { - ParsedCommand::ListFiles { - cmd: shlex_join(&script_tokens), - path, - } - } - } - ParsedCommand::Search { - query, path, cmd, .. - } => { - if had_connectors { - ParsedCommand::Search { - cmd: cmd.clone(), - query, - path, - } - } else { - ParsedCommand::Search { - cmd: shlex_join(&script_tokens), - query, - path, - } - } - } - ParsedCommand::Format { - tool, targets, cmd, .. - } => ParsedCommand::Format { - cmd: cmd.clone(), - tool, - targets, - }, - ParsedCommand::Test { cmd, .. } => { - ParsedCommand::Test { cmd: cmd.clone() } - } - ParsedCommand::Lint { - tool, targets, cmd, .. - } => ParsedCommand::Lint { - cmd: cmd.clone(), - tool, - targets, - }, - ParsedCommand::Unknown { .. } => ParsedCommand::Unknown { - cmd: script.clone(), - }, - ParsedCommand::Noop { .. } => ParsedCommand::Noop { - cmd: script.clone(), - }, - }) - .collect(); - } - return Some(commands); - } + if let Some(tree) = try_parse_bash(script) + && let Some(all_commands) = try_parse_word_only_commands_sequence(&tree, script) + && !all_commands.is_empty() + { + let script_tokens = shlex_split(script) + .unwrap_or_else(|| vec!["bash".to_string(), flag.clone(), script.clone()]); + // Strip small formatting helpers (e.g., head/tail/awk/wc/etc) so we + // bias toward the primary command when pipelines are present. + // First, drop obvious small formatting helpers (e.g., wc/awk/etc). + let had_multiple_commands = all_commands.len() > 1; + // The bash AST walker yields commands in right-to-left order for + // connector/pipeline sequences. Reverse to reflect actual execution order. + let mut filtered_commands = drop_small_formatting_commands(all_commands); + filtered_commands.reverse(); + if filtered_commands.is_empty() { + return Some(vec![ParsedCommand::Unknown { + cmd: script.clone(), + }]); } + let mut commands: Vec = filtered_commands + .into_iter() + .map(|tokens| summarize_main_tokens(&tokens)) + .collect(); + if commands.len() > 1 { + commands.retain(|pc| !matches!(pc, ParsedCommand::Noop { .. })); + } + if commands.len() == 1 { + // If we reduced to a single command, attribute the full original script + // for clearer UX in file-reading and listing scenarios, or when there were + // no connectors in the original script. For search commands that came from + // a pipeline (e.g. `rg --files | sed -n`), keep only the primary command. + let had_connectors = had_multiple_commands + || script_tokens + .iter() + .any(|t| t == "|" || t == "&&" || t == "||" || t == ";"); + commands = commands + .into_iter() + .map(|pc| match pc { + ParsedCommand::Read { name, cmd, .. } => { + if had_connectors { + let has_pipe = script_tokens.iter().any(|t| t == "|"); + let has_sed_n = script_tokens.windows(2).any(|w| { + w.first().map(|s| s.as_str()) == Some("sed") + && w.get(1).map(|s| s.as_str()) == Some("-n") + }); + if has_pipe && has_sed_n { + ParsedCommand::Read { + cmd: script.clone(), + name, + } + } else { + ParsedCommand::Read { + cmd: cmd.clone(), + name, + } + } + } else { + ParsedCommand::Read { + cmd: shlex_join(&script_tokens), + name, + } + } + } + ParsedCommand::ListFiles { path, cmd, .. } => { + if had_connectors { + ParsedCommand::ListFiles { + cmd: cmd.clone(), + path, + } + } else { + ParsedCommand::ListFiles { + cmd: shlex_join(&script_tokens), + path, + } + } + } + ParsedCommand::Search { + query, path, cmd, .. + } => { + if had_connectors { + ParsedCommand::Search { + cmd: cmd.clone(), + query, + path, + } + } else { + ParsedCommand::Search { + cmd: shlex_join(&script_tokens), + query, + path, + } + } + } + ParsedCommand::Format { + tool, targets, cmd, .. + } => ParsedCommand::Format { + cmd: cmd.clone(), + tool, + targets, + }, + ParsedCommand::Test { cmd, .. } => ParsedCommand::Test { cmd: cmd.clone() }, + ParsedCommand::Lint { + tool, targets, cmd, .. + } => ParsedCommand::Lint { + cmd: cmd.clone(), + tool, + targets, + }, + ParsedCommand::Unknown { .. } => ParsedCommand::Unknown { + cmd: script.clone(), + }, + ParsedCommand::Noop { .. } => ParsedCommand::Noop { + cmd: script.clone(), + }, + }) + .collect(); + } + return Some(commands); } Some(vec![ParsedCommand::Unknown { cmd: script.clone(), diff --git a/codex-rs/core/src/safety.rs b/codex-rs/core/src/safety.rs index c878a71110..37cb8f4e4c 100644 --- a/codex-rs/core/src/safety.rs +++ b/codex-rs/core/src/safety.rs @@ -231,10 +231,10 @@ fn is_write_patch_constrained_to_writable_paths( if !is_path_writable(path) { return false; } - if let Some(dest) = move_path { - if !is_path_writable(dest) { - return false; - } + if let Some(dest) = move_path + && !is_path_writable(dest) + { + return false; } } } diff --git a/codex-rs/core/src/shell.rs b/codex-rs/core/src/shell.rs index 0cde3b4a78..c269b87ef2 100644 --- a/codex-rs/core/src/shell.rs +++ b/codex-rs/core/src/shell.rs @@ -70,13 +70,13 @@ pub async fn default_user_shell() -> Shell { } let stdout = String::from_utf8_lossy(&o.stdout); for line in stdout.lines() { - if let Some(shell_path) = line.strip_prefix("UserShell: ") { - if shell_path.ends_with("/zsh") { - return Shell::Zsh(ZshShell { - shell_path: shell_path.to_string(), - zshrc_path: format!("{home}/.zshrc"), - }); - } + if let Some(shell_path) = line.strip_prefix("UserShell: ") + && shell_path.ends_with("/zsh") + { + return Shell::Zsh(ZshShell { + shell_path: shell_path.to_string(), + zshrc_path: format!("{home}/.zshrc"), + }); } } diff --git a/codex-rs/core/tests/cli_stream.rs b/codex-rs/core/tests/cli_stream.rs index 219e99787d..dd53a8d302 100644 --- a/codex-rs/core/tests/cli_stream.rs +++ b/codex-rs/core/tests/cli_stream.rs @@ -297,13 +297,12 @@ async fn integration_creates_and_checks_session_file() { Ok(v) => v, Err(_) => continue, }; - if item.get("type").and_then(|t| t.as_str()) == Some("message") { - if let Some(c) = item.get("content") { - if c.to_string().contains(&marker) { - matching_path = Some(path.to_path_buf()); - break; - } - } + if item.get("type").and_then(|t| t.as_str()) == Some("message") + && let Some(c) = item.get("content") + && c.to_string().contains(&marker) + { + matching_path = Some(path.to_path_buf()); + break; } } } @@ -376,13 +375,12 @@ async fn integration_creates_and_checks_session_file() { let Ok(item) = serde_json::from_str::(line) else { continue; }; - if item.get("type").and_then(|t| t.as_str()) == Some("message") { - if let Some(c) = item.get("content") { - if c.to_string().contains(&marker) { - found_message = true; - break; - } - } + if item.get("type").and_then(|t| t.as_str()) == Some("message") + && let Some(c) = item.get("content") + && c.to_string().contains(&marker) + { + found_message = true; + break; } } assert!( diff --git a/codex-rs/exec/src/event_processor.rs b/codex-rs/exec/src/event_processor.rs index b7b3c27dc5..1ba10c34e2 100644 --- a/codex-rs/exec/src/event_processor.rs +++ b/codex-rs/exec/src/event_processor.rs @@ -29,9 +29,9 @@ pub(crate) fn handle_last_message(last_agent_message: Option<&str>, output_file: } fn write_last_message_file(contents: &str, last_message_path: Option<&Path>) { - if let Some(path) = last_message_path { - if let Err(e) = std::fs::write(path, contents) { - eprintln!("Failed to write last message file {path:?}: {e}"); - } + if let Some(path) = last_message_path + && let Err(e) = std::fs::write(path, contents) + { + eprintln!("Failed to write last message file {path:?}: {e}"); } } diff --git a/codex-rs/execpolicy/src/execv_checker.rs b/codex-rs/execpolicy/src/execv_checker.rs index 242ea6d177..fcd80b2b5f 100644 --- a/codex-rs/execpolicy/src/execv_checker.rs +++ b/codex-rs/execpolicy/src/execv_checker.rs @@ -214,7 +214,12 @@ system_path=[{fake_cp:?}] // Only readable folders specified. assert_eq!( - checker.check(valid_exec.clone(), &cwd, &[root_path.clone()], &[]), + checker.check( + valid_exec.clone(), + &cwd, + std::slice::from_ref(&root_path), + &[] + ), Err(WriteablePathNotInWriteableFolders { file: dest_path.clone(), folders: vec![] @@ -226,8 +231,8 @@ system_path=[{fake_cp:?}] checker.check( valid_exec.clone(), &cwd, - &[root_path.clone()], - &[root_path.clone()] + std::slice::from_ref(&root_path), + std::slice::from_ref(&root_path) ), Ok(cp.clone()), ); @@ -246,8 +251,8 @@ system_path=[{fake_cp:?}] checker.check( valid_exec_call_folders_as_args, &cwd, - &[root_path.clone()], - &[root_path.clone()] + std::slice::from_ref(&root_path), + std::slice::from_ref(&root_path) ), Ok(cp.clone()), ); @@ -269,8 +274,8 @@ system_path=[{fake_cp:?}] checker.check( exec_with_parent_of_readable_folder, &cwd, - &[root_path.clone()], - &[dest_path.clone()] + std::slice::from_ref(&root_path), + std::slice::from_ref(&dest_path) ), Err(ReadablePathNotInReadableFolders { file: root_path.parent().unwrap().to_path_buf(), diff --git a/codex-rs/execpolicy/src/policy.rs b/codex-rs/execpolicy/src/policy.rs index 7ce148b7c2..825d6164a5 100644 --- a/codex-rs/execpolicy/src/policy.rs +++ b/codex-rs/execpolicy/src/policy.rs @@ -56,16 +56,16 @@ impl Policy { } for arg in args { - if let Some(regex) = &self.forbidden_substrings_pattern { - if regex.is_match(arg) { - return Ok(MatchedExec::Forbidden { - cause: Forbidden::Arg { - arg: arg.clone(), - exec_call: exec_call.clone(), - }, - reason: format!("arg `{arg}` contains forbidden substring"), - }); - } + if let Some(regex) = &self.forbidden_substrings_pattern + && regex.is_match(arg) + { + return Ok(MatchedExec::Forbidden { + cause: Forbidden::Arg { + arg: arg.clone(), + exec_call: exec_call.clone(), + }, + reason: format!("arg `{arg}` contains forbidden substring"), + }); } } diff --git a/codex-rs/execpolicy/src/sed_command.rs b/codex-rs/execpolicy/src/sed_command.rs index 64494ddf00..cc96aa98e7 100644 --- a/codex-rs/execpolicy/src/sed_command.rs +++ b/codex-rs/execpolicy/src/sed_command.rs @@ -3,12 +3,12 @@ use crate::error::Result; pub fn parse_sed_command(sed_command: &str) -> Result<()> { // For now, we parse only commands like `122,202p`. - if let Some(stripped) = sed_command.strip_suffix("p") { - if let Some((first, rest)) = stripped.split_once(",") { - if first.parse::().is_ok() && rest.parse::().is_ok() { - return Ok(()); - } - } + if let Some(stripped) = sed_command.strip_suffix("p") + && let Some((first, rest)) = stripped.split_once(",") + && first.parse::().is_ok() + && rest.parse::().is_ok() + { + return Ok(()); } Err(Error::SedCommandNotProvablySafe { diff --git a/codex-rs/file-search/src/lib.rs b/codex-rs/file-search/src/lib.rs index e0eb87656b..bfcfe922ef 100644 --- a/codex-rs/file-search/src/lib.rs +++ b/codex-rs/file-search/src/lib.rs @@ -228,11 +228,11 @@ pub fn run( for &Reverse((score, ref line)) in best_list.binary_heap.iter() { if global_heap.len() < limit.get() { global_heap.push(Reverse((score, line.clone()))); - } else if let Some(min_element) = global_heap.peek() { - if score > min_element.0.0 { - global_heap.pop(); - global_heap.push(Reverse((score, line.clone()))); - } + } else if let Some(min_element) = global_heap.peek() + && score > min_element.0.0 + { + global_heap.pop(); + global_heap.push(Reverse((score, line.clone()))); } } } @@ -320,11 +320,11 @@ impl BestMatchesList { if self.binary_heap.len() < self.max_count { self.binary_heap.push(Reverse((score, line.to_string()))); - } else if let Some(min_element) = self.binary_heap.peek() { - if score > min_element.0.0 { - self.binary_heap.pop(); - self.binary_heap.push(Reverse((score, line.to_string()))); - } + } else if let Some(min_element) = self.binary_heap.peek() + && score > min_element.0.0 + { + self.binary_heap.pop(); + self.binary_heap.push(Reverse((score, line.to_string()))); } } } diff --git a/codex-rs/login/src/server.rs b/codex-rs/login/src/server.rs index 32229484ff..6ffc86219d 100644 --- a/codex-rs/login/src/server.rs +++ b/codex-rs/login/src/server.rs @@ -364,10 +364,10 @@ async fn persist_tokens_async( let codex_home = codex_home.to_path_buf(); tokio::task::spawn_blocking(move || { let auth_file = get_auth_file(&codex_home); - if let Some(parent) = auth_file.parent() { - if !parent.exists() { - std::fs::create_dir_all(parent).map_err(io::Error::other)?; - } + if let Some(parent) = auth_file.parent() + && !parent.exists() + { + std::fs::create_dir_all(parent).map_err(io::Error::other)?; } let mut auth = read_or_default(&auth_file); diff --git a/codex-rs/ollama/src/client.rs b/codex-rs/ollama/src/client.rs index 55c86e31f8..45e0486156 100644 --- a/codex-rs/ollama/src/client.rs +++ b/codex-rs/ollama/src/client.rs @@ -166,9 +166,8 @@ impl OllamaClient { yield PullEvent::Error(err_msg.to_string()); return; } - if let Some(status) = value.get("status").and_then(|s| s.as_str()) { - if status == "success" { yield PullEvent::Success; return; } - } + if let Some(status) = value.get("status").and_then(|s| s.as_str()) + && status == "success" { yield PullEvent::Success; return; } } } } diff --git a/codex-rs/protocol-ts/src/lib.rs b/codex-rs/protocol-ts/src/lib.rs index a37130b83b..c2b196dada 100644 --- a/codex-rs/protocol-ts/src/lib.rs +++ b/codex-rs/protocol-ts/src/lib.rs @@ -48,18 +48,16 @@ pub fn generate_ts(out_dir: &Path, prettier: Option<&Path>) -> Result<()> { } // Format with Prettier by passing individual files (no shell globbing) - if let Some(prettier_bin) = prettier { - if !ts_files.is_empty() { - let status = Command::new(prettier_bin) - .arg("--write") - .args(ts_files.iter().map(|p| p.as_os_str())) - .status() - .with_context(|| { - format!("Failed to invoke Prettier at {}", prettier_bin.display()) - })?; - if !status.success() { - return Err(anyhow!("Prettier failed with status {}", status)); - } + if let Some(prettier_bin) = prettier + && !ts_files.is_empty() + { + let status = Command::new(prettier_bin) + .arg("--write") + .args(ts_files.iter().map(|p| p.as_os_str())) + .status() + .with_context(|| format!("Failed to invoke Prettier at {}", prettier_bin.display()))?; + if !status.success() { + return Err(anyhow!("Prettier failed with status {}", status)); } } diff --git a/codex-rs/rust-toolchain.toml b/codex-rs/rust-toolchain.toml index 72bafdf4b6..0a9f6e8169 100644 --- a/codex-rs/rust-toolchain.toml +++ b/codex-rs/rust-toolchain.toml @@ -1,3 +1,3 @@ [toolchain] -channel = "1.88.0" +channel = "1.89.0" components = [ "clippy", "rustfmt", "rust-src"] 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 0cfc24d780..b7e6e5e69a 100644 --- a/codex-rs/tui/src/bottom_pane/approval_modal_view.rs +++ b/codex-rs/tui/src/bottom_pane/approval_modal_view.rs @@ -33,10 +33,10 @@ impl ApprovalModalView<'_> { /// Advance to next request if the current one is finished. fn maybe_advance(&mut self) { - if self.current.is_complete() { - if let Some(req) = self.queue.pop() { - self.current = UserApprovalWidget::new(req, self.app_event_tx.clone()); - } + if self.current.is_complete() + && let Some(req) = self.queue.pop() + { + self.current = UserApprovalWidget::new(req, self.app_event_tx.clone()); } } } diff --git a/codex-rs/tui/src/bottom_pane/textarea.rs b/codex-rs/tui/src/bottom_pane/textarea.rs index d2a3916e55..8379deb9f8 100644 --- a/codex-rs/tui/src/bottom_pane/textarea.rs +++ b/codex-rs/tui/src/bottom_pane/textarea.rs @@ -1575,53 +1575,53 @@ mod tests { } 14 => { // Try inserting inside an existing element (should clamp to boundary) - if let Some(payload) = elem_texts.choose(&mut rng).cloned() { - if let Some(start) = ta.text().find(&payload) { - let end = start + payload.len(); - if end - start > 2 { - let pos = rng.random_range(start + 1..end - 1); - let ins = rand_grapheme(&mut rng); - ta.insert_str_at(pos, &ins); - } + if let Some(payload) = elem_texts.choose(&mut rng).cloned() + && let Some(start) = ta.text().find(&payload) + { + let end = start + payload.len(); + if end - start > 2 { + let pos = rng.random_range(start + 1..end - 1); + let ins = rand_grapheme(&mut rng); + ta.insert_str_at(pos, &ins); } } } 15 => { // Replace a range that intersects an element -> whole element should be replaced - if let Some(payload) = elem_texts.choose(&mut rng).cloned() { - if let Some(start) = ta.text().find(&payload) { - let end = start + payload.len(); - // Create an intersecting range [start-δ, end-δ2) - let mut s = start.saturating_sub(rng.random_range(0..=2)); - let mut e = (end + rng.random_range(0..=2)).min(ta.text().len()); - // Align to char boundaries to satisfy String::replace_range contract - let txt = ta.text(); - while s > 0 && !txt.is_char_boundary(s) { - s -= 1; - } - while e < txt.len() && !txt.is_char_boundary(e) { - e += 1; - } - if s < e { - // Small replacement text - let mut srep = String::new(); - for _ in 0..rng.random_range(0..=2) { - srep.push_str(&rand_grapheme(&mut rng)); - } - ta.replace_range(s..e, &srep); + if let Some(payload) = elem_texts.choose(&mut rng).cloned() + && let Some(start) = ta.text().find(&payload) + { + let end = start + payload.len(); + // Create an intersecting range [start-δ, end-δ2) + let mut s = start.saturating_sub(rng.random_range(0..=2)); + let mut e = (end + rng.random_range(0..=2)).min(ta.text().len()); + // Align to char boundaries to satisfy String::replace_range contract + let txt = ta.text(); + while s > 0 && !txt.is_char_boundary(s) { + s -= 1; + } + while e < txt.len() && !txt.is_char_boundary(e) { + e += 1; + } + if s < e { + // Small replacement text + let mut srep = String::new(); + for _ in 0..rng.random_range(0..=2) { + srep.push_str(&rand_grapheme(&mut rng)); } + ta.replace_range(s..e, &srep); } } } 16 => { // Try setting the cursor to a position inside an element; it should clamp out - if let Some(payload) = elem_texts.choose(&mut rng).cloned() { - if let Some(start) = ta.text().find(&payload) { - let end = start + payload.len(); - if end - start > 2 { - let pos = rng.random_range(start + 1..end - 1); - ta.set_cursor(pos); - } + if let Some(payload) = elem_texts.choose(&mut rng).cloned() + && let Some(start) = ta.text().find(&payload) + { + let end = start + payload.len(); + if end - start > 2 { + let pos = rng.random_range(start + 1..end - 1); + ta.set_cursor(pos); } } } diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 192ab3c6ae..c7e9ad5b03 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -339,18 +339,18 @@ async fn binary_size_transcript_matches_ideal_fixture() { } } "app_event" => { - if let Some(variant) = v.get("variant").and_then(|s| s.as_str()) { - if variant == "CommitTick" { - 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, - ); - } + if let Some(variant) = v.get("variant").and_then(|s| s.as_str()) + && variant == "CommitTick" + { + 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, + ); } } } diff --git a/codex-rs/tui/src/custom_terminal.rs b/codex-rs/tui/src/custom_terminal.rs index 1ada679fc1..91153f92ed 100644 --- a/codex-rs/tui/src/custom_terminal.rs +++ b/codex-rs/tui/src/custom_terminal.rs @@ -264,10 +264,10 @@ where #[allow(clippy::print_stderr)] fn drop(&mut self) { // Attempt to restore the cursor state - if self.hidden_cursor { - if let Err(err) = self.show_cursor() { - eprintln!("Failed to show the cursor: {err}"); - } + if self.hidden_cursor + && let Err(err) = self.show_cursor() + { + eprintln!("Failed to show the cursor: {err}"); } } } @@ -309,7 +309,7 @@ where } /// Get a Frame object which provides a consistent view into the terminal state for rendering. - pub fn get_frame(&mut self) -> Frame { + pub fn get_frame(&mut self) -> Frame<'_> { let count = self.frame_count; Frame { cursor_position: None, diff --git a/codex-rs/tui/src/exec_command.rs b/codex-rs/tui/src/exec_command.rs index 35c59b22a5..db3bf2fef4 100644 --- a/codex-rs/tui/src/exec_command.rs +++ b/codex-rs/tui/src/exec_command.rs @@ -33,10 +33,10 @@ where return None; } - if let Some(home_dir) = std::env::var_os("HOME").map(PathBuf::from) { - if let Ok(rel) = path.strip_prefix(&home_dir) { - return Some(rel.to_path_buf()); - } + if let Some(home_dir) = std::env::var_os("HOME").map(PathBuf::from) + && let Ok(rel) = path.strip_prefix(&home_dir) + { + return Some(rel.to_path_buf()); } None diff --git a/codex-rs/tui/src/file_search.rs b/codex-rs/tui/src/file_search.rs index 8a05b2c165..7f15e46abb 100644 --- a/codex-rs/tui/src/file_search.rs +++ b/codex-rs/tui/src/file_search.rs @@ -94,13 +94,13 @@ impl FileSearchManager { // If there is an in-flight search that is definitely obsolete, // cancel it now. - if let Some(active_search) = &st.active_search { - if !query.starts_with(&active_search.query) { - active_search - .cancellation_token - .store(true, Ordering::Relaxed); - st.active_search = None; - } + if let Some(active_search) = &st.active_search + && !query.starts_with(&active_search.query) + { + active_search + .cancellation_token + .store(true, Ordering::Relaxed); + st.active_search = None; } // Schedule a search to run after debounce. @@ -187,10 +187,10 @@ impl FileSearchManager { { #[expect(clippy::unwrap_used)] let mut st = search_state.lock().unwrap(); - if let Some(active_search) = &st.active_search { - if Arc::ptr_eq(&active_search.cancellation_token, &cancellation_token) { - st.active_search = None; - } + if let Some(active_search) = &st.active_search + && Arc::ptr_eq(&active_search.cancellation_token, &cancellation_token) + { + st.active_search = None; } } }); diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 58677c724a..021c09e337 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -541,33 +541,33 @@ pub(crate) fn new_status_output( // 👤 Account (only if ChatGPT tokens exist), shown under the first block let auth_file = get_auth_file(&config.codex_home); - if let Ok(auth) = try_read_auth_json(&auth_file) { - if let Some(tokens) = auth.tokens.clone() { - lines.push(Line::from(vec!["👤 ".into(), "Account".bold()])); - lines.push(Line::from(" • Signed in with ChatGPT")); + if let Ok(auth) = try_read_auth_json(&auth_file) + && let Some(tokens) = auth.tokens.clone() + { + lines.push(Line::from(vec!["👤 ".into(), "Account".bold()])); + lines.push(Line::from(" • Signed in with ChatGPT")); - let info = tokens.id_token; - if let Some(email) = &info.email { - lines.push(Line::from(vec![" • Login: ".into(), email.clone().into()])); - } - - match auth.openai_api_key.as_deref() { - Some(key) if !key.is_empty() => { - lines.push(Line::from( - " • Using API key. Run codex login to use ChatGPT plan", - )); - } - _ => { - let plan_text = info - .get_chatgpt_plan_type() - .map(|s| title_case(&s)) - .unwrap_or_else(|| "Unknown".to_string()); - lines.push(Line::from(vec![" • Plan: ".into(), plan_text.into()])); - } - } - - lines.push(Line::from("")); + let info = tokens.id_token; + if let Some(email) = &info.email { + lines.push(Line::from(vec![" • Login: ".into(), email.clone().into()])); } + + match auth.openai_api_key.as_deref() { + Some(key) if !key.is_empty() => { + lines.push(Line::from( + " • Using API key. Run codex login to use ChatGPT plan", + )); + } + _ => { + let plan_text = info + .get_chatgpt_plan_type() + .map(|s| title_case(&s)) + .unwrap_or_else(|| "Unknown".to_string()); + lines.push(Line::from(vec![" • Plan: ".into(), plan_text.into()])); + } + } + + lines.push(Line::from("")); } // 🧠 Model @@ -612,10 +612,10 @@ pub(crate) fn new_status_output( " • Input: ".into(), usage.non_cached_input().to_string().into(), ]; - if let Some(cached) = usage.cached_input_tokens { - if cached > 0 { - input_line_spans.push(format!(" (+ {cached} cached)").into()); - } + if let Some(cached) = usage.cached_input_tokens + && cached > 0 + { + input_line_spans.push(format!(" (+ {cached} cached)").into()); } lines.push(Line::from(input_line_spans)); // Output: @@ -688,16 +688,15 @@ pub(crate) fn new_mcp_tools_output( ])); } - if let Some(env) = cfg.env.as_ref() { - if !env.is_empty() { - let mut env_pairs: Vec = - env.iter().map(|(k, v)| format!("{k}={v}")).collect(); - env_pairs.sort(); - lines.push(Line::from(vec![ - " • Env: ".into(), - env_pairs.join(" ").into(), - ])); - } + if let Some(env) = cfg.env.as_ref() + && !env.is_empty() + { + let mut env_pairs: Vec = env.iter().map(|(k, v)| format!("{k}={v}")).collect(); + env_pairs.sort(); + lines.push(Line::from(vec![ + " • Env: ".into(), + env_pairs.join(" ").into(), + ])); } if names.is_empty() { diff --git a/codex-rs/tui/src/onboarding/auth.rs b/codex-rs/tui/src/onboarding/auth.rs index 2396b4d5fa..2ef70ca275 100644 --- a/codex-rs/tui/src/onboarding/auth.rs +++ b/codex-rs/tui/src/onboarding/auth.rs @@ -123,20 +123,20 @@ impl AuthModeWidget { // If the user is already authenticated but the method differs from their // preferred auth method, show a brief explanation. - if let LoginStatus::AuthMode(current) = self.login_status { - if current != self.preferred_auth_method { - let to_label = |mode: AuthMode| match mode { - AuthMode::ApiKey => "API key", - AuthMode::ChatGPT => "ChatGPT", - }; - let msg = format!( - " You’re currently using {} while your preferred method is {}.", - to_label(current), - to_label(self.preferred_auth_method) - ); - lines.push(Line::from(msg).style(Style::default())); - lines.push(Line::from("")); - } + if let LoginStatus::AuthMode(current) = self.login_status + && current != self.preferred_auth_method + { + let to_label = |mode: AuthMode| match mode { + AuthMode::ApiKey => "API key", + AuthMode::ChatGPT => "ChatGPT", + }; + let msg = format!( + " You’re currently using {} while your preferred method is {}.", + to_label(current), + to_label(self.preferred_auth_method) + ); + lines.push(Line::from(msg).style(Style::default())); + lines.push(Line::from("")); } let create_mode_item = |idx: usize, @@ -222,15 +222,15 @@ impl AuthModeWidget { 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 { - if !state.auth_url.is_empty() { - lines.push(Line::from(" If the link doesn't open automatically, open the following link to authenticate:")); - lines.push(Line::from(vec![ - Span::raw(" "), - state.auth_url.as_str().cyan().underlined(), - ])); - lines.push(Line::from("")); - } + if let SignInState::ChatGptContinueInBrowser(state) = &self.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:")); + lines.push(Line::from(vec![ + Span::raw(" "), + state.auth_url.as_str().cyan().underlined(), + ])); + lines.push(Line::from("")); } lines.push( diff --git a/codex-rs/tui/src/streaming/controller.rs b/codex-rs/tui/src/streaming/controller.rs index 161a111173..5eae2a91f6 100644 --- a/codex-rs/tui/src/streaming/controller.rs +++ b/codex-rs/tui/src/streaming/controller.rs @@ -96,26 +96,26 @@ 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 { - if 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 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) { diff --git a/codex-rs/tui/src/text_formatting.rs b/codex-rs/tui/src/text_formatting.rs index b77c6e5baa..3c4cfc31bb 100644 --- a/codex-rs/tui/src/text_formatting.rs +++ b/codex-rs/tui/src/text_formatting.rs @@ -53,12 +53,12 @@ pub(crate) fn format_json_compact(text: &str) -> Option { } ' ' | '\t' if !in_string => { // Add a space after : and , but only when not in a string - if let Some(&next_ch) = chars.peek() { - if let Some(last_ch) = result.chars().last() { - if (last_ch == ':' || last_ch == ',') && !matches!(next_ch, '}' | ']') { - result.push(' '); - } - } + if let Some(&next_ch) = chars.peek() + && let Some(last_ch) = result.chars().last() + && (last_ch == ':' || last_ch == ',') + && !matches!(next_ch, '}' | ']') + { + result.push(' '); } } _ => { diff --git a/codex-rs/tui/tests/vt100_history.rs b/codex-rs/tui/tests/vt100_history.rs index 402e847b47..1f01a6d3ba 100644 --- a/codex-rs/tui/tests/vt100_history.rs +++ b/codex-rs/tui/tests/vt100_history.rs @@ -112,12 +112,11 @@ fn long_token_wraps() { let mut count_a = 0usize; for row in 0..6 { for col in 0..20 { - if let Some(cell) = screen.cell(row, col) { - if let Some(ch) = cell.contents().chars().next() { - if ch == 'A' { - count_a += 1; - } - } + if let Some(cell) = screen.cell(row, col) + && let Some(ch) = cell.contents().chars().next() + && ch == 'A' + { + count_a += 1; } } } From e58125e6c10ac3918bb0d6520895bc1c1967b907 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Tue, 19 Aug 2025 13:22:46 -0700 Subject: [PATCH 12/27] chore: Rust 1.89 promoted file locking to the standard library, so prefer stdlib to fs2 (#2467) --- [//]: # (BEGIN SAPLING FOOTER) Stack created with [Sapling](https://sapling-scm.com). Best reviewed with [ReviewStack](https://reviewstack.dev/openai/codex/pull/2467). * __->__ #2467 * #2465 --- codex-rs/Cargo.lock | 11 ----------- codex-rs/core/Cargo.toml | 1 - codex-rs/core/src/message_history.rs | 26 +++++++++++++++----------- 3 files changed, 15 insertions(+), 23 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 80809434e3..dc9385d990 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -703,7 +703,6 @@ dependencies = [ "dirs", "env-flags", "eventsource-stream", - "fs2", "futures", "landlock", "libc", @@ -1745,16 +1744,6 @@ dependencies = [ "percent-encoding", ] -[[package]] -name = "fs2" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" -dependencies = [ - "libc", - "winapi", -] - [[package]] name = "futures" version = "0.3.31" diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index 78942ff69f..52c731ae66 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -23,7 +23,6 @@ codex-protocol = { path = "../protocol" } dirs = "6" env-flags = "0.1.1" eventsource-stream = "0.2.3" -fs2 = "0.4.3" futures = "0.3" libc = "0.2.175" mcp-types = { path = "../mcp-types" } diff --git a/codex-rs/core/src/message_history.rs b/codex-rs/core/src/message_history.rs index 29970c2a74..9f13ababb7 100644 --- a/codex-rs/core/src/message_history.rs +++ b/codex-rs/core/src/message_history.rs @@ -125,16 +125,18 @@ pub(crate) async fn append_entry(text: &str, session_id: &Uuid, config: &Config) /// times if the lock is currently held by another process. This prevents a /// potential indefinite wait while still giving other writers some time to /// finish their operation. -async fn acquire_exclusive_lock_with_retry(file: &std::fs::File) -> Result<()> { +async fn acquire_exclusive_lock_with_retry(file: &File) -> Result<()> { use tokio::time::sleep; for _ in 0..MAX_RETRIES { - match fs2::FileExt::try_lock_exclusive(file) { + match file.try_lock() { Ok(()) => return Ok(()), - Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => { - sleep(RETRY_SLEEP).await; - } - Err(e) => return Err(e), + Err(e) => match e { + std::fs::TryLockError::WouldBlock => { + sleep(RETRY_SLEEP).await; + } + other => return Err(other.into()), + }, } } @@ -259,12 +261,14 @@ pub(crate) fn lookup(log_id: u64, offset: usize, config: &Config) -> Option Result<()> { for _ in 0..MAX_RETRIES { - match fs2::FileExt::try_lock_shared(file) { + match file.try_lock_shared() { Ok(()) => return Ok(()), - Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => { - std::thread::sleep(RETRY_SLEEP); - } - Err(e) => return Err(e), + Err(e) => match e { + std::fs::TryLockError::WouldBlock => { + std::thread::sleep(RETRY_SLEEP); + } + other => return Err(other.into()), + }, } } From 0d6678936f83e9fbaa239a00938647ebf1501e41 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Tue, 19 Aug 2025 16:49:08 -0700 Subject: [PATCH 13/27] fix apply patch when only one file is rendered (#2468) image --- codex-rs/tui/src/chatwidget/tests.rs | 2 +- codex-rs/tui/src/diff_render.rs | 25 +++++++++++-------- ..._tui__diff_render__tests__add_details.snap | 2 +- ...er__tests__update_details_with_rename.snap | 2 +- 4 files changed, 17 insertions(+), 14 deletions(-) diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index c7e9ad5b03..3bb5d42f9f 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -695,7 +695,7 @@ fn apply_patch_request_shows_diff_summary() { // Per-file summary line should include the file path and counts assert!( - blob.contains("README.md (+2 -0)"), + blob.contains("README.md"), "missing per-file diff summary: {blob:?}" ); } diff --git a/codex-rs/tui/src/diff_render.rs b/codex-rs/tui/src/diff_render.rs index 24efefef46..aaa7617d91 100644 --- a/codex-rs/tui/src/diff_render.rs +++ b/codex-rs/tui/src/diff_render.rs @@ -130,17 +130,20 @@ pub(crate) fn create_diff_summary( for (idx, f) in files.iter().enumerate() { let mut spans: Vec> = Vec::new(); spans.push(RtSpan::raw(f.display_path.clone())); - spans.push(RtSpan::raw(" (")); - spans.push(RtSpan::styled( - format!("+{}", f.added), - Style::default().fg(Color::Green), - )); - spans.push(RtSpan::raw(" ")); - spans.push(RtSpan::styled( - format!("-{}", f.removed), - Style::default().fg(Color::Red), - )); - spans.push(RtSpan::raw(")")); + // Show per-file +/- counts only when there are multiple files + if file_count > 1 { + spans.push(RtSpan::raw(" (")); + spans.push(RtSpan::styled( + format!("+{}", f.added), + Style::default().fg(Color::Green), + )); + spans.push(RtSpan::raw(" ")); + spans.push(RtSpan::styled( + format!("-{}", f.removed), + Style::default().fg(Color::Red), + )); + spans.push(RtSpan::raw(")")); + } let mut line = RtLine::from(spans); let prefix = if idx == 0 { " └ " } else { " " }; diff --git a/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__add_details.snap b/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__add_details.snap index f86d2fb93a..143113fb68 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__add_details.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__add_details.snap @@ -3,7 +3,7 @@ source: tui/src/diff_render.rs expression: terminal.backend() --- "proposed patch to 1 file (+2 -0) " -" └ README.md (+2 -0) " +" └ README.md " " 1 +first line " " 2 +second line " " " 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 76151409ef..9a73c0c32b 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 @@ -4,7 +4,7 @@ assertion_line: 380 expression: terminal.backend() --- "proposed patch to 1 file (+1 -1) " -" └ src/lib.rs → src/lib_new.rs (+1 -1) " +" └ src/lib.rs → src/lib_new.rs " " 1 line one " " 2 -line two " " 2 +line two changed " From bc298e47cabc80c00a08cef8828cff9c1a9b519a Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Tue, 19 Aug 2025 16:56:49 -0700 Subject: [PATCH 14/27] Fix: Sign in appear even if using other providers. (#2475) We shouldn't show the login screen when using other providers. --- codex-rs/tui/src/app.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index ffb3d7cacb..dd863c0673 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -652,6 +652,12 @@ fn should_show_onboarding( } 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, From 17c98a7fd3abac41481c9586ce9f7875ebe441c0 Mon Sep 17 00:00:00 2001 From: Jamie Magee Date: Tue, 19 Aug 2025 18:07:21 -0700 Subject: [PATCH 15/27] Enable Dependabot updates for Rust toolchain (#2460) This change allows Dependabot to update the Rust toolchain version defined in `rust-toolchain.toml`. See [Dependabot now supports Rust toolchain updates - GitHub Changelog](https://github.blog/changelog/2025-08-19-dependabot-now-supports-rust-toolchain-updates/) for more details. --- .github/dependabot.yaml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/dependabot.yaml b/.github/dependabot.yaml index b895d49e20..bb67fe6896 100644 --- a/.github/dependabot.yaml +++ b/.github/dependabot.yaml @@ -24,3 +24,7 @@ updates: directory: / schedule: interval: weekly + - package-ecosystem: rust-toolchain + directory: codex-rs + schedule: + interval: weekly From 77148a5c612f161e7cc03f3df591ed0ca56a1b04 Mon Sep 17 00:00:00 2001 From: Gabriel Peal Date: Tue, 19 Aug 2025 19:50:28 -0700 Subject: [PATCH 16/27] Diff command (#2476) --- codex-rs/core/src/git_info.rs | 499 +++++++++++++++++- .../mcp-server/src/codex_message_processor.rs | 26 + codex-rs/protocol-ts/src/lib.rs | 1 + codex-rs/protocol/src/mcp_protocol.rs | 28 + 4 files changed, 553 insertions(+), 1 deletion(-) diff --git a/codex-rs/core/src/git_info.rs b/codex-rs/core/src/git_info.rs index ccb43ae55a..5f25d8fe7b 100644 --- a/codex-rs/core/src/git_info.rs +++ b/codex-rs/core/src/git_info.rs @@ -1,11 +1,16 @@ +use std::collections::HashSet; use std::path::Path; +use codex_protocol::mcp_protocol::GitSha; +use futures::future::join_all; use serde::Deserialize; use serde::Serialize; use tokio::process::Command; use tokio::time::Duration as TokioDuration; use tokio::time::timeout; +use crate::util::is_inside_git_repo; + /// Timeout for git commands to prevent freezing on large repositories const GIT_COMMAND_TIMEOUT: TokioDuration = TokioDuration::from_secs(5); @@ -22,6 +27,12 @@ pub struct GitInfo { pub repository_url: Option, } +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct GitDiffToRemote { + pub sha: GitSha, + pub diff: String, +} + /// Collect git repository information from the given working directory using command-line git. /// Returns None if no git repository is found or if git operations fail. /// Uses timeouts to prevent freezing on large repositories. @@ -80,6 +91,23 @@ pub async fn collect_git_info(cwd: &Path) -> Option { Some(git_info) } +/// Returns the closest git sha to HEAD that is on a remote as well as the diff to that sha. +pub async fn git_diff_to_remote(cwd: &Path) -> Option { + if !is_inside_git_repo(cwd) { + return None; + } + + let remotes = get_git_remotes(cwd).await?; + let branches = branch_ancestry(cwd).await?; + let base_sha = find_closest_sha(cwd, &branches, &remotes).await?; + let diff = diff_against_sha(cwd, &base_sha).await?; + + Some(GitDiffToRemote { + sha: base_sha, + diff, + }) +} + /// Run a git command with a timeout to prevent blocking on large repositories async fn run_git_command_with_timeout(args: &[&str], cwd: &Path) -> Option { let result = timeout( @@ -94,6 +122,309 @@ async fn run_git_command_with_timeout(args: &[&str], cwd: &Path) -> Option Option> { + let output = run_git_command_with_timeout(&["remote"], cwd).await?; + if !output.status.success() { + return None; + } + let mut remotes: Vec = String::from_utf8(output.stdout) + .ok()? + .lines() + .map(|s| s.to_string()) + .collect(); + if let Some(pos) = remotes.iter().position(|r| r == "origin") { + let origin = remotes.remove(pos); + remotes.insert(0, origin); + } + Some(remotes) +} + +/// Attempt to determine the repository's default branch name. +/// +/// Preference order: +/// 1) The symbolic ref at `refs/remotes//HEAD` for the first remote (origin prioritized) +/// 2) `git remote show ` parsed for "HEAD branch: " +/// 3) Local fallback to existing `main` or `master` if present +async fn get_default_branch(cwd: &Path) -> Option { + // Prefer the first remote (with origin prioritized) + let remotes = get_git_remotes(cwd).await.unwrap_or_default(); + for remote in remotes { + // Try symbolic-ref, which returns something like: refs/remotes/origin/main + if let Some(symref_output) = run_git_command_with_timeout( + &[ + "symbolic-ref", + "--quiet", + &format!("refs/remotes/{remote}/HEAD"), + ], + cwd, + ) + .await + && symref_output.status.success() + && let Ok(sym) = String::from_utf8(symref_output.stdout) + { + let trimmed = sym.trim(); + if let Some((_, name)) = trimmed.rsplit_once('/') { + return Some(name.to_string()); + } + } + + // Fall back to parsing `git remote show ` output + if let Some(show_output) = + run_git_command_with_timeout(&["remote", "show", &remote], cwd).await + && show_output.status.success() + && let Ok(text) = String::from_utf8(show_output.stdout) + { + for line in text.lines() { + let line = line.trim(); + if let Some(rest) = line.strip_prefix("HEAD branch:") { + let name = rest.trim(); + if !name.is_empty() { + return Some(name.to_string()); + } + } + } + } + } + + // No remote-derived default; try common local defaults if they exist + for candidate in ["main", "master"] { + if let Some(verify) = run_git_command_with_timeout( + &[ + "rev-parse", + "--verify", + "--quiet", + &format!("refs/heads/{candidate}"), + ], + cwd, + ) + .await + && verify.status.success() + { + return Some(candidate.to_string()); + } + } + + None +} + +/// Build an ancestry of branches starting at the current branch and ending at the +/// repository's default branch (if determinable).. +async fn branch_ancestry(cwd: &Path) -> Option> { + // Discover current branch (ignore detached HEAD by treating it as None) + let current_branch = run_git_command_with_timeout(&["rev-parse", "--abbrev-ref", "HEAD"], cwd) + .await + .and_then(|o| { + if o.status.success() { + String::from_utf8(o.stdout).ok() + } else { + None + } + }) + .map(|s| s.trim().to_string()) + .filter(|s| s != "HEAD"); + + // Discover default branch + let default_branch = get_default_branch(cwd).await; + + let mut ancestry: Vec = Vec::new(); + let mut seen: HashSet = HashSet::new(); + if let Some(cb) = current_branch.clone() { + seen.insert(cb.clone()); + ancestry.push(cb); + } + if let Some(db) = default_branch + && !seen.contains(&db) + { + seen.insert(db.clone()); + ancestry.push(db); + } + + // Expand candidates: include any remote branches that already contain HEAD. + // This addresses cases where we're on a new local-only branch forked from a + // remote branch that isn't the repository default. We prioritize remotes in + // the order returned by get_git_remotes (origin first). + let remotes = get_git_remotes(cwd).await.unwrap_or_default(); + for remote in remotes { + if let Some(output) = run_git_command_with_timeout( + &[ + "for-each-ref", + "--format=%(refname:short)", + "--contains=HEAD", + &format!("refs/remotes/{remote}"), + ], + cwd, + ) + .await + && output.status.success() + && let Ok(text) = String::from_utf8(output.stdout) + { + for line in text.lines() { + let short = line.trim(); + // Expect format like: "origin/feature"; extract the branch path after "remote/" + if let Some(stripped) = short.strip_prefix(&format!("{remote}/")) + && !stripped.is_empty() + && !seen.contains(stripped) + { + seen.insert(stripped.to_string()); + ancestry.push(stripped.to_string()); + } + } + } + } + + // Ensure we return Some vector, even if empty, to allow caller logic to proceed + Some(ancestry) +} + +// Helper for a single branch: return the remote SHA if present on any remote +// and the distance (commits ahead of HEAD) for that branch. The first item is +// None if the branch is not present on any remote. Returns None if distance +// could not be computed due to git errors/timeouts. +async fn branch_remote_and_distance( + cwd: &Path, + branch: &str, + remotes: &[String], +) -> Option<(Option, usize)> { + // Try to find the first remote ref that exists for this branch (origin prioritized by caller). + let mut found_remote_sha: Option = None; + let mut found_remote_ref: Option = None; + for remote in remotes { + let remote_ref = format!("refs/remotes/{remote}/{branch}"); + let Some(verify_output) = + run_git_command_with_timeout(&["rev-parse", "--verify", "--quiet", &remote_ref], cwd) + .await + else { + // Mirror previous behavior: if the verify call times out/fails at the process level, + // treat the entire branch as unusable. + return None; + }; + if !verify_output.status.success() { + continue; + } + let Ok(sha) = String::from_utf8(verify_output.stdout) else { + // Mirror previous behavior and skip the entire branch on parse failure. + return None; + }; + found_remote_sha = Some(GitSha::new(sha.trim())); + found_remote_ref = Some(remote_ref); + break; + } + + // Compute distance as the number of commits HEAD is ahead of the branch. + // Prefer local branch name if it exists; otherwise fall back to the remote ref (if any). + let count_output = if let Some(local_count) = + run_git_command_with_timeout(&["rev-list", "--count", &format!("{branch}..HEAD")], cwd) + .await + { + if local_count.status.success() { + local_count + } else if let Some(remote_ref) = &found_remote_ref { + match run_git_command_with_timeout( + &["rev-list", "--count", &format!("{remote_ref}..HEAD")], + cwd, + ) + .await + { + Some(remote_count) => remote_count, + None => return None, + } + } else { + return None; + } + } else if let Some(remote_ref) = &found_remote_ref { + match run_git_command_with_timeout( + &["rev-list", "--count", &format!("{remote_ref}..HEAD")], + cwd, + ) + .await + { + Some(remote_count) => remote_count, + None => return None, + } + } else { + return None; + }; + + if !count_output.status.success() { + return None; + } + let Ok(distance_str) = String::from_utf8(count_output.stdout) else { + return None; + }; + let Ok(distance) = distance_str.trim().parse::() else { + return None; + }; + + Some((found_remote_sha, distance)) +} + +// Finds the closest sha that exist on any of branches and also exists on any of the remotes. +async fn find_closest_sha(cwd: &Path, branches: &[String], remotes: &[String]) -> Option { + // A sha and how many commits away from HEAD it is. + let mut closest_sha: Option<(GitSha, usize)> = None; + for branch in branches { + let Some((maybe_remote_sha, distance)) = + branch_remote_and_distance(cwd, branch, remotes).await + else { + continue; + }; + let Some(remote_sha) = maybe_remote_sha else { + // Preserve existing behavior: skip branches that are not present on a remote. + continue; + }; + match &closest_sha { + None => closest_sha = Some((remote_sha, distance)), + Some((_, best_distance)) if distance < *best_distance => { + closest_sha = Some((remote_sha, distance)); + } + _ => {} + } + } + closest_sha.map(|(sha, _)| sha) +} + +async fn diff_against_sha(cwd: &Path, sha: &GitSha) -> Option { + let output = run_git_command_with_timeout(&["diff", &sha.0], cwd).await?; + // 0 is success and no diff. + // 1 is success but there is a diff. + let exit_ok = output.status.code().is_some_and(|c| c == 0 || c == 1); + if !exit_ok { + return None; + } + let mut diff = String::from_utf8(output.stdout).ok()?; + + if let Some(untracked_output) = + run_git_command_with_timeout(&["ls-files", "--others", "--exclude-standard"], cwd).await + && untracked_output.status.success() + { + let untracked: Vec = String::from_utf8(untracked_output.stdout) + .ok()? + .lines() + .map(|s| s.to_string()) + .filter(|s| !s.is_empty()) + .collect(); + + if !untracked.is_empty() { + let futures_iter = untracked.into_iter().map(|file| async move { + let file_owned = file; + let args_vec: Vec<&str> = + vec!["diff", "--binary", "--no-index", "/dev/null", &file_owned]; + run_git_command_with_timeout(&args_vec, cwd).await + }); + let results = join_all(futures_iter).await; + for extra in results.into_iter().flatten() { + if extra.status.code().is_some_and(|c| c == 0 || c == 1) + && let Ok(s) = String::from_utf8(extra.stdout) + { + diff.push_str(&s); + } + } + } + } + + Some(diff) +} + #[cfg(test)] mod tests { use super::*; @@ -104,7 +435,8 @@ mod tests { // Helper function to create a test git repository async fn create_test_git_repo(temp_dir: &TempDir) -> PathBuf { - let repo_path = temp_dir.path().to_path_buf(); + let repo_path = temp_dir.path().join("repo"); + fs::create_dir(&repo_path).expect("Failed to create repo dir"); let envs = vec![ ("GIT_CONFIG_GLOBAL", "/dev/null"), ("GIT_CONFIG_NOSYSTEM", "1"), @@ -159,6 +491,41 @@ mod tests { repo_path } + async fn create_test_git_repo_with_remote(temp_dir: &TempDir) -> (PathBuf, String) { + let repo_path = create_test_git_repo(temp_dir).await; + let remote_path = temp_dir.path().join("remote.git"); + + Command::new("git") + .args(["init", "--bare", remote_path.to_str().unwrap()]) + .output() + .await + .expect("Failed to init bare remote"); + + Command::new("git") + .args(["remote", "add", "origin", remote_path.to_str().unwrap()]) + .current_dir(&repo_path) + .output() + .await + .expect("Failed to add remote"); + + let output = Command::new("git") + .args(["rev-parse", "--abbrev-ref", "HEAD"]) + .current_dir(&repo_path) + .output() + .await + .expect("Failed to get branch"); + let branch = String::from_utf8(output.stdout).unwrap().trim().to_string(); + + Command::new("git") + .args(["push", "-u", "origin", &branch]) + .current_dir(&repo_path) + .output() + .await + .expect("Failed to push initial commit"); + + (repo_path, branch) + } + #[tokio::test] async fn test_collect_git_info_non_git_directory() { let temp_dir = TempDir::new().expect("Failed to create temp dir"); @@ -272,6 +639,136 @@ mod tests { assert_eq!(git_info.branch, Some("feature-branch".to_string())); } + #[tokio::test] + async fn test_get_git_working_tree_state_clean_repo() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let (repo_path, branch) = create_test_git_repo_with_remote(&temp_dir).await; + + let remote_sha = Command::new("git") + .args(["rev-parse", &format!("origin/{branch}")]) + .current_dir(&repo_path) + .output() + .await + .expect("Failed to rev-parse remote"); + let remote_sha = String::from_utf8(remote_sha.stdout) + .unwrap() + .trim() + .to_string(); + + let state = git_diff_to_remote(&repo_path) + .await + .expect("Should collect working tree state"); + assert_eq!(state.sha, GitSha::new(&remote_sha)); + assert!(state.diff.is_empty()); + } + + #[tokio::test] + async fn test_get_git_working_tree_state_with_changes() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let (repo_path, branch) = create_test_git_repo_with_remote(&temp_dir).await; + + let tracked = repo_path.join("test.txt"); + fs::write(&tracked, "modified").unwrap(); + fs::write(repo_path.join("untracked.txt"), "new").unwrap(); + + let remote_sha = Command::new("git") + .args(["rev-parse", &format!("origin/{branch}")]) + .current_dir(&repo_path) + .output() + .await + .expect("Failed to rev-parse remote"); + let remote_sha = String::from_utf8(remote_sha.stdout) + .unwrap() + .trim() + .to_string(); + + let state = git_diff_to_remote(&repo_path) + .await + .expect("Should collect working tree state"); + assert_eq!(state.sha, GitSha::new(&remote_sha)); + assert!(state.diff.contains("test.txt")); + assert!(state.diff.contains("untracked.txt")); + } + + #[tokio::test] + async fn test_get_git_working_tree_state_branch_fallback() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let (repo_path, _branch) = create_test_git_repo_with_remote(&temp_dir).await; + + Command::new("git") + .args(["checkout", "-b", "feature"]) + .current_dir(&repo_path) + .output() + .await + .expect("Failed to create feature branch"); + Command::new("git") + .args(["push", "-u", "origin", "feature"]) + .current_dir(&repo_path) + .output() + .await + .expect("Failed to push feature branch"); + + Command::new("git") + .args(["checkout", "-b", "local-branch"]) + .current_dir(&repo_path) + .output() + .await + .expect("Failed to create local branch"); + + let remote_sha = Command::new("git") + .args(["rev-parse", "origin/feature"]) + .current_dir(&repo_path) + .output() + .await + .expect("Failed to rev-parse remote"); + let remote_sha = String::from_utf8(remote_sha.stdout) + .unwrap() + .trim() + .to_string(); + + let state = git_diff_to_remote(&repo_path) + .await + .expect("Should collect working tree state"); + assert_eq!(state.sha, GitSha::new(&remote_sha)); + } + + #[tokio::test] + async fn test_get_git_working_tree_state_unpushed_commit() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let (repo_path, branch) = create_test_git_repo_with_remote(&temp_dir).await; + + let remote_sha = Command::new("git") + .args(["rev-parse", &format!("origin/{branch}")]) + .current_dir(&repo_path) + .output() + .await + .expect("Failed to rev-parse remote"); + let remote_sha = String::from_utf8(remote_sha.stdout) + .unwrap() + .trim() + .to_string(); + + fs::write(repo_path.join("test.txt"), "updated").unwrap(); + Command::new("git") + .args(["add", "test.txt"]) + .current_dir(&repo_path) + .output() + .await + .expect("Failed to add file"); + Command::new("git") + .args(["commit", "-m", "local change"]) + .current_dir(&repo_path) + .output() + .await + .expect("Failed to commit"); + + let state = git_diff_to_remote(&repo_path) + .await + .expect("Should collect working tree state"); + assert_eq!(state.sha, GitSha::new(&remote_sha)); + assert!(state.diff.contains("updated")); + } + #[test] fn test_git_info_serialization() { let git_info = GitInfo { diff --git a/codex-rs/mcp-server/src/codex_message_processor.rs b/codex-rs/mcp-server/src/codex_message_processor.rs index 1decf11da6..07e06d66eb 100644 --- a/codex-rs/mcp-server/src/codex_message_processor.rs +++ b/codex-rs/mcp-server/src/codex_message_processor.rs @@ -8,11 +8,13 @@ use codex_core::ConversationManager; use codex_core::NewConversation; use codex_core::config::Config; use codex_core::config::ConfigOverrides; +use codex_core::git_info::git_diff_to_remote; use codex_core::protocol::ApplyPatchApprovalRequestEvent; 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::GitDiffToRemoteResponse; use mcp_types::JSONRPCErrorError; use mcp_types::RequestId; use tokio::sync::Mutex; @@ -126,6 +128,9 @@ impl CodexMessageProcessor { ClientRequest::CancelLoginChatGpt { request_id, params } => { self.cancel_login_chatgpt(request_id, params.login_id).await; } + ClientRequest::GitDiffToRemote { request_id, params } => { + self.git_diff_to_origin(request_id, params.cwd).await; + } } } @@ -514,6 +519,27 @@ impl CodexMessageProcessor { } } } + + async fn git_diff_to_origin(&self, request_id: RequestId, cwd: PathBuf) { + let diff = git_diff_to_remote(&cwd).await; + match diff { + Some(value) => { + let response = GitDiffToRemoteResponse { + sha: value.sha, + diff: value.diff, + }; + self.outgoing.send_response(request_id, response).await; + } + None => { + let error = JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: format!("failed to compute git diff to remote for cwd: {cwd:?}"), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + } + } + } } async fn apply_bespoke_event_handling( diff --git a/codex-rs/protocol-ts/src/lib.rs b/codex-rs/protocol-ts/src/lib.rs index c2b196dada..6bbc926988 100644 --- a/codex-rs/protocol-ts/src/lib.rs +++ b/codex-rs/protocol-ts/src/lib.rs @@ -36,6 +36,7 @@ pub fn generate_ts(out_dir: &Path, prettier: Option<&Path>) -> Result<()> { codex_protocol::mcp_protocol::LoginChatGptCompleteNotification::export_all_to(out_dir)?; codex_protocol::mcp_protocol::CancelLoginChatGptParams::export_all_to(out_dir)?; codex_protocol::mcp_protocol::CancelLoginChatGptResponse::export_all_to(out_dir)?; + codex_protocol::mcp_protocol::GitDiffToRemoteParams::export_all_to(out_dir)?; codex_protocol::mcp_protocol::ApplyPatchApprovalParams::export_all_to(out_dir)?; codex_protocol::mcp_protocol::ApplyPatchApprovalResponse::export_all_to(out_dir)?; codex_protocol::mcp_protocol::ExecCommandApprovalParams::export_all_to(out_dir)?; diff --git a/codex-rs/protocol/src/mcp_protocol.rs b/codex-rs/protocol/src/mcp_protocol.rs index 383b2033d7..68f5c01d07 100644 --- a/codex-rs/protocol/src/mcp_protocol.rs +++ b/codex-rs/protocol/src/mcp_protocol.rs @@ -26,6 +26,16 @@ impl Display for ConversationId { } } +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, TS)] +#[ts(type = "string")] +pub struct GitSha(pub String); + +impl GitSha { + pub fn new(sha: &str) -> Self { + Self(sha.to_string()) + } +} + /// Request from the client to the server. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] #[serde(tag = "method", rename_all = "camelCase")] @@ -69,6 +79,11 @@ pub enum ClientRequest { request_id: RequestId, params: CancelLoginChatGptParams, }, + GitDiffToRemote { + #[serde(rename = "id")] + request_id: RequestId, + params: GitDiffToRemoteParams, + }, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, TS)] @@ -139,6 +154,13 @@ pub struct LoginChatGptResponse { pub auth_url: String, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[serde(rename_all = "camelCase")] +pub struct GitDiffToRemoteResponse { + pub sha: GitSha, + 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"; @@ -157,6 +179,12 @@ pub struct CancelLoginChatGptParams { pub login_id: Uuid, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[serde(rename_all = "camelCase")] +pub struct GitDiffToRemoteParams { + pub cwd: PathBuf, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] #[serde(rename_all = "camelCase")] pub struct CancelLoginChatGptResponse {} From eaae56a1b0bbb79cfc0e8b76c0012604de25c0f5 Mon Sep 17 00:00:00 2001 From: Gabriel Peal Date: Tue, 19 Aug 2025 20:32:15 -0700 Subject: [PATCH 17/27] Client headers (#2487) --- codex-rs/core/src/config.rs | 7 ++++++- codex-rs/core/src/spawn.rs | 3 +++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/codex-rs/core/src/config.rs b/codex-rs/core/src/config.rs index e0ff411f15..90c5ba4a60 100644 --- a/codex-rs/core/src/config.rs +++ b/codex-rs/core/src/config.rs @@ -13,6 +13,7 @@ use crate::model_provider_info::built_in_model_providers; use crate::openai_model_info::get_model_info; use crate::protocol::AskForApproval; use crate::protocol::SandboxPolicy; +use crate::spawn::CODEX_ORIGINATOR_ENV_VAR; use codex_login::AuthMode; use codex_protocol::config_types::ReasoningEffort; use codex_protocol::config_types::ReasoningSummary; @@ -625,6 +626,10 @@ impl Config { let include_apply_patch_tool_val = include_apply_patch_tool.unwrap_or(model_family.uses_apply_patch_tool); + let originator = std::env::var(CODEX_ORIGINATOR_ENV_VAR) + .ok() + .or(cfg.internal_originator); + let config = Self { model, model_family, @@ -678,7 +683,7 @@ impl Config { experimental_resume, include_plan_tool: include_plan_tool.unwrap_or(false), include_apply_patch_tool: include_apply_patch_tool_val, - internal_originator: cfg.internal_originator, + internal_originator: originator, preferred_auth_method: cfg.preferred_auth_method.unwrap_or(AuthMode::ChatGPT), }; Ok(config) diff --git a/codex-rs/core/src/spawn.rs b/codex-rs/core/src/spawn.rs index 1c82df3180..d93e28ef63 100644 --- a/codex-rs/core/src/spawn.rs +++ b/codex-rs/core/src/spawn.rs @@ -22,6 +22,9 @@ pub const CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR: &str = "CODEX_SANDBOX_NETWORK_ /// accommodate sandboxing configuration and other sandboxing mechanisms. pub const CODEX_SANDBOX_ENV_VAR: &str = "CODEX_SANDBOX"; +/// Set this to change the originator sent with all network requests. +pub const CODEX_ORIGINATOR_ENV_VAR: &str = "CODEX_ORIGINATOR"; + #[derive(Debug, Clone, Copy)] pub enum StdioPolicy { RedirectForShellTool, From d1f1e368368622055086d310c2bd78b74f992e0c Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Tue, 19 Aug 2025 21:01:31 -0700 Subject: [PATCH 18/27] Refresh ChatGPT auth token (#2484) ChatGPT token's live for only 1 hour. If the session is longer we don't refresh the token. We should get the expiry timestamp and attempt to refresh before it. --- codex-rs/core/src/client.rs | 8 ++++++++ codex-rs/login/src/lib.rs | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index 86a711e436..35d2c92e44 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -252,6 +252,14 @@ 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; + } + // The OpenAI Responses endpoint returns structured JSON bodies even for 4xx/5xx // errors. When we bubble early with only the HTTP status the caller sees an opaque // "unexpected status 400 Bad Request" which makes debugging nearly impossible. diff --git a/codex-rs/login/src/lib.rs b/codex-rs/login/src/lib.rs index 327daa7e9b..d6fa4aef9a 100644 --- a/codex-rs/login/src/lib.rs +++ b/codex-rs/login/src/lib.rs @@ -62,6 +62,39 @@ impl CodexAuth { } } + pub async fn refresh_token(&self) -> Result { + let token_data = self + .get_current_token_data() + .ok_or(std::io::Error::other("Token data is not available."))?; + let token = token_data.refresh_token; + + let refresh_response = try_refresh_token(token) + .await + .map_err(std::io::Error::other)?; + + let updated = update_tokens( + &self.auth_file, + refresh_response.id_token, + refresh_response.access_token, + refresh_response.refresh_token, + ) + .await?; + + if let Ok(mut auth_lock) = self.auth_dot_json.lock() { + *auth_lock = Some(updated.clone()); + } + + let access = match updated.tokens { + Some(t) => t.access_token, + None => { + return Err(std::io::Error::other( + "Token data is not available after refresh.", + )); + } + }; + Ok(access) + } + /// Loads the available auth information from the auth.json or /// OPENAI_API_KEY environment variable. pub fn from_codex_home( From ce434b121958101df8b84ef43a34e039e2ac9c25 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Tue, 19 Aug 2025 21:51:59 -0700 Subject: [PATCH 19/27] fix: prefer config var to env var (#2495) --- codex-rs/core/src/client.rs | 6 +----- codex-rs/core/src/config.rs | 21 +++++++++++---------- codex-rs/core/src/spawn.rs | 3 --- codex-rs/core/tests/client.rs | 2 +- 4 files changed, 13 insertions(+), 19 deletions(-) diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index 35d2c92e44..9552a53d7f 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -208,11 +208,7 @@ impl ModelClient { req_builder = req_builder.header("chatgpt-account-id", account_id); } - let originator = self - .config - .internal_originator - .as_deref() - .unwrap_or("codex_cli_rs"); + let originator = &self.config.responses_originator_header; req_builder = req_builder.header("originator", originator); req_builder = req_builder.header("User-Agent", get_codex_user_agent(Some(originator))); diff --git a/codex-rs/core/src/config.rs b/codex-rs/core/src/config.rs index 90c5ba4a60..4b6f8ac9ef 100644 --- a/codex-rs/core/src/config.rs +++ b/codex-rs/core/src/config.rs @@ -13,7 +13,6 @@ use crate::model_provider_info::built_in_model_providers; use crate::openai_model_info::get_model_info; use crate::protocol::AskForApproval; use crate::protocol::SandboxPolicy; -use crate::spawn::CODEX_ORIGINATOR_ENV_VAR; use codex_login::AuthMode; use codex_protocol::config_types::ReasoningEffort; use codex_protocol::config_types::ReasoningSummary; @@ -36,6 +35,8 @@ pub(crate) const PROJECT_DOC_MAX_BYTES: usize = 32 * 1024; // 32 KiB const CONFIG_TOML_FILE: &str = "config.toml"; +const DEFAULT_RESPONSES_ORIGINATOR_HEADER: &str = "codex_cli_rs"; + /// Application configuration loaded from disk and merged with overrides. #[derive(Debug, Clone, PartialEq)] pub struct Config { @@ -164,7 +165,7 @@ pub struct Config { pub include_apply_patch_tool: bool, /// The value for the `originator` header included with Responses API requests. - pub internal_originator: Option, + pub responses_originator_header: String, /// If set to `true`, the API key will be signed with the `originator` header. pub preferred_auth_method: AuthMode, @@ -411,7 +412,7 @@ pub struct ConfigToml { pub experimental_instructions_file: Option, /// The value for the `originator` header included with Responses API requests. - pub internal_originator: Option, + pub responses_originator_header_internal_override: Option, pub projects: Option>, @@ -626,9 +627,9 @@ impl Config { let include_apply_patch_tool_val = include_apply_patch_tool.unwrap_or(model_family.uses_apply_patch_tool); - let originator = std::env::var(CODEX_ORIGINATOR_ENV_VAR) - .ok() - .or(cfg.internal_originator); + let responses_originator_header: String = cfg + .responses_originator_header_internal_override + .unwrap_or(DEFAULT_RESPONSES_ORIGINATOR_HEADER.to_owned()); let config = Self { model, @@ -683,7 +684,7 @@ impl Config { experimental_resume, include_plan_tool: include_plan_tool.unwrap_or(false), include_apply_patch_tool: include_apply_patch_tool_val, - internal_originator: originator, + responses_originator_header, preferred_auth_method: cfg.preferred_auth_method.unwrap_or(AuthMode::ChatGPT), }; Ok(config) @@ -1048,7 +1049,7 @@ disable_response_storage = true base_instructions: None, include_plan_tool: false, include_apply_patch_tool: false, - internal_originator: None, + responses_originator_header: "codex_cli_rs".to_string(), preferred_auth_method: AuthMode::ChatGPT, }, o3_profile_config @@ -1101,7 +1102,7 @@ disable_response_storage = true base_instructions: None, include_plan_tool: false, include_apply_patch_tool: false, - internal_originator: None, + responses_originator_header: "codex_cli_rs".to_string(), preferred_auth_method: AuthMode::ChatGPT, }; @@ -1169,7 +1170,7 @@ disable_response_storage = true base_instructions: None, include_plan_tool: false, include_apply_patch_tool: false, - internal_originator: None, + responses_originator_header: "codex_cli_rs".to_string(), preferred_auth_method: AuthMode::ChatGPT, }; diff --git a/codex-rs/core/src/spawn.rs b/codex-rs/core/src/spawn.rs index d93e28ef63..1c82df3180 100644 --- a/codex-rs/core/src/spawn.rs +++ b/codex-rs/core/src/spawn.rs @@ -22,9 +22,6 @@ pub const CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR: &str = "CODEX_SANDBOX_NETWORK_ /// accommodate sandboxing configuration and other sandboxing mechanisms. pub const CODEX_SANDBOX_ENV_VAR: &str = "CODEX_SANDBOX"; -/// Set this to change the originator sent with all network requests. -pub const CODEX_ORIGINATOR_ENV_VAR: &str = "CODEX_ORIGINATOR"; - #[derive(Debug, Clone, Copy)] pub enum StdioPolicy { RedirectForShellTool, diff --git a/codex-rs/core/tests/client.rs b/codex-rs/core/tests/client.rs index 075c496a6b..30ba62eeae 100644 --- a/codex-rs/core/tests/client.rs +++ b/codex-rs/core/tests/client.rs @@ -260,7 +260,7 @@ async fn originator_config_override_is_used() { let codex_home = TempDir::new().unwrap(); let mut config = load_default_config_for_test(&codex_home); config.model_provider = model_provider; - config.internal_originator = Some("my_override".to_string()); + config.responses_originator_header = "my_override".to_owned(); let conversation_manager = ConversationManager::default(); let codex = conversation_manager From 202af129262ca6897d1ff051eb6d6b1c266072b5 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Tue, 19 Aug 2025 22:34:37 -0700 Subject: [PATCH 20/27] Add a slash command to control permissions (#2474) A slash command to control permissions https://github.com/user-attachments/assets/c0edafcd-2085-4e09-8009-ba69c4f1c153 --------- Co-authored-by: ae --- codex-rs/common/src/approval_presets.rs | 46 +++++++++++++++++++++ codex-rs/common/src/lib.rs | 3 ++ codex-rs/config.md | 10 +++++ codex-rs/tui/Cargo.toml | 6 +-- codex-rs/tui/src/app.rs | 15 +++++++ codex-rs/tui/src/app_event.rs | 8 ++++ codex-rs/tui/src/chatwidget.rs | 55 +++++++++++++++++++++++++ codex-rs/tui/src/slash_command.rs | 2 + 8 files changed, 142 insertions(+), 3 deletions(-) create mode 100644 codex-rs/common/src/approval_presets.rs diff --git a/codex-rs/common/src/approval_presets.rs b/codex-rs/common/src/approval_presets.rs new file mode 100644 index 0000000000..6c3bf395ad --- /dev/null +++ b/codex-rs/common/src/approval_presets.rs @@ -0,0 +1,46 @@ +use codex_core::protocol::AskForApproval; +use codex_core::protocol::SandboxPolicy; + +/// A simple preset pairing an approval policy with a sandbox policy. +#[derive(Debug, Clone)] +pub struct ApprovalPreset { + /// Stable identifier for the preset. + pub id: &'static str, + /// Display label shown in UIs. + pub label: &'static str, + /// Short human description shown next to the label in UIs. + pub description: &'static str, + /// Approval policy to apply. + pub approval: AskForApproval, + /// Sandbox policy to apply. + pub sandbox: SandboxPolicy, +} + +/// Built-in list of approval presets that pair approval and sandbox policy. +/// +/// Keep this UI-agnostic so it can be reused by both TUI and MCP server. +pub fn builtin_approval_presets() -> Vec { + 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.", + 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.", + 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.", + approval: AskForApproval::Never, + sandbox: SandboxPolicy::DangerFullAccess, + }, + ] +} diff --git a/codex-rs/common/src/lib.rs b/codex-rs/common/src/lib.rs index dc684c21d8..292503f77e 100644 --- a/codex-rs/common/src/lib.rs +++ b/codex-rs/common/src/lib.rs @@ -31,3 +31,6 @@ pub use config_summary::create_config_summary_entries; pub mod fuzzy_match; // Shared model presets used by TUI and MCP server pub mod model_presets; +// Shared approval presets (AskForApproval + Sandbox) used by TUI and MCP server +// Not to be confused with AskForApproval, which we should probably rename to EscalationPolicy. +pub mod approval_presets; diff --git a/codex-rs/config.md b/codex-rs/config.md index 8c2a7831dc..1af963a16f 100644 --- a/codex-rs/config.md +++ b/codex-rs/config.md @@ -300,6 +300,16 @@ This is reasonable to use if Codex is running in an environment that provides it Though using this option may also be necessary if you try to use Codex in environments where its native sandboxing mechanisms are unsupported, such as older Linux kernels or on Windows. +## Approval presets + +Codex provides three main Approval Presets: + +- Read Only: Codex can read files and answer questions; edits, running commands, and network access require approval. +- Auto: Codex can read files, make edits, and run commands in the workspace without approval; asks for approval outside the workspace or for network access. +- Full Access: Full disk and network access without prompts; extremely risky. + +You can further customize how Codex runs at the command line using the `--ask-for-approval` and `--sandbox` options. + ## mcp_servers Defines the list of MCP servers that Codex can consult for tool use. Currently, only servers that are launched by executing a program that communicate over stdio are supported. For servers that use the SSE transport, consider an adapter like [mcp-proxy](https://github.com/sparfenyuk/mcp-proxy). diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index 78a6a77821..9e0e31e172 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -33,18 +33,19 @@ codex-common = { path = "../common", features = [ "sandbox_summary", ] } codex-core = { path = "../core" } -codex-protocol = { path = "../protocol" } codex-file-search = { path = "../file-search" } 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"] } diffy = "0.4.2" image = { version = "^0.25.6", default-features = false, features = ["jpeg"] } lazy_static = "1" -once_cell = "1" mcp-types = { path = "../mcp-types" } +once_cell = "1" path-clean = "1.0.1" +rand = "0.9" ratatui = { version = "0.29.0", features = [ "scrolling-regions", "unstable-rendered-line-info", @@ -75,7 +76,6 @@ tui-markdown = "0.3.3" unicode-segmentation = "1.12.0" unicode-width = "0.1" uuid = "1" -rand = "0.9" [target.'cfg(unix)'.dependencies] libc = "0.2" diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index dd863c0673..c7a1693617 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -387,6 +387,11 @@ impl App<'_> { widget.open_model_popup(); } } + SlashCommand::Approvals => { + if let AppState::Chat { widget } = &mut self.app_state { + widget.open_approvals_popup(); + } + } SlashCommand::Quit => { break; } @@ -514,6 +519,16 @@ impl App<'_> { widget.set_model(model); } } + 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()?; diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 1780dbc710..fc8b510ff8 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -6,6 +6,8 @@ use std::time::Duration; use crate::app::ChatWidgetArgs; use crate::slash_command::SlashCommand; +use codex_core::protocol::AskForApproval; +use codex_core::protocol::SandboxPolicy; use codex_core::protocol_config_types::ReasoningEffort; #[allow(clippy::large_enum_variant)] @@ -70,4 +72,10 @@ pub(crate) enum AppEvent { /// Update the current model slug in the running app and widget. UpdateModel(String), + + /// Update the current approval policy in the running app and widget. + UpdateAskForApprovalPolicy(AskForApproval), + + /// Update the current sandbox policy in the running app and widget. + UpdateSandboxPolicy(SandboxPolicy), } diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 360a9f8ef1..0c72b62134 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -60,9 +60,13 @@ mod agent; use self::agent::spawn_agent; use crate::streaming::controller::AppEventHistorySink; use crate::streaming::controller::StreamController; +use codex_common::approval_presets::ApprovalPreset; +use codex_common::approval_presets::builtin_approval_presets; use codex_common::model_presets::ModelPreset; use codex_common::model_presets::builtin_model_presets; use codex_core::ConversationManager; +use codex_core::protocol::AskForApproval; +use codex_core::protocol::SandboxPolicy; use codex_core::protocol_config_types::ReasoningEffort as ReasoningEffortConfig; use codex_file_search::FileMatch; use uuid::Uuid; @@ -733,6 +737,57 @@ impl ChatWidget<'_> { ); } + /// Open a popup to choose the approvals mode (ask for approval policy + sandbox policy). + pub(crate) fn open_approvals_popup(&mut self) { + let current_approval = self.config.approval_policy; + let current_sandbox = self.config.sandbox_policy.clone(); + let mut items: Vec = Vec::new(); + let presets: Vec = builtin_approval_presets(); + for preset in presets.into_iter() { + let is_current = + current_approval == preset.approval && current_sandbox == preset.sandbox; + let approval = preset.approval; + let sandbox = preset.sandbox.clone(); + let name = preset.label.to_string(); + let description = Some(preset.description.to_string()); + let actions: Vec = vec![Box::new(move |tx| { + tx.send(AppEvent::CodexOp(Op::OverrideTurnContext { + cwd: None, + approval_policy: Some(approval), + sandbox_policy: Some(sandbox.clone()), + model: None, + effort: None, + summary: None, + })); + tx.send(AppEvent::UpdateAskForApprovalPolicy(approval)); + tx.send(AppEvent::UpdateSandboxPolicy(sandbox.clone())); + })]; + items.push(SelectionItem { + name, + description, + is_current, + actions, + }); + } + + self.bottom_pane.show_selection_view( + "Select Approvals Mode".to_string(), + None, + Some("Press Enter to confirm or Esc to go back".to_string()), + items, + ); + } + + /// Set the approval policy in the widget's config copy. + pub(crate) fn set_approval_policy(&mut self, policy: AskForApproval) { + self.config.approval_policy = policy; + } + + /// Set the sandbox policy in the widget's config copy. + pub(crate) fn set_sandbox_policy(&mut self, policy: SandboxPolicy) { + self.config.sandbox_policy = policy; + } + /// Set the reasoning effort in the widget's config copy. pub(crate) fn set_reasoning_effort(&mut self, effort: ReasoningEffortConfig) { self.config.model_reasoning_effort = effort; diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index d572895abb..13ce2ff0fa 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -13,6 +13,7 @@ pub enum SlashCommand { // DO NOT ALPHA-SORT! Enum order is presentation order in the popup, so // more frequently used commands should be listed first. Model, + Approvals, New, Init, Compact, @@ -38,6 +39,7 @@ impl SlashCommand { SlashCommand::Mention => "mention a file", SlashCommand::Status => "show current session configuration and token usage", SlashCommand::Model => "choose a model preset (model + reasoning effort)", + SlashCommand::Approvals => "choose what Codex can do without approval", SlashCommand::Mcp => "list configured MCP tools", SlashCommand::Logout => "log out of Codex", #[cfg(debug_assertions)] From ee8c4ad23a4a30a2753fd3db659e56db0913f69c Mon Sep 17 00:00:00 2001 From: ae Date: Wed, 20 Aug 2025 00:26:14 -0700 Subject: [PATCH 21/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 22/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 23/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 24/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 25/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 26/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 27/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.