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.

<img width="1612" height="1312" alt="image"
src="https://github.com/user-attachments/assets/694bcf87-8be5-4e88-a412-e562af62d5f7"
/>
    
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.
This commit is contained in:
Felipe Coury
2026-04-19 14:01:58 -03:00
committed by GitHub
Unverified
parent 996aa23e4c
commit 241136b0e9
4 changed files with 98 additions and 6 deletions
+31
View File
@@ -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<String> {
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()
}
@@ -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<CollaborationModeMask>,
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,
@@ -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
+44 -5
View File
@@ -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]