From 241136b0e9cd5d787d74a905af315252930c886b Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Sun, 19 Apr 2026 14:01:58 -0300 Subject: [PATCH] feat(tui): show context used in plan implementation prompt (#18573) # Summary When a user finishes planning, the TUI asks whether to implement in the current conversation or start fresh with the approved plan. The clear-context choice is easier to evaluate when the prompt shows how much context has already been used, because the user can see when carrying the full prior conversation is likely to be less useful than preserving only the plan. image This PR adds that context signal directly to the clear-context option while keeping the copy compact enough for the Plan-mode selection popup. # What Changed - Compute an optional context-usage label when opening the plan implementation prompt. - Show the label only on `Yes, clear context and implement`, where it informs the cleanup decision. - Prefer a percentage-used label when context-window information is available, with a compact token-used fallback when only token totals are known. - Preserve the original option description when usage is unknown or effectively zero. - Add rustdoc comments around the prompt-copy boundary so future changes keep the context label formatting and selection rendering responsibilities clear. # Testing - `cargo test -p codex-tui plan_implementation` # Notes The footer continues to show context remaining as ambient status. The implementation prompt intentionally shows context used because the user is choosing whether to clean up the current thread before implementation. --- codex-rs/tui/src/chatwidget.rs | 31 ++++++++++++ .../tui/src/chatwidget/plan_implementation.rs | 13 ++++- ...an_implementation_popup_context_usage.snap | 11 +++++ .../tui/src/chatwidget/tests/plan_mode.rs | 49 +++++++++++++++++-- 4 files changed, 98 insertions(+), 6 deletions(-) create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plan_implementation_popup_context_usage.snap diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 2c88baf85..1ddd8235e 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 c5b253a8d..dd810dc49 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 000000000..4b8ae0099 --- /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 fdf906db7..757a46635 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]