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;