From c6bcd278329826c9e836099be9e24a0f84b8aa38 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Tue, 28 Apr 2026 14:34:10 -0300 Subject: [PATCH] feat(tui): suggest plan mode from composer drafts (#19901) ## Summary - suggest Plan mode when the current composer draft contains the standalone word `plan` - shares the Codex App heuristics for detection - excludes things line `/plan` and the word plan in shell mode - reuse the existing `Shift+Tab` mode cycle and add thread-scoped dismissal with `Esc` - replace the normal footer hint while the reminder is visible so the statusline stays anchored https://github.com/user-attachments/assets/01123ae8-cee6-4e95-b563-44655c071cde ## Why The desktop app already nudges users toward Plan mode when their draft clearly signals planning intent. The TUI had the underlying `/plan` and `Shift+Tab` flows, but no equivalent reminder at the moment the user was most likely to benefit from them. ## Details The reminder is shown only when Plan mode is available, the draft contains standalone `plan`, the user is not already in Plan mode, the composer is actionable, and the current thread has not dismissed the reminder. Slash-command and shell-command drafts are excluded. The first implementation used an extra composer row, but that moved the statusline whenever the heuristic fired. This version keeps the layout stable by rendering the reminder in the existing footer row instead. ## Validation - `INSTA_UPDATE=always cargo test -p codex-tui chatwidget::tests::plan_mode::plan_mode_nudge -- --nocapture` - `just fmt` - `just fix -p codex-tui` - `./tools/argument-comment-lint/run.py -p codex-tui` - `cargo insta pending-snapshots` - `git diff --check` --- codex-rs/tui/src/bottom_pane/chat_composer.rs | 52 ++++++++ codex-rs/tui/src/bottom_pane/mod.rs | 17 +++ codex-rs/tui/src/chatwidget.rs | 90 +++++++++++++ ...i__chatwidget__tests__plan_mode_nudge.snap | 7 + ...widget__tests__plan_mode_nudge_narrow.snap | 7 + codex-rs/tui/src/chatwidget/tests/helpers.rs | 1 + .../tui/src/chatwidget/tests/plan_mode.rs | 120 ++++++++++++++++++ 7 files changed, 294 insertions(+) create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plan_mode_nudge.snap create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plan_mode_nudge_narrow.snap diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 8ee722113..302c09223 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -354,6 +354,11 @@ pub(crate) struct ChatComposer { disable_paste_burst: bool, footer_mode: FooterMode, footer_hint_override: Option>, + /// Whether the ambient footer row is currently replaced by the Plan-mode nudge. + /// + /// Eligibility is decided by `ChatWidget`; the composer only owns presentation so enabling + /// the nudge never changes layout height or reimplements mode-selection policy here. + plan_mode_nudge_visible: bool, remote_image_urls: Vec, /// Tracks keyboard selection for the remote-image rows so Up/Down + Delete/Backspace /// can highlight and remove remote attachments from the composer UI. @@ -459,6 +464,19 @@ fn status_line_right_indicator( .or_else(|| goal_status_indicator_line(goal_status_indicator)) } +/// 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 builtin_command_flags(&self) -> BuiltinCommandFlags { BuiltinCommandFlags { @@ -534,6 +552,7 @@ impl ChatComposer { disable_paste_burst: false, footer_mode: FooterMode::ComposerEmpty, footer_hint_override: None, + plan_mode_nudge_visible: false, remote_image_urls: Vec::new(), selected_remote_image_index: None, pending_slash_command_history: None, @@ -1027,6 +1046,11 @@ impl ChatComposer { text } + /// Returns whether the composer currently accepts interactive draft edits. + pub(crate) fn input_enabled(&self) -> bool { + self.input_enabled + } + pub(crate) fn pending_pastes(&self) -> Vec<(String, String)> { self.pending_pastes.clone() } @@ -1045,6 +1069,23 @@ 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.plan_mode_nudge_visible == visible { + return false; + } + self.plan_mode_nudge_visible = visible; + true + } + + #[cfg(test)] + pub(crate) fn plan_mode_nudge_visible(&self) -> bool { + self.plan_mode_nudge_visible + } + pub(crate) fn set_remote_image_urls(&mut self, urls: Vec) { self.remote_image_urls = urls; self.selected_remote_image_index = None; @@ -4047,6 +4088,17 @@ impl ChatComposer { }; if let Some(line) = self.history_search_footer_line() { render_footer_line(hint_rect, buf, line); + } else if self.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; diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index d2b065dfa..d3274e96d 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -774,6 +774,11 @@ impl BottomPane { self.composer.current_text_with_pending() } + /// Returns whether the composer currently accepts interactive draft edits. + pub(crate) fn composer_input_enabled(&self) -> bool { + self.composer.input_enabled() + } + pub(crate) fn composer_pending_pastes(&self) -> Vec<(String, String)> { self.composer.pending_pastes() } @@ -788,6 +793,18 @@ 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) { self.composer.set_remote_image_urls(urls); self.request_redraw(); diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 6c9b57729..fc2f20085 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -934,6 +934,11 @@ pub(crate) struct ChatWidget { pending_status_indicator_restore: bool, suppress_queue_autosend: bool, thread_id: Option, + /// 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, last_turn_id: Option, budget_limited_turn_ids: HashSet, thread_name: Option, @@ -1594,6 +1599,25 @@ 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), +} + +/// 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, @@ -1993,6 +2017,7 @@ impl ChatWidget { fn update_task_running_state(&mut self) { self.bottom_pane .set_task_running(self.agent_turn_running || self.mcp_startup_status.is_some()); + self.refresh_plan_mode_nudge(); self.refresh_status_surfaces(); } @@ -2331,6 +2356,7 @@ impl ChatWidget { if previous_thread_id != self.thread_id { self.recent_auto_review_denials = RecentAutoReviewDenials::default(); } + self.refresh_plan_mode_nudge(); self.last_turn_id = None; self.thread_name = event.thread_name.clone(); self.current_goal_status_indicator = None; @@ -4915,6 +4941,7 @@ impl ChatWidget { self.update_due_hook_visibility(); self.schedule_hook_timer_if_needed(); self.bottom_pane.pre_draw_tick(); + 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(); @@ -5584,6 +5611,7 @@ impl ChatWidget { pending_status_indicator_restore: false, suppress_queue_autosend: false, thread_id: None, + dismissed_plan_mode_nudge_scopes: HashSet::new(), last_turn_id: None, budget_limited_turn_ids: HashSet::new(), thread_name: None, @@ -5806,6 +5834,14 @@ 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; + } + match key_event { KeyEvent { code: KeyCode::BackTab, @@ -5816,6 +5852,7 @@ impl ChatWidget { && self.bottom_pane.no_modal_or_popup_active() => { self.cycle_collaboration_mode(); + self.refresh_plan_mode_nudge(); } _ => { let had_modal_or_popup = !self.bottom_pane.no_modal_or_popup_active(); @@ -5893,6 +5930,7 @@ 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(); } } } @@ -5920,6 +5958,7 @@ 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(); } @@ -5937,6 +5976,7 @@ 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(); } @@ -6060,11 +6100,13 @@ 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 @@ -10799,6 +10841,48 @@ 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. + 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. + 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. + fn dismiss_plan_mode_nudge(&mut self) { + self.dismissed_plan_mode_nudge_scopes + .insert(self.plan_mode_nudge_scope()); + self.refresh_plan_mode_nudge(); + } + fn initial_collaboration_mask( _config: &Config, model_catalog: &ModelCatalog, @@ -10989,8 +11073,13 @@ 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(); @@ -11616,6 +11705,7 @@ impl ChatWidget { ) { self.bottom_pane .set_composer_text(text, text_elements, local_image_paths); + self.refresh_plan_mode_nudge(); } pub(crate) fn set_remote_image_urls(&mut self, remote_image_urls: Vec) { diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plan_mode_nudge.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plan_mode_nudge.snap new file mode 100644 index 000000000..d3df6e626 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plan_mode_nudge.snap @@ -0,0 +1,7 @@ +--- +source: tui/src/chatwidget/tests/plan_mode.rs +expression: "render_bottom_popup(&chat, 80)" +--- +› make a plan + + Create a plan? shift + tab use Plan mode esc dismiss diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plan_mode_nudge_narrow.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plan_mode_nudge_narrow.snap new file mode 100644 index 000000000..eca4819e3 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plan_mode_nudge_narrow.snap @@ -0,0 +1,7 @@ +--- +source: tui/src/chatwidget/tests/plan_mode.rs +expression: "render_bottom_popup(&chat, 36)" +--- +› make a plan + + Create a plan? shift + tab use P… diff --git a/codex-rs/tui/src/chatwidget/tests/helpers.rs b/codex-rs/tui/src/chatwidget/tests/helpers.rs index b9a223c88..464508c7a 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -257,6 +257,7 @@ pub(super) async fn make_chatwidget_manual( pending_status_indicator_restore: false, suppress_queue_autosend: false, thread_id: None, + dismissed_plan_mode_nudge_scopes: HashSet::new(), last_turn_id: None, budget_limited_turn_ids: HashSet::new(), thread_name: None, diff --git a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs index dbe580e5c..2ea2e0d3c 100644 --- a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs @@ -1,6 +1,126 @@ use super::*; use pretty_assertions::assert_eq; +#[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; + chat.set_composer_text("make a plan".to_string(), Vec::new(), Vec::new()); + chat.pre_draw_tick(); + assert!(chat.bottom_pane.plan_mode_nudge_visible()); + + chat.set_composer_text("/plan".to_string(), Vec::new(), Vec::new()); + chat.pre_draw_tick(); + assert!(!chat.bottom_pane.plan_mode_nudge_visible()); + + chat.set_composer_text("!plan".to_string(), Vec::new(), Vec::new()); + chat.pre_draw_tick(); + assert!(!chat.bottom_pane.plan_mode_nudge_visible()); + + chat.set_composer_text("make a plan".to_string(), Vec::new(), Vec::new()); + 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; + chat.set_composer_text("make a plan".to_string(), Vec::new(), Vec::new()); + 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, /*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); + chat.set_composer_text("make a plan".to_string(), Vec::new(), Vec::new()); + 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; + chat.set_composer_text("make a plan".to_string(), Vec::new(), Vec::new()); + 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() { + 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, + ))); + chat.set_composer_text("make a plan".to_string(), Vec::new(), Vec::new()); + 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; + chat.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) + ); +} + #[tokio::test] async fn plan_implementation_popup_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5")).await;