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`
This commit is contained in:
Felipe Coury
2026-04-28 14:34:10 -03:00
committed by GitHub
Unverified
parent 273c2e21a9
commit c6bcd27832
7 changed files with 294 additions and 0 deletions
@@ -354,6 +354,11 @@ pub(crate) struct ChatComposer {
disable_paste_burst: bool,
footer_mode: FooterMode,
footer_hint_override: Option<Vec<(String, String)>>,
/// 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<String>,
/// 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<String>) {
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;
+17
View File
@@ -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<String>) {
self.composer.set_remote_image_urls(urls);
self.request_redraw();
+90
View File
@@ -934,6 +934,11 @@ pub(crate) struct ChatWidget {
pending_status_indicator_restore: bool,
suppress_queue_autosend: bool,
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>,
last_turn_id: Option<String>,
budget_limited_turn_ids: HashSet<String>,
thread_name: Option<String>,
@@ -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<String>) {
@@ -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
@@ -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…
@@ -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,
@@ -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;