Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions codex-rs/tui/src/chatwidget.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand Down
13 changes: 12 additions & 1 deletion codex-rs/tui/src/chatwidget/plan_implementation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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
49 changes: 44 additions & 5 deletions codex-rs/tui/src/chatwidget/tests/plan_mode.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)
Expand All @@ -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]
Expand Down
Loading