diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 2c88baf85e2c..1ddd8235ed02 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -2480,17 +2480,48 @@ impl ChatWidget { fn open_plan_implementation_prompt(&mut self) { let default_mask = collaboration_modes::default_mode_mask(self.model_catalog.as_ref()); + let context_usage_label = self.plan_implementation_context_usage_label(); self.bottom_pane .show_selection_view(plan_implementation::selection_view_params( default_mask, self.latest_proposed_plan_markdown.as_deref(), + context_usage_label.as_deref(), )); self.notify(Notification::PlanModePrompt { title: PLAN_IMPLEMENTATION_TITLE.to_string(), }); } + /// Returns a context-used label for the plan implementation prompt. + /// + /// The footer reports context remaining because it is ambient status, but + /// this prompt is asking whether to discard prior conversation state before + /// implementing a plan. Reporting used context makes the cleanup tradeoff + /// explicit. A fully fresh or unknown context window returns no label so + /// the clear-context option does not imply urgency without evidence. + fn plan_implementation_context_usage_label(&self) -> Option { + let info = self.token_info.as_ref()?; + let percent = self.context_remaining_percent(info); + + let used_tokens = self.context_used_tokens(info, percent.is_some()); + if let Some(percent) = percent { + let used_percent = 100 - percent.clamp(0, 100); + if used_percent <= 0 { + return None; + } + return Some(format!("{used_percent}% used")); + } + + if let Some(tokens) = used_tokens + && tokens > 0 + { + return Some(format!("{} used", format_tokens_compact(tokens))); + } + + None + } + fn has_queued_follow_up_messages(&self) -> bool { !self.rejected_steers_queue.is_empty() || !self.queued_user_messages.is_empty() } diff --git a/codex-rs/tui/src/chatwidget/plan_implementation.rs b/codex-rs/tui/src/chatwidget/plan_implementation.rs index c5b253a8d199..dd810dc493c7 100644 --- a/codex-rs/tui/src/chatwidget/plan_implementation.rs +++ b/codex-rs/tui/src/chatwidget/plan_implementation.rs @@ -20,9 +20,15 @@ pub(super) const PLAN_IMPLEMENTATION_CLEAR_CONTEXT_PREFIX: &str = concat!( pub(super) const PLAN_IMPLEMENTATION_DEFAULT_UNAVAILABLE: &str = "Default mode unavailable"; pub(super) const PLAN_IMPLEMENTATION_NO_APPROVED_PLAN: &str = "No approved plan available"; +/// Builds the confirmation prompt shown after a plan is approved in Plan mode. +/// +/// The optional usage label is already phrased for display, such as `89% used` +/// or `123K used`. This module only decides where that label belongs in the +/// decision copy so action wiring stays separate from token accounting. pub(super) fn selection_view_params( default_mask: Option, plan_markdown: Option<&str>, + clear_context_usage_label: Option<&str>, ) -> SelectionViewParams { let (implement_actions, implement_disabled_reason) = match default_mask.clone() { Some(mask) => { @@ -63,6 +69,11 @@ pub(super) fn selection_view_params( ), }; + let clear_context_description = clear_context_usage_label.map_or_else( + || "Fresh thread with this plan.".to_string(), + |label| format!("Fresh thread. Context: {label}."), + ); + SelectionViewParams { title: Some(PLAN_IMPLEMENTATION_TITLE.to_string()), subtitle: None, @@ -80,7 +91,7 @@ pub(super) fn selection_view_params( }, SelectionItem { name: PLAN_IMPLEMENTATION_CLEAR_CONTEXT.to_string(), - description: Some("Fresh thread with this plan.".to_string()), + description: Some(clear_context_description), selected_description: None, is_current: false, actions: clear_context_actions, diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plan_implementation_popup_context_usage.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plan_implementation_popup_context_usage.snap new file mode 100644 index 000000000000..4b8ae0099aa5 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plan_implementation_popup_context_usage.snap @@ -0,0 +1,11 @@ +--- +source: tui/src/chatwidget/tests/plan_mode.rs +expression: popup +--- + Implement this plan? + +› 1. Yes, implement this plan Switch to Default and start coding. + 2. Yes, clear context and implement Fresh thread. Context: 89% used. + 3. No, stay in Plan mode Continue planning with the model. + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs index fdf906db7ca6..757a46635fc3 100644 --- a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs @@ -11,6 +11,19 @@ async fn plan_implementation_popup_snapshot() { assert_chatwidget_snapshot!("plan_implementation_popup", popup); } +#[tokio::test] +async fn plan_implementation_popup_context_usage_snapshot() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5")).await; + chat.set_token_info(Some(make_token_info( + /*total_tokens*/ 90_000, /*context_window*/ 100_000, + ))); + chat.on_plan_item_completed("- Step 1\n- Step 2\n".to_string()); + chat.open_plan_implementation_prompt(); + + let popup = render_bottom_popup(&chat, /*width*/ 80); + assert_chatwidget_snapshot!("plan_implementation_popup_context_usage", popup); +} + #[tokio::test] async fn plan_implementation_popup_no_selected_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5")).await; @@ -74,8 +87,11 @@ async fn plan_implementation_clear_context_requires_default_mode_and_plan() { let default_mask = collaboration_modes::default_mode_mask(chat.model_catalog.as_ref()) .expect("expected default collaboration mode"); - let params = - plan_implementation::selection_view_params(/*default_mask*/ None, Some("- Step\n")); + let params = plan_implementation::selection_view_params( + /*default_mask*/ None, + Some("- Step\n"), + /*clear_context_usage_label*/ None, + ); assert_eq!( params.items[1].disabled_reason.as_deref(), Some(plan_implementation::PLAN_IMPLEMENTATION_DEFAULT_UNAVAILABLE) @@ -84,22 +100,45 @@ async fn plan_implementation_clear_context_requires_default_mode_and_plan() { let params = plan_implementation::selection_view_params( Some(default_mask.clone()), /*plan_markdown*/ None, + /*clear_context_usage_label*/ None, ); assert_eq!( params.items[1].disabled_reason.as_deref(), Some(plan_implementation::PLAN_IMPLEMENTATION_NO_APPROVED_PLAN) ); - let params = - plan_implementation::selection_view_params(Some(default_mask.clone()), Some(" \n")); + let params = plan_implementation::selection_view_params( + Some(default_mask.clone()), + Some(" \n"), + /*clear_context_usage_label*/ None, + ); assert_eq!( params.items[1].disabled_reason.as_deref(), Some(plan_implementation::PLAN_IMPLEMENTATION_NO_APPROVED_PLAN) ); - let params = plan_implementation::selection_view_params(Some(default_mask), Some("- Step\n")); + let params = plan_implementation::selection_view_params( + Some(default_mask.clone()), + Some("- Step\n"), + /*clear_context_usage_label*/ None, + ); assert_eq!(params.items[1].disabled_reason, None); assert!(!params.items[1].actions.is_empty()); + + assert_eq!( + params.items[1].description.as_deref(), + Some("Fresh thread with this plan.") + ); + + let params = plan_implementation::selection_view_params( + Some(default_mask), + Some("- Step\n"), + Some("89% used"), + ); + assert_eq!( + params.items[1].description.as_deref(), + Some("Fresh thread. Context: 89% used.") + ); } #[tokio::test]