From 4210fb9e6cb3f50bb93d8fdfcb4494af27a36352 Mon Sep 17 00:00:00 2001 From: charley-oai Date: Thu, 22 Jan 2026 09:31:11 -0800 Subject: [PATCH] Modes label below textarea (#9645) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Summary - Add a collaboration mode indicator rendered at the bottom-right of the TUI composer footer. - Style modes per design (Plan in #D72EE1, Execute matching dim context style, Pair Programming using the same cyan as text elements). - Add shared “(shift+tab to cycle)” hint text for all mode labels and align the indicator with the left footer margin. NOTE: currently this is hidden if the Collaboration Modes feature flag is disabled, or in Custom mode. Maybe we should show it in Custom mode too? I'll leave that out of this PR though # UI - Mode indicator appears below the textarea, bottom-right of the footer line. - Includes “(shift+tab to cycle)” and keeps right padding aligned to the left footer indent. Screenshot 2026-01-21 at 7 17 54 PM Screenshot 2026-01-21 at 7 18 53 PM Screenshot 2026-01-21 at 7 19 12 PM --- codex-rs/tui/src/bottom_pane/chat_composer.rs | 24 +++ codex-rs/tui/src/bottom_pane/footer.rs | 151 ++++++++++++++++-- codex-rs/tui/src/bottom_pane/mod.rs | 31 ++-- ...r_mode_indicator_narrow_overlap_hides.snap | 5 + ...er__tests__footer_mode_indicator_wide.snap | 5 + codex-rs/tui/src/chatwidget.rs | 36 +++-- 6 files changed, 210 insertions(+), 42 deletions(-) create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_mode_indicator_narrow_overlap_hides.snap create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_mode_indicator_wide.snap diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index f75a26024..f8627f1a9 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -93,13 +93,17 @@ use super::command_popup::CommandItem; use super::command_popup::CommandPopup; use super::command_popup::CommandPopupFlags; use super::file_search_popup::FileSearchPopup; +use super::footer::CollaborationModeIndicator; use super::footer::FooterMode; use super::footer::FooterProps; use super::footer::esc_hint_mode; use super::footer::footer_height; +use super::footer::footer_hint_items_width; +use super::footer::footer_line_width; use super::footer::inset_footer_hint_area; use super::footer::render_footer; use super::footer::render_footer_hint_items; +use super::footer::render_mode_indicator; use super::footer::reset_mode_after_activity; use super::footer::toggle_shortcut_mode; use super::paste_burst::CharDecision; @@ -229,6 +233,7 @@ pub(crate) struct ChatComposer { /// When enabled, `Enter` submits immediately and `Tab` requests queuing behavior. steer_enabled: bool, collaboration_modes_enabled: bool, + collaboration_mode_indicator: Option, } #[derive(Clone, Debug)] @@ -289,6 +294,7 @@ impl ChatComposer { dismissed_skill_popup_token: None, steer_enabled: false, collaboration_modes_enabled: false, + collaboration_mode_indicator: None, }; // Apply configuration via the setter to keep side-effects centralized. this.set_disable_paste_burst(disable_paste_burst); @@ -313,6 +319,13 @@ impl ChatComposer { self.collaboration_modes_enabled = enabled; } + pub fn set_collaboration_mode_indicator( + &mut self, + indicator: Option, + ) { + self.collaboration_mode_indicator = indicator; + } + fn layout_areas(&self, area: Rect) -> [Rect; 3] { let footer_props = self.footer_props(); let footer_hint_height = self @@ -545,6 +558,7 @@ impl ChatComposer { self.footer_hint_override = items; } + #[cfg(test)] pub(crate) fn show_footer_flash(&mut self, line: Line<'static>, duration: Duration) { let expires_at = Instant::now() .checked_add(duration) @@ -2510,15 +2524,25 @@ impl Renderable for ChatComposer { } else { popup_rect }; + let mut left_content_width = None; if self.footer_flash_visible() { if let Some(flash) = self.footer_flash.as_ref() { flash.line.render(inset_footer_hint_area(hint_rect), buf); + left_content_width = Some(flash.line.width() as u16); } } else if let Some(items) = self.footer_hint_override.as_ref() { render_footer_hint_items(hint_rect, buf, items); + left_content_width = Some(footer_hint_items_width(items)); } else { render_footer(hint_rect, buf, footer_props); + left_content_width = Some(footer_line_width(footer_props)); } + render_mode_indicator( + hint_rect, + buf, + self.collaboration_mode_indicator, + left_content_width, + ); } } let style = user_message_style(); diff --git a/codex-rs/tui/src/bottom_pane/footer.rs b/codex-rs/tui/src/bottom_pane/footer.rs index 5a54dd11d..e7aaabf83 100644 --- a/codex-rs/tui/src/bottom_pane/footer.rs +++ b/codex-rs/tui/src/bottom_pane/footer.rs @@ -46,6 +46,36 @@ pub(crate) struct FooterProps { pub(crate) context_window_used_tokens: Option, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum CollaborationModeIndicator { + Plan, + PairProgramming, + Execute, +} + +const MODE_CYCLE_HINT: &str = "shift+tab to cycle"; + +impl CollaborationModeIndicator { + fn label(self) -> String { + match self { + CollaborationModeIndicator::Plan => format!("Plan mode ({MODE_CYCLE_HINT})"), + CollaborationModeIndicator::PairProgramming => { + format!("Pair Programming mode ({MODE_CYCLE_HINT})") + } + CollaborationModeIndicator::Execute => format!("Execute mode ({MODE_CYCLE_HINT})"), + } + } + + fn styled_span(self) -> Span<'static> { + let label = self.label(); + match self { + CollaborationModeIndicator::Plan => Span::from(label).magenta(), + CollaborationModeIndicator::PairProgramming => Span::from(label).cyan(), + CollaborationModeIndicator::Execute => Span::from(label).dim(), + } + } +} + /// Selects which footer content is rendered. /// /// The current mode is owned by `ChatComposer`, which may override it based on transient state @@ -104,6 +134,40 @@ pub(crate) fn render_footer(area: Rect, buf: &mut Buffer, props: FooterProps) { .render(area, buf); } +pub(crate) fn render_mode_indicator( + area: Rect, + buf: &mut Buffer, + indicator: Option, + left_content_width: Option, +) { + let Some(indicator) = indicator else { + return; + }; + if area.is_empty() { + return; + } + + let span = indicator.styled_span(); + let label_width = span.width() as u16; + if label_width == 0 || label_width > area.width { + return; + } + + let x = area + .x + .saturating_add(area.width) + .saturating_sub(label_width) + .saturating_sub(FOOTER_INDENT_COLS as u16); + let y = area.y + area.height.saturating_sub(1); + if let Some(left_content_width) = left_content_width { + let left_extent = FOOTER_INDENT_COLS as u16 + left_content_width; + if left_extent >= x.saturating_sub(area.x) { + return; + } + } + buf.set_span(x, y, &span, label_width); +} + pub(crate) fn inset_footer_hint_area(mut area: Rect) -> Rect { if area.width > 2 { area.x += 2; @@ -117,16 +181,7 @@ pub(crate) fn render_footer_hint_items(area: Rect, buf: &mut Buffer, items: &[(S return; } - let mut spans = Vec::with_capacity(items.len() * 4); - for (idx, (key, label)) in items.iter().enumerate() { - spans.push(" ".into()); - spans.push(key.clone().bold()); - spans.push(format!(" {label}").into()); - if idx + 1 != items.len() { - spans.push(" ".into()); - } - } - Line::from(spans).render(inset_footer_hint_area(area), buf); + footer_hint_items_line(items).render(inset_footer_hint_area(area), buf); } fn footer_lines(props: FooterProps) -> Vec> { @@ -180,6 +235,33 @@ fn footer_lines(props: FooterProps) -> Vec> { } } +pub(crate) fn footer_line_width(props: FooterProps) -> u16 { + footer_lines(props) + .last() + .map(|line| line.width() as u16) + .unwrap_or(0) +} + +pub(crate) fn footer_hint_items_width(items: &[(String, String)]) -> u16 { + if items.is_empty() { + return 0; + } + footer_hint_items_line(items).width() as u16 +} + +fn footer_hint_items_line(items: &[(String, String)]) -> Line<'static> { + let mut spans = Vec::with_capacity(items.len() * 4); + for (idx, (key, label)) in items.iter().enumerate() { + spans.push(" ".into()); + spans.push(key.clone().bold()); + spans.push(format!(" {label}").into()); + if idx + 1 != items.len() { + spans.push(" ".into()); + } + } + Line::from(spans) +} + #[derive(Clone, Copy, Debug)] struct ShortcutsState { use_shift_enter_hint: bool, @@ -535,6 +617,29 @@ mod tests { assert_snapshot!(name, terminal.backend()); } + fn snapshot_footer_with_indicator( + name: &str, + width: u16, + props: FooterProps, + indicator: Option, + ) { + let height = footer_height(props).max(1); + let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap(); + terminal + .draw(|f| { + let area = Rect::new(0, 0, f.area().width, height); + render_footer(area, f.buffer_mut(), props); + render_mode_indicator( + area, + f.buffer_mut(), + indicator, + Some(footer_line_width(props)), + ); + }) + .unwrap(); + assert_snapshot!(name, terminal.backend()); + } + #[test] fn footer_snapshots() { snapshot_footer( @@ -701,5 +806,31 @@ mod tests { context_window_used_tokens: None, }, ); + + let props = FooterProps { + mode: FooterMode::ShortcutSummary, + esc_backtrack_hint: false, + use_shift_enter_hint: false, + is_task_running: false, + steer_enabled: false, + collaboration_modes_enabled: true, + quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), + context_window_percent: None, + context_window_used_tokens: None, + }; + + snapshot_footer_with_indicator( + "footer_mode_indicator_wide", + 120, + props, + Some(CollaborationModeIndicator::Plan), + ); + + snapshot_footer_with_indicator( + "footer_mode_indicator_narrow_overlap_hides", + 50, + props, + Some(CollaborationModeIndicator::Plan), + ); } } diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index d8039b5d0..e772a6021 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -34,7 +34,6 @@ use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use ratatui::buffer::Buffer; use ratatui::layout::Rect; -use ratatui::text::Line; use std::time::Duration; mod approval_overlay; @@ -60,11 +59,14 @@ mod list_selection_view; mod prompt_args; mod skill_popup; mod skills_toggle_view; +pub(crate) use footer::CollaborationModeIndicator; pub(crate) use list_selection_view::SelectionViewParams; mod feedback_view; pub(crate) use feedback_view::feedback_disabled_params; pub(crate) use feedback_view::feedback_selection_params; pub(crate) use feedback_view::feedback_upload_consent_params; +pub(crate) use skills_toggle_view::SkillsToggleItem; +pub(crate) use skills_toggle_view::SkillsToggleView; mod paste_burst; pub mod popup_consts; mod queued_user_messages; @@ -110,8 +112,6 @@ pub(crate) use experimental_features_view::BetaFeatureItem; pub(crate) use experimental_features_view::ExperimentalFeaturesView; pub(crate) use list_selection_view::SelectionAction; pub(crate) use list_selection_view::SelectionItem; -pub(crate) use skills_toggle_view::SkillsToggleItem; -pub(crate) use skills_toggle_view::SkillsToggleView; /// Pane displayed in the lower half of the chat UI. /// @@ -207,6 +207,14 @@ impl BottomPane { self.request_redraw(); } + pub fn set_collaboration_mode_indicator( + &mut self, + indicator: Option, + ) { + self.composer.set_collaboration_mode_indicator(indicator); + self.request_redraw(); + } + pub fn status_widget(&self) -> Option<&StatusIndicatorWidget> { self.status.as_ref() } @@ -548,23 +556,6 @@ impl BottomPane { self.request_redraw(); } - pub(crate) fn flash_footer_hint(&mut self, line: Line<'static>, duration: Duration) { - self.composer.show_footer_flash(line, duration); - let frame_requester = self.frame_requester.clone(); - if let Ok(handle) = tokio::runtime::Handle::try_current() { - handle.spawn(async move { - tokio::time::sleep(duration).await; - frame_requester.schedule_frame(); - }); - } else { - std::thread::spawn(move || { - std::thread::sleep(duration); - frame_requester.schedule_frame(); - }); - } - self.request_redraw(); - } - pub(crate) fn composer_is_empty(&self) -> bool { self.composer.is_empty() } diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_mode_indicator_narrow_overlap_hides.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_mode_indicator_narrow_overlap_hides.snap new file mode 100644 index 000000000..ed9fea7c8 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_mode_indicator_narrow_overlap_hides.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/bottom_pane/footer.rs +expression: terminal.backend() +--- +" 100% context left · ? for shortcuts " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_mode_indicator_wide.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_mode_indicator_wide.snap new file mode 100644 index 000000000..7212d6de5 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_mode_indicator_wide.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/bottom_pane/footer.rs +expression: terminal.backend() +--- +" 100% context left · ? for shortcuts Plan mode (shift+tab to cycle) " diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 4c4438c3c..866c71d45 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -131,6 +131,7 @@ use crate::bottom_pane::BetaFeatureItem; use crate::bottom_pane::BottomPane; use crate::bottom_pane::BottomPaneParams; use crate::bottom_pane::CancellationEvent; +use crate::bottom_pane::CollaborationModeIndicator; use crate::bottom_pane::DOUBLE_PRESS_QUIT_SHORTCUT_ENABLED; use crate::bottom_pane::ExperimentalFeaturesView; use crate::bottom_pane::InputResult; @@ -1937,6 +1938,7 @@ impl ChatWidget { widget.bottom_pane.set_collaboration_modes_enabled( widget.config.features.enabled(Feature::CollaborationModes), ); + widget.update_collaboration_mode_indicator(); widget } @@ -2055,6 +2057,7 @@ impl ChatWidget { widget.bottom_pane.set_collaboration_modes_enabled( widget.config.features.enabled(Feature::CollaborationModes), ); + widget.update_collaboration_mode_indicator(); widget } @@ -4330,6 +4333,7 @@ impl ChatWidget { } else { CollaborationMode::Custom(settings) }; + self.update_collaboration_mode_indicator(); } } @@ -4415,6 +4419,25 @@ impl ChatWidget { } } + fn collaboration_mode_indicator(&self) -> Option { + if !self.collaboration_modes_enabled() { + return None; + } + match &self.stored_collaboration_mode { + CollaborationMode::Plan(_) => Some(CollaborationModeIndicator::Plan), + CollaborationMode::PairProgramming(_) => { + Some(CollaborationModeIndicator::PairProgramming) + } + CollaborationMode::Execute(_) => Some(CollaborationModeIndicator::Execute), + CollaborationMode::Custom(_) => None, + } + } + + fn update_collaboration_mode_indicator(&mut self) { + let indicator = self.collaboration_mode_indicator(); + self.bottom_pane.set_collaboration_mode_indicator(indicator); + } + /// Cycle to the next collaboration mode variant (Plan -> PairProgramming -> Execute -> Plan). fn cycle_collaboration_mode(&mut self) { if !self.collaboration_modes_enabled() { @@ -4439,18 +4462,7 @@ impl ChatWidget { } self.stored_collaboration_mode = mode; - - let label = self.collaboration_mode_label(); - if let Some(label) = label { - let flash = Line::from(vec![ - label.bold(), - " (".dim(), - key_hint::shift(KeyCode::Tab).into(), - " to change mode)".dim(), - ]); - const FLASH_DURATION: Duration = Duration::from_secs(2); - self.bottom_pane.flash_footer_hint(flash, FLASH_DURATION); - } + self.update_collaboration_mode_indicator(); self.request_redraw(); }