Remove the Plan mode composer nudge (#40200)

## What changed

- Keep the normal composer footer visible when a draft contains the word `plan`.
- Remove the contextual “Create a plan?” prompt, its Escape dismissal behavior, and its per-thread dismissal state.

## Testing

- Update wide and narrow composer snapshots to cover the standard footer for plan-related drafts.

GitOrigin-RevId: 1a3c8cfce9d4effa2149790975133f652aa7c914
This commit is contained in:
Eric Traut
2026-08-23 07:24:57 +00:00
committed by copyberry
parent 4582c0a498
commit 83d1fe0e67
14 changed files with 22 additions and 295 deletions
@@ -556,19 +556,6 @@ pub(crate) struct ComposerDraftSnapshot {
const FOOTER_SPACING_HEIGHT: u16 = 0;
/// Builds the one-line nudge that replaces the ambient footer without adding layout height.
fn plan_mode_nudge_line() -> Line<'static> {
Line::from(vec![
"Create a plan?".magenta(),
" ".into(),
key_hint::shift(KeyCode::Tab).into(),
" use Plan mode".into(),
" ".into(),
key_hint::plain(KeyCode::Esc).into(),
" dismiss".into(),
])
}
impl ChatComposer {
fn slash_input(&self) -> SlashInput<'_> {
SlashInput::new(
@@ -639,7 +626,6 @@ impl ChatComposer {
use_shift_enter_hint,
mode: FooterMode::ComposerEmpty,
hint_override: None,
plan_mode_nudge_visible: false,
flash: None,
context_window_percent: None,
context_window_used_tokens: None,
@@ -746,7 +732,6 @@ impl ChatComposer {
self.effort_ignition = Some(EffortIgnition::new(tier, style));
self.effort_animation_style = Some(style);
if self.footer.status_line_enabled
&& !self.footer.plan_mode_nudge_visible
&& let Some(previous) = passive_footer_status_line(&self.footer_props())
{
self.effort_status_line_transition =
@@ -1440,23 +1425,6 @@ impl ChatComposer {
self.footer.hint_override = items;
}
/// Updates whether the Plan-mode nudge replaces the ambient footer row.
///
/// Returns `true` only when the rendered footer can change so callers can avoid scheduling
/// redundant redraws while reevaluating nudge policy on routine composer updates.
pub(crate) fn set_plan_mode_nudge_visible(&mut self, visible: bool) -> bool {
if self.footer.plan_mode_nudge_visible == visible {
return false;
}
self.footer.plan_mode_nudge_visible = visible;
true
}
#[cfg(test)]
pub(crate) fn plan_mode_nudge_visible(&self) -> bool {
self.footer.plan_mode_nudge_visible
}
pub(crate) fn set_remote_image_urls(&mut self, urls: Vec<String>) {
self.attachments
.set_remote_image_urls(urls, &mut self.draft.textarea);
@@ -4275,7 +4243,6 @@ impl ChatComposer {
self.footer.quit_shortcut_expires_at = None;
self.footer.mode = FooterMode::ComposerEmpty;
self.footer.hint_override = Some(Vec::new());
self.footer.plan_mode_nudge_visible = false;
self.footer.flash = None;
}
@@ -4596,17 +4563,6 @@ impl ChatComposer {
};
if let Some(line) = self.history_search_footer_line() {
render_footer_line(hint_rect, buf, line);
} else if self.footer.plan_mode_nudge_visible {
let available_width =
hint_rect.width.saturating_sub(FOOTER_INDENT_COLS as u16) as usize;
render_footer_line(
hint_rect,
buf,
truncate_line_with_ellipsis_if_overflow(
plan_mode_nudge_line(),
available_width,
),
);
} else {
let available_width =
hint_rect.width.saturating_sub(FOOTER_INDENT_COLS as u16) as usize;
@@ -19,7 +19,6 @@ pub(super) struct FooterState {
pub(super) use_shift_enter_hint: bool,
pub(super) mode: FooterMode,
pub(super) hint_override: Option<Vec<(String, String)>>,
pub(super) plan_mode_nudge_visible: bool,
pub(super) flash: Option<FooterFlash>,
pub(super) context_window_percent: Option<i64>,
pub(super) context_window_used_tokens: Option<i64>,
@@ -59,25 +59,6 @@ fn effort_transition_does_not_queue_a_missing_outgoing_status_line() {
assert!(composer.effort_status_line_transition.is_none());
}
#[test]
fn effort_transition_does_not_queue_while_plan_mode_nudge_is_visible() {
let (mut composer, _rx) = new_test_composer();
composer.set_status_line_enabled(/*enabled*/ true);
composer.set_status_line(Some(Line::from("gpt-5.4 high · main")));
composer.set_plan_mode_nudge_visible(/*visible*/ true);
composer.set_active_reasoning_effort(
Some(&ReasoningEffort::High),
/*animations_enabled*/ true,
);
assert!(composer.set_active_reasoning_effort(
Some(&ReasoningEffort::Ultra),
/*animations_enabled*/ true,
));
assert!(composer.effort_ignition.is_some());
assert!(composer.effort_status_line_transition.is_none());
}
#[test]
fn effort_composer_restored_baseline_and_reduced_motion_do_not_start() {
let (mut composer, _rx) = new_test_composer();
-12
View File
@@ -933,18 +933,6 @@ impl BottomPane {
self.request_redraw();
}
/// Applies the externally decided Plan-mode nudge visibility to the footer presentation.
pub(crate) fn set_plan_mode_nudge_visible(&mut self, visible: bool) {
if self.composer.set_plan_mode_nudge_visible(visible) {
self.request_redraw();
}
}
#[cfg(test)]
pub(crate) fn plan_mode_nudge_visible(&self) -> bool {
self.composer.plan_mode_nudge_visible()
}
pub(crate) fn set_remote_image_urls(&mut self, urls: Vec<String>) {
self.composer.set_remote_image_urls(urls);
self.request_redraw();
-25
View File
@@ -667,11 +667,6 @@ pub(crate) struct ChatWidget {
#[cfg(test)]
pet_image_support_override: Option<crate::pets::PetImageSupport>,
thread_id: Option<ThreadId>,
/// Nudge dismissals that should survive draft edits within the current thread scope.
///
/// The nudge is only a discovery aid, so once a user dismisses it or enters Plan mode we keep it
/// hidden for that thread instead of resurfacing it on every matching draft.
dismissed_plan_mode_nudge_scopes: HashSet<PlanModeNudgeScope>,
thread_name: Option<String>,
thread_rename_block_message: Option<String>,
active_side_conversation: bool,
@@ -835,31 +830,12 @@ enum SessionConfiguredDisplay {
SideConversation,
}
/// Scope used to keep Plan-mode nudge dismissal local to one conversation context.
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
enum PlanModeNudgeScope {
/// Drafts entered before the server has assigned a thread id.
NewThread,
/// Drafts associated with one configured thread.
Thread(ThreadId),
}
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
pub(crate) enum TurnAbortReason {
Interrupted,
BudgetLimited,
}
/// Returns whether `text` contains the standalone word `plan`.
///
/// This intentionally mirrors the App suggestion heuristic instead of trying to infer broader
/// planning intent from substrings such as `planning`. Slash and shell drafts still match here so
/// callers can keep lexical matching separate from presentation policy.
fn contains_plan_keyword(text: &str) -> bool {
text.split(|ch: char| !ch.is_alphanumeric() && ch != '_')
.any(|word| word.eq_ignore_ascii_case("plan"))
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum ThreadItemRenderSource {
Live,
@@ -1224,7 +1200,6 @@ impl ChatWidget {
if let Some(pet) = self.ambient_pet.as_ref() {
pet.schedule_next_frame();
}
self.refresh_plan_mode_nudge();
self.refresh_goal_status_indicator_for_time_tick();
if self.terminal_title_shows_action_required() != self.last_terminal_title_requires_action {
self.refresh_terminal_title();
@@ -198,7 +198,6 @@ impl ChatWidget {
#[cfg(test)]
pet_image_support_override: None,
thread_id: None,
dismissed_plan_mode_nudge_scopes: HashSet::new(),
thread_name: None,
thread_rename_block_message: None,
active_side_conversation: false,
@@ -78,7 +78,6 @@ impl ChatWidget {
if had_modal_or_popup && self.bottom_pane.no_modal_or_popup_active() {
self.maybe_send_next_queued_input();
}
self.refresh_plan_mode_nudge();
}
pub(super) fn defer_input_until_settings_applied(&mut self) {
+2 -22
View File
@@ -153,14 +153,6 @@ impl ChatWidget {
return;
}
if matches!(key_event.code, KeyCode::Esc)
&& key_event.kind == KeyEventKind::Press
&& self.should_show_plan_mode_nudge()
{
self.dismiss_plan_mode_nudge();
return;
}
if self.handle_plugins_popup_key_event(key_event) {
return;
}
@@ -178,7 +170,6 @@ impl ChatWidget {
self.add_error_message(PARENT_OWNED_INPUT_MESSAGE.to_string());
} else {
self.cycle_collaboration_mode();
self.refresh_plan_mode_nudge();
}
}
_ => {
@@ -220,7 +211,6 @@ impl ChatWidget {
pub(crate) fn apply_external_edit(&mut self, text: String) {
self.bottom_pane.apply_external_edit(text);
self.refresh_plan_mode_nudge();
self.request_redraw();
}
@@ -238,13 +228,11 @@ impl ChatWidget {
pub(crate) fn show_selection_view(&mut self, params: SelectionViewParams) {
self.bottom_pane.show_selection_view(params);
self.refresh_plan_mode_nudge();
self.request_redraw();
}
pub(crate) fn show_bottom_pane_view(&mut self, view: Box<dyn BottomPaneView>) {
self.bottom_pane.show_view(view);
self.refresh_plan_mode_nudge();
self.request_redraw();
}
@@ -254,7 +242,6 @@ impl ChatWidget {
view: Box<dyn BottomPaneView>,
) {
self.bottom_pane.replace_view_if_present(view_id, view);
self.refresh_plan_mode_nudge();
}
pub(crate) fn selected_index_for_present_view(&self, view_id: &'static str) -> Option<usize> {
@@ -266,13 +253,8 @@ impl ChatWidget {
view_id: &'static str,
params: SelectionViewParams,
) -> bool {
let replaced = self
.bottom_pane
.replace_selection_view_if_present(view_id, params);
if replaced {
self.refresh_plan_mode_nudge();
}
replaced
self.bottom_pane
.replace_selection_view_if_present(view_id, params)
}
pub(crate) fn no_modal_or_popup_active(&self) -> bool {
@@ -465,13 +447,11 @@ impl ChatWidget {
pub(crate) fn handle_paste(&mut self, text: String) {
self.bottom_pane.handle_paste(text);
self.refresh_plan_mode_nudge();
}
// Returns true if caller should skip rendering this frame (a future frame is scheduled).
pub(crate) fn handle_paste_burst_tick(&mut self, frame_requester: FrameRequester) -> bool {
if self.bottom_pane.flush_paste_burst_if_due() {
self.refresh_plan_mode_nudge();
// A paste just flushed; request an immediate redraw and skip this frame.
self.request_redraw();
true
@@ -28,7 +28,6 @@ impl ChatWidget {
self.review.recent_auto_review_denials = RecentAutoReviewDenials::default();
self.clear_thread_usage_state();
}
self.refresh_plan_mode_nudge();
self.turn_lifecycle.reset_thread();
self.clear_safety_buffering();
self.thread_name = session.thread_name.clone();
@@ -106,7 +105,6 @@ impl ChatWidget {
mask.reasoning_effort = Some(session.reasoning_effort.clone());
}
self.update_collaboration_mode_indicator();
self.refresh_plan_mode_nudge();
}
}
let effort = self.effective_reasoning_effort();
-48
View File
@@ -384,48 +384,6 @@ impl ChatWidget {
true
}
/// Returns the dismissal scope that applies to the currently visible draft.
fn plan_mode_nudge_scope(&self) -> PlanModeNudgeScope {
self.thread_id
.map_or(PlanModeNudgeScope::NewThread, PlanModeNudgeScope::Thread)
}
/// Returns whether the current draft should replace the normal footer with the Plan-mode nudge.
///
/// `ChatWidget` owns this policy because it can combine lexical draft matching with mode
/// availability, interaction state, and thread-scoped dismissal. `ChatComposer` only renders
/// the resulting visibility bit. Keeping slash and shell drafts out here avoids advertising a
/// mode switch while the user is intentionally composing another local command.
pub(super) fn should_show_plan_mode_nudge(&self) -> bool {
let text = self.bottom_pane.composer_text();
let trimmed = text.trim_start();
self.collaboration_modes_enabled()
&& collaboration_modes::plan_mask(self.model_catalog.as_ref()).is_some()
&& self.active_mode_kind() != ModeKind::Plan
&& self.bottom_pane.composer_input_enabled()
&& !self.bottom_pane.is_task_running()
&& self.bottom_pane.no_modal_or_popup_active()
&& !trimmed.starts_with('/')
&& !trimmed.starts_with('!')
&& contains_plan_keyword(&text)
&& !self
.dismissed_plan_mode_nudge_scopes
.contains(&self.plan_mode_nudge_scope())
}
/// Synchronizes the footer presentation with the current Plan-mode nudge policy.
pub(super) fn refresh_plan_mode_nudge(&mut self) {
self.bottom_pane
.set_plan_mode_nudge_visible(self.should_show_plan_mode_nudge());
}
/// Hides the nudge for the current thread scope until the user changes conversation context.
pub(super) fn dismiss_plan_mode_nudge(&mut self) {
self.dismissed_plan_mode_nudge_scopes
.insert(self.plan_mode_nudge_scope());
self.refresh_plan_mode_nudge();
}
pub(super) fn initial_collaboration_mask(
_config: &Config,
model_catalog: &ModelCatalog,
@@ -580,7 +538,6 @@ impl ChatWidget {
developer_instructions: Some(settings.developer_instructions),
});
self.update_collaboration_mode_indicator();
self.refresh_plan_mode_nudge();
self.refresh_model_dependent_surfaces();
}
@@ -702,13 +659,8 @@ impl ChatWidget {
{
mask.reasoning_effort = Some(Some(effort));
}
if mask.mode == Some(ModeKind::Plan) {
self.dismissed_plan_mode_nudge_scopes
.insert(self.plan_mode_nudge_scope());
}
self.active_collaboration_mask = Some(mask);
self.update_collaboration_mode_indicator();
self.refresh_plan_mode_nudge();
self.refresh_model_dependent_surfaces();
let next_mode = self.active_mode_kind();
let next_model = self.current_model();
@@ -4,4 +4,4 @@ expression: "render_bottom_popup(&chat, 80)"
---
› make a plan
Create a plan? shift + tab use Plan mode esc dismiss
gpt-5 default · /tmp/project
@@ -4,4 +4,4 @@ expression: "render_bottom_popup(&chat, 36)"
---
› make a plan
Create a plan? shift + tab use P…
gpt-5 default · /tmp/project
+18 -117
View File
@@ -1,12 +1,6 @@
use super::*;
use pretty_assertions::assert_eq;
fn set_composer_text(chat: &mut ChatWidget, text: &str) {
chat.bottom_pane
.set_composer_text(text.to_string(), Vec::new(), Vec::new());
chat.refresh_plan_mode_nudge();
}
fn paste_hidden_plan_shell_payload(chat: &mut ChatWidget) -> String {
let payload = format!("!echo {}", "x".repeat(1000));
chat.bottom_pane
@@ -68,125 +62,32 @@ fn assert_literal_plan_prompt(chat: &ChatWidget, op: Result<Op, TryRecvError>, p
assert_eq!(chat.active_collaboration_mode_kind(), ModeKind::Plan);
}
#[test]
fn plan_mode_nudge_matches_only_standalone_plain_text_keyword() {
assert!(contains_plan_keyword("plan"));
assert!(contains_plan_keyword("Make a Plan first."));
assert!(!contains_plan_keyword("plane"));
assert!(!contains_plan_keyword("planning"));
assert!(contains_plan_keyword("/plan"));
assert!(contains_plan_keyword("!plan"));
}
#[tokio::test]
async fn plan_mode_nudge_shows_only_for_eligible_default_mode_drafts() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5")).await;
set_composer_text(&mut chat, "make a plan");
chat.pre_draw_tick();
assert!(chat.bottom_pane.plan_mode_nudge_visible());
set_composer_text(&mut chat, "/plan");
chat.pre_draw_tick();
assert!(!chat.bottom_pane.plan_mode_nudge_visible());
set_composer_text(&mut chat, "!plan");
chat.pre_draw_tick();
assert!(!chat.bottom_pane.plan_mode_nudge_visible());
set_composer_text(&mut chat, "make a plan");
let plan_mask = collaboration_modes::plan_mask(chat.model_catalog.as_ref())
.expect("expected plan collaboration mode");
chat.set_collaboration_mask(plan_mask);
chat.pre_draw_tick();
assert!(!chat.bottom_pane.plan_mode_nudge_visible());
}
#[tokio::test]
async fn plan_mode_nudge_hides_while_task_or_modal_is_active() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5")).await;
set_composer_text(&mut chat, "make a plan");
chat.pre_draw_tick();
assert!(chat.bottom_pane.plan_mode_nudge_visible());
chat.on_task_started();
chat.pre_draw_tick();
assert!(!chat.bottom_pane.plan_mode_nudge_visible());
chat.on_task_complete(
/*last_agent_message*/ None, /*duration_ms*/ None, /*from_replay*/ false,
);
chat.show_selection_view(SelectionViewParams {
items: vec![SelectionItem {
name: "Keep planning".to_string(),
..Default::default()
}],
..Default::default()
});
chat.pre_draw_tick();
assert!(!chat.bottom_pane.plan_mode_nudge_visible());
}
#[tokio::test]
async fn plan_mode_nudge_dismissal_is_scoped_to_current_thread() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5")).await;
let first_thread = ThreadId::new();
let second_thread = ThreadId::new();
chat.thread_id = Some(first_thread);
set_composer_text(&mut chat, "make a plan");
chat.pre_draw_tick();
assert!(chat.bottom_pane.plan_mode_nudge_visible());
chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
chat.pre_draw_tick();
assert!(!chat.bottom_pane.plan_mode_nudge_visible());
chat.thread_id = Some(second_thread);
chat.pre_draw_tick();
assert!(chat.bottom_pane.plan_mode_nudge_visible());
chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
chat.pre_draw_tick();
assert!(!chat.bottom_pane.plan_mode_nudge_visible());
chat.thread_id = Some(first_thread);
chat.pre_draw_tick();
assert!(!chat.bottom_pane.plan_mode_nudge_visible());
}
#[tokio::test]
async fn plan_mode_nudge_shift_tab_uses_existing_mode_cycle_path() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5")).await;
set_composer_text(&mut chat, "make a plan");
chat.pre_draw_tick();
assert!(chat.bottom_pane.plan_mode_nudge_visible());
chat.handle_key_event(KeyEvent::from(KeyCode::BackTab));
chat.pre_draw_tick();
assert_eq!(chat.active_collaboration_mode_kind(), ModeKind::Plan);
assert!(!chat.bottom_pane.plan_mode_nudge_visible());
}
#[tokio::test]
async fn plan_mode_nudge_snapshot() {
async fn plan_draft_footer_snapshot() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5")).await;
chat.set_token_info(Some(make_token_info(
/*total_tokens*/ 50_000, /*context_window*/ 100_000,
)));
set_composer_text(&mut chat, "make a plan");
chat.pre_draw_tick();
assert_chatwidget_snapshot!("plan_mode_nudge", render_bottom_popup(&chat, /*width*/ 80));
}
#[tokio::test]
async fn plan_mode_nudge_narrow_snapshot() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5")).await;
set_composer_text(&mut chat, "make a plan");
chat.bottom_pane
.set_composer_text("make a plan".to_string(), Vec::new(), Vec::new());
chat.pre_draw_tick();
assert_chatwidget_snapshot!(
"plan_mode_nudge_narrow",
render_bottom_popup(&chat, /*width*/ 36)
"plan_draft_footer",
normalize_snapshot_paths(render_bottom_popup(&chat, /*width*/ 80))
);
}
#[tokio::test]
async fn plan_draft_footer_narrow_snapshot() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5")).await;
chat.bottom_pane
.set_composer_text("make a plan".to_string(), Vec::new(), Vec::new());
chat.pre_draw_tick();
assert_chatwidget_snapshot!(
"plan_draft_footer_narrow",
normalize_snapshot_paths(render_bottom_popup(&chat, /*width*/ 36))
);
}
@@ -36,7 +36,6 @@ impl ChatWidget {
|| self.review.is_review_mode
|| self.mcp_startup_status.is_some(),
);
self.refresh_plan_mode_nudge();
self.refresh_status_surfaces();
}