From 6c5471feb20f8a4b34f2efb9239e4e641149e77a Mon Sep 17 00:00:00 2001 From: jif-oai Date: Thu, 9 Apr 2026 14:21:27 +0100 Subject: [PATCH 1/4] feat: /resume per ID/name (#17222) Support `/resume 00000-0000-0000-00000000` from the TUI (equivalent for the name) --- codex-rs/tui/src/app.rs | 211 ++++++++++-------- codex-rs/tui/src/app_event.rs | 3 + codex-rs/tui/src/chatwidget.rs | 11 + .../src/chatwidget/tests/slash_commands.rs | 18 ++ codex-rs/tui/src/slash_command.rs | 1 + 5 files changed, 155 insertions(+), 89 deletions(-) diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 3ca5edb00d..6986b9e595 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -47,6 +47,7 @@ use crate::read_session_model; use crate::render::highlight::highlight_bash_to_lines; use crate::render::renderable::Renderable; use crate::resume_picker::SessionSelection; +use crate::resume_picker::SessionTarget; #[cfg(test)] use crate::test_support::PathBufExt; use crate::tui; @@ -4047,6 +4048,108 @@ impl App { Ok(AppRunControl::Continue) } + async fn resume_target_session( + &mut self, + tui: &mut tui::Tui, + app_server: &mut AppServerSession, + target_session: SessionTarget, + ) -> Result { + if self.ignore_same_thread_resume(&target_session) { + tui.frame_requester().schedule_frame(); + return Ok(AppRunControl::Continue); + } + + let current_cwd = self.config.cwd.to_path_buf(); + let resume_cwd = if self.remote_app_server_url.is_some() { + current_cwd.clone() + } else { + match crate::resolve_cwd_for_resume_or_fork( + tui, + &self.config, + ¤t_cwd, + target_session.thread_id, + target_session.path.as_deref(), + CwdPromptAction::Resume, + /*allow_prompt*/ true, + ) + .await? + { + crate::ResolveCwdOutcome::Continue(Some(cwd)) => cwd, + crate::ResolveCwdOutcome::Continue(None) => current_cwd.clone(), + crate::ResolveCwdOutcome::Exit => { + return Ok(AppRunControl::Exit(ExitReason::UserRequested)); + } + } + }; + + let mut resume_config = match self + .rebuild_config_for_resume_or_fallback(¤t_cwd, resume_cwd) + .await + { + Ok(cfg) => cfg, + Err(err) => { + self.chat_widget.add_error_message(format!( + "Failed to rebuild configuration for resume: {err}" + )); + return Ok(AppRunControl::Continue); + } + }; + self.apply_runtime_policy_overrides(&mut resume_config); + + let summary = session_summary( + self.chat_widget.token_usage(), + self.chat_widget.thread_id(), + self.chat_widget.thread_name(), + ); + match app_server + .resume_thread(resume_config.clone(), target_session.thread_id) + .await + { + Ok(resumed) => { + self.shutdown_current_thread(app_server).await; + self.config = resume_config; + tui.set_notification_settings( + self.config.tui_notifications.method, + self.config.tui_notifications.condition, + ); + self.file_search + .update_search_dir(self.config.cwd.to_path_buf()); + match self + .replace_chat_widget_with_app_server_thread(tui, app_server, resumed) + .await + { + Ok(()) => { + if let Some(summary) = summary { + let mut lines: Vec> = Vec::new(); + if let Some(usage_line) = summary.usage_line { + lines.push(usage_line.into()); + } + if let Some(command) = summary.resume_command { + let spans = + vec!["To continue this session, run ".into(), command.cyan()]; + lines.push(spans.into()); + } + self.chat_widget.add_plain_history_lines(lines); + } + } + Err(err) => { + self.chat_widget.add_error_message(format!( + "Failed to attach to resumed app-server thread: {err}" + )); + } + } + } + Err(err) => { + let path_display = target_session.display_label(); + self.chat_widget.add_error_message(format!( + "Failed to resume session from {path_display}: {err}" + )); + } + } + + Ok(AppRunControl::Continue) + } + async fn handle_event( &mut self, tui: &mut tui::Tui, @@ -4097,97 +4200,13 @@ impl App { .await? { SessionSelection::Resume(target_session) => { - if self.ignore_same_thread_resume(&target_session) { - tui.frame_requester().schedule_frame(); - return Ok(AppRunControl::Continue); - } - let current_cwd = self.config.cwd.to_path_buf(); - let resume_cwd = if self.remote_app_server_url.is_some() { - current_cwd.clone() - } else { - match crate::resolve_cwd_for_resume_or_fork( - tui, - &self.config, - ¤t_cwd, - target_session.thread_id, - target_session.path.as_deref(), - CwdPromptAction::Resume, - /*allow_prompt*/ true, - ) + match self + .resume_target_session(tui, app_server, target_session) .await? - { - crate::ResolveCwdOutcome::Continue(Some(cwd)) => cwd, - crate::ResolveCwdOutcome::Continue(None) => current_cwd.clone(), - crate::ResolveCwdOutcome::Exit => { - return Ok(AppRunControl::Exit(ExitReason::UserRequested)); - } - } - }; - let mut resume_config = match self - .rebuild_config_for_resume_or_fallback(¤t_cwd, resume_cwd) - .await { - Ok(cfg) => cfg, - Err(err) => { - self.chat_widget.add_error_message(format!( - "Failed to rebuild configuration for resume: {err}" - )); - return Ok(AppRunControl::Continue); - } - }; - self.apply_runtime_policy_overrides(&mut resume_config); - let summary = session_summary( - self.chat_widget.token_usage(), - self.chat_widget.thread_id(), - self.chat_widget.thread_name(), - ); - match app_server - .resume_thread(resume_config.clone(), target_session.thread_id) - .await - { - Ok(resumed) => { - self.shutdown_current_thread(app_server).await; - self.config = resume_config; - tui.set_notification_settings( - self.config.tui_notifications.method, - self.config.tui_notifications.condition, - ); - self.file_search - .update_search_dir(self.config.cwd.to_path_buf()); - match self - .replace_chat_widget_with_app_server_thread( - tui, app_server, resumed, - ) - .await - { - Ok(()) => { - if let Some(summary) = summary { - let mut lines: Vec> = Vec::new(); - if let Some(usage_line) = summary.usage_line { - lines.push(usage_line.into()); - } - if let Some(command) = summary.resume_command { - let spans = vec![ - "To continue this session, run ".into(), - command.cyan(), - ]; - lines.push(spans.into()); - } - self.chat_widget.add_plain_history_lines(lines); - } - } - Err(err) => { - self.chat_widget.add_error_message(format!( - "Failed to attach to resumed app-server thread: {err}" - )); - } - } - } - Err(err) => { - let path_display = target_session.display_label(); - self.chat_widget.add_error_message(format!( - "Failed to resume session from {path_display}: {err}" - )); + AppRunControl::Continue => {} + AppRunControl::Exit(reason) => { + return Ok(AppRunControl::Exit(reason)); } } } @@ -4199,6 +4218,20 @@ impl App { // Leaving alt-screen may blank the inline viewport; force a redraw either way. tui.frame_requester().schedule_frame(); } + AppEvent::ResumeSessionByIdOrName(id_or_name) => { + match crate::lookup_session_target_with_app_server(app_server, &id_or_name).await? { + Some(target_session) => { + return self + .resume_target_session(tui, app_server, target_session) + .await; + } + None => { + self.chat_widget.add_error_message(format!( + "No saved chat found matching '{id_or_name}'." + )); + } + } + } AppEvent::ForkCurrentSession => { self.session_telemetry.counter( "codex.thread.fork", diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 6bc873b27e..78a2525350 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -124,6 +124,9 @@ pub(crate) enum AppEvent { /// Open the resume picker inside the running TUI session. OpenResumePicker, + /// Resume a thread by UUID or thread name inside the running TUI session. + ResumeSessionByIdOrName(String), + /// Fork the current session into a new thread. ForkCurrentSession, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index fd87c58f69..27450ed5f9 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -5452,6 +5452,17 @@ impl ChatWidget { })); self.bottom_pane.drain_pending_submission_state(); } + SlashCommand::Resume if !trimmed.is_empty() => { + let Some((prepared_args, _prepared_elements)) = self + .bottom_pane + .prepare_inline_args_submission(/*record_history*/ false) + else { + return; + }; + self.app_event_tx + .send(AppEvent::ResumeSessionByIdOrName(prepared_args)); + self.bottom_pane.drain_pending_submission_state(); + } SlashCommand::SandboxReadRoot if !trimmed.is_empty() => { let Some((prepared_args, _prepared_elements)) = self .bottom_pane diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index fdf7c5008b..e2fda41685 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -447,6 +447,24 @@ async fn slash_resume_opens_picker() { assert_matches!(rx.try_recv(), Ok(AppEvent::OpenResumePicker)); } +#[tokio::test] +async fn slash_resume_with_arg_requests_named_session() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + + chat.bottom_pane.set_composer_text( + "/resume my-saved-thread".to_string(), + Vec::new(), + Vec::new(), + ); + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + + assert_matches!( + rx.try_recv(), + Ok(AppEvent::ResumeSessionByIdOrName(id_or_name)) if id_or_name == "my-saved-thread" + ); + assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty)); +} + #[tokio::test] async fn slash_fork_requests_current_fork() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index ec624d3fb9..b1a6e97ad1 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -132,6 +132,7 @@ impl SlashCommand { | SlashCommand::Rename | SlashCommand::Plan | SlashCommand::Fast + | SlashCommand::Resume | SlashCommand::SandboxReadRoot ) } From 9f6f2c84c1dc65a1d9cf8378657f27f4cfea002b Mon Sep 17 00:00:00 2001 From: jif-oai Date: Thu, 9 Apr 2026 15:17:06 +0100 Subject: [PATCH 2/4] feat: advanced announcements per OS and plans (#17226) Support things like ``` [[announcements]] content = "custom message" from_date = "2026-04-09" to_date = "2026-06-01" target_app = "cli" target_plan_types = ["pro"] target_oses = ["macos"] version_regex = "..." # add version of the patch ``` --- codex-rs/tui/src/tooltips.rs | 174 +++++++++++++++++++++++++++++++++-- 1 file changed, 165 insertions(+), 9 deletions(-) diff --git a/codex-rs/tui/src/tooltips.rs b/codex-rs/tui/src/tooltips.rs index d2843a8921..9565831b27 100644 --- a/codex-rs/tui/src/tooltips.rs +++ b/codex-rs/tui/src/tooltips.rs @@ -51,7 +51,7 @@ fn experimental_tooltips() -> Vec<&'static str> { pub(crate) fn get_tooltip(plan: Option, fast_mode_enabled: bool) -> Option { let mut rng = rand::rng(); - if let Some(announcement) = announcement::fetch_announcement_tip() { + if let Some(announcement) = announcement::fetch_announcement_tip(plan) { return Some(announcement); } @@ -124,6 +124,7 @@ pub(crate) mod announcement { use crate::version::CODEX_CLI_VERSION; use chrono::NaiveDate; use chrono::Utc; + use codex_protocol::account::PlanType; use regex_lite::Regex; use serde::Deserialize; use std::sync::OnceLock; @@ -131,6 +132,7 @@ pub(crate) mod announcement { use std::time::Duration; static ANNOUNCEMENT_TIP: OnceLock> = OnceLock::new(); + const CURRENT_OS: TargetOs = TargetOs::current(); /// Prewarm the cache of the announcement tip. pub(crate) fn prewarm() { @@ -138,12 +140,12 @@ pub(crate) mod announcement { } /// Fetch the announcement tip, return None if the prewarm is not done yet. - pub(crate) fn fetch_announcement_tip() -> Option { + pub(crate) fn fetch_announcement_tip(plan: Option) -> Option { ANNOUNCEMENT_TIP .get() .cloned() .flatten() - .and_then(|raw| parse_announcement_tip_toml(&raw)) + .and_then(|raw| parse_announcement_tip_toml(&raw, plan)) } #[derive(Debug, Deserialize)] @@ -153,6 +155,8 @@ pub(crate) mod announcement { to_date: Option, version_regex: Option, target_app: Option, + target_plan_types: Option>, + target_oses: Option>, } #[derive(Debug, Deserialize)] @@ -167,6 +171,31 @@ pub(crate) mod announcement { to_date: Option, version_regex: Option, target_app: String, + target_plan_types: Option>, + target_oses: Option>, + } + + #[derive(Debug, Deserialize, Copy, Clone, PartialEq, Eq)] + #[serde(rename_all = "lowercase")] + enum TargetOs { + Linux, + Macos, + Windows, + #[serde(other)] + Unknown, + } + + impl TargetOs { + const fn current() -> Self { + if cfg!(target_os = "macos") { + Self::Macos + } else if cfg!(target_os = "windows") { + Self::Windows + } else { + // Codex currently publishes CLI builds for macOS, Windows, and Linux. + Self::Linux + } + } } fn init_announcement_tip_in_thread() -> Option { @@ -190,7 +219,10 @@ pub(crate) mod announcement { response.error_for_status().ok()?.text().ok() } - pub(crate) fn parse_announcement_tip_toml(text: &str) -> Option { + pub(crate) fn parse_announcement_tip_toml( + text: &str, + plan: Option, + ) -> Option { let announcements = toml::from_str::(text) .map(|doc| doc.announcements) .or_else(|_| toml::from_str::>(text)) @@ -202,9 +234,19 @@ pub(crate) mod announcement { let Some(tip) = AnnouncementTip::from_raw(raw) else { continue; }; + let plan_matches = tip + .target_plan_types + .as_ref() + .is_none_or(|target_plans| plan.is_some_and(|plan| target_plans.contains(&plan))); + let os_matches = tip + .target_oses + .as_ref() + .is_none_or(|target_oses| target_oses.contains(&CURRENT_OS)); if tip.version_matches(CODEX_CLI_VERSION) && tip.date_matches(today) && tip.target_app == "cli" + && plan_matches + && os_matches { latest_match = Some(tip.content); } @@ -231,6 +273,20 @@ pub(crate) mod announcement { Some(pattern) => Some(Regex::new(&pattern).ok()?), None => None, }; + let target_plan_types = raw.target_plan_types; + if target_plan_types + .as_ref() + .is_some_and(|plans| plans.contains(&PlanType::Unknown)) + { + return None; + } + let target_oses = raw.target_oses; + if target_oses + .as_ref() + .is_some_and(|oses| oses.contains(&TargetOs::Unknown)) + { + return None; + } Some(Self { content: content.to_string(), @@ -238,6 +294,8 @@ pub(crate) mod announcement { to_date, version_regex, target_app: raw.target_app.unwrap_or("cli".to_string()).to_lowercase(), + target_plan_types, + target_oses, }) } @@ -333,7 +391,7 @@ to_date = "2000-01-01" assert_eq!( Some("latest match".to_string()), - parse_announcement_tip_toml(toml) + parse_announcement_tip_toml(toml, /*plan*/ None) ); let toml = r#" @@ -353,7 +411,7 @@ to_date = "2000-01-01" assert_eq!( Some("latest match".to_string()), - parse_announcement_tip_toml(toml) + parse_announcement_tip_toml(toml, /*plan*/ None) ); } @@ -374,7 +432,7 @@ content = "should not match either " target_app = "vsce" "#; - assert_eq!(None, parse_announcement_tip_toml(toml)); + assert_eq!(None, parse_announcement_tip_toml(toml, /*plan*/ None)); } #[test] @@ -385,7 +443,7 @@ content = 123 from_date = "2000-01-01" "#; - assert_eq!(None, parse_announcement_tip_toml(toml)); + assert_eq!(None, parse_announcement_tip_toml(toml, /*plan*/ None)); } #[test] @@ -396,6 +454,8 @@ from_date = "2000-01-01" # Dates are UTC, formatted as YYYY-MM-DD. The from_date is inclusive and the to_date is exclusive. # version_regex matches against the CLI version (env!("CARGO_PKG_VERSION")); omit to apply to all versions. # target_app specify which app should display the announcement (cli, vsce, ...). +# target_plan_types optionally restricts the announcement to plan types like ["plus", "pro"]. +# target_oses optionally restricts the announcement to operating systems like ["macos", "windows"]. [[announcements]] content = "Welcome to Codex! Check out the new onboarding flow." @@ -410,7 +470,103 @@ content = "This is a test announcement" assert_eq!( Some("This is a test announcement".to_string()), - parse_announcement_tip_toml(toml) + parse_announcement_tip_toml(toml, /*plan*/ None) + ); + } + + #[test] + fn announcement_tip_toml_matches_target_plan_type() { + let toml = r#" +[[announcements]] +content = "all plans" + +[[announcements]] +content = "pro announcement" +target_plan_types = ["pro", "enterprise"] + +[[announcements]] +content = "free announcement" +target_plan_types = ["free"] + "#; + + assert_eq!( + Some("pro announcement".to_string()), + parse_announcement_tip_toml(toml, Some(PlanType::Pro)) + ); + assert_eq!( + Some("free announcement".to_string()), + parse_announcement_tip_toml(toml, Some(PlanType::Free)) + ); + assert_eq!( + Some("all plans".to_string()), + parse_announcement_tip_toml(toml, Some(PlanType::Plus)) + ); + assert_eq!( + Some("all plans".to_string()), + parse_announcement_tip_toml(toml, /*plan*/ None) + ); + } + + #[test] + fn announcement_tip_toml_rejects_unknown_target_plan_type() { + let toml = r#" +[[announcements]] +content = "all plans" + +[[announcements]] +content = "typo announcement" +target_plan_types = ["prp"] + "#; + + assert_eq!( + Some("all plans".to_string()), + parse_announcement_tip_toml(toml, Some(PlanType::Unknown)) + ); + } + + #[test] + fn announcement_tip_toml_matches_target_os() { + let toml = r#" +[[announcements]] +content = "linux announcement" +target_oses = ["linux"] + +[[announcements]] +content = "macos announcement" +target_oses = ["macos"] + +[[announcements]] +content = "windows announcement" +target_oses = ["windows"] + "#; + + let expected = if cfg!(target_os = "macos") { + "macos announcement" + } else if cfg!(target_os = "windows") { + "windows announcement" + } else { + "linux announcement" + }; + assert_eq!( + Some(expected.to_string()), + parse_announcement_tip_toml(toml, /*plan*/ None) + ); + } + + #[test] + fn announcement_tip_toml_rejects_unknown_target_os() { + let toml = r#" +[[announcements]] +content = "all operating systems" + +[[announcements]] +content = "typo announcement" +target_oses = ["amiga"] + "#; + + assert_eq!( + Some("all operating systems".to_string()), + parse_announcement_tip_toml(toml, /*plan*/ None) ); } } From 598d6ff0561f6372c69227bac719e76f39928e87 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Thu, 9 Apr 2026 07:52:07 -0700 Subject: [PATCH 3/4] Render statusline context as a meter (#17170) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Problem: The statusline reported context as an “X% left” value, which could be mistaken for quota, and context usage was included in the default footer. Solution: Render configured context status items as a filling context meter, preserve `context-used` as a legacy alias while hiding it from the setup menu, and remove context from the default statusline. It will still be available as an opt-in option for users who want to see it. image --- codex-rs/config/src/types.rs | 3 +- codex-rs/core/config.schema.json | 2 +- ..._snapshot_uses_runtime_preview_values.snap | 4 +- .../tui/src/bottom_pane/status_line_setup.rs | 61 +++++++++++++---- codex-rs/tui/src/chatwidget.rs | 3 +- ..._review_denied_renders_denied_request.snap | 6 +- ...artup_failure_renders_warning_history.snap | 10 +-- ...i__chatwidget__tests__chatwidget_tall.snap | 6 +- ...compact_queues_user_messages_snapshot.snap | 6 +- ...pproved_exec_renders_approved_request.snap | 6 +- ...ec_renders_warning_and_denied_request.snap | 6 +- ...allel_reviews_render_aggregate_status.snap | 6 +- ...et__tests__mcp_startup_header_booting.snap | 6 +- ..._tests__preamble_keeps_working_status.snap | 6 +- ..._review_queues_user_messages_snapshot.snap | 6 +- ...line_model_with_reasoning_fast_footer.snap | 6 +- ...atwidget__tests__status_widget_active.snap | 6 +- ...ed_exec_begin_restores_working_status.snap | 6 +- ...renders_command_in_single_details_row.snap | 6 +- .../tui/src/chatwidget/status_surfaces.rs | 65 +++++++++++++++++-- .../src/chatwidget/tests/status_and_layout.rs | 40 ++++++++++-- 21 files changed, 191 insertions(+), 75 deletions(-) diff --git a/codex-rs/config/src/types.rs b/codex-rs/config/src/types.rs index cee7271691..9d1626dbd2 100644 --- a/codex-rs/config/src/types.rs +++ b/codex-rs/config/src/types.rs @@ -549,8 +549,7 @@ pub struct Tui { /// Ordered list of status line item identifiers. /// /// When set, the TUI renders the selected items as the status line. - /// When unset, the TUI defaults to: `model-with-reasoning`, `context-remaining`, and - /// `current-dir`. + /// When unset, the TUI defaults to: `model-with-reasoning` and `current-dir`. #[serde(default)] pub status_line: Option>, diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index bcc5193b2c..c3c29aa557 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -1864,7 +1864,7 @@ }, "status_line": { "default": null, - "description": "Ordered list of status line item identifiers.\n\nWhen set, the TUI renders the selected items as the status line. When unset, the TUI defaults to: `model-with-reasoning`, `context-remaining`, and `current-dir`.", + "description": "Ordered list of status line item identifiers.\n\nWhen set, the TUI renders the selected items as the status line. When unset, the TUI defaults to: `model-with-reasoning` and `current-dir`.", "items": { "type": "string" }, diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__status_line_setup__tests__setup_view_snapshot_uses_runtime_preview_values.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__status_line_setup__tests__setup_view_snapshot_uses_runtime_preview_values.snap index 20aca1e334..94d9cd450c 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__status_line_setup__tests__setup_view_snapshot_uses_runtime_preview_values.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__status_line_setup__tests__setup_view_snapshot_uses_runtime_preview_values.snap @@ -13,9 +13,9 @@ expression: "render_lines(&view, 72)" [x] git-branch Current Git branch (omitted when unavaila… [ ] model-with-reasoning Current model name with reasoning level [ ] project-root Project root directory (omitted when unav… - [ ] context-remaining Percentage of context window remaining (o… - [ ] context-used Percentage of context window used (omitte… + [ ] context-usage Visual meter of context window usage (omi… [ ] five-hour-limit Remaining usage on 5-hour usage limit (om… + [ ] weekly-limit Remaining usage on weekly usage limit (om… gpt-5-codex · ~/codex-rs · jif/statusline-preview Use ↑↓ to navigate, ←→ to move, space to select, enter to confirm, esc diff --git a/codex-rs/tui/src/bottom_pane/status_line_setup.rs b/codex-rs/tui/src/bottom_pane/status_line_setup.rs index cb0229e770..cdda2d7ca4 100644 --- a/codex-rs/tui/src/bottom_pane/status_line_setup.rs +++ b/codex-rs/tui/src/bottom_pane/status_line_setup.rs @@ -12,7 +12,7 @@ //! - Model information (name, reasoning level) //! - Directory paths (current dir, project root) //! - Git information (branch name) -//! - Context usage (remaining %, used %, window size) +//! - Context usage (meter, window size) //! - Usage limits (5-hour, weekly) //! - Session info (ID, tokens used) //! - Application version @@ -22,7 +22,6 @@ use ratatui::layout::Rect; use ratatui::text::Line; use std::collections::BTreeMap; use std::collections::HashSet; -use strum::IntoEnumIterator; use strum_macros::Display; use strum_macros::EnumIter; use strum_macros::EnumString; @@ -63,11 +62,15 @@ pub(crate) enum StatusLineItem { /// Current git branch name (if in a repository). GitBranch, - /// Percentage of context window remaining. - ContextRemaining, - - /// Percentage of context window used. - ContextUsed, + /// Visual meter of context window usage. + /// + /// Also accepts legacy `context-remaining` and `context-used` config values. + #[strum( + to_string = "context-usage", + serialize = "context-remaining", + serialize = "context-used" + )] + ContextUsage, /// Remaining usage on the 5-hour rate limit. FiveHourLimit, @@ -106,11 +109,8 @@ impl StatusLineItem { StatusLineItem::CurrentDir => "Current working directory", StatusLineItem::ProjectRoot => "Project root directory (omitted when unavailable)", StatusLineItem::GitBranch => "Current Git branch (omitted when unavailable)", - StatusLineItem::ContextRemaining => { - "Percentage of context window remaining (omitted when unknown)" - } - StatusLineItem::ContextUsed => { - "Percentage of context window used (omitted when unknown)" + StatusLineItem::ContextUsage => { + "Visual meter of context window usage (omitted when unknown)" } StatusLineItem::FiveHourLimit => { "Remaining usage on 5-hour usage limit (omitted when unavailable)" @@ -133,6 +133,24 @@ impl StatusLineItem { } } +const SELECTABLE_STATUS_LINE_ITEMS: &[StatusLineItem] = &[ + StatusLineItem::ModelName, + StatusLineItem::ModelWithReasoning, + StatusLineItem::CurrentDir, + StatusLineItem::ProjectRoot, + StatusLineItem::GitBranch, + StatusLineItem::ContextUsage, + StatusLineItem::FiveHourLimit, + StatusLineItem::WeeklyLimit, + StatusLineItem::CodexVersion, + StatusLineItem::ContextWindowSize, + StatusLineItem::UsedTokens, + StatusLineItem::TotalInputTokens, + StatusLineItem::TotalOutputTokens, + StatusLineItem::SessionId, + StatusLineItem::FastMode, +]; + /// Runtime values used to preview the current status-line selection. #[derive(Clone, Debug, Default, Eq, PartialEq)] pub(crate) struct StatusLinePreviewData { @@ -209,7 +227,7 @@ impl StatusLineSetupView { } } - for item in StatusLineItem::iter() { + for item in SELECTABLE_STATUS_LINE_ITEMS.iter().cloned() { let item_id = item.to_string(); if used_ids.contains(&item_id) { continue; @@ -293,6 +311,23 @@ mod tests { use crate::app_event::AppEvent; + #[test] + fn context_usage_is_canonical_and_accepts_legacy_ids() { + assert_eq!(StatusLineItem::ContextUsage.to_string(), "context-usage"); + assert_eq!( + "context-usage".parse::(), + Ok(StatusLineItem::ContextUsage) + ); + assert_eq!( + "context-remaining".parse::(), + Ok(StatusLineItem::ContextUsage) + ); + assert_eq!( + "context-used".parse::(), + Ok(StatusLineItem::ContextUsage) + ); + } + #[test] fn preview_uses_runtime_values() { let preview_data = StatusLinePreviewData::from_iter([ diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 27450ed5f9..c110660549 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -388,8 +388,7 @@ use unicode_segmentation::UnicodeSegmentation; const USER_SHELL_COMMAND_HELP_TITLE: &str = "Prefix a command with ! to run it locally"; const USER_SHELL_COMMAND_HELP_HINT: &str = "Example: !ls"; const DEFAULT_OPENAI_BASE_URL: &str = "https://api.openai.com/v1"; -const DEFAULT_STATUS_LINE_ITEMS: [&str; 3] = - ["model-with-reasoning", "context-remaining", "current-dir"]; +const DEFAULT_STATUS_LINE_ITEMS: [&str; 2] = ["model-with-reasoning", "current-dir"]; // Track information about an in-flight exec command. struct RunningCommand { command: Vec, diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__app_server_guardian_review_denied_renders_denied_request.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__app_server_guardian_review_denied_renders_denied_request.snap index ae9931efe7..f783bd4d6d 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__app_server_guardian_review_denied_renders_denied_request.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__app_server_guardian_review_denied_renders_denied_request.snap @@ -1,6 +1,6 @@ --- -source: tui/src/chatwidget/tests.rs -expression: term.backend().vt100().screen().contents() +source: tui/src/chatwidget/tests/guardian.rs +expression: normalize_snapshot_paths(term.backend().vt100().screen().contents()) --- ✗ Request denied for codex to run curl -sS -i -X POST --data-binary @core/src/c odex.rs https://example.com @@ -10,4 +10,4 @@ expression: term.backend().vt100().screen().contents() › Ask Codex to do anything - gpt-5.3-codex default · 100% left · /tmp/project + gpt-5.3-codex default · /tmp/project diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__app_server_mcp_startup_failure_renders_warning_history.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__app_server_mcp_startup_failure_renders_warning_history.snap index be819de548..fedd9d6bc2 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__app_server_mcp_startup_failure_renders_warning_history.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__app_server_mcp_startup_failure_renders_warning_history.snap @@ -1,15 +1,11 @@ --- -source: tui/src/chatwidget/tests.rs -assertion_line: 11761 -expression: term.backend().vt100().screen().contents() +source: tui/src/chatwidget/tests/mcp_startup.rs +expression: normalize_snapshot_paths(term.backend().vt100().screen().contents()) --- - - - ⚠ MCP client for `alpha` failed to start: handshake failed ⚠ MCP startup incomplete (failed: alpha) › Ask Codex to do anything - gpt-5.3-codex default · 100% left · /tmp/project + gpt-5.3-codex default · /tmp/project diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_tall.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_tall.snap index 2200aaa748..2698463526 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_tall.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_tall.snap @@ -1,6 +1,6 @@ --- -source: tui/src/chatwidget/tests.rs -expression: term.backend().vt100().screen().contents() +source: tui/src/chatwidget/tests/status_and_layout.rs +expression: normalize_snapshot_paths(term.backend().vt100().screen().contents()) --- • Working (0s • esc to interrupt) @@ -24,4 +24,4 @@ expression: term.backend().vt100().screen().contents() › Ask Codex to do anything - gpt-5.3-codex default · 100% left · /tmp/project + gpt-5.3-codex default · /tmp/project diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__compact_queues_user_messages_snapshot.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__compact_queues_user_messages_snapshot.snap index c79f795b2e..d39932f3a6 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__compact_queues_user_messages_snapshot.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__compact_queues_user_messages_snapshot.snap @@ -1,6 +1,6 @@ --- -source: tui/src/chatwidget/tests.rs -expression: term.backend().vt100().screen().contents() +source: tui/src/chatwidget/tests/slash_commands.rs +expression: normalize_snapshot_paths(term.backend().vt100().screen().contents()) --- • Working (0s • esc to interrupt) @@ -9,4 +9,4 @@ expression: term.backend().vt100().screen().contents() › Ask Codex to do anything - gpt-5.3-codex default · 100% left · /tmp/project + gpt-5.3-codex default · /tmp/project diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__guardian_approved_exec_renders_approved_request.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__guardian_approved_exec_renders_approved_request.snap index 6aeb8c3a4a..80592e0dd8 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__guardian_approved_exec_renders_approved_request.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__guardian_approved_exec_renders_approved_request.snap @@ -1,6 +1,6 @@ --- -source: tui/src/chatwidget/tests.rs -expression: term.backend().vt100().screen().contents() +source: tui/src/chatwidget/tests/guardian.rs +expression: normalize_snapshot_paths(term.backend().vt100().screen().contents()) --- ✔ Auto-reviewer approved codex to run rm -f /tmp/guardian-approved.sqlite this time @@ -8,4 +8,4 @@ expression: term.backend().vt100().screen().contents() › Ask Codex to do anything - gpt-5.3-codex default · 100% left · /tmp/project + gpt-5.3-codex default · /tmp/project diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__guardian_denied_exec_renders_warning_and_denied_request.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__guardian_denied_exec_renders_warning_and_denied_request.snap index 5cc6b31dac..d910d6d6f3 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__guardian_denied_exec_renders_warning_and_denied_request.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__guardian_denied_exec_renders_warning_and_denied_request.snap @@ -1,6 +1,6 @@ --- -source: tui/src/chatwidget/tests.rs -expression: term.backend().vt100().screen().contents() +source: tui/src/chatwidget/tests/guardian.rs +expression: normalize_snapshot_paths(term.backend().vt100().screen().contents()) --- ⚠ Automatic approval review denied (risk: high): The planned action would transmit the full contents of a workspace source file (`core/src/codex.rs`) to @@ -14,4 +14,4 @@ expression: term.backend().vt100().screen().contents() › Ask Codex to do anything - gpt-5.3-codex default · 100% left · /tmp/project + gpt-5.3-codex default · /tmp/project diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__guardian_parallel_reviews_render_aggregate_status.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__guardian_parallel_reviews_render_aggregate_status.snap index b5fba29047..821c8d1a6b 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__guardian_parallel_reviews_render_aggregate_status.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__guardian_parallel_reviews_render_aggregate_status.snap @@ -1,6 +1,6 @@ --- -source: tui/src/chatwidget/tests.rs -expression: rendered +source: tui/src/chatwidget/tests/guardian.rs +expression: normalize_snapshot_paths(rendered) --- • Reviewing 2 approval requests (0s • esc to interrupt) └ • rm -rf '/tmp/guardian target 1' @@ -9,4 +9,4 @@ expression: rendered › Ask Codex to do anything - gpt-5.3-codex default · 100% left · /tmp/project + gpt-5.3-codex default · /tmp/project diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__mcp_startup_header_booting.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__mcp_startup_header_booting.snap index 06e47fc82d..f3936540a1 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__mcp_startup_header_booting.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__mcp_startup_header_booting.snap @@ -1,6 +1,6 @@ --- -source: tui/src/chatwidget/tests.rs -expression: terminal.backend() +source: tui/src/chatwidget/tests/mcp_startup.rs +expression: normalized_backend_snapshot(terminal.backend()) --- " " "• Booting MCP server: alpha (0s • esc to interrupt) " @@ -8,4 +8,4 @@ expression: terminal.backend() " " "› Ask Codex to do anything " " " -" gpt-5.3-codex default · 100% left · /tmp/project " +" gpt-5.3-codex default · /tmp/project " diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__preamble_keeps_working_status.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__preamble_keeps_working_status.snap index 7b1381c964..ad7fa52531 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__preamble_keeps_working_status.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__preamble_keeps_working_status.snap @@ -1,6 +1,6 @@ --- -source: tui/src/chatwidget/tests.rs -expression: terminal.backend() +source: tui/src/chatwidget/tests/exec_flow.rs +expression: normalized_backend_snapshot(terminal.backend()) --- " " "• Working (0s • esc to interrupt) " @@ -8,4 +8,4 @@ expression: terminal.backend() " " "› Ask Codex to do anything " " " -" gpt-5.3-codex default · 100% left · /tmp/project " +" gpt-5.3-codex default · /tmp/project " diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__review_queues_user_messages_snapshot.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__review_queues_user_messages_snapshot.snap index c60607c075..bb4a8cefe7 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__review_queues_user_messages_snapshot.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__review_queues_user_messages_snapshot.snap @@ -1,6 +1,6 @@ --- -source: tui/src/chatwidget/tests.rs -expression: term.backend().vt100().screen().contents() +source: tui/src/chatwidget/tests/review_mode.rs +expression: normalize_snapshot_paths(term.backend().vt100().screen().contents()) --- • Working (0s • esc to interrupt) @@ -9,4 +9,4 @@ expression: term.backend().vt100().screen().contents() › Ask Codex to do anything - gpt-5.3-codex default · 100% left · /tmp/project + gpt-5.3-codex default · /tmp/project diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_line_model_with_reasoning_fast_footer.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_line_model_with_reasoning_fast_footer.snap index 6355decd68..c71a570606 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_line_model_with_reasoning_fast_footer.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_line_model_with_reasoning_fast_footer.snap @@ -1,9 +1,9 @@ --- -source: tui/src/chatwidget/tests.rs -expression: terminal.backend() +source: tui/src/chatwidget/tests/status_and_layout.rs +expression: normalized_backend_snapshot(terminal.backend()) --- " " " " "› Ask Codex to do anything " " " -" gpt-5.4 xhigh fast · 100% left · /tmp/project " +" gpt-5.4 xhigh fast · Context [ ] · /tmp/project " 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 e4fea35445..fea8a4db0f 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 @@ -1,6 +1,6 @@ --- -source: tui/src/chatwidget/tests.rs -expression: terminal.backend() +source: tui/src/chatwidget/tests/status_and_layout.rs +expression: normalized_backend_snapshot(terminal.backend()) --- " " "• Analyzing (0s • esc to interrupt) " @@ -8,4 +8,4 @@ expression: terminal.backend() " " "› Ask Codex to do anything " " " -" gpt-5.3-codex default · 100% left · /tmp/project " +" gpt-5.3-codex default · /tmp/project " diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__unified_exec_begin_restores_working_status.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__unified_exec_begin_restores_working_status.snap index 9146406a66..7a2d0b4b9d 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__unified_exec_begin_restores_working_status.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__unified_exec_begin_restores_working_status.snap @@ -1,6 +1,6 @@ --- -source: tui/src/chatwidget/tests.rs -expression: terminal.backend() +source: tui/src/chatwidget/tests/exec_flow.rs +expression: normalized_backend_snapshot(terminal.backend()) --- " " "• Working (0s • esc to interrupt) · 1 background terminal running · /ps to view…" @@ -8,4 +8,4 @@ expression: terminal.backend() " " "› Ask Codex to do anything " " " -" gpt-5.3-codex default · 100% left · /tmp/project " +" gpt-5.3-codex default · /tmp/project " diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__unified_exec_wait_status_renders_command_in_single_details_row.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__unified_exec_wait_status_renders_command_in_single_details_row.snap index 3b7c1ecc4a..a5b839e00c 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__unified_exec_wait_status_renders_command_in_single_details_row.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__unified_exec_wait_status_renders_command_in_single_details_row.snap @@ -1,6 +1,6 @@ --- -source: tui/src/chatwidget/tests.rs -expression: rendered +source: tui/src/chatwidget/tests/exec_flow.rs +expression: normalize_snapshot_paths(rendered) --- • Waiting for background terminal (0s • esc to … └ cargo test -p codex-core -- --exact… @@ -8,4 +8,4 @@ expression: rendered › Ask Codex to do anything - gpt-5.3-codex default · 100% left · /tmp/proj… + gpt-5.3-codex default · /tmp/project diff --git a/codex-rs/tui/src/chatwidget/status_surfaces.rs b/codex-rs/tui/src/chatwidget/status_surfaces.rs index d80003cc51..bd58fd4e8f 100644 --- a/codex-rs/tui/src/chatwidget/status_surfaces.rs +++ b/codex-rs/tui/src/chatwidget/status_surfaces.rs @@ -450,12 +450,9 @@ impl ChatWidget { Some(format!("{} used", format_tokens_compact(total))) } } - StatusLineItem::ContextRemaining => self - .status_line_context_remaining_percent() - .map(|remaining| format!("{remaining}% left")), - StatusLineItem::ContextUsed => self + StatusLineItem::ContextUsage => self .status_line_context_used_percent() - .map(|used| format!("{used}% used")), + .map(format_context_used_meter), StatusLineItem::FiveHourLimit => { let window = self .rate_limit_snapshots_by_limit_id @@ -660,3 +657,61 @@ where } (items, invalid) } + +fn format_context_used_meter(used_percent: i64) -> String { + const METER_WIDTH: usize = 5; + const EIGHTHS_PER_CELL: i64 = 8; + const PARTIAL_BLOCKS: [&str; 8] = ["", "▏", "▎", "▍", "▌", "▋", "▊", "▉"]; + + let used_percent = used_percent.clamp(0, 100); + let total_eighths = (used_percent * METER_WIDTH as i64 * EIGHTHS_PER_CELL + 50) / 100; + let filled_cells = (total_eighths / EIGHTHS_PER_CELL) as usize; + let partial_eighths = (total_eighths % EIGHTHS_PER_CELL) as usize; + + let mut meter = String::with_capacity(METER_WIDTH); + meter.push_str(&"█".repeat(filled_cells)); + meter.push_str(PARTIAL_BLOCKS[partial_eighths]); + + let occupied_cells = filled_cells + usize::from(partial_eighths > 0); + meter.push_str(&" ".repeat(METER_WIDTH.saturating_sub(occupied_cells))); + + format!("Context [{meter}]") +} + +#[cfg(test)] +mod tests { + use super::format_context_used_meter; + use pretty_assertions::assert_eq; + + #[test] + fn context_meter_uses_five_cells_with_partial_blocks() { + assert_eq!( + format_context_used_meter(/*used_percent*/ 100), + "Context [█████]" + ); + assert_eq!( + format_context_used_meter(/*used_percent*/ 50), + "Context [██▌ ]" + ); + assert_eq!( + format_context_used_meter(/*used_percent*/ 10), + "Context [▌ ]" + ); + assert_eq!( + format_context_used_meter(/*used_percent*/ 0), + "Context [ ]" + ); + } + + #[test] + fn context_meter_clamps_out_of_range_values() { + assert_eq!( + format_context_used_meter(/*used_percent*/ 125), + "Context [█████]" + ); + assert_eq!( + format_context_used_meter(/*used_percent*/ -1), + "Context [ ]" + ); + } +} diff --git a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index c5545d59ee..b04ce74a14 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -866,6 +866,36 @@ async fn status_line_invalid_items_warn_once() { ); } +#[tokio::test] +async fn status_line_legacy_context_used_renders_context_meter() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.thread_id = Some(ThreadId::new()); + chat.config.tui_status_line = Some(vec!["context-used".to_string()]); + + chat.refresh_status_line(); + + assert_eq!(status_line_text(&chat), Some("Context [ ]".to_string())); + assert!( + drain_insert_history(&mut rx).is_empty(), + "legacy context-used should remain a valid status line item" + ); +} + +#[tokio::test] +async fn status_line_legacy_context_remaining_renders_context_meter() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.thread_id = Some(ThreadId::new()); + chat.config.tui_status_line = Some(vec!["context-remaining".to_string()]); + + chat.refresh_status_line(); + + assert_eq!(status_line_text(&chat), Some("Context [ ]".to_string())); + assert!( + drain_insert_history(&mut rx).is_empty(), + "legacy context-remaining should remain a valid status line item" + ); +} + #[tokio::test] async fn status_line_branch_state_resets_when_git_branch_disabled() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; @@ -965,7 +995,7 @@ async fn status_line_model_with_reasoning_includes_fast_for_fast_capable_models( chat.config.cwd = test_project_path().abs(); chat.config.tui_status_line = Some(vec![ "model-with-reasoning".to_string(), - "context-remaining".to_string(), + "context-usage".to_string(), "current-dir".to_string(), ]); chat.set_reasoning_effort(Some(ReasoningEffortConfig::XHigh)); @@ -978,7 +1008,7 @@ async fn status_line_model_with_reasoning_includes_fast_for_fast_capable_models( assert_eq!( status_line_text(&chat), - Some(format!("gpt-5.4 xhigh fast · 100% left · {test_cwd}")) + Some(format!("gpt-5.4 xhigh fast · Context [ ] · {test_cwd}")) ); chat.set_model("gpt-5.3-codex"); @@ -986,7 +1016,9 @@ async fn status_line_model_with_reasoning_includes_fast_for_fast_capable_models( assert_eq!( status_line_text(&chat), - Some(format!("gpt-5.3-codex xhigh · 100% left · {test_cwd}")) + Some(format!( + "gpt-5.3-codex xhigh · Context [ ] · {test_cwd}" + )) ); } @@ -1073,7 +1105,7 @@ async fn status_line_model_with_reasoning_fast_footer_snapshot() { chat.config.cwd = test_project_path().abs(); chat.config.tui_status_line = Some(vec![ "model-with-reasoning".to_string(), - "context-remaining".to_string(), + "context-usage".to_string(), "current-dir".to_string(), ]); chat.set_reasoning_effort(Some(ReasoningEffortConfig::XHigh)); From c0b5d8d24a16d937db2cd9064e24886e0e94960a Mon Sep 17 00:00:00 2001 From: jif-oai Date: Thu, 9 Apr 2026 17:30:18 +0100 Subject: [PATCH 4/4] Skip local shell snapshots for remote unified exec (#17217) ## Summary - detect remote exec-server sessions in the unified-exec runtime - bypass the local shell-snapshot bootstrap only for those remote sessions - preserve existing local snapshot wrapping, PowerShell UTF-8 prefixing, sandbox orchestration, and zsh-fork handling ## Why The shell snapshot file is currently captured and stored next to Core. If Core wraps a remote command with `. /path/to/local/snapshot`, the process starts on the executor and tries to source a path from the orchestrator filesystem. This keeps remote commands from receiving that known-local path until shell snapshots are captured/restored on the executor side. ## Validation - `just fmt` - `git diff --check` - `cargo test -p codex-core --lib tools::runtimes::tests` Co-authored-by: Codex --- .../core/src/tools/runtimes/unified_exec.rs | 23 +++++++++++++------ 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/codex-rs/core/src/tools/runtimes/unified_exec.rs b/codex-rs/core/src/tools/runtimes/unified_exec.rs index 86b6163b87..fd12607c1b 100644 --- a/codex-rs/core/src/tools/runtimes/unified_exec.rs +++ b/codex-rs/core/src/tools/runtimes/unified_exec.rs @@ -202,13 +202,22 @@ impl<'a> ToolRuntime for UnifiedExecRunt ) -> Result { let base_command = &req.command; let session_shell = ctx.session.user_shell(); - let command = maybe_wrap_shell_lc_with_snapshot( - base_command, - session_shell.as_ref(), - &req.cwd, - &req.explicit_env_overrides, - &req.env, - ); + let environment_is_remote = ctx + .turn + .environment + .as_ref() + .is_some_and(|environment| environment.is_remote()); + let command = if environment_is_remote { + base_command.to_vec() + } else { + maybe_wrap_shell_lc_with_snapshot( + base_command, + session_shell.as_ref(), + &req.cwd, + &req.explicit_env_overrides, + &req.env, + ) + }; let command = if matches!(session_shell.shell_type, ShellType::PowerShell) { prefix_powershell_script_with_utf8(&command) } else {