From a3254696c860d9981e01d97a933522faa8a6247f Mon Sep 17 00:00:00 2001 From: Thibault Sottiaux Date: Fri, 26 Sep 2025 21:45:46 -0700 Subject: [PATCH 01/16] docs: refresh README under codex-rs (#4333) --- codex-rs/README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/codex-rs/README.md b/codex-rs/README.md index 043d872afe..f51f746892 100644 --- a/codex-rs/README.md +++ b/codex-rs/README.md @@ -4,18 +4,18 @@ We provide Codex CLI as a standalone, native executable to ensure a zero-depende ## Installing Codex -Today, the easiest way to install Codex is via `npm`, though we plan to publish Codex to other package managers soon. +Today, the easiest way to install Codex is via `npm`: ```shell -npm i -g @openai/codex@native +npm i -g @openai/codex codex ``` -You can also download a platform-specific release directly from our [GitHub Releases](https://github.com/openai/codex/releases). +You can also install via Homebrew (`brew install codex`) or download a platform-specific release directly from our [GitHub Releases](https://github.com/openai/codex/releases). ## What's new in the Rust CLI -While we are [working to close the gap between the TypeScript and Rust implementations of Codex CLI](https://github.com/openai/codex/issues/1262), note that the Rust CLI has a number of features that the TypeScript CLI does not! +The Rust implementation is now the maintained Codex CLI and serves as the default experience. It includes a number of features that the legacy TypeScript CLI never supported. ### Config From 90c3a5650cfa2ef208a70209303ebacd2091c007 Mon Sep 17 00:00:00 2001 From: Thibault Sottiaux Date: Fri, 26 Sep 2025 22:31:39 -0700 Subject: [PATCH 02/16] fix: set gpt-5-codex medium preset reasoning (#4335) Otherwise it shows up as none, see https://github.com/openai/codex/issues/4321 --- codex-rs/common/src/model_presets.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/codex-rs/common/src/model_presets.rs b/codex-rs/common/src/model_presets.rs index 8eb5beacda..1acfee88f6 100644 --- a/codex-rs/common/src/model_presets.rs +++ b/codex-rs/common/src/model_presets.rs @@ -29,7 +29,7 @@ const PRESETS: &[ModelPreset] = &[ label: "gpt-5-codex medium", description: "", model: "gpt-5-codex", - effort: None, + effort: Some(ReasoningEffort::Medium), }, ModelPreset { id: "gpt-5-codex-high", From c0960c0f4955cf529e34d037f6e5794da09795e0 Mon Sep 17 00:00:00 2001 From: Jeremy Rose <172423086+nornagon-openai@users.noreply.github.com> Date: Fri, 26 Sep 2025 22:49:59 -0700 Subject: [PATCH 03/16] tui: separator above final agent message (#4324) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a separator line before the final agent message Screenshot 2025-09-26 at 4 55 01 PM --- codex-rs/tui/src/bottom_pane/mod.rs | 4 +++ codex-rs/tui/src/chatwidget.rs | 15 ++++++++ ...et__tests__binary_size_ideal_response.snap | 5 +++ codex-rs/tui/src/chatwidget/tests.rs | 1 + codex-rs/tui/src/history_cell.rs | 34 +++++++++++++++++++ codex-rs/tui/src/status_indicator_widget.rs | 4 +-- 6 files changed, 61 insertions(+), 2 deletions(-) diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index b6c93d2813..c65067b581 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -102,6 +102,10 @@ impl BottomPane { } } + pub fn status_widget(&self) -> Option<&StatusIndicatorWidget> { + self.status.as_ref() + } + fn active_view(&self) -> Option<&dyn BottomPaneView> { self.view_stack.last().map(std::convert::AsRef::as_ref) } diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 85ecad284a..9a36addb0a 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -254,6 +254,8 @@ pub(crate) struct ChatWidget { // List of ghost commits corresponding to each turn. ghost_snapshots: Vec, ghost_snapshots_disabled: bool, + // Whether to add a final message separator after the last message + needs_final_message_separator: bool, } struct UserMessage { @@ -648,6 +650,14 @@ impl ChatWidget { self.flush_active_cell(); if self.stream_controller.is_none() { + if self.needs_final_message_separator { + let elapsed_seconds = self + .bottom_pane + .status_widget() + .map(super::status_indicator_widget::StatusIndicatorWidget::elapsed_seconds); + self.add_to_history(history_cell::FinalMessageSeparator::new(elapsed_seconds)); + self.needs_final_message_separator = false; + } self.stream_controller = Some(StreamController::new(self.config.clone())); } if let Some(controller) = self.stream_controller.as_mut() @@ -901,6 +911,7 @@ impl ChatWidget { is_review_mode: false, ghost_snapshots: Vec::new(), ghost_snapshots_disabled: true, + needs_final_message_separator: false, } } @@ -962,6 +973,7 @@ impl ChatWidget { is_review_mode: false, ghost_snapshots: Vec::new(), ghost_snapshots_disabled: true, + needs_final_message_separator: false, } } @@ -1188,6 +1200,7 @@ impl ChatWidget { fn flush_active_cell(&mut self) { if let Some(active) = self.active_cell.take() { + self.needs_final_message_separator = true; self.app_event_tx.send(AppEvent::InsertHistoryCell(active)); } } @@ -1200,6 +1213,7 @@ impl ChatWidget { if !cell.display_lines(u16::MAX).is_empty() { // Only break exec grouping if the cell renders visible lines. self.flush_active_cell(); + self.needs_final_message_separator = true; } self.app_event_tx.send(AppEvent::InsertHistoryCell(cell)); } @@ -1241,6 +1255,7 @@ impl ChatWidget { if !text.is_empty() { self.add_to_history(history_cell::new_user_prompt(text)); } + self.needs_final_message_separator = false; } fn capture_ghost_snapshot(&mut self) { diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__binary_size_ideal_response.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__binary_size_ideal_response.snap index 97709b613c..e3121774f4 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__binary_size_ideal_response.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__binary_size_ideal_response.snap @@ -1,5 +1,6 @@ --- source: tui/src/chatwidget/tests.rs +assertion_line: 1152 expression: "lines[start_idx..].join(\"\\n\")" --- • I need to check the codex-rs repository to explain why the project's binaries @@ -9,6 +10,8 @@ expression: "lines[start_idx..].join(\"\\n\")" is set up. I should look into the Cargo.toml file to confirm features and profiles without needing to edit any code. Let's get started on this! +─ Worked for 0s ──────────────────────────────────────────────────────────────── + • I’m going to scan the workspace and Cargo manifests to see build profiles and dependencies that impact binary size. Then I’ll summarize the main causes. @@ -110,6 +113,8 @@ expression: "lines[start_idx..].join(\"\\n\")" "Main Causes" and "Build-Mode Notes." I can also include brief suggestions for reducing size, but I want to stay focused on answering the user's question. +─ Worked for 0s ──────────────────────────────────────────────────────────────── + • Here’s what’s driving size in this workspace’s binaries. Main Causes diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 0c9ebc73aa..f3799ad336 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -340,6 +340,7 @@ fn make_chatwidget_manual() -> ( is_review_mode: false, ghost_snapshots: Vec::new(), ghost_snapshots_disabled: false, + needs_final_message_separator: false, }; (widget, rx, op_rx) } diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 2951ca5b78..f93042f91d 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -1090,6 +1090,40 @@ pub(crate) fn new_reasoning_summary_block( Box::new(new_reasoning_block(full_reasoning_buffer, config)) } +#[derive(Debug)] +pub struct FinalMessageSeparator { + elapsed_seconds: Option, +} +impl FinalMessageSeparator { + pub(crate) fn new(elapsed_seconds: Option) -> Self { + Self { elapsed_seconds } + } +} +impl HistoryCell for FinalMessageSeparator { + fn display_lines(&self, width: u16) -> Vec> { + let elapsed_seconds = self + .elapsed_seconds + .map(super::status_indicator_widget::fmt_elapsed_compact); + if let Some(elapsed_seconds) = elapsed_seconds { + let worked_for = format!("─ Worked for {elapsed_seconds} ─"); + let worked_for_width = worked_for.width(); + vec![ + Line::from_iter([ + worked_for, + "─".repeat((width as usize).saturating_sub(worked_for_width)), + ]) + .dim(), + ] + } else { + vec![Line::from_iter(["─".repeat(width as usize).dim()])] + } + } + + fn transcript_lines(&self) -> Vec> { + vec![] + } +} + fn format_mcp_invocation<'a>(invocation: McpInvocation) -> Line<'a> { let args_str = invocation .arguments diff --git a/codex-rs/tui/src/status_indicator_widget.rs b/codex-rs/tui/src/status_indicator_widget.rs index 96f2c49fee..b6fa2fd530 100644 --- a/codex-rs/tui/src/status_indicator_widget.rs +++ b/codex-rs/tui/src/status_indicator_widget.rs @@ -34,7 +34,7 @@ pub(crate) struct StatusIndicatorWidget { // Format elapsed seconds into a compact human-friendly form used by the status line. // Examples: 0s, 59s, 1m 00s, 59m 59s, 1h 00m 00s, 2h 03m 09s -fn fmt_elapsed_compact(elapsed_secs: u64) -> String { +pub fn fmt_elapsed_compact(elapsed_secs: u64) -> String { if elapsed_secs < 60 { return format!("{elapsed_secs}s"); } @@ -142,7 +142,7 @@ impl StatusIndicatorWidget { elapsed.as_secs() } - fn elapsed_seconds(&self) -> u64 { + pub fn elapsed_seconds(&self) -> u64 { self.elapsed_seconds_at(Instant::now()) } } From 5c67dc3af12ab5426ba5002a90a50b24f1dcf8f9 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Fri, 26 Sep 2025 22:54:38 -0700 Subject: [PATCH 04/16] Edit the spacing in shortcuts (#4330) image --- codex-rs/tui/src/bottom_pane/chat_composer.rs | 28 ++++--- codex-rs/tui/src/bottom_pane/footer.rs | 76 ++++++++++--------- ...__tests__footer_mode_shortcut_overlay.snap | 6 +- ...tests__footer_shortcuts_shift_and_esc.snap | 6 +- 4 files changed, 63 insertions(+), 53 deletions(-) diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 3b02c59334..541f3817b4 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -1204,17 +1204,25 @@ impl ChatComposer { } fn handle_shortcut_overlay_key(&mut self, key_event: &KeyEvent) -> bool { - if key_event.kind == KeyEventKind::Press - && key_event.modifiers.is_empty() - && matches!(key_event.code, KeyCode::Char('?')) - { - let next = toggle_shortcut_mode(self.footer_mode, self.ctrl_c_quit_hint); - let changed = next != self.footer_mode; - self.footer_mode = next; - changed - } else { - false + if key_event.kind != KeyEventKind::Press { + return false; } + + let toggles = match key_event.code { + KeyCode::Char('?') if key_event.modifiers.is_empty() => true, + KeyCode::BackTab => true, + KeyCode::Tab if key_event.modifiers.contains(KeyModifiers::SHIFT) => true, + _ => false, + }; + + if !toggles { + return false; + } + + let next = toggle_shortcut_mode(self.footer_mode, self.ctrl_c_quit_hint); + let changed = next != self.footer_mode; + self.footer_mode = next; + changed } fn footer_props(&self) -> FooterProps { diff --git a/codex-rs/tui/src/bottom_pane/footer.rs b/codex-rs/tui/src/bottom_pane/footer.rs index 2e63493e31..aa0659ed9f 100644 --- a/codex-rs/tui/src/bottom_pane/footer.rs +++ b/codex-rs/tui/src/bottom_pane/footer.rs @@ -140,6 +140,7 @@ fn build_columns(entries: Vec) -> Vec> { const COLUMNS: usize = 3; const MAX_PADDED_WIDTHS: [usize; COLUMNS - 1] = [24, 28]; + const MIN_PADDED_WIDTHS: [usize; COLUMNS - 1] = [22, 0]; let rows = entries.len().div_ceil(COLUMNS); let mut column_widths = [0usize; COLUMNS]; @@ -160,7 +161,8 @@ fn build_columns(entries: Vec) -> Vec> { let entry = &entries[idx]; if col < COLUMNS - 1 { let max_width = MAX_PADDED_WIDTHS[col]; - let target_width = column_widths[col].min(max_width); + let mut target_width = column_widths[col]; + target_width = target_width.max(MIN_PADDED_WIDTHS[col]).min(max_width); let pad_width = target_width + 2; line.push_str(&format!("{entry:) -> Vec> { #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum ShortcutId { Commands, - FilePaths, InsertNewline, + ChangeMode, + FilePaths, PasteImage, + EditPrevious, Quit, ShowTranscript, - ToggleOverlay, - EditPrevious, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -272,17 +274,6 @@ const SHORTCUTS: &[ShortcutDescriptor] = &[ prefix: "", label: " for commands", }, - ShortcutDescriptor { - id: ShortcutId::FilePaths, - bindings: &[ShortcutBinding { - code: KeyCode::Char('@'), - modifiers: KeyModifiers::NONE, - overlay_text: "@", - condition: DisplayCondition::Always, - }], - prefix: "", - label: " for file paths", - }, ShortcutDescriptor { id: ShortcutId::InsertNewline, bindings: &[ @@ -302,6 +293,28 @@ const SHORTCUTS: &[ShortcutDescriptor] = &[ prefix: "", label: " for newline", }, + ShortcutDescriptor { + id: ShortcutId::ChangeMode, + bindings: &[ShortcutBinding { + code: KeyCode::BackTab, + modifiers: KeyModifiers::SHIFT, + overlay_text: "shift + tab", + condition: DisplayCondition::Always, + }], + prefix: "", + label: " to change mode", + }, + ShortcutDescriptor { + id: ShortcutId::FilePaths, + bindings: &[ShortcutBinding { + code: KeyCode::Char('@'), + modifiers: KeyModifiers::NONE, + overlay_text: "@", + condition: DisplayCondition::Always, + }], + prefix: "", + label: " for file paths", + }, ShortcutDescriptor { id: ShortcutId::PasteImage, bindings: &[ShortcutBinding { @@ -313,6 +326,17 @@ const SHORTCUTS: &[ShortcutDescriptor] = &[ prefix: "", label: " to paste images", }, + ShortcutDescriptor { + id: ShortcutId::EditPrevious, + bindings: &[ShortcutBinding { + code: KeyCode::Esc, + modifiers: KeyModifiers::NONE, + overlay_text: "esc", + condition: DisplayCondition::Always, + }], + prefix: "", + label: "", + }, ShortcutDescriptor { id: ShortcutId::Quit, bindings: &[ShortcutBinding { @@ -335,28 +359,6 @@ const SHORTCUTS: &[ShortcutDescriptor] = &[ prefix: "", label: " to view transcript", }, - ShortcutDescriptor { - id: ShortcutId::ToggleOverlay, - bindings: &[ShortcutBinding { - code: KeyCode::Char('?'), - modifiers: KeyModifiers::NONE, - overlay_text: "?", - condition: DisplayCondition::Always, - }], - prefix: "", - label: " to hide shortcuts", - }, - ShortcutDescriptor { - id: ShortcutId::EditPrevious, - bindings: &[ShortcutBinding { - code: KeyCode::Esc, - modifiers: KeyModifiers::NONE, - overlay_text: "esc", - condition: DisplayCondition::Always, - }], - prefix: "", - label: "", - }, ]; #[cfg(test)] diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay.snap index c08e7f2d83..9633aab2ae 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay.snap @@ -10,6 +10,6 @@ expression: terminal.backend() " " " " " " -" / for commands @ for file paths shift + enter for newline " -" ctrl + v to paste images ctrl + c to exit ctrl + t to view transcript " -" ? to hide shortcuts esc again to edit previous message " +" / for commands shift + enter for newline shift + tab to change mode " +" @ for file paths ctrl + v to paste images esc again to edit previous message " +" ctrl + c to exit ctrl + t to view transcript " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_shift_and_esc.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_shift_and_esc.snap index 96c10733dc..111c14136a 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_shift_and_esc.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_shift_and_esc.snap @@ -2,6 +2,6 @@ source: tui/src/bottom_pane/footer.rs expression: terminal.backend() --- -" / for commands @ for file paths shift + enter for ne" -" ctrl + v to paste images ctrl + c to exit ctrl + t to view tra" -" ? to hide shortcuts esc again to edit previous message " +" / for commands shift + enter for newline shift + tab to change m" +" @ for file paths ctrl + v to paste images esc again to edit previ" +" ctrl + c to exit ctrl + t to view transcript " From 68765214b333a9294e55b7b7c0bd200b93fdd2c8 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Sat, 27 Sep 2025 07:54:32 -0700 Subject: [PATCH 05/16] fix: remove default timeout of 30s in the proxy (#4336) This is likely the reason that I saw some conversations "freeze up" when using the proxy. Note the client in `core` does not specify a timeout when making requests to the Responses API, so the proxy should not, either. --- codex-rs/responses-api-proxy/src/lib.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/codex-rs/responses-api-proxy/src/lib.rs b/codex-rs/responses-api-proxy/src/lib.rs index 600a8d6c7b..59c8510beb 100644 --- a/codex-rs/responses-api-proxy/src/lib.rs +++ b/codex-rs/responses-api-proxy/src/lib.rs @@ -6,6 +6,7 @@ use std::net::TcpListener; use std::path::Path; use std::path::PathBuf; use std::sync::Arc; +use std::time::Duration; use anyhow::Context; use anyhow::Result; @@ -62,6 +63,8 @@ pub fn run_main(args: Args) -> Result<()> { .map_err(|err| anyhow!("creating HTTP server: {err}"))?; let client = Arc::new( Client::builder() + // Disable reqwest's 30s default so long-lived response streams keep flowing. + .timeout(None::) .build() .context("building reqwest client")?, ); From bcf2bc0aa5fb45c91ddaa9111cd85fe6a931361f Mon Sep 17 00:00:00 2001 From: Fouad Matin <169186268+fouad-openai@users.noreply.github.com> Date: Sat, 27 Sep 2025 12:18:33 -0700 Subject: [PATCH 06/16] fix(tui): make `?` work again (#4362) Revert #4330 #4316 --- codex-rs/tui/src/bottom_pane/chat_composer.rs | 247 +-------- codex-rs/tui/src/bottom_pane/footer.rs | 505 ++++++++---------- codex-rs/tui/src/bottom_pane/mod.rs | 8 + ...mposer__tests__backspace_after_pastes.snap | 2 +- ...tom_pane__chat_composer__tests__empty.snap | 2 +- ...__tests__footer_mode_ctrl_c_interrupt.snap | 13 - ...poser__tests__footer_mode_ctrl_c_quit.snap | 13 - ...sts__footer_mode_ctrl_c_then_esc_hint.snap | 13 - ...tests__footer_mode_esc_hint_backtrack.snap | 13 - ...ts__footer_mode_esc_hint_from_overlay.snap | 13 - ...r_mode_overlay_then_external_esc_hint.snap | 13 - ...__tests__footer_mode_shortcut_overlay.snap | 15 - ...tom_pane__chat_composer__tests__large.snap | 2 +- ...chat_composer__tests__multiple_pastes.snap | 2 +- ...tom_pane__chat_composer__tests__small.snap | 2 +- ...ooter__tests__footer_ctrl_c_quit_idle.snap | 4 +- ...er__tests__footer_ctrl_c_quit_running.snap | 4 +- ...__footer__tests__footer_esc_hint_idle.snap | 5 - ...footer__tests__footer_esc_hint_primed.snap | 5 - ...oter__tests__footer_shortcuts_default.snap | 4 +- ...tests__footer_shortcuts_shift_and_esc.snap | 6 +- codex-rs/tui/src/chatwidget.rs | 2 + ...exec_and_status_layout_vt100_snapshot.snap | 3 +- ...atwidget__tests__status_widget_active.snap | 3 +- codex-rs/tui/src/key_hint.rs | 26 + 25 files changed, 307 insertions(+), 618 deletions(-) delete mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_interrupt.snap delete mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_quit.snap delete mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_then_esc_hint.snap delete mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_esc_hint_backtrack.snap delete mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_esc_hint_from_overlay.snap delete mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_overlay_then_external_esc_hint.snap delete mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay.snap delete mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_esc_hint_idle.snap delete mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_esc_hint_primed.snap diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 541f3817b4..b3c1eea6d9 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -1,3 +1,4 @@ +use codex_core::protocol::TokenUsageInfo; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyEventKind; @@ -18,14 +19,8 @@ use super::chat_composer_history::ChatComposerHistory; use super::command_popup::CommandItem; use super::command_popup::CommandPopup; use super::file_search_popup::FileSearchPopup; -use super::footer::FooterMode; use super::footer::FooterProps; -use super::footer::esc_hint_mode; -use super::footer::footer_height; -use super::footer::prompt_mode; use super::footer::render_footer; -use super::footer::reset_mode_after_activity; -use super::footer::toggle_shortcut_mode; use super::paste_burst::CharDecision; use super::paste_burst::PasteBurst; use crate::bottom_pane::paste_burst::FlushResult; @@ -79,6 +74,7 @@ pub(crate) struct ChatComposer { dismissed_file_popup_token: Option, current_file_query: Option, pending_pastes: Vec<(String, String)>, + token_usage_info: Option, has_focus: bool, attached_images: Vec, placeholder_text: String, @@ -88,7 +84,6 @@ pub(crate) struct ChatComposer { // When true, disables paste-burst logic and inserts characters immediately. disable_paste_burst: bool, custom_prompts: Vec, - footer_mode: FooterMode, } /// Popup state – at most one can be visible at any time. @@ -98,7 +93,7 @@ enum ActivePopup { File(FileSearchPopup), } -const FOOTER_SPACING_HEIGHT: u16 = 1; +const FOOTER_HINT_HEIGHT: u16 = 1; impl ChatComposer { pub fn new( @@ -122,6 +117,7 @@ impl ChatComposer { dismissed_file_popup_token: None, current_file_query: None, pending_pastes: Vec::new(), + token_usage_info: None, has_focus: has_input_focus, attached_images: Vec::new(), placeholder_text, @@ -129,7 +125,6 @@ impl ChatComposer { paste_burst: PasteBurst::default(), disable_paste_burst: false, custom_prompts: Vec::new(), - footer_mode: FooterMode::ShortcutPrompt, }; // Apply configuration via the setter to keep side-effects centralized. this.set_disable_paste_burst(disable_paste_burst); @@ -137,41 +132,26 @@ impl ChatComposer { } pub fn desired_height(&self, width: u16) -> u16 { - let footer_props = self.footer_props(); - let footer_hint_height = footer_height(footer_props); - let footer_spacing = if footer_hint_height > 0 { - FOOTER_SPACING_HEIGHT - } else { - 0 - }; - let footer_total_height = footer_hint_height + footer_spacing; self.textarea .desired_height(width.saturating_sub(LIVE_PREFIX_COLS)) + 2 + match &self.active_popup { - ActivePopup::None => footer_total_height, + ActivePopup::None => FOOTER_HINT_HEIGHT, ActivePopup::Command(c) => c.calculate_required_height(width), ActivePopup::File(c) => c.calculate_required_height(), } } fn layout_areas(&self, area: Rect) -> [Rect; 3] { - let footer_props = self.footer_props(); - let footer_hint_height = footer_height(footer_props); - let footer_spacing = if footer_hint_height > 0 { - FOOTER_SPACING_HEIGHT - } else { - 0 - }; - let footer_total_height = footer_hint_height + footer_spacing; let popup_constraint = match &self.active_popup { ActivePopup::Command(popup) => { Constraint::Max(popup.calculate_required_height(area.width)) } ActivePopup::File(popup) => Constraint::Max(popup.calculate_required_height()), - ActivePopup::None => Constraint::Max(footer_total_height), + ActivePopup::None => Constraint::Max(FOOTER_HINT_HEIGHT), }; let mut area = area; + // Leave an empty row at the top, unless there isn't room. if area.height > 1 { area.height -= 1; area.y += 1; @@ -195,6 +175,13 @@ impl ChatComposer { self.textarea.is_empty() } + /// Update the cached *context-left* percentage and refresh the placeholder + /// text. The UI relies on the placeholder to convey the remaining + /// context when the composer is empty. + pub(crate) fn set_token_usage(&mut self, token_info: Option) { + self.token_usage_info = token_info; + } + /// Record the history metadata advertised by `SessionConfiguredEvent` so /// that the composer can navigate cross-session history. pub(crate) fn set_history_metadata(&mut self, log_id: u64, entry_count: usize) { @@ -332,11 +319,6 @@ impl ChatComposer { pub fn set_ctrl_c_quit_hint(&mut self, show: bool, has_focus: bool) { self.ctrl_c_quit_hint = show; - if show { - self.footer_mode = prompt_mode(); - } else { - self.footer_mode = reset_mode_after_activity(self.footer_mode); - } self.set_has_focus(has_focus); } @@ -372,18 +354,6 @@ impl ChatComposer { /// Handle key event when the slash-command popup is visible. fn handle_key_event_with_slash_popup(&mut self, key_event: KeyEvent) -> (InputResult, bool) { - if self.handle_shortcut_overlay_key(&key_event) { - return (InputResult::None, true); - } - if key_event.code == KeyCode::Esc { - let next_mode = esc_hint_mode(self.footer_mode, self.is_task_running); - if next_mode != self.footer_mode { - self.footer_mode = next_mode; - return (InputResult::None, true); - } - } else { - self.footer_mode = reset_mode_after_activity(self.footer_mode); - } let ActivePopup::Command(popup) = &mut self.active_popup else { unreachable!(); }; @@ -508,18 +478,6 @@ impl ChatComposer { /// Handle key events when file search popup is visible. fn handle_key_event_with_file_popup(&mut self, key_event: KeyEvent) -> (InputResult, bool) { - if self.handle_shortcut_overlay_key(&key_event) { - return (InputResult::None, true); - } - if key_event.code == KeyCode::Esc { - let next_mode = esc_hint_mode(self.footer_mode, self.is_task_running); - if next_mode != self.footer_mode { - self.footer_mode = next_mode; - return (InputResult::None, true); - } - } else { - self.footer_mode = reset_mode_after_activity(self.footer_mode); - } let ActivePopup::File(popup) = &mut self.active_popup else { unreachable!(); }; @@ -771,18 +729,6 @@ impl ChatComposer { /// Handle key event when no popup is visible. fn handle_key_event_without_popup(&mut self, key_event: KeyEvent) -> (InputResult, bool) { - if self.handle_shortcut_overlay_key(&key_event) { - return (InputResult::None, true); - } - if key_event.code == KeyCode::Esc { - let next_mode = esc_hint_mode(self.footer_mode, self.is_task_running); - if next_mode != self.footer_mode { - self.footer_mode = next_mode; - return (InputResult::None, true); - } - } else { - self.footer_mode = reset_mode_after_activity(self.footer_mode); - } match key_event { KeyEvent { code: KeyCode::Char('d'), @@ -926,10 +872,6 @@ impl ChatComposer { let now = Instant::now(); self.handle_paste_burst_flush(now); - if !matches!(input.code, KeyCode::Esc) { - self.footer_mode = reset_mode_after_activity(self.footer_mode); - } - // If we're capturing a burst and receive Enter, accumulate it instead of inserting. if matches!(input.code, KeyCode::Enter) && self.paste_burst.is_active() @@ -1203,47 +1145,6 @@ impl ChatComposer { false } - fn handle_shortcut_overlay_key(&mut self, key_event: &KeyEvent) -> bool { - if key_event.kind != KeyEventKind::Press { - return false; - } - - let toggles = match key_event.code { - KeyCode::Char('?') if key_event.modifiers.is_empty() => true, - KeyCode::BackTab => true, - KeyCode::Tab if key_event.modifiers.contains(KeyModifiers::SHIFT) => true, - _ => false, - }; - - if !toggles { - return false; - } - - let next = toggle_shortcut_mode(self.footer_mode, self.ctrl_c_quit_hint); - let changed = next != self.footer_mode; - self.footer_mode = next; - changed - } - - fn footer_props(&self) -> FooterProps { - FooterProps { - mode: self.footer_mode(), - esc_backtrack_hint: self.esc_backtrack_hint, - use_shift_enter_hint: self.use_shift_enter_hint, - is_task_running: self.is_task_running, - } - } - - fn footer_mode(&self) -> FooterMode { - if matches!(self.footer_mode, FooterMode::EscHint) { - FooterMode::EscHint - } else if self.ctrl_c_quit_hint { - FooterMode::CtrlCReminder - } else { - self.footer_mode - } - } - /// Synchronize `self.command_popup` with the current text in the /// textarea. This must be called after every modification that can change /// the text so the popup is shown/updated/hidden as appropriate. @@ -1327,18 +1228,10 @@ impl ChatComposer { pub fn set_task_running(&mut self, running: bool) { self.is_task_running = running; - if running { - self.footer_mode = prompt_mode(); - } } pub(crate) fn set_esc_backtrack_hint(&mut self, show: bool) { self.esc_backtrack_hint = show; - if show { - self.footer_mode = esc_hint_mode(self.footer_mode, self.is_task_running); - } else { - self.footer_mode = reset_mode_after_activity(self.footer_mode); - } } } @@ -1359,26 +1252,20 @@ impl WidgetRef for ChatComposer { popup.render_ref(popup_rect, buf); } ActivePopup::None => { - let footer_hint_height = footer_height(self.footer_props()); - let footer_spacing = if footer_hint_height > 0 { - FOOTER_SPACING_HEIGHT - } else { - 0 - }; - let hint_rect = if footer_spacing > 0 && footer_hint_height > 0 { - let [_, hint_rect] = Layout::vertical([ - Constraint::Length(footer_spacing), - Constraint::Length(footer_hint_height), - ]) - .areas(popup_rect); - hint_rect - } else { - popup_rect - }; - let mut footer_rect = hint_rect; - footer_rect.x = footer_rect.x.saturating_add(2); - footer_rect.width = footer_rect.width.saturating_sub(2); - render_footer(footer_rect, buf, self.footer_props()); + let mut hint_rect = popup_rect; + hint_rect.x += 2; + hint_rect.width = hint_rect.width.saturating_sub(2); + render_footer( + hint_rect, + buf, + FooterProps { + ctrl_c_quit_hint: self.ctrl_c_quit_hint, + is_task_running: self.is_task_running, + esc_backtrack_hint: self.esc_backtrack_hint, + use_shift_enter_hint: self.use_shift_enter_hint, + token_usage_info: self.token_usage_info.as_ref(), + }, + ); } } let style = user_message_style(terminal_palette::default_bg()); @@ -1417,7 +1304,6 @@ mod tests { use crate::bottom_pane::InputResult; use crate::bottom_pane::chat_composer::AttachedImage; use crate::bottom_pane::chat_composer::LARGE_PASTE_CHAR_THRESHOLD; - use crate::bottom_pane::footer::footer_height; use crate::bottom_pane::textarea::TextArea; use tokio::sync::mpsc::unbounded_channel; @@ -1448,7 +1334,7 @@ mod tests { let mut hint_row: Option<(u16, String)> = None; for y in 0..area.height { let row = row_to_string(y); - if row.contains("? for shortcuts") { + if row.contains(" send") { hint_row = Some((y, row)); break; } @@ -1475,81 +1361,6 @@ mod tests { ); } - fn snapshot_composer_state(name: &str, enhanced_keys_supported: bool, setup: F) - where - F: FnOnce(&mut ChatComposer), - { - use ratatui::Terminal; - use ratatui::backend::TestBackend; - - let width = 100; - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - enhanced_keys_supported, - "Ask Codex to do anything".to_string(), - false, - ); - setup(&mut composer); - let footer_lines = footer_height(composer.footer_props()); - let height = footer_lines + 8; - let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap(); - terminal - .draw(|f| f.render_widget_ref(composer, f.area())) - .unwrap(); - insta::assert_snapshot!(name, terminal.backend()); - } - - #[test] - fn footer_mode_snapshots() { - use crossterm::event::KeyCode; - use crossterm::event::KeyEvent; - use crossterm::event::KeyModifiers; - - snapshot_composer_state("footer_mode_shortcut_overlay", true, |composer| { - composer.set_esc_backtrack_hint(true); - let _ = - composer.handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE)); - }); - - snapshot_composer_state("footer_mode_ctrl_c_quit", true, |composer| { - composer.set_ctrl_c_quit_hint(true, true); - }); - - snapshot_composer_state("footer_mode_ctrl_c_interrupt", true, |composer| { - composer.set_task_running(true); - composer.set_ctrl_c_quit_hint(true, true); - }); - - snapshot_composer_state("footer_mode_ctrl_c_then_esc_hint", true, |composer| { - composer.set_ctrl_c_quit_hint(true, true); - let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); - }); - - snapshot_composer_state("footer_mode_esc_hint_from_overlay", true, |composer| { - let _ = - composer.handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE)); - let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); - }); - - snapshot_composer_state("footer_mode_esc_hint_backtrack", true, |composer| { - composer.set_esc_backtrack_hint(true); - let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); - }); - - snapshot_composer_state( - "footer_mode_overlay_then_external_esc_hint", - true, - |composer| { - let _ = composer - .handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE)); - composer.set_esc_backtrack_hint(true); - }, - ); - } - #[test] fn test_current_at_token_basic_cases() { let test_cases = vec![ diff --git a/codex-rs/tui/src/bottom_pane/footer.rs b/codex-rs/tui/src/bottom_pane/footer.rs index aa0659ed9f..77e2c106e5 100644 --- a/codex-rs/tui/src/bottom_pane/footer.rs +++ b/codex-rs/tui/src/bottom_pane/footer.rs @@ -1,206 +1,180 @@ +use codex_core::protocol::TokenUsageInfo; +use codex_protocol::num_format::format_si_suffix; use crossterm::event::KeyCode; use crossterm::event::KeyModifiers; use ratatui::buffer::Buffer; use ratatui::layout::Rect; +use ratatui::style::Color; +use ratatui::style::Modifier; +use ratatui::style::Style; use ratatui::style::Stylize; use ratatui::text::Line; use ratatui::text::Span; use ratatui::widgets::WidgetRef; +use crate::key_hint; + #[derive(Clone, Copy, Debug)] -pub(crate) struct FooterProps { - pub(crate) mode: FooterMode, +pub(crate) struct FooterProps<'a> { + pub(crate) ctrl_c_quit_hint: bool, + pub(crate) is_task_running: bool, pub(crate) esc_backtrack_hint: bool, pub(crate) use_shift_enter_hint: bool, - pub(crate) is_task_running: bool, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub(crate) enum FooterMode { - CtrlCReminder, - ShortcutPrompt, - ShortcutOverlay, - EscHint, -} - -pub(crate) fn toggle_shortcut_mode(current: FooterMode, ctrl_c_hint: bool) -> FooterMode { - if ctrl_c_hint { - return current; - } - match current { - FooterMode::ShortcutOverlay | FooterMode::CtrlCReminder => FooterMode::ShortcutPrompt, - _ => FooterMode::ShortcutOverlay, - } -} - -pub(crate) fn esc_hint_mode(current: FooterMode, is_task_running: bool) -> FooterMode { - if is_task_running { - current - } else { - FooterMode::EscHint - } -} - -pub(crate) fn reset_mode_after_activity(current: FooterMode) -> FooterMode { - match current { - FooterMode::EscHint | FooterMode::ShortcutOverlay => FooterMode::ShortcutPrompt, - other => other, - } -} - -pub(crate) fn prompt_mode() -> FooterMode { - FooterMode::ShortcutPrompt -} - -pub(crate) fn footer_height(props: FooterProps) -> u16 { - footer_lines(props).len() as u16 -} - -pub(crate) fn render_footer(area: Rect, buf: &mut Buffer, props: FooterProps) { - let lines = footer_lines(props); - for (idx, line) in lines.into_iter().enumerate() { - let y = area.y + idx as u16; - if y >= area.y + area.height { - break; - } - let row = Rect::new(area.x, y, area.width, 1); - line.render_ref(row, buf); - } -} - -fn footer_lines(props: FooterProps) -> Vec> { - match props.mode { - FooterMode::CtrlCReminder => { - vec![ctrl_c_reminder_line(CtrlCReminderState { - is_task_running: props.is_task_running, - })] - } - FooterMode::ShortcutPrompt => vec![Line::from(vec!["? for shortcuts".dim()])], - FooterMode::ShortcutOverlay => shortcut_overlay_lines(ShortcutsState { - use_shift_enter_hint: props.use_shift_enter_hint, - esc_backtrack_hint: props.esc_backtrack_hint, - is_task_running: props.is_task_running, - }), - FooterMode::EscHint => { - vec![esc_hint_line(ShortcutsState { - use_shift_enter_hint: props.use_shift_enter_hint, - esc_backtrack_hint: props.esc_backtrack_hint, - is_task_running: props.is_task_running, - })] - } - } + pub(crate) token_usage_info: Option<&'a TokenUsageInfo>, } #[derive(Clone, Copy, Debug)] struct CtrlCReminderState { - is_task_running: bool, + pub(crate) is_task_running: bool, } #[derive(Clone, Copy, Debug)] struct ShortcutsState { - use_shift_enter_hint: bool, - esc_backtrack_hint: bool, - is_task_running: bool, + pub(crate) use_shift_enter_hint: bool, + pub(crate) esc_backtrack_hint: bool, } -fn ctrl_c_reminder_line(state: CtrlCReminderState) -> Line<'static> { - let action = if state.is_task_running { - "interrupt" +#[derive(Clone, Copy, Debug)] +enum FooterContent { + Shortcuts(ShortcutsState), + CtrlCReminder(CtrlCReminderState), +} + +pub(crate) fn render_footer(area: Rect, buf: &mut Buffer, props: FooterProps<'_>) { + let content = if props.ctrl_c_quit_hint { + FooterContent::CtrlCReminder(CtrlCReminderState { + is_task_running: props.is_task_running, + }) } else { - "quit" + FooterContent::Shortcuts(ShortcutsState { + use_shift_enter_hint: props.use_shift_enter_hint, + esc_backtrack_hint: props.esc_backtrack_hint, + }) }; - Line::from(vec![ - Span::from(format!(" ctrl + c again to {action}")).dim(), - ]) + + let mut spans = footer_spans(content); + if let Some(token_usage_info) = props.token_usage_info { + append_token_usage_spans(&mut spans, token_usage_info); + } + + let spans = spans + .into_iter() + .map(|span| span.patch_style(Style::default().dim())) + .collect::>(); + Line::from(spans).render_ref(area, buf); } -fn esc_hint_line(state: ShortcutsState) -> Line<'static> { - let text = if state.esc_backtrack_hint { - " esc again to edit previous message" - } else { - " esc esc to edit previous message" - }; - Line::from(vec![Span::from(text).dim()]) +fn footer_spans(content: FooterContent) -> Vec> { + match content { + FooterContent::Shortcuts(state) => shortcuts_spans(state), + FooterContent::CtrlCReminder(state) => ctrl_c_reminder_spans(state), + } } -fn shortcut_overlay_lines(state: ShortcutsState) -> Vec> { - let mut rendered = Vec::new(); +fn append_token_usage_spans(spans: &mut Vec>, token_usage_info: &TokenUsageInfo) { + let token_usage = &token_usage_info.total_token_usage; + spans.push(" ".into()); + spans.push( + Span::from(format!( + "{} tokens used", + format_si_suffix(token_usage.blended_total()) + )) + .style(Style::default().add_modifier(Modifier::DIM)), + ); + + 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 { + last_token_usage.percent_of_context_window_remaining(context_window) + } else { + 100 + }; + + let context_style = if percent_remaining < 20 { + Style::default().fg(Color::Yellow) + } else { + Style::default().add_modifier(Modifier::DIM) + }; + spans.push(" ".into()); + spans.push(Span::styled( + format!("{percent_remaining}% context left"), + context_style, + )); + } +} + +fn shortcuts_spans(state: ShortcutsState) -> Vec> { + let mut spans = Vec::new(); for descriptor in SHORTCUTS { - if let Some(text) = descriptor.overlay_entry(state) { - rendered.push(text); + if let Some(segment) = descriptor.footer_segment(state) { + if !segment.prefix.is_empty() { + spans.push(segment.prefix.into()); + } + spans.push(segment.binding.span()); + spans.push(segment.label.into()); } } - build_columns(rendered) + spans } -fn build_columns(entries: Vec) -> Vec> { - if entries.is_empty() { - return Vec::new(); - } - - const COLUMNS: usize = 3; - const MAX_PADDED_WIDTHS: [usize; COLUMNS - 1] = [24, 28]; - const MIN_PADDED_WIDTHS: [usize; COLUMNS - 1] = [22, 0]; - - let rows = entries.len().div_ceil(COLUMNS); - let mut column_widths = [0usize; COLUMNS]; - - for (idx, entry) in entries.iter().enumerate() { - let column = idx % COLUMNS; - column_widths[column] = column_widths[column].max(entry.len()); - } - - let mut lines = Vec::new(); - for row in 0..rows { - let mut line = String::from(" "); - for col in 0..COLUMNS { - let idx = row * COLUMNS + col; - if idx >= entries.len() { - continue; - } - let entry = &entries[idx]; - if col < COLUMNS - 1 { - let max_width = MAX_PADDED_WIDTHS[col]; - let mut target_width = column_widths[col]; - target_width = target_width.max(MIN_PADDED_WIDTHS[col]).min(max_width); - let pad_width = target_width + 2; - line.push_str(&format!("{entry: Vec> { + let followup = if state.is_task_running { + " to interrupt" + } else { + " to quit" + }; + vec![ + " ".into(), + key_hint::ctrl('C'), + " again".into(), + followup.into(), + ] } -#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[derive(Clone, Copy, Debug)] +struct FooterSegment { + prefix: &'static str, + binding: ShortcutBinding, + label: &'static str, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)] enum ShortcutId { - Commands, + Send, InsertNewline, - ChangeMode, - FilePaths, - PasteImage, - EditPrevious, - Quit, ShowTranscript, + Quit, + EditPrevious, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] struct ShortcutBinding { code: KeyCode, modifiers: KeyModifiers, - overlay_text: &'static str, + display: ShortcutDisplay, condition: DisplayCondition, } impl ShortcutBinding { - fn matches(&self, state: ShortcutsState) -> bool { - self.condition.matches(state) + fn span(&self) -> Span<'static> { + self.display.into_span() + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum ShortcutDisplay { + Plain(&'static str), + Ctrl(char), + Shift(char), +} + +impl ShortcutDisplay { + fn into_span(self) -> Span<'static> { + match self { + ShortcutDisplay::Plain(text) => key_hint::plain(text), + ShortcutDisplay::Ctrl(ch) => key_hint::ctrl(ch), + ShortcutDisplay::Shift(ch) => key_hint::shift(ch), + } } } @@ -224,55 +198,49 @@ impl DisplayCondition { struct ShortcutDescriptor { id: ShortcutId, bindings: &'static [ShortcutBinding], - prefix: &'static str, - label: &'static str, + footer_label: &'static str, + footer_prefix: &'static str, } impl ShortcutDescriptor { - fn binding_for(&self, state: ShortcutsState) -> Option<&'static ShortcutBinding> { - self.bindings.iter().find(|binding| binding.matches(state)) + fn binding_for(&self, state: ShortcutsState) -> Option { + self.bindings + .iter() + .find(|binding| binding.condition.matches(state)) + .copied() } - fn overlay_entry(&self, state: ShortcutsState) -> Option { + fn should_show(&self, state: ShortcutsState) -> bool { + match self.id { + ShortcutId::EditPrevious => state.esc_backtrack_hint, + _ => true, + } + } + + fn footer_segment(&self, state: ShortcutsState) -> Option { + if !self.should_show(state) { + return None; + } let binding = self.binding_for(state)?; - let label = match self.id { - ShortcutId::Quit => { - if state.is_task_running { - " to interrupt" - } else { - self.label - } - } - ShortcutId::EditPrevious => { - if state.esc_backtrack_hint { - " again to edit previous message" - } else { - " esc to edit previous message" - } - } - _ => self.label, - }; - let text = match self.id { - ShortcutId::Quit if state.is_task_running => { - format!("{}{} to interrupt", self.prefix, binding.overlay_text) - } - _ => format!("{}{}{}", self.prefix, binding.overlay_text, label), - }; - Some(text) + Some(FooterSegment { + prefix: self.footer_prefix, + binding, + label: self.footer_label, + }) } } const SHORTCUTS: &[ShortcutDescriptor] = &[ ShortcutDescriptor { - id: ShortcutId::Commands, + id: ShortcutId::Send, bindings: &[ShortcutBinding { - code: KeyCode::Char('/'), + code: KeyCode::Enter, modifiers: KeyModifiers::NONE, - overlay_text: "/", + display: ShortcutDisplay::Plain("⏎"), condition: DisplayCondition::Always, }], - prefix: "", - label: " for commands", + footer_label: " send ", + footer_prefix: "", }, ShortcutDescriptor { id: ShortcutId::InsertNewline, @@ -280,165 +248,138 @@ const SHORTCUTS: &[ShortcutDescriptor] = &[ ShortcutBinding { code: KeyCode::Enter, modifiers: KeyModifiers::SHIFT, - overlay_text: "shift + enter", + display: ShortcutDisplay::Shift('⏎'), condition: DisplayCondition::WhenShiftEnterHint, }, ShortcutBinding { code: KeyCode::Char('j'), modifiers: KeyModifiers::CONTROL, - overlay_text: "ctrl + j", + display: ShortcutDisplay::Ctrl('J'), condition: DisplayCondition::WhenNotShiftEnterHint, }, ], - prefix: "", - label: " for newline", - }, - ShortcutDescriptor { - id: ShortcutId::ChangeMode, - bindings: &[ShortcutBinding { - code: KeyCode::BackTab, - modifiers: KeyModifiers::SHIFT, - overlay_text: "shift + tab", - condition: DisplayCondition::Always, - }], - prefix: "", - label: " to change mode", - }, - ShortcutDescriptor { - id: ShortcutId::FilePaths, - bindings: &[ShortcutBinding { - code: KeyCode::Char('@'), - modifiers: KeyModifiers::NONE, - overlay_text: "@", - condition: DisplayCondition::Always, - }], - prefix: "", - label: " for file paths", - }, - ShortcutDescriptor { - id: ShortcutId::PasteImage, - bindings: &[ShortcutBinding { - code: KeyCode::Char('v'), - modifiers: KeyModifiers::CONTROL, - overlay_text: "ctrl + v", - condition: DisplayCondition::Always, - }], - prefix: "", - label: " to paste images", - }, - ShortcutDescriptor { - id: ShortcutId::EditPrevious, - bindings: &[ShortcutBinding { - code: KeyCode::Esc, - modifiers: KeyModifiers::NONE, - overlay_text: "esc", - condition: DisplayCondition::Always, - }], - prefix: "", - label: "", - }, - ShortcutDescriptor { - id: ShortcutId::Quit, - bindings: &[ShortcutBinding { - code: KeyCode::Char('c'), - modifiers: KeyModifiers::CONTROL, - overlay_text: "ctrl + c", - condition: DisplayCondition::Always, - }], - prefix: "", - label: " to exit", + footer_label: " newline ", + footer_prefix: "", }, ShortcutDescriptor { id: ShortcutId::ShowTranscript, bindings: &[ShortcutBinding { code: KeyCode::Char('t'), modifiers: KeyModifiers::CONTROL, - overlay_text: "ctrl + t", + display: ShortcutDisplay::Ctrl('T'), condition: DisplayCondition::Always, }], - prefix: "", - label: " to view transcript", + footer_label: " transcript ", + footer_prefix: "", + }, + ShortcutDescriptor { + id: ShortcutId::Quit, + bindings: &[ShortcutBinding { + code: KeyCode::Char('c'), + modifiers: KeyModifiers::CONTROL, + display: ShortcutDisplay::Ctrl('C'), + condition: DisplayCondition::Always, + }], + footer_label: " quit", + footer_prefix: "", + }, + ShortcutDescriptor { + id: ShortcutId::EditPrevious, + bindings: &[ShortcutBinding { + code: KeyCode::Esc, + modifiers: KeyModifiers::NONE, + display: ShortcutDisplay::Plain("Esc"), + condition: DisplayCondition::Always, + }], + footer_label: " edit prev", + footer_prefix: " ", }, ]; #[cfg(test)] mod tests { use super::*; + use codex_core::protocol::TokenUsage; use insta::assert_snapshot; use ratatui::Terminal; use ratatui::backend::TestBackend; - fn snapshot_footer(name: &str, props: FooterProps) { - let height = footer_height(props).max(1); - let mut terminal = Terminal::new(TestBackend::new(80, height)).unwrap(); + fn snapshot_footer(name: &str, props: FooterProps<'_>) { + let mut terminal = Terminal::new(TestBackend::new(80, 3)).unwrap(); terminal .draw(|f| { - let area = Rect::new(0, 0, f.area().width, height); + let area = Rect::new(0, 0, f.area().width, 1); render_footer(area, f.buffer_mut(), props); }) .unwrap(); assert_snapshot!(name, terminal.backend()); } + fn token_usage(total_tokens: u64, last_tokens: u64, context_window: u64) -> TokenUsageInfo { + let usage = TokenUsage { + input_tokens: total_tokens, + cached_input_tokens: 0, + output_tokens: 0, + reasoning_output_tokens: 0, + total_tokens, + }; + let last = TokenUsage { + input_tokens: last_tokens, + cached_input_tokens: 0, + output_tokens: 0, + reasoning_output_tokens: 0, + total_tokens: last_tokens, + }; + TokenUsageInfo { + total_token_usage: usage, + last_token_usage: last, + model_context_window: Some(context_window), + } + } + #[test] fn footer_snapshots() { snapshot_footer( "footer_shortcuts_default", FooterProps { - mode: FooterMode::ShortcutPrompt, + ctrl_c_quit_hint: false, + is_task_running: false, esc_backtrack_hint: false, use_shift_enter_hint: false, - is_task_running: false, + token_usage_info: None, }, ); snapshot_footer( "footer_shortcuts_shift_and_esc", FooterProps { - mode: FooterMode::ShortcutOverlay, + ctrl_c_quit_hint: false, + is_task_running: false, esc_backtrack_hint: true, use_shift_enter_hint: true, - is_task_running: false, + token_usage_info: Some(&token_usage(4_200, 900, 8_000)), }, ); snapshot_footer( "footer_ctrl_c_quit_idle", FooterProps { - mode: FooterMode::CtrlCReminder, + ctrl_c_quit_hint: true, + is_task_running: false, esc_backtrack_hint: false, use_shift_enter_hint: false, - is_task_running: false, + token_usage_info: None, }, ); snapshot_footer( "footer_ctrl_c_quit_running", FooterProps { - mode: FooterMode::CtrlCReminder, - esc_backtrack_hint: false, - use_shift_enter_hint: false, + ctrl_c_quit_hint: true, is_task_running: true, - }, - ); - - snapshot_footer( - "footer_esc_hint_idle", - FooterProps { - mode: FooterMode::EscHint, esc_backtrack_hint: false, use_shift_enter_hint: false, - is_task_running: false, - }, - ); - - snapshot_footer( - "footer_esc_hint_primed", - FooterProps { - mode: FooterMode::EscHint, - esc_backtrack_hint: true, - use_shift_enter_hint: false, - is_task_running: false, + token_usage_info: None, }, ); } diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index c65067b581..a01ffb498f 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -4,6 +4,7 @@ use std::path::PathBuf; use crate::app_event_sender::AppEventSender; use crate::tui::FrameRequester; use bottom_pane_view::BottomPaneView; +use codex_core::protocol::TokenUsageInfo; use codex_file_search::FileMatch; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; @@ -376,6 +377,13 @@ impl BottomPane { !self.is_task_running && self.view_stack.is_empty() && !self.composer.popup_active() } + /// Update the *context-window remaining* indicator in the composer. This + /// is forwarded directly to the underlying `ChatComposer`. + pub(crate) fn set_token_usage(&mut self, token_info: Option) { + self.composer.set_token_usage(token_info); + self.request_redraw(); + } + pub(crate) fn show_view(&mut self, view: Box) { self.push_view(view); } diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__backspace_after_pastes.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__backspace_after_pastes.snap index 3c84d708b0..3f57cab0f6 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__backspace_after_pastes.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__backspace_after_pastes.snap @@ -11,4 +11,4 @@ expression: terminal.backend() " " " " " " -" ? for shortcuts " +" ⏎ send ⌃J newline ⌃T transcript ⌃C quit " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__empty.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__empty.snap index 3bdd7260ff..beccbaf7c7 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__empty.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__empty.snap @@ -11,4 +11,4 @@ expression: terminal.backend() " " " " " " -" ? for shortcuts " +" ⏎ send ⌃J newline ⌃T transcript ⌃C quit " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_interrupt.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_interrupt.snap deleted file mode 100644 index 6f8ad7b98c..0000000000 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_interrupt.snap +++ /dev/null @@ -1,13 +0,0 @@ ---- -source: tui/src/bottom_pane/chat_composer.rs -expression: terminal.backend() ---- -" " -"› Ask Codex to do anything " -" " -" " -" " -" " -" " -" " -" ctrl + c again to interrupt " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_quit.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_quit.snap deleted file mode 100644 index f7917277fd..0000000000 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_quit.snap +++ /dev/null @@ -1,13 +0,0 @@ ---- -source: tui/src/bottom_pane/chat_composer.rs -expression: terminal.backend() ---- -" " -"› Ask Codex to do anything " -" " -" " -" " -" " -" " -" " -" ctrl + c again to quit " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_then_esc_hint.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_then_esc_hint.snap deleted file mode 100644 index 99d263da31..0000000000 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_then_esc_hint.snap +++ /dev/null @@ -1,13 +0,0 @@ ---- -source: tui/src/bottom_pane/chat_composer.rs -expression: terminal.backend() ---- -" " -"› Ask Codex to do anything " -" " -" " -" " -" " -" " -" " -" esc esc to edit previous message " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_esc_hint_backtrack.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_esc_hint_backtrack.snap deleted file mode 100644 index 404aa48d8c..0000000000 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_esc_hint_backtrack.snap +++ /dev/null @@ -1,13 +0,0 @@ ---- -source: tui/src/bottom_pane/chat_composer.rs -expression: terminal.backend() ---- -" " -"› Ask Codex to do anything " -" " -" " -" " -" " -" " -" " -" esc again to edit previous message " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_esc_hint_from_overlay.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_esc_hint_from_overlay.snap deleted file mode 100644 index 99d263da31..0000000000 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_esc_hint_from_overlay.snap +++ /dev/null @@ -1,13 +0,0 @@ ---- -source: tui/src/bottom_pane/chat_composer.rs -expression: terminal.backend() ---- -" " -"› Ask Codex to do anything " -" " -" " -" " -" " -" " -" " -" esc esc to edit previous message " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_overlay_then_external_esc_hint.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_overlay_then_external_esc_hint.snap deleted file mode 100644 index 404aa48d8c..0000000000 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_overlay_then_external_esc_hint.snap +++ /dev/null @@ -1,13 +0,0 @@ ---- -source: tui/src/bottom_pane/chat_composer.rs -expression: terminal.backend() ---- -" " -"› Ask Codex to do anything " -" " -" " -" " -" " -" " -" " -" esc again to edit previous message " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay.snap deleted file mode 100644 index 9633aab2ae..0000000000 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay.snap +++ /dev/null @@ -1,15 +0,0 @@ ---- -source: tui/src/bottom_pane/chat_composer.rs -expression: terminal.backend() ---- -" " -"› Ask Codex to do anything " -" " -" " -" " -" " -" " -" " -" / for commands shift + enter for newline shift + tab to change mode " -" @ for file paths ctrl + v to paste images esc again to edit previous message " -" ctrl + c to exit ctrl + t to view transcript " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__large.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__large.snap index 53cfb9d76d..263c7cc549 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__large.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__large.snap @@ -11,4 +11,4 @@ expression: terminal.backend() " " " " " " -" ? for shortcuts " +" ⏎ send ⌃J newline ⌃T transcript ⌃C quit " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__multiple_pastes.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__multiple_pastes.snap index 52474a1fd8..beb2d8e464 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__multiple_pastes.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__multiple_pastes.snap @@ -11,4 +11,4 @@ expression: terminal.backend() " " " " " " -" ? for shortcuts " +" ⏎ send ⌃J newline ⌃T transcript ⌃C quit " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__small.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__small.snap index d4c6bdedf6..41f0f03d8b 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__small.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__small.snap @@ -11,4 +11,4 @@ expression: terminal.backend() " " " " " " -" ? for shortcuts " +" ⏎ send ⌃J newline ⌃T transcript ⌃C quit " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_ctrl_c_quit_idle.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_ctrl_c_quit_idle.snap index 31a1b743b8..32482ba1cd 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_ctrl_c_quit_idle.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_ctrl_c_quit_idle.snap @@ -2,4 +2,6 @@ source: tui/src/bottom_pane/footer.rs expression: terminal.backend() --- -" ctrl + c again to quit " +" ⌃C again to quit " +" " +" " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_ctrl_c_quit_running.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_ctrl_c_quit_running.snap index 9979372a1b..8c9c2b0d46 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_ctrl_c_quit_running.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_ctrl_c_quit_running.snap @@ -2,4 +2,6 @@ source: tui/src/bottom_pane/footer.rs expression: terminal.backend() --- -" ctrl + c again to interrupt " +" ⌃C again to interrupt " +" " +" " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_esc_hint_idle.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_esc_hint_idle.snap deleted file mode 100644 index b2333b025f..0000000000 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_esc_hint_idle.snap +++ /dev/null @@ -1,5 +0,0 @@ ---- -source: tui/src/bottom_pane/footer.rs -expression: terminal.backend() ---- -" esc esc to edit previous message " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_esc_hint_primed.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_esc_hint_primed.snap deleted file mode 100644 index 20f9b178b4..0000000000 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_esc_hint_primed.snap +++ /dev/null @@ -1,5 +0,0 @@ ---- -source: tui/src/bottom_pane/footer.rs -expression: terminal.backend() ---- -" esc again to edit previous message " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_default.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_default.snap index aff75fdcdf..8c8a10df85 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_default.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_default.snap @@ -2,4 +2,6 @@ source: tui/src/bottom_pane/footer.rs expression: terminal.backend() --- -"? for shortcuts " +"⏎ send ⌃J newline ⌃T transcript ⌃C quit " +" " +" " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_shift_and_esc.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_shift_and_esc.snap index 111c14136a..9b94d89f98 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_shift_and_esc.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_shift_and_esc.snap @@ -2,6 +2,6 @@ source: tui/src/bottom_pane/footer.rs expression: terminal.backend() --- -" / for commands shift + enter for newline shift + tab to change m" -" @ for file paths ctrl + v to paste images esc again to edit previ" -" ctrl + c to exit ctrl + t to view transcript " +"⏎ send ⇧⏎ newline ⌃T transcript ⌃C quit Esc edit prev 4.20K tokens use" +" " +" " diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 9a36addb0a..724feb29f2 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -395,6 +395,7 @@ impl ChatWidget { pub(crate) fn set_token_info(&mut self, info: Option) { if info.is_some() { + self.bottom_pane.set_token_usage(info.clone()); self.token_info = info; } } @@ -1974,6 +1975,7 @@ impl ChatWidget { pub(crate) fn clear_token_usage(&mut self) { self.token_info = None; + self.bottom_pane.set_token_usage(None); } pub fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_exec_and_status_layout_vt100_snapshot.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_exec_and_status_layout_vt100_snapshot.snap index 1698f2c77f..248a57f8f3 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_exec_and_status_layout_vt100_snapshot.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_exec_and_status_layout_vt100_snapshot.snap @@ -14,5 +14,4 @@ expression: term.backend().vt100().screen().contents() › Summarize recent commits - - ? for shortcuts + ⏎ send ⌃J newline ⌃T transcript ⌃C quit diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_active.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_active.snap index fcd146282b..9c94206a44 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_active.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_active.snap @@ -8,6 +8,5 @@ expression: terminal.backend() " " "› Ask Codex to do anything " " " -" " -" ? for shortcuts " +" ⏎ send ⌃J newline ⌃T transcript ⌃C quit " " " diff --git a/codex-rs/tui/src/key_hint.rs b/codex-rs/tui/src/key_hint.rs index f68a493e9c..80f91d50b2 100644 --- a/codex-rs/tui/src/key_hint.rs +++ b/codex-rs/tui/src/key_hint.rs @@ -10,6 +10,20 @@ const ALT_PREFIX: &str = "⌥"; #[cfg(all(not(test), not(target_os = "macos")))] const ALT_PREFIX: &str = "Alt+"; +#[cfg(test)] +const CTRL_PREFIX: &str = "⌃"; +#[cfg(all(not(test), target_os = "macos"))] +const CTRL_PREFIX: &str = "⌃"; +#[cfg(all(not(test), not(target_os = "macos")))] +const CTRL_PREFIX: &str = "Ctrl+"; + +#[cfg(test)] +const SHIFT_PREFIX: &str = "⇧"; +#[cfg(all(not(test), target_os = "macos"))] +const SHIFT_PREFIX: &str = "⇧"; +#[cfg(all(not(test), not(target_os = "macos")))] +const SHIFT_PREFIX: &str = "Shift+"; + fn key_hint_style() -> Style { Style::default().bold() } @@ -18,6 +32,18 @@ fn modifier_span(prefix: &str, key: impl Display) -> Span<'static> { Span::styled(format!("{prefix}{key}"), key_hint_style()) } +pub(crate) fn ctrl(key: impl Display) -> Span<'static> { + modifier_span(CTRL_PREFIX, key) +} + pub(crate) fn alt(key: impl Display) -> Span<'static> { modifier_span(ALT_PREFIX, key) } + +pub(crate) fn shift(key: impl Display) -> Span<'static> { + modifier_span(SHIFT_PREFIX, key) +} + +pub(crate) fn plain(key: impl Display) -> Span<'static> { + Span::styled(format!("{key}"), key_hint_style()) +} From d7286e9829bae93adb8c64f77a32bed23ebf3b71 Mon Sep 17 00:00:00 2001 From: Thibault Sottiaux Date: Sat, 27 Sep 2025 13:25:09 -0700 Subject: [PATCH 07/16] chore: remove model upgrade popup (#4332) --- codex-rs/core/src/internal_storage.rs | 89 ------------ codex-rs/core/src/lib.rs | 1 - codex-rs/tui/src/ascii_animation.rs | 1 + codex-rs/tui/src/lib.rs | 188 +------------------------- codex-rs/tui/src/new_model_popup.rs | 179 ------------------------ 5 files changed, 4 insertions(+), 454 deletions(-) delete mode 100644 codex-rs/core/src/internal_storage.rs delete mode 100644 codex-rs/tui/src/new_model_popup.rs diff --git a/codex-rs/core/src/internal_storage.rs b/codex-rs/core/src/internal_storage.rs deleted file mode 100644 index 666d9287eb..0000000000 --- a/codex-rs/core/src/internal_storage.rs +++ /dev/null @@ -1,89 +0,0 @@ -use anyhow::Context; -use serde::Deserialize; -use serde::Serialize; -use std::io::ErrorKind; -use std::path::Path; -use std::path::PathBuf; - -pub(crate) const INTERNAL_STORAGE_FILE: &str = "internal_storage.json"; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct InternalStorage { - #[serde(skip)] - storage_path: PathBuf, - #[serde(default = "default_gpt_5_codex_model_prompt_seen")] - pub gpt_5_codex_model_prompt_seen: bool, -} - -const fn default_gpt_5_codex_model_prompt_seen() -> bool { - true -} - -impl Default for InternalStorage { - fn default() -> Self { - Self { - storage_path: PathBuf::new(), - gpt_5_codex_model_prompt_seen: default_gpt_5_codex_model_prompt_seen(), - } - } -} - -// TODO(jif) generalise all the file writers and build proper async channel inserters. -impl InternalStorage { - pub fn load(codex_home: &Path) -> Self { - let storage_path = codex_home.join(INTERNAL_STORAGE_FILE); - - match std::fs::read_to_string(&storage_path) { - Ok(serialized) => match serde_json::from_str::(&serialized) { - Ok(mut storage) => { - storage.storage_path = storage_path; - storage - } - Err(error) => { - tracing::warn!("failed to parse internal storage: {error:?}"); - Self::empty(storage_path) - } - }, - Err(error) => { - if error.kind() == ErrorKind::NotFound { - tracing::debug!( - "internal storage not found at {}; initializing defaults", - storage_path.display() - ); - } else { - tracing::warn!("failed to read internal storage: {error:?}"); - } - Self::empty(storage_path) - } - } - } - - fn empty(storage_path: PathBuf) -> Self { - Self { - storage_path, - ..Default::default() - } - } - - pub async fn persist(&self) -> anyhow::Result<()> { - let serialized = serde_json::to_string_pretty(self)?; - - if let Some(parent) = self.storage_path.parent() { - tokio::fs::create_dir_all(parent).await.with_context(|| { - format!( - "failed to create internal storage directory at {}", - parent.display() - ) - })?; - } - - tokio::fs::write(&self.storage_path, serialized) - .await - .with_context(|| { - format!( - "failed to persist internal storage at {}", - self.storage_path.display() - ) - }) - } -} diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index ad040ec855..f3f6106692 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -29,7 +29,6 @@ mod exec_command; pub mod exec_env; mod flags; pub mod git_info; -pub mod internal_storage; pub mod landlock; mod mcp_connection_manager; mod mcp_tool_call; diff --git a/codex-rs/tui/src/ascii_animation.rs b/codex-rs/tui/src/ascii_animation.rs index ee90191181..b2d9fc1d19 100644 --- a/codex-rs/tui/src/ascii_animation.rs +++ b/codex-rs/tui/src/ascii_animation.rs @@ -90,6 +90,7 @@ impl AsciiAnimation { true } + #[allow(dead_code)] pub(crate) fn request_frame(&self) { self.request_frame.schedule_frame(); } diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 4c5e7ee97c..d82fa19acc 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -12,10 +12,8 @@ use codex_core::RolloutRecorder; use codex_core::config::Config; use codex_core::config::ConfigOverrides; use codex_core::config::ConfigToml; -use codex_core::config::GPT_5_CODEX_MEDIUM_MODEL; use codex_core::config::find_codex_home; use codex_core::config::load_config_as_toml_with_cli_overrides; -use codex_core::config::persist_model_selection; use codex_core::find_conversation_path_by_id_str; use codex_core::protocol::AskForApproval; use codex_core::protocol::SandboxPolicy; @@ -54,7 +52,6 @@ pub mod live_wrap; mod markdown; mod markdown_render; mod markdown_stream; -mod new_model_popup; pub mod onboarding; mod pager_overlay; mod render; @@ -79,14 +76,11 @@ pub mod test_backend; #[cfg(not(debug_assertions))] mod updates; -use crate::new_model_popup::ModelUpgradeDecision; -use crate::new_model_popup::run_model_upgrade_popup; use crate::onboarding::TrustDirectorySelection; use crate::onboarding::onboarding_screen::OnboardingScreenArgs; use crate::onboarding::onboarding_screen::run_onboarding_app; use crate::tui::Tui; pub use cli::Cli; -use codex_core::internal_storage::InternalStorage; // (tests access modules directly within the crate) @@ -204,8 +198,6 @@ pub async fn run_main( cli_profile_override, )?; - let internal_storage = InternalStorage::load(&config.codex_home); - let log_dir = codex_core::config::log_dir(&config)?; std::fs::create_dir_all(&log_dir)?; // Open (or create) your log file, appending to it. @@ -249,21 +241,14 @@ pub async fn run_main( let _ = tracing_subscriber::registry().with(file_layer).try_init(); - run_ratatui_app( - cli, - config, - internal_storage, - active_profile, - should_show_trust_screen, - ) - .await - .map_err(|err| std::io::Error::other(err.to_string())) + run_ratatui_app(cli, config, active_profile, should_show_trust_screen) + .await + .map_err(|err| std::io::Error::other(err.to_string())) } async fn run_ratatui_app( cli: Cli, config: Config, - mut internal_storage: InternalStorage, active_profile: Option, should_show_trust_screen: bool, ) -> color_eyre::Result { @@ -389,36 +374,6 @@ async fn run_ratatui_app( resume_picker::ResumeSelection::StartFresh }; - if should_show_model_rollout_prompt( - &cli, - &config, - active_profile.as_deref(), - internal_storage.gpt_5_codex_model_prompt_seen, - ) { - internal_storage.gpt_5_codex_model_prompt_seen = true; - if let Err(e) = internal_storage.persist().await { - error!("Failed to persist internal storage: {e:?}"); - } - - let upgrade_decision = run_model_upgrade_popup(&mut tui).await?; - let switch_to_new_model = upgrade_decision == ModelUpgradeDecision::Switch; - - if switch_to_new_model { - config.model = GPT_5_CODEX_MEDIUM_MODEL.to_owned(); - config.model_reasoning_effort = None; - if let Err(e) = persist_model_selection( - &config.codex_home, - active_profile.as_deref(), - &config.model, - config.model_reasoning_effort, - ) - .await - { - error!("Failed to persist model selection: {e:?}"); - } - } - } - let Cli { prompt, images, .. } = cli; let app_result = App::run( @@ -532,140 +487,3 @@ fn should_show_login_screen(login_status: LoginStatus, config: &Config) -> bool login_status == LoginStatus::NotAuthenticated } - -fn should_show_model_rollout_prompt( - cli: &Cli, - config: &Config, - active_profile: Option<&str>, - gpt_5_codex_model_prompt_seen: bool, -) -> bool { - let login_status = get_login_status(config); - - active_profile.is_none() - && cli.model.is_none() - && !gpt_5_codex_model_prompt_seen - && config.model_provider.requires_openai_auth - && matches!(login_status, LoginStatus::AuthMode(AuthMode::ChatGPT)) - && !cli.oss -} - -#[cfg(test)] -mod tests { - use super::*; - use clap::Parser; - use codex_core::auth::AuthDotJson; - use codex_core::auth::get_auth_file; - use codex_core::auth::login_with_api_key; - use codex_core::auth::write_auth_json; - use codex_core::token_data::IdTokenInfo; - use codex_core::token_data::TokenData; - use std::sync::atomic::AtomicUsize; - use std::sync::atomic::Ordering; - - fn get_next_codex_home() -> PathBuf { - static NEXT_CODEX_HOME_ID: AtomicUsize = AtomicUsize::new(0); - let mut codex_home = std::env::temp_dir(); - let unique_suffix = format!( - "codex_tui_test_{}_{}", - std::process::id(), - NEXT_CODEX_HOME_ID.fetch_add(1, Ordering::Relaxed) - ); - codex_home.push(unique_suffix); - codex_home - } - - fn make_config() -> Config { - // Create a unique CODEX_HOME per test to isolate auth.json writes. - let codex_home = get_next_codex_home(); - std::fs::create_dir_all(&codex_home).expect("create unique CODEX_HOME"); - Config::load_from_base_config_with_overrides( - ConfigToml::default(), - ConfigOverrides::default(), - codex_home, - ) - .expect("load default config") - } - - /// Test helper to write an `auth.json` with the requested auth mode into the - /// provided CODEX_HOME directory. This ensures `get_login_status()` reads the - /// intended mode deterministically. - fn set_auth_method(codex_home: &std::path::Path, mode: AuthMode) { - match mode { - AuthMode::ApiKey => { - login_with_api_key(codex_home, "sk-test-key").expect("write api key auth.json"); - } - AuthMode::ChatGPT => { - // Minimal valid JWT payload: header.payload.signature (all base64url, no padding) - const FAKE_JWT: &str = "eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.e30.c2ln"; // {"alg":"none","typ":"JWT"}.{}."sig" - let mut id_info = IdTokenInfo::default(); - id_info.raw_jwt = FAKE_JWT.to_string(); - let auth = AuthDotJson { - openai_api_key: None, - tokens: Some(TokenData { - id_token: id_info, - access_token: "access-token".to_string(), - refresh_token: "refresh-token".to_string(), - account_id: None, - }), - last_refresh: None, - }; - let file = get_auth_file(codex_home); - write_auth_json(&file, &auth).expect("write chatgpt auth.json"); - } - } - } - - #[test] - fn shows_login_when_not_authenticated() { - let cfg = make_config(); - assert!(should_show_login_screen( - LoginStatus::NotAuthenticated, - &cfg - )); - } - - #[test] - fn shows_model_rollout_prompt_for_default_model() { - let cli = Cli::parse_from(["codex"]); - let cfg = make_config(); - set_auth_method(&cfg.codex_home, AuthMode::ChatGPT); - assert!(should_show_model_rollout_prompt(&cli, &cfg, None, false,)); - } - - #[test] - fn hides_model_rollout_prompt_when_api_auth_mode() { - let cli = Cli::parse_from(["codex"]); - let cfg = make_config(); - set_auth_method(&cfg.codex_home, AuthMode::ApiKey); - assert!(!should_show_model_rollout_prompt(&cli, &cfg, None, false,)); - } - - #[test] - fn hides_model_rollout_prompt_when_marked_seen() { - let cli = Cli::parse_from(["codex"]); - let cfg = make_config(); - set_auth_method(&cfg.codex_home, AuthMode::ChatGPT); - assert!(!should_show_model_rollout_prompt(&cli, &cfg, None, true,)); - } - - #[test] - fn hides_model_rollout_prompt_when_cli_overrides_model() { - let cli = Cli::parse_from(["codex", "--model", "gpt-4.1"]); - let cfg = make_config(); - set_auth_method(&cfg.codex_home, AuthMode::ChatGPT); - assert!(!should_show_model_rollout_prompt(&cli, &cfg, None, false,)); - } - - #[test] - fn hides_model_rollout_prompt_when_profile_active() { - let cli = Cli::parse_from(["codex"]); - let cfg = make_config(); - set_auth_method(&cfg.codex_home, AuthMode::ChatGPT); - assert!(!should_show_model_rollout_prompt( - &cli, - &cfg, - Some("gpt5"), - false, - )); - } -} diff --git a/codex-rs/tui/src/new_model_popup.rs b/codex-rs/tui/src/new_model_popup.rs deleted file mode 100644 index e2389aad59..0000000000 --- a/codex-rs/tui/src/new_model_popup.rs +++ /dev/null @@ -1,179 +0,0 @@ -use crate::ascii_animation::AsciiAnimation; -use crate::tui::FrameRequester; -use crate::tui::Tui; -use crate::tui::TuiEvent; -use color_eyre::eyre::Result; -use crossterm::event::KeyCode; -use crossterm::event::KeyEvent; -use crossterm::event::KeyModifiers; -use ratatui::buffer::Buffer; -use ratatui::layout::Rect; -use ratatui::prelude::Widget; -use ratatui::style::Stylize; -use ratatui::text::Line; -use ratatui::widgets::Clear; -use ratatui::widgets::Paragraph; -use ratatui::widgets::WidgetRef; -use ratatui::widgets::Wrap; -use tokio_stream::StreamExt; - -const MIN_ANIMATION_HEIGHT: u16 = 24; -const MIN_ANIMATION_WIDTH: u16 = 60; - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(crate) enum ModelUpgradeDecision { - Switch, - KeepCurrent, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum ModelUpgradeOption { - TryNewModel, - KeepCurrent, -} - -struct ModelUpgradePopup { - highlighted: ModelUpgradeOption, - decision: Option, - animation: AsciiAnimation, -} - -impl ModelUpgradePopup { - fn new(request_frame: FrameRequester) -> Self { - Self { - highlighted: ModelUpgradeOption::TryNewModel, - decision: None, - animation: AsciiAnimation::new(request_frame), - } - } - - fn handle_key_event(&mut self, key_event: KeyEvent) { - match key_event.code { - KeyCode::Up | KeyCode::Char('k') => self.highlight(ModelUpgradeOption::TryNewModel), - KeyCode::Down | KeyCode::Char('j') => self.highlight(ModelUpgradeOption::KeepCurrent), - KeyCode::Char('1') => self.select(ModelUpgradeOption::TryNewModel), - KeyCode::Char('2') => self.select(ModelUpgradeOption::KeepCurrent), - KeyCode::Enter => self.select(self.highlighted), - KeyCode::Esc => self.select(ModelUpgradeOption::KeepCurrent), - KeyCode::Char('.') => { - if key_event.modifiers.contains(KeyModifiers::CONTROL) { - let _ = self.animation.pick_random_variant(); - } - } - _ => {} - } - } - - fn highlight(&mut self, option: ModelUpgradeOption) { - if self.highlighted != option { - self.highlighted = option; - self.animation.request_frame(); - } - } - - fn select(&mut self, option: ModelUpgradeOption) { - self.decision = Some(option.into()); - self.animation.request_frame(); - } -} - -impl From for ModelUpgradeDecision { - fn from(option: ModelUpgradeOption) -> Self { - match option { - ModelUpgradeOption::TryNewModel => ModelUpgradeDecision::Switch, - ModelUpgradeOption::KeepCurrent => ModelUpgradeDecision::KeepCurrent, - } - } -} - -impl WidgetRef for &ModelUpgradePopup { - fn render_ref(&self, area: Rect, buf: &mut Buffer) { - Clear.render(area, buf); - self.animation.schedule_next_frame(); - - // Skip the animation entirely when the viewport is too small so we don't clip frames. - let show_animation = - area.height >= MIN_ANIMATION_HEIGHT && area.width >= MIN_ANIMATION_WIDTH; - - let mut lines: Vec = Vec::new(); - if show_animation { - let frame = self.animation.current_frame(); - lines.extend(frame.lines().map(Into::into)); - // Spacer between animation and text content. - lines.push("".into()); - } - - lines.push(Line::from(vec![ - " ".into(), - "Introducing GPT-5-Codex".bold(), - ])); - lines.push("".into()); - lines.push( - " GPT-5-Codex works faster through easy tasks and harder on complex tasks,".into(), - ); - lines.push(" improves on code quality, and is more steerable with AGENTS.md.".into()); - lines.push("".into()); - - let create_option = - |index: usize, option: ModelUpgradeOption, text: &str| -> Line<'static> { - if self.highlighted == option { - Line::from(vec![ - format!("> {}. ", index + 1).cyan(), - text.to_owned().cyan(), - ]) - } else { - format!(" {}. {text}", index + 1).into() - } - }; - - lines.push(create_option( - 0, - ModelUpgradeOption::TryNewModel, - "Try the new GPT-5-Codex model", - )); - lines.push("".into()); - lines.push(create_option( - 1, - ModelUpgradeOption::KeepCurrent, - "Continue using current model", - )); - lines.push("".into()); - lines.push( - " Press Enter to confirm or Esc to keep your current model" - .dim() - .into(), - ); - - Paragraph::new(lines) - .wrap(Wrap { trim: false }) - .render(area, buf); - } -} - -pub(crate) async fn run_model_upgrade_popup(tui: &mut Tui) -> Result { - let mut popup = ModelUpgradePopup::new(tui.frame_requester()); - - tui.draw(u16::MAX, |frame| { - frame.render_widget_ref(&popup, frame.area()); - })?; - - let events = tui.event_stream(); - tokio::pin!(events); - while popup.decision.is_none() { - if let Some(event) = events.next().await { - match event { - TuiEvent::Key(key_event) => popup.handle_key_event(key_event), - TuiEvent::Draw => { - let _ = tui.draw(u16::MAX, |frame| { - frame.render_widget_ref(&popup, frame.area()); - }); - } - _ => {} - } - } else { - break; - } - } - - Ok(popup.decision.unwrap_or(ModelUpgradeDecision::KeepCurrent)) -} From 9ee6e6f3422f77a7e851264585dfa2abbccdb775 Mon Sep 17 00:00:00 2001 From: dedrisian-oai Date: Sun, 28 Sep 2025 11:46:15 -0700 Subject: [PATCH 08/16] Improve update nudge (#4405) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Makes the update nudge larger and adds a link to see latest release: Screenshot 2025-09-28 at 11 19 05 AM --- codex-rs/tui/src/lib.rs | 38 +++++++++++++++++++++++++++----------- 1 file changed, 27 insertions(+), 11 deletions(-) diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index d82fa19acc..e959973b22 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -273,6 +273,8 @@ async fn run_ratatui_app( // within the TUI scrollback. Building spans keeps styling consistent. #[cfg(not(debug_assertions))] if let Some(latest_version) = updates::get_upgrade_version(&config) { + use crate::history_cell::padded_emoji; + use crate::history_cell::with_border_with_inner_width; use ratatui::style::Stylize as _; use ratatui::text::Line; @@ -280,16 +282,27 @@ async fn run_ratatui_app( let exe = std::env::current_exe()?; let managed_by_npm = std::env::var_os("CODEX_MANAGED_BY_NPM").is_some(); - let mut lines: Vec> = Vec::new(); - lines.push(Line::from(vec![ - "✨⬆️ Update available!".bold().cyan(), - " ".into(), - format!("{current_version} -> {latest_version}.").into(), - ])); + let mut content_lines: Vec> = vec![ + Line::from(vec![ + padded_emoji("✨").bold().cyan(), + "Update available!".bold().cyan(), + " ".into(), + format!("{current_version} -> {latest_version}.").bold(), + ]), + Line::from(""), + Line::from("See full release notes:"), + Line::from(""), + Line::from( + "https://github.com/openai/codex/releases/latest" + .cyan() + .underlined(), + ), + Line::from(""), + ]; if managed_by_npm { let npm_cmd = "npm install -g @openai/codex@latest"; - lines.push(Line::from(vec![ + content_lines.push(Line::from(vec![ "Run ".into(), npm_cmd.cyan(), " to update.".into(), @@ -298,19 +311,22 @@ async fn run_ratatui_app( && (exe.starts_with("/opt/homebrew") || exe.starts_with("/usr/local")) { let brew_cmd = "brew upgrade codex"; - lines.push(Line::from(vec![ + content_lines.push(Line::from(vec![ "Run ".into(), brew_cmd.cyan(), " to update.".into(), ])); } else { - lines.push(Line::from(vec![ + content_lines.push(Line::from(vec![ "See ".into(), - "https://github.com/openai/codex/releases/latest".cyan(), - " for the latest releases and installation options.".into(), + "https://github.com/openai/codex".cyan().underlined(), + " for installation options.".into(), ])); } + let viewport_width = tui.terminal.viewport_area.width as usize; + let inner_width = viewport_width.saturating_sub(4).max(1); + let mut lines = with_border_with_inner_width(content_lines, inner_width); lines.push("".into()); tui.insert_history_lines(lines); } From 43615becf0cee2e05e61af0b077869dda0ba7d13 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Sun, 28 Sep 2025 14:35:14 -0700 Subject: [PATCH 09/16] chore: move pre_main_hardening() utility into its own crate (#4403) --- codex-rs/Cargo.lock | 9 +++++++- codex-rs/Cargo.toml | 6 ++++-- codex-rs/cli/Cargo.toml | 10 +-------- codex-rs/cli/src/main.rs | 10 +-------- codex-rs/process-hardening/Cargo.toml | 21 +++++++++++++++++++ .../src/lib.rs} | 16 ++++++++++++++ 6 files changed, 51 insertions(+), 21 deletions(-) create mode 100644 codex-rs/process-hardening/Cargo.toml rename codex-rs/{cli/src/pre_main_hardening.rs => process-hardening/src/lib.rs} (84%) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index eb97878072..00d95eb4e9 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -692,12 +692,12 @@ dependencies = [ "codex-exec", "codex-login", "codex-mcp-server", + "codex-process-hardening", "codex-protocol", "codex-protocol-ts", "codex-responses-api-proxy", "codex-tui", "ctor 0.5.0", - "libc", "owo-colors", "predicates", "pretty_assertions", @@ -949,6 +949,13 @@ dependencies = [ "wiremock", ] +[[package]] +name = "codex-process-hardening" +version = "0.0.0" +dependencies = [ + "libc", +] + [[package]] name = "codex-protocol" version = "0.0.0" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index af111c6f19..ddd7ed51a3 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -16,6 +16,7 @@ members = [ "mcp-server", "mcp-types", "ollama", + "process-hardening", "protocol", "protocol-ts", "rmcp-client", @@ -49,10 +50,11 @@ codex-login = { path = "login" } codex-mcp-client = { path = "mcp-client" } codex-mcp-server = { path = "mcp-server" } codex-ollama = { path = "ollama" } +codex-process-hardening = { path = "process-hardening" } codex-protocol = { path = "protocol" } -codex-rmcp-client = { path = "rmcp-client" } codex-protocol-ts = { path = "protocol-ts" } codex-responses-api-proxy = { path = "responses-api-proxy" } +codex-rmcp-client = { path = "rmcp-client" } codex-tui = { path = "tui" } codex-utils-readiness = { path = "utils/readiness" } core_test_support = { path = "core/tests/common" } @@ -83,8 +85,8 @@ dirs = "6" dotenvy = "0.15.7" env-flags = "0.1.1" env_logger = "0.11.5" -eventsource-stream = "0.2.3" escargot = "0.5" +eventsource-stream = "0.2.3" futures = "0.3" icu_decimal = "2.0.0" icu_locale_core = "2.0.0" diff --git a/codex-rs/cli/Cargo.toml b/codex-rs/cli/Cargo.toml index e61285c822..094a35b42c 100644 --- a/codex-rs/cli/Cargo.toml +++ b/codex-rs/cli/Cargo.toml @@ -25,6 +25,7 @@ codex-core = { workspace = true } codex-exec = { workspace = true } codex-login = { workspace = true } codex-mcp-server = { workspace = true } +codex-process-hardening = { workspace = true } codex-protocol = { workspace = true } codex-protocol-ts = { workspace = true } codex-responses-api-proxy = { workspace = true } @@ -43,15 +44,6 @@ tokio = { workspace = true, features = [ tracing = { workspace = true } tracing-subscriber = { workspace = true } -[target.'cfg(target_os = "linux")'.dependencies] -libc = { workspace = true } - -[target.'cfg(target_os = "android")'.dependencies] -libc = { workspace = true } - -[target.'cfg(target_os = "macos")'.dependencies] -libc = { workspace = true } - [dev-dependencies] assert_cmd = { workspace = true } predicates = { workspace = true } diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 871966b0a7..7dcfdae2f3 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -23,7 +23,6 @@ use std::path::PathBuf; use supports_color::Stream; mod mcp_cmd; -mod pre_main_hardening; use crate::mcp_cmd::McpCli; use crate::proto::ProtoCli; @@ -213,14 +212,7 @@ fn pre_main_hardening() { }; if secure_mode == "1" { - #[cfg(any(target_os = "linux", target_os = "android"))] - crate::pre_main_hardening::pre_main_hardening_linux(); - - #[cfg(target_os = "macos")] - crate::pre_main_hardening::pre_main_hardening_macos(); - - #[cfg(windows)] - crate::pre_main_hardening::pre_main_hardening_windows(); + codex_process_hardening::pre_main_hardening(); } // Always clear this env var so child processes don't inherit it. diff --git a/codex-rs/process-hardening/Cargo.toml b/codex-rs/process-hardening/Cargo.toml new file mode 100644 index 0000000000..7294b6e268 --- /dev/null +++ b/codex-rs/process-hardening/Cargo.toml @@ -0,0 +1,21 @@ +[package] +edition = "2024" +name = "codex-process-hardening" +version = { workspace = true } + +[lib] +name = "codex_process_hardening" +path = "src/lib.rs" + +[lints] +workspace = true + +[dependencies] +[target.'cfg(target_os = "linux")'.dependencies] +libc = { workspace = true } + +[target.'cfg(target_os = "android")'.dependencies] +libc = { workspace = true } + +[target.'cfg(target_os = "macos")'.dependencies] +libc = { workspace = true } diff --git a/codex-rs/cli/src/pre_main_hardening.rs b/codex-rs/process-hardening/src/lib.rs similarity index 84% rename from codex-rs/cli/src/pre_main_hardening.rs rename to codex-rs/process-hardening/src/lib.rs index a75cb3561c..a787b4097d 100644 --- a/codex-rs/cli/src/pre_main_hardening.rs +++ b/codex-rs/process-hardening/src/lib.rs @@ -1,3 +1,19 @@ +/// This is designed to be called pre-main() (using `#[ctor::ctor]`) to perform +/// various process hardening steps, such as +/// - disabling core dumps +/// - disabling ptrace attach on Linux and macOS. +/// - removing dangerous environment variables such as LD_PRELOAD and DYLD_* +pub fn pre_main_hardening() { + #[cfg(any(target_os = "linux", target_os = "android"))] + pre_main_hardening_linux(); + + #[cfg(target_os = "macos")] + pre_main_hardening_macos(); + + #[cfg(windows)] + pre_main_hardening_windows(); +} + #[cfg(any(target_os = "linux", target_os = "android"))] const PRCTL_FAILED_EXIT_CODE: i32 = 5; From 7407469791857c9ec40a1af3c60e7d70c9799554 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Sun, 28 Sep 2025 15:13:44 -0700 Subject: [PATCH 10/16] chore: lower logging level from error to info for MCP startup (#4412) --- codex-rs/core/src/mcp_connection_manager.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/codex-rs/core/src/mcp_connection_manager.rs b/codex-rs/core/src/mcp_connection_manager.rs index 015ca8c6bb..a488791c2b 100644 --- a/codex-rs/core/src/mcp_connection_manager.rs +++ b/codex-rs/core/src/mcp_connection_manager.rs @@ -108,7 +108,7 @@ impl McpClientAdapter { params: mcp_types::InitializeRequestParams, startup_timeout: Duration, ) -> Result { - tracing::error!( + info!( "new_stdio_client use_rmcp_client: {use_rmcp_client} program: {program:?} args: {args:?} env: {env:?} params: {params:?} startup_timeout: {startup_timeout:?}" ); if use_rmcp_client { From 99841332e24b5bbd26b0aa3831d7b8d5c3fa640e Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Sun, 28 Sep 2025 15:22:27 -0700 Subject: [PATCH 11/16] chore: remove responses-api-proxy from the multitool (#4404) This removes the `codex responses-api-proxy` subcommand in favor of running it as a standalone CLI. As part of this change, we: - remove the dependency on `tokio`/`async/await` as well as `codex_arg0` - introduce the use of `pre_main_hardening()` so `CODEX_SECURE_MODE=1` is not required --- [//]: # (BEGIN SAPLING FOOTER) Stack created with [Sapling](https://sapling-scm.com). Best reviewed with [ReviewStack](https://reviewstack.dev/openai/codex/pull/4404). * #4406 * __->__ #4404 * #4403 --- codex-rs/Cargo.lock | 5 ++--- codex-rs/Cargo.toml | 1 - codex-rs/cli/Cargo.toml | 1 - codex-rs/cli/src/main.rs | 11 ----------- codex-rs/responses-api-proxy/Cargo.toml | 6 +++--- codex-rs/responses-api-proxy/src/main.rs | 18 ++++++++---------- 6 files changed, 13 insertions(+), 29 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 00d95eb4e9..fc0eb20a88 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -695,7 +695,6 @@ dependencies = [ "codex-process-hardening", "codex-protocol", "codex-protocol-ts", - "codex-responses-api-proxy", "codex-tui", "ctor 0.5.0", "owo-colors", @@ -996,13 +995,13 @@ version = "0.0.0" dependencies = [ "anyhow", "clap", - "codex-arg0", + "codex-process-hardening", + "ctor 0.5.0", "libc", "reqwest", "serde", "serde_json", "tiny_http", - "tokio", "zeroize", ] diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index ddd7ed51a3..862f06aa17 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -53,7 +53,6 @@ codex-ollama = { path = "ollama" } codex-process-hardening = { path = "process-hardening" } codex-protocol = { path = "protocol" } codex-protocol-ts = { path = "protocol-ts" } -codex-responses-api-proxy = { path = "responses-api-proxy" } codex-rmcp-client = { path = "rmcp-client" } codex-tui = { path = "tui" } codex-utils-readiness = { path = "utils/readiness" } diff --git a/codex-rs/cli/Cargo.toml b/codex-rs/cli/Cargo.toml index 094a35b42c..618f4b7b03 100644 --- a/codex-rs/cli/Cargo.toml +++ b/codex-rs/cli/Cargo.toml @@ -28,7 +28,6 @@ codex-mcp-server = { workspace = true } codex-process-hardening = { workspace = true } codex-protocol = { workspace = true } codex-protocol-ts = { workspace = true } -codex-responses-api-proxy = { workspace = true } codex-tui = { workspace = true } ctor = { workspace = true } owo-colors = { workspace = true } diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 7dcfdae2f3..5c95df0afa 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -1,4 +1,3 @@ -use anyhow::Context; use clap::CommandFactory; use clap::Parser; use clap_complete::Shell; @@ -15,7 +14,6 @@ use codex_cli::login::run_logout; use codex_cli::proto; use codex_common::CliConfigOverrides; use codex_exec::Cli as ExecCli; -use codex_responses_api_proxy::Args as ResponsesApiProxyArgs; use codex_tui::AppExitInfo; use codex_tui::Cli as TuiCli; use owo_colors::OwoColorize; @@ -87,10 +85,6 @@ enum Subcommand { /// Internal: generate TypeScript protocol bindings. #[clap(hide = true)] GenerateTs(GenerateTsCommand), - - /// Internal: run the responses API proxy. - #[clap(hide = true)] - ResponsesApiProxy(ResponsesApiProxyArgs), } #[derive(Debug, Parser)] @@ -339,11 +333,6 @@ async fn cli_main(codex_linux_sandbox_exe: Option) -> anyhow::Result<() Some(Subcommand::GenerateTs(gen_cli)) => { codex_protocol_ts::generate_ts(&gen_cli.out_dir, gen_cli.prettier.as_deref())?; } - Some(Subcommand::ResponsesApiProxy(args)) => { - tokio::task::spawn_blocking(move || codex_responses_api_proxy::run_main(args)) - .await - .context("responses-api-proxy blocking task panicked")??; - } } Ok(()) diff --git a/codex-rs/responses-api-proxy/Cargo.toml b/codex-rs/responses-api-proxy/Cargo.toml index b66473b195..651019a816 100644 --- a/codex-rs/responses-api-proxy/Cargo.toml +++ b/codex-rs/responses-api-proxy/Cargo.toml @@ -8,7 +8,7 @@ name = "codex_responses_api_proxy" path = "src/lib.rs" [[bin]] -name = "responses-api-proxy" +name = "codex-responses-api-proxy" path = "src/main.rs" [lints] @@ -17,11 +17,11 @@ workspace = true [dependencies] anyhow = { workspace = true } clap = { workspace = true, features = ["derive"] } -codex-arg0 = { workspace = true } +codex-process-hardening = { workspace = true } +ctor = { workspace = true } libc = { workspace = true } reqwest = { workspace = true, features = ["blocking", "json", "rustls-tls"] } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } tiny_http = { workspace = true } -tokio = { workspace = true } zeroize = { workspace = true } diff --git a/codex-rs/responses-api-proxy/src/main.rs b/codex-rs/responses-api-proxy/src/main.rs index eaa534d981..c4568d76aa 100644 --- a/codex-rs/responses-api-proxy/src/main.rs +++ b/codex-rs/responses-api-proxy/src/main.rs @@ -1,14 +1,12 @@ -use anyhow::Context; use clap::Parser; -use codex_arg0::arg0_dispatch_or_else; use codex_responses_api_proxy::Args as ResponsesApiProxyArgs; -pub fn main() -> anyhow::Result<()> { - arg0_dispatch_or_else(|_codex_linux_sandbox_exe| async move { - let args = ResponsesApiProxyArgs::parse(); - tokio::task::spawn_blocking(move || codex_responses_api_proxy::run_main(args)) - .await - .context("responses-api-proxy blocking task panicked")??; - Ok(()) - }) +#[ctor::ctor] +fn pre_main() { + codex_process_hardening::pre_main_hardening(); +} + +pub fn main() -> anyhow::Result<()> { + let args = ResponsesApiProxyArgs::parse(); + codex_responses_api_proxy::run_main(args) } From ff6dbff0b64b0d8aeef947d990adef11770812a4 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Sun, 28 Sep 2025 15:25:15 -0700 Subject: [PATCH 12/16] feat: build codex-responses-api-proxy for all platforms as part of the GitHub Release (#4406) This should make the `codex-responses-api-proxy` binaries available for all platforms in a GitHub Release as well as a corresponding DotSlash file. Making `codex-responses-api-proxy` available as an `npm` module will be done in a follow-up PR. --- [//]: # (BEGIN SAPLING FOOTER) Stack created with [Sapling](https://sapling-scm.com). Best reviewed with [ReviewStack](https://reviewstack.dev/openai/codex/pull/4404). * __->__ #4406 * #4404 * #4403 --- .github/dotslash-config.json | 28 ++++++++++++++++++++++++++ .github/workflows/rust-release.yml | 4 +++- codex-cli/scripts/build_npm_package.py | 7 ++----- 3 files changed, 33 insertions(+), 6 deletions(-) diff --git a/.github/dotslash-config.json b/.github/dotslash-config.json index 8566e03490..5e28cdf207 100644 --- a/.github/dotslash-config.json +++ b/.github/dotslash-config.json @@ -27,6 +27,34 @@ "path": "codex.exe" } } + }, + "codex-responses-api-proxy": { + "platforms": { + "macos-aarch64": { + "regex": "^codex-responses-api-proxy-aarch64-apple-darwin\\.zst$", + "path": "codex-responses-api-proxy" + }, + "macos-x86_64": { + "regex": "^codex-responses-api-proxy-x86_64-apple-darwin\\.zst$", + "path": "codex-responses-api-proxy" + }, + "linux-x86_64": { + "regex": "^codex-responses-api-proxy-x86_64-unknown-linux-musl\\.zst$", + "path": "codex-responses-api-proxy" + }, + "linux-aarch64": { + "regex": "^codex-responses-api-proxy-aarch64-unknown-linux-musl\\.zst$", + "path": "codex-responses-api-proxy" + }, + "windows-x86_64": { + "regex": "^codex-responses-api-proxy-x86_64-pc-windows-msvc\\.exe\\.zst$", + "path": "codex-responses-api-proxy.exe" + }, + "windows-aarch64": { + "regex": "^codex-responses-api-proxy-aarch64-pc-windows-msvc\\.exe\\.zst$", + "path": "codex-responses-api-proxy.exe" + } + } } } } diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index c808216d96..345fd091a7 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -97,7 +97,7 @@ jobs: sudo apt install -y musl-tools pkg-config - name: Cargo build - run: cargo build --target ${{ matrix.target }} --release --bin codex + run: cargo build --target ${{ matrix.target }} --release --bin codex --bin codex-responses-api-proxy - name: Stage artifacts shell: bash @@ -107,8 +107,10 @@ jobs: if [[ "${{ matrix.runner }}" == windows* ]]; then cp target/${{ matrix.target }}/release/codex.exe "$dest/codex-${{ matrix.target }}.exe" + cp target/${{ matrix.target }}/release/codex-responses-api-proxy.exe "$dest/codex-responses-api-proxy-${{ matrix.target }}.exe" else cp target/${{ matrix.target }}/release/codex "$dest/codex-${{ matrix.target }}" + cp target/${{ matrix.target }}/release/codex-responses-api-proxy "$dest/codex-responses-api-proxy-${{ matrix.target }}" fi - if: ${{ matrix.runner == 'windows-11-arm' }} diff --git a/codex-cli/scripts/build_npm_package.py b/codex-cli/scripts/build_npm_package.py index 0401f10385..bedb1c2438 100755 --- a/codex-cli/scripts/build_npm_package.py +++ b/codex-cli/scripts/build_npm_package.py @@ -158,11 +158,8 @@ def stage_sources(staging_dir: Path, version: str) -> None: out.write("\n") -def install_native_binaries(staging_dir: Path, workflow_url: str | None) -> None: - cmd = ["./scripts/install_native_deps.py"] - if workflow_url: - cmd.extend(["--workflow-url", workflow_url]) - cmd.append(str(staging_dir)) +def install_native_binaries(staging_dir: Path, workflow_url: str) -> None: + cmd = ["./scripts/install_native_deps.py", "--workflow-url", workflow_url, str(staging_dir)] subprocess.check_call(cmd, cwd=CODEX_CLI_ROOT) From 79e51dd60704f9b5a11989dff33ca2e49c26475f Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Sun, 28 Sep 2025 15:31:17 -0700 Subject: [PATCH 13/16] fix: clean up some minor issues with .github/workflows/ci.yml (#4408) --- .github/workflows/ci.yml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7cbb1a1983..a10d25f533 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,7 +1,7 @@ name: ci on: - pull_request: { branches: [main] } + pull_request: {} push: { branches: [main] } jobs: @@ -31,6 +31,7 @@ jobs: - uses: facebook/install-dotslash@v2 - name: Stage npm package + id: stage_npm_package env: GH_TOKEN: ${{ github.token }} run: | @@ -40,13 +41,13 @@ jobs: python3 ./codex-cli/scripts/build_npm_package.py \ --release-version "$CODEX_VERSION" \ --pack-output "$PACK_OUTPUT" - echo "PACK_OUTPUT=$PACK_OUTPUT" >> "$GITHUB_ENV" + echo "pack_output=$PACK_OUTPUT" >> "$GITHUB_OUTPUT" - name: Upload staged npm package artifact uses: actions/upload-artifact@v4 with: name: codex-npm-staging - path: ${{ env.PACK_OUTPUT }} + path: ${{ steps.stage_npm_package.outputs.pack_output }} - name: Ensure root README.md contains only ASCII and certain Unicode code points run: ./scripts/asciicheck.py README.md From a9d54b9e92ca8b9f194e77313f016169dcde0e7c Mon Sep 17 00:00:00 2001 From: dedrisian-oai Date: Sun, 28 Sep 2025 16:59:39 -0700 Subject: [PATCH 14/16] Add /review to main commands (#4416) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Screenshot 2025-09-28 at 4 33 26 PM --- codex-rs/tui/src/history_cell.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index f93042f91d..d0e13003ff 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -414,6 +414,11 @@ pub(crate) fn new_session_info( "/model".into(), " - choose what model and reasoning effort to use".dim(), ]), + Line::from(vec![ + " ".into(), + "/review".into(), + " - review any changes and find issues".dim(), + ]), ]; CompositeHistoryCell { From 618a42adf5ad920dded00b6117330ec28827b4cb Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Sun, 28 Sep 2025 19:34:06 -0700 Subject: [PATCH 15/16] feat: introduce npm module for codex-responses-api-proxy (#4417) This PR expands `.github/workflows/rust-release.yml` so that it also builds and publishes the `npm` module for `@openai/codex-responses-api-proxy` in addition to `@openai/codex`. Note both `npm` modules are similar, in that they each contain a single `.js` file that is a thin launcher around the appropriate native executable. (Since we have a minimal dependency on Node.js, I also lowered the minimum version from 20 to 16 and verified that works on my machine.) As part of this change, we tighten up some of the docs around `codex-responses-api-proxy` and ensure the details regarding protecting the `OPENAI_API_KEY` in memory match the implementation. To test the `npm` build process, I ran: ``` ./codex-cli/scripts/build_npm_package.py --package codex-responses-api-proxy --version 0.43.0-alpha.3 ``` which stages the `npm` module for `@openai/codex-responses-api-proxy` in a temp directory, using the binary artifacts from https://github.com/openai/codex/releases/tag/rust-v0.43.0-alpha.3. --- .github/workflows/rust-release.yml | 30 ++++- codex-cli/README.md | 4 +- codex-cli/bin/codex.js | 2 +- codex-cli/package-lock.json | 2 +- codex-cli/package.json | 2 +- codex-cli/scripts/build_npm_package.py | 79 +++++++++--- codex-cli/scripts/install_native_deps.py | 121 ++++++++++++++---- codex-rs/process-hardening/README.md | 7 + codex-rs/responses-api-proxy/README.md | 30 ++++- codex-rs/responses-api-proxy/npm/README.md | 13 ++ .../npm/bin/codex-responses-api-proxy.js | 97 ++++++++++++++ codex-rs/responses-api-proxy/npm/package.json | 21 +++ .../responses-api-proxy/src/read_api_key.rs | 43 ++++++- 13 files changed, 387 insertions(+), 64 deletions(-) create mode 100644 codex-rs/process-hardening/README.md create mode 100644 codex-rs/responses-api-proxy/npm/README.md create mode 100755 codex-rs/responses-api-proxy/npm/bin/codex-responses-api-proxy.js create mode 100644 codex-rs/responses-api-proxy/npm/package.json diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index 345fd091a7..05bbb9a4d9 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -218,17 +218,30 @@ jobs: # build_npm_package.py requires DotSlash when staging releases. - uses: facebook/install-dotslash@v2 - - name: Stage npm package + - name: Stage codex CLI npm package env: GH_TOKEN: ${{ github.token }} run: | set -euo pipefail TMP_DIR="${RUNNER_TEMP}/npm-stage" ./codex-cli/scripts/build_npm_package.py \ + --package codex \ --release-version "${{ steps.release_name.outputs.name }}" \ --staging-dir "${TMP_DIR}" \ --pack-output "${GITHUB_WORKSPACE}/dist/npm/codex-npm-${{ steps.release_name.outputs.name }}.tgz" + - name: Stage responses API proxy npm package + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + TMP_DIR="${RUNNER_TEMP}/npm-stage-responses" + ./codex-cli/scripts/build_npm_package.py \ + --package codex-responses-api-proxy \ + --release-version "${{ steps.release_name.outputs.name }}" \ + --staging-dir "${TMP_DIR}" \ + --pack-output "${GITHUB_WORKSPACE}/dist/npm/codex-responses-api-proxy-npm-${{ steps.release_name.outputs.name }}.tgz" + - name: Create GitHub Release uses: softprops/action-gh-release@v2 with: @@ -271,7 +284,7 @@ jobs: - name: Update npm run: npm install -g npm@latest - - name: Download npm tarball from release + - name: Download npm tarballs from release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | @@ -283,6 +296,10 @@ jobs: --repo "${GITHUB_REPOSITORY}" \ --pattern "codex-npm-${version}.tgz" \ --dir dist/npm + gh release download "$tag" \ + --repo "${GITHUB_REPOSITORY}" \ + --pattern "codex-responses-api-proxy-npm-${version}.tgz" \ + --dir dist/npm # No NODE_AUTH_TOKEN needed because we use OIDC. - name: Publish to npm @@ -296,7 +313,14 @@ jobs: tag_args+=(--tag "${NPM_TAG}") fi - npm publish "${GITHUB_WORKSPACE}/dist/npm/codex-npm-${VERSION}.tgz" "${tag_args[@]}" + tarballs=( + "codex-npm-${VERSION}.tgz" + "codex-responses-api-proxy-npm-${VERSION}.tgz" + ) + + for tarball in "${tarballs[@]}"; do + npm publish "${GITHUB_WORKSPACE}/dist/npm/${tarball}" "${tag_args[@]}" + done update-branch: name: Update latest-alpha-cli branch diff --git a/codex-cli/README.md b/codex-cli/README.md index e988b384ab..f3414f1c4b 100644 --- a/codex-cli/README.md +++ b/codex-cli/README.md @@ -208,7 +208,7 @@ The hardening mechanism Codex uses depends on your OS: | Requirement | Details | | --------------------------- | --------------------------------------------------------------- | | Operating systems | macOS 12+, Ubuntu 20.04+/Debian 10+, or Windows 11 **via WSL2** | -| Node.js | **22 or newer** (LTS recommended) | +| Node.js | **16 or newer** (Node 20 LTS recommended) | | Git (optional, recommended) | 2.23+ for built-in PR helpers | | RAM | 4-GB minimum (8-GB recommended) | @@ -513,7 +513,7 @@ Codex runs model-generated commands in a sandbox. If a proposed command or file
Does it work on Windows? -Not directly. It requires [Windows Subsystem for Linux (WSL2)](https://learn.microsoft.com/en-us/windows/wsl/install) - Codex has been tested on macOS and Linux with Node 22. +Not directly. It requires [Windows Subsystem for Linux (WSL2)](https://learn.microsoft.com/en-us/windows/wsl/install) - Codex is regularly tested on macOS and Linux with Node 20+, and also supports Node 16.
diff --git a/codex-cli/bin/codex.js b/codex-cli/bin/codex.js index 3621f48c12..17dd98a8e8 100755 --- a/codex-cli/bin/codex.js +++ b/codex-cli/bin/codex.js @@ -1,6 +1,7 @@ #!/usr/bin/env node // Unified entry point for the Codex CLI. +import { spawn } from "node:child_process"; import { existsSync } from "fs"; import path from "path"; import { fileURLToPath } from "url"; @@ -68,7 +69,6 @@ const binaryPath = path.join(archRoot, "codex", codexBinaryName); // executing. This allows us to forward those signals to the child process // and guarantees that when either the child terminates or the parent // receives a fatal signal, both processes exit in a predictable manner. -const { spawn } = await import("child_process"); function getUpdatedPath(newDirs) { const pathSep = process.platform === "win32" ? ";" : ":"; diff --git a/codex-cli/package-lock.json b/codex-cli/package-lock.json index a28bb9cd9f..58ee846306 100644 --- a/codex-cli/package-lock.json +++ b/codex-cli/package-lock.json @@ -11,7 +11,7 @@ "codex": "bin/codex.js" }, "engines": { - "node": ">=20" + "node": ">=16" } } } diff --git a/codex-cli/package.json b/codex-cli/package.json index 03f234d677..b83309e42b 100644 --- a/codex-cli/package.json +++ b/codex-cli/package.json @@ -7,7 +7,7 @@ }, "type": "module", "engines": { - "node": ">=20" + "node": ">=16" }, "files": [ "bin", diff --git a/codex-cli/scripts/build_npm_package.py b/codex-cli/scripts/build_npm_package.py index bedb1c2438..2ae8a96e43 100755 --- a/codex-cli/scripts/build_npm_package.py +++ b/codex-cli/scripts/build_npm_package.py @@ -13,6 +13,7 @@ from pathlib import Path SCRIPT_DIR = Path(__file__).resolve().parent CODEX_CLI_ROOT = SCRIPT_DIR.parent REPO_ROOT = CODEX_CLI_ROOT.parent +RESPONSES_API_PROXY_NPM_ROOT = REPO_ROOT / "codex-rs" / "responses-api-proxy" / "npm" GITHUB_REPO = "openai/codex" # The docs are not clear on what the expected value/format of @@ -23,6 +24,12 @@ WORKFLOW_NAME = ".github/workflows/rust-release.yml" def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description="Build or stage the Codex CLI npm package.") + parser.add_argument( + "--package", + choices=("codex", "codex-responses-api-proxy"), + default="codex", + help="Which npm package to stage (default: codex).", + ) parser.add_argument( "--version", help="Version number to write to package.json inside the staged package.", @@ -63,6 +70,7 @@ def parse_args() -> argparse.Namespace: def main() -> int: args = parse_args() + package = args.package version = args.version release_version = args.release_version if release_version: @@ -76,7 +84,7 @@ def main() -> int: staging_dir, created_temp = prepare_staging_dir(args.staging_dir) try: - stage_sources(staging_dir, version) + stage_sources(staging_dir, version, package) workflow_url = args.workflow_url resolved_head_sha: str | None = None @@ -100,16 +108,23 @@ def main() -> int: if not workflow_url: raise RuntimeError("Unable to determine workflow URL for native binaries.") - install_native_binaries(staging_dir, workflow_url) + install_native_binaries(staging_dir, workflow_url, package) if release_version: staging_dir_str = str(staging_dir) - print( - f"Staged version {version} for release in {staging_dir_str}\n\n" - "Verify the CLI:\n" - f" node {staging_dir_str}/bin/codex.js --version\n" - f" node {staging_dir_str}/bin/codex.js --help\n\n" - ) + if package == "codex": + print( + f"Staged version {version} for release in {staging_dir_str}\n\n" + "Verify the CLI:\n" + f" node {staging_dir_str}/bin/codex.js --version\n" + f" node {staging_dir_str}/bin/codex.js --help\n\n" + ) + else: + print( + f"Staged version {version} for release in {staging_dir_str}\n\n" + "Verify the responses API proxy:\n" + f" node {staging_dir_str}/bin/codex-responses-api-proxy.js --help\n\n" + ) else: print(f"Staged package in {staging_dir}") @@ -136,20 +151,34 @@ def prepare_staging_dir(staging_dir: Path | None) -> tuple[Path, bool]: return temp_dir, True -def stage_sources(staging_dir: Path, version: str) -> None: +def stage_sources(staging_dir: Path, version: str, package: str) -> None: bin_dir = staging_dir / "bin" bin_dir.mkdir(parents=True, exist_ok=True) - shutil.copy2(CODEX_CLI_ROOT / "bin" / "codex.js", bin_dir / "codex.js") - rg_manifest = CODEX_CLI_ROOT / "bin" / "rg" - if rg_manifest.exists(): - shutil.copy2(rg_manifest, bin_dir / "rg") + if package == "codex": + shutil.copy2(CODEX_CLI_ROOT / "bin" / "codex.js", bin_dir / "codex.js") + rg_manifest = CODEX_CLI_ROOT / "bin" / "rg" + if rg_manifest.exists(): + shutil.copy2(rg_manifest, bin_dir / "rg") - readme_src = REPO_ROOT / "README.md" - if readme_src.exists(): - shutil.copy2(readme_src, staging_dir / "README.md") + readme_src = REPO_ROOT / "README.md" + if readme_src.exists(): + shutil.copy2(readme_src, staging_dir / "README.md") - with open(CODEX_CLI_ROOT / "package.json", "r", encoding="utf-8") as fh: + package_json_path = CODEX_CLI_ROOT / "package.json" + elif package == "codex-responses-api-proxy": + launcher_src = RESPONSES_API_PROXY_NPM_ROOT / "bin" / "codex-responses-api-proxy.js" + shutil.copy2(launcher_src, bin_dir / "codex-responses-api-proxy.js") + + readme_src = RESPONSES_API_PROXY_NPM_ROOT / "README.md" + if readme_src.exists(): + shutil.copy2(readme_src, staging_dir / "README.md") + + package_json_path = RESPONSES_API_PROXY_NPM_ROOT / "package.json" + else: + raise RuntimeError(f"Unknown package '{package}'.") + + with open(package_json_path, "r", encoding="utf-8") as fh: package_json = json.load(fh) package_json["version"] = version @@ -158,8 +187,20 @@ def stage_sources(staging_dir: Path, version: str) -> None: out.write("\n") -def install_native_binaries(staging_dir: Path, workflow_url: str) -> None: - cmd = ["./scripts/install_native_deps.py", "--workflow-url", workflow_url, str(staging_dir)] +def install_native_binaries(staging_dir: Path, workflow_url: str, package: str) -> None: + package_components = { + "codex": ["codex", "rg"], + "codex-responses-api-proxy": ["codex-responses-api-proxy"], + } + + components = package_components.get(package) + if components is None: + raise RuntimeError(f"Unknown package '{package}'.") + + cmd = ["./scripts/install_native_deps.py", "--workflow-url", workflow_url] + for component in components: + cmd.extend(["--component", component]) + cmd.append(str(staging_dir)) subprocess.check_call(cmd, cwd=CODEX_CLI_ROOT) diff --git a/codex-cli/scripts/install_native_deps.py b/codex-cli/scripts/install_native_deps.py index 7fbb44393a..8d3909c9e1 100755 --- a/codex-cli/scripts/install_native_deps.py +++ b/codex-cli/scripts/install_native_deps.py @@ -9,6 +9,7 @@ import subprocess import tarfile import tempfile import zipfile +from dataclasses import dataclass from concurrent.futures import ThreadPoolExecutor, as_completed from pathlib import Path from typing import Iterable, Sequence @@ -20,7 +21,7 @@ CODEX_CLI_ROOT = SCRIPT_DIR.parent DEFAULT_WORKFLOW_URL = "https://github.com/openai/codex/actions/runs/17952349351" # rust-v0.40.0 VENDOR_DIR_NAME = "vendor" RG_MANIFEST = CODEX_CLI_ROOT / "bin" / "rg" -CODEX_TARGETS = ( +BINARY_TARGETS = ( "x86_64-unknown-linux-musl", "aarch64-unknown-linux-musl", "x86_64-apple-darwin", @@ -29,6 +30,27 @@ CODEX_TARGETS = ( "aarch64-pc-windows-msvc", ) + +@dataclass(frozen=True) +class BinaryComponent: + artifact_prefix: str # matches the artifact filename prefix (e.g. codex-.zst) + dest_dir: str # directory under vendor// where the binary is installed + binary_basename: str # executable name inside dest_dir (before optional .exe) + + +BINARY_COMPONENTS = { + "codex": BinaryComponent( + artifact_prefix="codex", + dest_dir="codex", + binary_basename="codex", + ), + "codex-responses-api-proxy": BinaryComponent( + artifact_prefix="codex-responses-api-proxy", + dest_dir="codex-responses-api-proxy", + binary_basename="codex-responses-api-proxy", + ), +} + RG_TARGET_PLATFORM_PAIRS: list[tuple[str, str]] = [ ("x86_64-unknown-linux-musl", "linux-x86_64"), ("aarch64-unknown-linux-musl", "linux-aarch64"), @@ -50,6 +72,16 @@ def parse_args() -> argparse.Namespace: "known good run when omitted." ), ) + parser.add_argument( + "--component", + dest="components", + action="append", + choices=tuple(list(BINARY_COMPONENTS) + ["rg"]), + help=( + "Limit installation to the specified components." + " May be repeated. Defaults to 'codex' and 'rg'." + ), + ) parser.add_argument( "root", nargs="?", @@ -69,18 +101,28 @@ def main() -> int: vendor_dir = codex_cli_root / VENDOR_DIR_NAME vendor_dir.mkdir(parents=True, exist_ok=True) + components = args.components or ["codex", "rg"] + workflow_url = (args.workflow_url or DEFAULT_WORKFLOW_URL).strip() if not workflow_url: workflow_url = DEFAULT_WORKFLOW_URL workflow_id = workflow_url.rstrip("/").split("/")[-1] + print(f"Downloading native artifacts from workflow {workflow_id}...") with tempfile.TemporaryDirectory(prefix="codex-native-artifacts-") as artifacts_dir_str: artifacts_dir = Path(artifacts_dir_str) _download_artifacts(workflow_id, artifacts_dir) - install_codex_binaries(artifacts_dir, vendor_dir, CODEX_TARGETS) + install_binary_components( + artifacts_dir, + vendor_dir, + BINARY_TARGETS, + [name for name in components if name in BINARY_COMPONENTS], + ) - fetch_rg(vendor_dir, DEFAULT_RG_TARGETS, manifest_path=RG_MANIFEST) + if "rg" in components: + print("Fetching ripgrep binaries...") + fetch_rg(vendor_dir, DEFAULT_RG_TARGETS, manifest_path=RG_MANIFEST) print(f"Installed native dependencies into {vendor_dir}") return 0 @@ -124,6 +166,8 @@ def fetch_rg( results: dict[str, Path] = {} max_workers = min(len(task_configs), max(1, (os.cpu_count() or 1))) + print("Installing ripgrep binaries for targets: " + ", ".join(targets)) + with ThreadPoolExecutor(max_workers=max_workers) as executor: future_map = { executor.submit( @@ -140,6 +184,7 @@ def fetch_rg( for future in as_completed(future_map): target = future_map[future] results[target] = future.result() + print(f" installed ripgrep for {target}") return [results[target] for target in targets] @@ -158,40 +203,60 @@ def _download_artifacts(workflow_id: str, dest_dir: Path) -> None: subprocess.check_call(cmd) -def install_codex_binaries( - artifacts_dir: Path, vendor_dir: Path, targets: Iterable[str] -) -> list[Path]: +def install_binary_components( + artifacts_dir: Path, + vendor_dir: Path, + targets: Iterable[str], + component_names: Sequence[str], +) -> None: + selected_components = [BINARY_COMPONENTS[name] for name in component_names if name in BINARY_COMPONENTS] + if not selected_components: + return + targets = list(targets) if not targets: - return [] + return - results: dict[str, Path] = {} - max_workers = min(len(targets), max(1, (os.cpu_count() or 1))) - - with ThreadPoolExecutor(max_workers=max_workers) as executor: - future_map = { - executor.submit(_install_single_codex_binary, artifacts_dir, vendor_dir, target): target - for target in targets - } - - for future in as_completed(future_map): - target = future_map[future] - results[target] = future.result() - - return [results[target] for target in targets] + for component in selected_components: + print( + f"Installing {component.binary_basename} binaries for targets: " + + ", ".join(targets) + ) + max_workers = min(len(targets), max(1, (os.cpu_count() or 1))) + with ThreadPoolExecutor(max_workers=max_workers) as executor: + futures = { + executor.submit( + _install_single_binary, + artifacts_dir, + vendor_dir, + target, + component, + ): target + for target in targets + } + for future in as_completed(futures): + installed_path = future.result() + print(f" installed {installed_path}") -def _install_single_codex_binary(artifacts_dir: Path, vendor_dir: Path, target: str) -> Path: +def _install_single_binary( + artifacts_dir: Path, + vendor_dir: Path, + target: str, + component: BinaryComponent, +) -> Path: artifact_subdir = artifacts_dir / target - archive_name = _archive_name_for_target(target) + archive_name = _archive_name_for_target(component.artifact_prefix, target) archive_path = artifact_subdir / archive_name if not archive_path.exists(): raise FileNotFoundError(f"Expected artifact not found: {archive_path}") - dest_dir = vendor_dir / target / "codex" + dest_dir = vendor_dir / target / component.dest_dir dest_dir.mkdir(parents=True, exist_ok=True) - binary_name = "codex.exe" if "windows" in target else "codex" + binary_name = ( + f"{component.binary_basename}.exe" if "windows" in target else component.binary_basename + ) dest = dest_dir / binary_name dest.unlink(missing_ok=True) extract_archive(archive_path, "zst", None, dest) @@ -200,10 +265,10 @@ def _install_single_codex_binary(artifacts_dir: Path, vendor_dir: Path, target: return dest -def _archive_name_for_target(target: str) -> str: +def _archive_name_for_target(artifact_prefix: str, target: str) -> str: if "windows" in target: - return f"codex-{target}.exe.zst" - return f"codex-{target}.zst" + return f"{artifact_prefix}-{target}.exe.zst" + return f"{artifact_prefix}-{target}.zst" def _fetch_single_rg( diff --git a/codex-rs/process-hardening/README.md b/codex-rs/process-hardening/README.md new file mode 100644 index 0000000000..66a8060afa --- /dev/null +++ b/codex-rs/process-hardening/README.md @@ -0,0 +1,7 @@ +# codex-process-hardening + +This crate provides `pre_main_hardening()`, which is designed to be called pre-`main()` (using `#[ctor::ctor]`) to perform various process hardening steps, such as + +- disabling core dumps +- disabling ptrace attach on Linux and macOS +- removing dangerous environment variables such as `LD_PRELOAD` and `DYLD_*` diff --git a/codex-rs/responses-api-proxy/README.md b/codex-rs/responses-api-proxy/README.md index 4f5304a553..4b74fe10d6 100644 --- a/codex-rs/responses-api-proxy/README.md +++ b/codex-rs/responses-api-proxy/README.md @@ -4,12 +4,12 @@ A strict HTTP proxy that only forwards `POST` requests to `/v1/responses` to the ## Expected Usage -**IMPORTANT:** This is designed to be used with `CODEX_SECURE_MODE=1` so that an unprivileged user cannot inspect or tamper with this process. Though if `--http-shutdown` is specified, an unprivileged user _can_ shutdown the server. +**IMPORTANT:** `codex-responses-api-proxy` is designed to be run by a privileged user with access to `OPENAI_API_KEY` so that an unprivileged user cannot inspect or tamper with the process. Though if `--http-shutdown` is specified, an unprivileged user _can_ make a `GET` request to `/shutdown` to shutdown the server, as an unprivileged could not send `SIGTERM` to kill the process. -A privileged user (i.e., `root` or a user with `sudo`) who has access to `OPENAI_API_KEY` would run the following to start the server: +A privileged user (i.e., `root` or a user with `sudo`) who has access to `OPENAI_API_KEY` would run the following to start the server, as `codex-responses-api-proxy` reads the auth token from `stdin`: ```shell -printenv OPENAI_API_KEY | CODEX_SECURE_MODE=1 codex responses-api-proxy --http-shutdown --server-info /tmp/server-info.json +printenv OPENAI_API_KEY | codex-responses-api-proxy --http-shutdown --server-info /tmp/server-info.json ``` A non-privileged user would then run Codex as follows, specifying the `model_provider` dynamically: @@ -22,7 +22,7 @@ codex exec -c "model_providers.openai-proxy={ name = 'OpenAI Proxy', base_url = 'Your prompt here' ``` -When the unprivileged user was finished, they could shutdown the server using `curl` (since `kill -9` is not an option): +When the unprivileged user was finished, they could shutdown the server using `curl` (since `kill -SIGTERM` is not an option): ```shell curl --fail --silent --show-error "${PROXY_BASE_URL}/shutdown" @@ -30,17 +30,17 @@ curl --fail --silent --show-error "${PROXY_BASE_URL}/shutdown" ## Behavior -- Reads the API key from `stdin`. All callers should pipe the key in (for example, `printenv OPENAI_API_KEY | codex responses-api-proxy`). +- Reads the API key from `stdin`. All callers should pipe the key in (for example, `printenv OPENAI_API_KEY | codex-responses-api-proxy`). - Formats the header value as `Bearer ` and attempts to `mlock(2)` the memory holding that header so it is not swapped to disk. - Listens on the provided port or an ephemeral port if `--port` is not specified. - Accepts exactly `POST /v1/responses` (no query string). The request body is forwarded to `https://api.openai.com/v1/responses` with `Authorization: Bearer ` set. All original request headers (except any incoming `Authorization`) are forwarded upstream. For other requests, it responds with `403`. - Optionally writes a single-line JSON file with server info, currently `{ "port": }`. -- Optional `--http-shutdown` enables `GET /shutdown` to terminate the process with exit code 0. This allows one user (e.g., root) to start the proxy and another unprivileged user on the host to shut it down. +- Optional `--http-shutdown` enables `GET /shutdown` to terminate the process with exit code 0. This allows one user (e.g., `root`) to start the proxy and another unprivileged user on the host to shut it down. ## CLI ``` -responses-api-proxy [--port ] [--server-info ] [--http-shutdown] +codex-responses-api-proxy [--port ] [--server-info ] [--http-shutdown] ``` - `--port `: Port to bind on `127.0.0.1`. If omitted, an ephemeral port is chosen. @@ -51,3 +51,19 @@ responses-api-proxy [--port ] [--server-info ] [--http-shutdown] - Only `POST /v1/responses` is permitted. No query strings are allowed. - All request headers are forwarded to the upstream call (aside from overriding `Authorization`). Response status and content-type are mirrored from upstream. + +## Hardening Details + +Care is taken to restrict access/copying to the value of `OPENAI_API_KEY` retained in memory: + +- We leverage [`codex_process_hardening`](https://github.com/openai/codex/blob/main/codex-rs/process-hardening/README.md) so `codex-responses-api-proxy` is run with standard process-hardening techniques. +- At startup, we allocate a `1024` byte buffer on the stack and write `"Bearer "` as the first `7` bytes. +- We then read from `stdin`, copying the contents into the buffer after `"Bearer "`. +- After verifying the key matches `/^[a-zA-Z0-9_-]+$/` (and does not exceed the buffer), we create a `String` from that buffer (so the data is now on the heap). +- We zero out the stack-allocated buffer using https://crates.io/crates/zeroize so it is not optimized away by the compiler. +- We invoke `.leak()` on the `String` so we can treat its contents as a `&'static str`, as it will live for the rest of the process. +- On UNIX, we `mlock(2)` the memory backing the `&'static str`. +- When using the `&'static str` when building an HTTP request, we use `HeaderValue::from_static()` to avoid copying the `&str`. +- We also invoke `.set_sensitive(true)` on the `HeaderValue`, which in theory indicates to other parts of the HTTP stack that the header should be treated with "special care" to avoid leakage: + +https://github.com/hyperium/http/blob/439d1c50d71e3be3204b6c4a1bf2255ed78e1f93/src/header/value.rs#L346-L376 diff --git a/codex-rs/responses-api-proxy/npm/README.md b/codex-rs/responses-api-proxy/npm/README.md new file mode 100644 index 0000000000..3458e52780 --- /dev/null +++ b/codex-rs/responses-api-proxy/npm/README.md @@ -0,0 +1,13 @@ +# @openai/codex-responses-api-proxy + +

npm i -g @openai/codex-responses-api-proxy to install codex-responses-api-proxy

+ +This package distributes the prebuilt [Codex Responses API proxy binary](https://github.com/openai/codex/tree/main/codex-rs/responses-api-proxy) for macOS, Linux, and Windows. + +To see available options, run: + +``` +node ./bin/codex-responses-api-proxy.js --help +``` + +Refer to [`codex-rs/responses-api-proxy/README.md`](https://github.com/openai/codex/blob/main/codex-rs/responses-api-proxy/README.md) for detailed documentation. diff --git a/codex-rs/responses-api-proxy/npm/bin/codex-responses-api-proxy.js b/codex-rs/responses-api-proxy/npm/bin/codex-responses-api-proxy.js new file mode 100755 index 0000000000..e2c3ee7d3e --- /dev/null +++ b/codex-rs/responses-api-proxy/npm/bin/codex-responses-api-proxy.js @@ -0,0 +1,97 @@ +#!/usr/bin/env node +// Entry point for the Codex responses API proxy binary. + +import { spawn } from "node:child_process"; +import path from "path"; +import { fileURLToPath } from "url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +function determineTargetTriple(platform, arch) { + switch (platform) { + case "linux": + case "android": + if (arch === "x64") { + return "x86_64-unknown-linux-musl"; + } + if (arch === "arm64") { + return "aarch64-unknown-linux-musl"; + } + break; + case "darwin": + if (arch === "x64") { + return "x86_64-apple-darwin"; + } + if (arch === "arm64") { + return "aarch64-apple-darwin"; + } + break; + case "win32": + if (arch === "x64") { + return "x86_64-pc-windows-msvc"; + } + if (arch === "arm64") { + return "aarch64-pc-windows-msvc"; + } + break; + default: + break; + } + return null; +} + +const targetTriple = determineTargetTriple(process.platform, process.arch); +if (!targetTriple) { + throw new Error( + `Unsupported platform: ${process.platform} (${process.arch})`, + ); +} + +const vendorRoot = path.join(__dirname, "..", "vendor"); +const archRoot = path.join(vendorRoot, targetTriple); +const binaryBaseName = "codex-responses-api-proxy"; +const binaryPath = path.join( + archRoot, + binaryBaseName, + process.platform === "win32" ? `${binaryBaseName}.exe` : binaryBaseName, +); + +const child = spawn(binaryPath, process.argv.slice(2), { + stdio: "inherit", +}); + +child.on("error", (err) => { + console.error(err); + process.exit(1); +}); + +const forwardSignal = (signal) => { + if (!child.killed) { + try { + child.kill(signal); + } catch { + /* ignore */ + } + } +}; + +["SIGINT", "SIGTERM", "SIGHUP"].forEach((sig) => { + process.on(sig, () => forwardSignal(sig)); +}); + +const childResult = await new Promise((resolve) => { + child.on("exit", (code, signal) => { + if (signal) { + resolve({ type: "signal", signal }); + } else { + resolve({ type: "code", exitCode: code ?? 1 }); + } + }); +}); + +if (childResult.type === "signal") { + process.kill(process.pid, childResult.signal); +} else { + process.exit(childResult.exitCode); +} diff --git a/codex-rs/responses-api-proxy/npm/package.json b/codex-rs/responses-api-proxy/npm/package.json new file mode 100644 index 0000000000..f3956a77d6 --- /dev/null +++ b/codex-rs/responses-api-proxy/npm/package.json @@ -0,0 +1,21 @@ +{ + "name": "@openai/codex-responses-api-proxy", + "version": "0.0.0-dev", + "license": "Apache-2.0", + "bin": { + "codex-responses-api-proxy": "bin/codex-responses-api-proxy.js" + }, + "type": "module", + "engines": { + "node": ">=16" + }, + "files": [ + "bin", + "vendor" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/openai/codex.git", + "directory": "codex-rs/responses-api-proxy/npm" + } +} diff --git a/codex-rs/responses-api-proxy/src/read_api_key.rs b/codex-rs/responses-api-proxy/src/read_api_key.rs index 8ffad2aab3..1dda92f380 100644 --- a/codex-rs/responses-api-proxy/src/read_api_key.rs +++ b/codex-rs/responses-api-proxy/src/read_api_key.rs @@ -54,9 +54,16 @@ where )); } + if let Err(err) = validate_auth_header_bytes(&buf[AUTH_HEADER_PREFIX.len()..total]) { + buf.zeroize(); + return Err(err); + } + let header_str = match std::str::from_utf8(&buf[..total]) { Ok(value) => value, Err(err) => { + // In theory, validate_auth_header_bytes() should have caught + // any invalid UTF-8 sequences, but just in case... buf.zeroize(); return Err(err).context("reading Authorization header from stdin as UTF-8"); } @@ -113,6 +120,21 @@ fn mlock_str(value: &str) { #[cfg(not(unix))] fn mlock_str(_value: &str) {} +/// The key should match /^[A-Za-z0-9\-_]+$/. Ensure there is no funny business +/// with NUL characters and whatnot. +fn validate_auth_header_bytes(key_bytes: &[u8]) -> Result<()> { + if key_bytes + .iter() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) + { + return Ok(()); + } + + Err(anyhow!( + "OPENAI_API_KEY may only contain ASCII letters, numbers, '-' or '_'" + )) +} + #[cfg(test)] mod tests { use super::*; @@ -158,7 +180,7 @@ mod tests { }) .unwrap_err(); let message = format!("{err:#}"); - assert!(message.contains("too large")); + assert!(message.contains("OPENAI_API_KEY is too large to fit in the 512-byte buffer")); } #[test] @@ -180,6 +202,23 @@ mod tests { .unwrap_err(); let message = format!("{err:#}"); - assert!(message.contains("UTF-8")); + assert!( + message.contains("OPENAI_API_KEY may only contain ASCII letters, numbers, '-' or '_'") + ); + } + + #[test] + fn errors_on_invalid_characters() { + let err = read_auth_header_with(|buf| { + let data = b"sk-abc!23"; + buf[..data.len()].copy_from_slice(data); + Ok(data.len()) + }) + .unwrap_err(); + + let message = format!("{err:#}"); + assert!( + message.contains("OPENAI_API_KEY may only contain ASCII letters, numbers, '-' or '_'") + ); } } From c4120a265b9444587d6c69e170b64d2440274e10 Mon Sep 17 00:00:00 2001 From: Ed Bayes Date: Sun, 28 Sep 2025 20:50:04 -0700 Subject: [PATCH 16/16] [CODEX-3595] Remove period when copying highlighted text in iTerm (#4419) --- codex-rs/cli/src/main.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 5c95df0afa..ca248229c7 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -181,7 +181,7 @@ fn format_exit_messages(exit_info: AppExitInfo, color_enabled: bool) -> Vec