From 64bf4e7e629b0243f483db3e5cecd8e451707007 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Mon, 28 Sep 2026 17:24:39 +0000 Subject: [PATCH] Show the Plan mode cycling hint in the fullscreen status line (#49037) ## Why The fullscreen status line showed Plan mode without explaining how to leave it. ## What changed Show `Plan mode (shift+tab to cycle)` when the composer is idle and accepts input, and the hint fits alongside the status text. Fall back to the mode label when space is limited, and hide the shortcut during tasks, popups, interactive footer use, or other non-composer modes. ## Testing Add a rendering snapshot and regression assertions for wide and narrow layouts, running tasks, interactive search, empty status text, disabled input, and parent-owned threads. GitOrigin-RevId: a1cf2762b33f5442ab0611a15205f2e36c84b075 --- codex-rs/tui/src/bottom_pane/chat_composer.rs | 2 +- ...ce__tests__fullscreen_plan_cycle_hint.snap | 18 ++++++ .../chat_composer/status_surface.rs | 31 +++++++++- .../chat_composer/status_surface_tests.rs | 59 +++++++++++++++++++ 4 files changed, 107 insertions(+), 3 deletions(-) create mode 100644 codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__status_surface__tests__fullscreen_plan_cycle_hint.snap diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 625c614920..72722559fc 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -4617,7 +4617,7 @@ impl ChatComposer { popup: popup_rect, footer: footer_rect, } = self.layout_with_options(area, options); - self.render_status_surface(status, buf); + self.render_status_surface(status, buf, options); if self.popups.active.is_above_composer() && options.command_popup_placement != CommandPopupPlacement::Hidden { diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__status_surface__tests__fullscreen_plan_cycle_hint.snap b/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__status_surface__tests__fullscreen_plan_cycle_hint.snap new file mode 100644 index 0000000000..f083a97c97 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__status_surface__tests__fullscreen_plan_cycle_hint.snap @@ -0,0 +1,18 @@ +--- +source: tui/src/bottom_pane/chat_composer/status_surface_tests.rs +assertion_line: 102 +expression: wide +--- + + + + + + + + + +› Ask Codex + + MODEL · ~/project · Context 20% used Plan mode (shift+tab to cycle) + ? for shortcuts diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/status_surface.rs b/codex-rs/tui/src/bottom_pane/chat_composer/status_surface.rs index 14fe0a9dbc..60ee2e0fa9 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/status_surface.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer/status_surface.rs @@ -19,16 +19,43 @@ impl ChatComposer { props } - pub(super) fn render_status_surface(&self, area: Rect, buf: &mut Buffer) { + pub(super) fn render_status_surface( + &self, + area: Rect, + buf: &mut Buffer, + options: ComposerRenderOptions<'_>, + ) { if area.is_empty() { return; } let mut props = self.footer_props(); + let show_cycle_hint = !props.is_task_running + && !self.blocks_direct_input + && self.draft.input_enabled + && !self.popup_active() + && matches!( + props.mode, + FooterMode::ComposerEmpty | FooterMode::ComposerHasDraft + ) + && options.footer.is_none_or(|footer| !footer.is_interactive); // Persistent context is independent of help, queue, search, and quit modes. props.mode = FooterMode::ComposerHasDraft; props.is_task_running = false; let line = passive_footer_status_line(&props); - let right = self.mode_indicator_line(/*show_cycle_hint*/ false); + let full = self.mode_indicator_line(show_cycle_hint); + let left_width = line + .as_ref() + .map_or(/*default*/ 0, |line| line.width() as u16); + let full_width = full + .as_ref() + .map_or(/*default*/ 0, |line| line.width() as u16); + let right = if full_width <= inset_footer_hint_area(area).width + && can_show_left_with_context(area, left_width, full_width) + { + full + } else { + self.mode_indicator_line(/*show_cycle_hint*/ false) + }; let right_width = right .as_ref() .map_or(/*default*/ 0, |line| line.width() as u16); diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/status_surface_tests.rs b/codex-rs/tui/src/bottom_pane/chat_composer/status_surface_tests.rs index 4a7bc01f8a..369bca1726 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/status_surface_tests.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer/status_surface_tests.rs @@ -89,6 +89,65 @@ fn passive_activity_keeps_shortcuts_only_when_the_complete_hint_fits() { ); } +#[test] +fn fullscreen_plan_indicator_keeps_the_cycle_hint_when_it_fits() { + let mut composer = composer(); + composer.set_collaboration_modes_enabled(/*enabled*/ true); + composer.set_collaboration_mode_indicator(Some(CollaborationModeIndicator::Plan)); + let (wide, _) = render(&composer, /*width*/ 100, /*footer*/ None); + assert!( + wide.contains("Plan mode (shift+tab to cycle)"), + "idle fullscreen must explain how to leave Plan mode: {wide}" + ); + insta::assert_snapshot!("fullscreen_plan_cycle_hint", wide); + + let (narrow, _) = render(&composer, /*width*/ 44, /*footer*/ None); + assert!(narrow.contains("Plan mode"), "{narrow}"); + assert!(!narrow.contains("shift+tab"), "{narrow}"); + + composer.set_task_running(/*running*/ true); + let (running, _) = render(&composer, /*width*/ 100, /*footer*/ None); + assert!(running.contains("Plan mode"), "{running}"); + assert!(!running.contains("shift+tab"), "{running}"); + composer.set_task_running(/*running*/ false); + + let footer = TranscriptFooter { + text: "Search: verify".into(), + cursor_column: Some(8), + is_interactive: true, + }; + let (search, _) = render(&composer, /*width*/ 100, Some(&footer)); + assert!(search.contains("Plan mode"), "{search}"); + assert!(!search.contains("shift+tab"), "{search}"); + + // Configured items such as git-branch can have no value outside a repository. + composer.set_status_line(/*status_line*/ None); + let (empty_narrow, _) = render(&composer, /*width*/ 24, /*footer*/ None); + assert_eq!( + empty_narrow.lines().rev().nth(1).map(str::trim), + Some("Plan mode") + ); + let (empty_wide, _) = render(&composer, /*width*/ 100, /*footer*/ None); + assert!( + empty_wide.contains("Plan mode (shift+tab to cycle)"), + "{empty_wide}" + ); + + composer.set_input_enabled(/*enabled*/ false, /*placeholder*/ None); + let (read_only, _) = render(&composer, /*width*/ 100, /*footer*/ None); + assert_eq!( + read_only.lines().rev().nth(1).map(str::trim), + Some("Plan mode") + ); + composer.set_input_enabled(/*enabled*/ true, /*placeholder*/ None); + composer.set_parent_owned_thread(); + let (parent_owned, _) = render(&composer, /*width*/ 100, /*footer*/ None); + assert_eq!( + parent_owned.lines().rev().nth(1).map(str::trim), + Some("Plan mode") + ); +} + #[test] fn shortcut_help_stays_above_the_prompt_and_bottom_status() { let mut composer = composer();