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(); }