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/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 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/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", 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/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; 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/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 3b02c59334..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,39 +1145,6 @@ impl ChatComposer { false } - 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 - } - } - - 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. @@ -1319,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); - } } } @@ -1351,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()); @@ -1409,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; @@ -1440,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; } @@ -1467,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 2e63493e31..77e2c106e5 100644 --- a/codex-rs/tui/src/bottom_pane/footer.rs +++ b/codex-rs/tui/src/bottom_pane/footer.rs @@ -1,190 +1,149 @@ +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]; - - 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 target_width = column_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, - FilePaths, + Send, InsertNewline, - PasteImage, - Quit, ShowTranscript, - ToggleOverlay, + Quit, EditPrevious, } @@ -192,13 +151,30 @@ enum ShortcutId { 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), + } } } @@ -222,66 +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", - }, - ShortcutDescriptor { - id: ShortcutId::FilePaths, - bindings: &[ShortcutBinding { - code: KeyCode::Char('@'), - modifiers: KeyModifiers::NONE, - overlay_text: "@", - condition: DisplayCondition::Always, - }], - prefix: "", - label: " for file paths", + footer_label: " send ", + footer_prefix: "", }, ShortcutDescriptor { id: ShortcutId::InsertNewline, @@ -289,154 +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::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::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::ToggleOverlay, + id: ShortcutId::Quit, bindings: &[ShortcutBinding { - code: KeyCode::Char('?'), - modifiers: KeyModifiers::NONE, - overlay_text: "?", + code: KeyCode::Char('c'), + modifiers: KeyModifiers::CONTROL, + display: ShortcutDisplay::Ctrl('C'), condition: DisplayCondition::Always, }], - prefix: "", - label: " to hide shortcuts", + footer_label: " quit", + footer_prefix: "", }, ShortcutDescriptor { id: ShortcutId::EditPrevious, bindings: &[ShortcutBinding { code: KeyCode::Esc, modifiers: KeyModifiers::NONE, - overlay_text: "esc", + display: ShortcutDisplay::Plain("Esc"), condition: DisplayCondition::Always, }], - prefix: "", - label: "", + 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 b6c93d2813..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; @@ -102,6 +103,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) } @@ -372,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 c08e7f2d83..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 @ 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 " 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 96c10733dc..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 @ 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 " +"⏎ 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 85ecad284a..724feb29f2 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 { @@ -393,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; } } @@ -648,6 +651,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 +912,7 @@ impl ChatWidget { is_review_mode: false, ghost_snapshots: Vec::new(), ghost_snapshots_disabled: true, + needs_final_message_separator: false, } } @@ -962,6 +974,7 @@ impl ChatWidget { is_review_mode: false, ghost_snapshots: Vec::new(), ghost_snapshots_disabled: true, + needs_final_message_separator: false, } } @@ -1188,6 +1201,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 +1214,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 +1256,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) { @@ -1959,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__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/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/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/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()) +} 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)) -} 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()) } }