From 87b7bdbf2efb9cb6fb15d821ef535061012c0138 Mon Sep 17 00:00:00 2001 From: Joe Savona Date: Tue, 10 Nov 2026 23:19:12 +0000 Subject: [PATCH] Fit composer footer hints to available terminal space (#60683) ## Why Narrow composer footers can clip hints or crowd out mode indicators and actions. ## What changed Use a shared layout for navigation, actions, mode indicators, context, and warnings. Shorten or drop whole labels in priority order instead of clipping them. Add a second row when input modes and queue or active side-conversation actions fit separately but not together, keeping navigation and modes above actions. ## Testing Add coverage for fullscreen and scrollback layouts, narrow Plan mode hints, active side-conversation actions, oversized goal indicators, and warning hit areas. GitOrigin-RevId: 849a53d45ef1b36b40c52282e329b48ec7d7d12e --- ...r_hints__tests__composer_usage_notice.snap | 2 +- ..._tests__running_copy_feedback_spacing.snap | 6 +- ...turn_tips__tests__turn_tip_placements.snap | 4 +- ...tive_reconnect__restored_conversation.snap | 2 +- ...tests__embedded_exit_running_sessions.snap | 1 - codex-rs/tui/src/bottom_pane/chat_composer.rs | 91 ++--- .../bottom_pane/chat_composer/footer_state.rs | 33 +- .../chat_composer/passive_hints.rs | 353 ++++++++++++++++++ ...r_layout_in_fullscreen_and_scrollback.snap | 31 ++ ...ce__tests__fullscreen_plan_cycle_hint.snap | 2 +- ...tests__persistent_status_and_messages.snap | 4 +- ...er_status_and_hints_use_separate_rows.snap | 4 +- ...c_footer_rows_and_before_extra_status.snap | 4 +- .../chat_composer/status_surface_tests.rs | 155 ++++++-- .../chat_composer/warning_notice.rs | 24 +- .../chat_composer/warning_notice_tests.rs | 5 +- codex-rs/tui/src/bottom_pane/footer.rs | 28 +- ..._transition_keeps_the_full_footer_row.snap | 2 +- ...ests__footer_collapse_plan_empty_full.snap | 2 +- ...se_plan_empty_mode_cycle_with_context.snap | 2 +- ...plan_empty_mode_cycle_without_context.snap | 2 +- ..._footer_collapse_plan_empty_mode_only.snap | 2 +- ...ests__footer_collapse_plan_queue_full.snap | 2 +- ...se_plan_queue_message_without_context.snap | 2 +- ..._footer_collapse_plan_queue_mode_only.snap | 4 +- ...ollapse_plan_queue_short_with_context.snap | 2 +- ...apse_plan_queue_short_without_context.snap | 2 +- ...ollapse_queue_message_without_context.snap | 2 +- ..._collapse_queue_short_without_context.snap | 2 +- ...ooter_mode_shell_command_absorbs_bang.snap | 2 +- ...__recovered_answer_cancels_vim_search.snap | 3 +- ...mposer__tests__vim_empty_normal_slash.snap | 2 +- ...r__tests__vim_empty_redo_stays_normal.snap | 3 +- ..._tests__vim_enter_after_expired_paste.snap | 2 +- ...chat_composer__tests__vim_fresh_draft.snap | 2 +- ..._tests__vim_replace_completion_suffix.snap | 2 +- ...hat_composer__tests__vim_replace_mode.snap | 2 +- ...er__tests__vim_replace_slash_recovery.snap | 3 +- ...at_composer__tests__vim_rich_register.snap | 3 +- ...composer__tests__vim_search_cancelled.snap | 2 +- ...hidden_after_provider_settings_change.snap | 2 +- ...sts__esc_interrupt_goal_paused_footer.snap | 2 +- ..._interrupt_goal_paused_footer@windows.snap | 2 +- ...s__reasoning_shortcut_max_plan_footer.snap | 2 +- ...e_context_label_preserves_status_line.snap | 2 +- ..._side_context_label_shows_hidden_side.snap | 2 +- ...ide_context_label_shows_parent_status.snap | 2 +- ...rked_side_question_while_task_running.snap | 2 +- ..._line_goal_active_token_budget_footer.snap | 2 +- ...tus_line_goal_complete_elapsed_footer.snap | 2 +- ...model_with_reasoning_plan_mode_footer.snap | 2 +- ...ts__windows_sandbox_setup_fresh_clock.snap | 1 - codex-rs/tui/src/chatwidget/tests/side.rs | 22 +- ...ts__hide_and_reopen_side_conversation.snap | 10 +- ...tui__app__tests__keyboard_pane_widths.snap | 6 +- ...ver_session__tests__resumed_plan_mode.snap | 2 +- ...artup_draft_fresh_session_transitions.snap | 4 - ...aft__tests__startup_draft_full_frames.snap | 2 - 58 files changed, 642 insertions(+), 228 deletions(-) create mode 100644 codex-rs/tui/src/bottom_pane/chat_composer/passive_hints.rs create mode 100644 codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__status_surface__tests__footer_layout_in_fullscreen_and_scrollback.snap diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__composer_hints__tests__composer_usage_notice.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__composer_hints__tests__composer_usage_notice.snap index d598740ad4..aa9e37515a 100644 --- a/codex-rs/tui/src/app/snapshots/codex_tui__app__composer_hints__tests__composer_usage_notice.snap +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__composer_hints__tests__composer_usage_notice.snap @@ -50,7 +50,7 @@ Conversation › draft stays GPT-5.6-Sol def… - tab to queue mes + tab to queue 80 columns, 18 rows, running=true, queued=true Conversation row 18 diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_transcript__follow_tests__running_copy_feedback_spacing.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_transcript__follow_tests__running_copy_feedback_spacing.snap index f9839b1e29..bf17823140 100644 --- a/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_transcript__follow_tests__running_copy_feedback_spacing.snap +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_transcript__follow_tests__running_copy_feedback_spacing.snap @@ -30,7 +30,7 @@ transcript row 39 here GPT-5.6-Sol def… - tab to queue mes + tab to queue 6 columns, 18 rows row 38 @@ -49,8 +49,8 @@ row 39 e GPT- - 5.6… - tab + 5.6- + Sol… 80 columns, 6 rows • Working (0s • esc to interrupt) diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__turn_tips__tests__turn_tip_placements.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__turn_tips__tests__turn_tip_placements.snap index f6cfeee615..d9e0fe32ec 100644 --- a/codex-rs/tui/src/app/snapshots/codex_tui__app__turn_tips__tests__turn_tip_placements.snap +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__turn_tips__tests__turn_tip_placements.snap @@ -152,8 +152,8 @@ working=false, response rows=1, 12x12 › Ask Codex - gpt-test … - ? for shor + gpt-test + default… working=false, response rows=1, 80x4 diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__active_reconnect__restored_conversation.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__active_reconnect__restored_conversation.snap index e97ad3986b..94161abadc 100644 --- a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__active_reconnect__restored_conversation.snap +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__active_reconnect__restored_conversation.snap @@ -5,4 +5,4 @@ expression: "render_bottom_popup(&app.chat_widget, 80)" › kept draft gpt-test default - Plan mode (⇧tab to cycle) + Plan mode (⇧tab to cycle) diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__background_exit_tests__embedded_exit_running_sessions.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__background_exit_tests__embedded_exit_running_sessions.snap index 6428314af1..6139aabaa6 100644 --- a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__background_exit_tests__embedded_exit_running_sessions.snap +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__background_exit_tests__embedded_exit_running_sessions.snap @@ -1,6 +1,5 @@ --- source: tui/src/app/tests/background_exit_tests.rs -assertion_line: 57 expression: "render_bottom_popup(&app.chat_widget, 90)" --- Quit and stop running sessions? diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index ca88946067..db3b79fb62 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -352,7 +352,6 @@ use super::footer::CollaborationModeIndicator; use super::footer::FooterMode; use super::footer::FooterProps; use super::footer::GoalStatusIndicator; -use super::footer::SummaryLeft; use super::footer::can_show_left_with_context; use super::footer::context_window_line; use super::footer::esc_hint_mode; @@ -367,7 +366,6 @@ use super::footer::render_footer_hint_items; use super::footer::render_footer_line; use super::footer::reset_mode_after_activity; use super::footer::side_conversation_context_line; -use super::footer::single_line_footer_layout; use super::footer::status_line_right_indicator_line; use super::footer::toggle_shortcut_mode; use super::mention_usage::MentionUsage; @@ -410,6 +408,7 @@ mod history_search; mod inline_input; mod mouse; pub(crate) use mouse::ClipboardPasteTarget; +mod passive_hints; mod paste_input; mod popup_state; mod reconnect; @@ -4717,22 +4716,30 @@ impl ChatComposer { } } let transcript_hint_area = inset_footer_hint_area(footer_rect); - let warning_notice = self - .warning_notice_layout(transcript_hint_area, options) - .filter(|_| !options.unfocused); + let passive = self.passive_hints(options); + let warning_notice = if !options.unfocused && passive.is_none() { + self.warning_notice_layout(transcript_hint_area, options) + } else { + None + }; let warning_area = warning_notice.as_ref().map(|(area, _)| *area); - match options.footer { + let mut painted_warning = warning_area; + match (options.footer, passive) { _ if options.unfocused => {} - Some(footer) => { + (_, Some(hints)) => { + let hint_area = passive_hints::hint_area(footer_rect); + painted_warning = hints.render(hint_area, buf); + } + (Some(footer), None) => { self.render_transcript_footer(transcript_hint_area, buf, footer, warning_area); } - None if self.shortcuts_above_composer(options) => { + (None, None) if self.shortcuts_above_composer(options) => { let props = self.hint_footer_props(); super::shortcut_overlay::render(&props, inset_footer_hint_area(popup_rect), buf); super::shortcut_overlay::close_hint(&props, transcript_hint_area.width) .render(transcript_hint_area, buf); } - None => { + (None, None) => { let hint_rect = warning_notice::before_warning(footer_rect, warning_area); if let Some(input) = self.draft.textarea.vim_query() { input.render(inset_footer_hint_area(hint_rect), buf); @@ -4795,73 +4802,23 @@ impl ChatComposer { can_show_left_with_context(hint_rect, left_width, right_width); let has_override = self.footer.flash_visible() || active_footer_hint_override.is_some(); - let single_line_layout = if has_override { - None - } else { - match footer_props.mode { - FooterMode::ComposerEmpty | FooterMode::ComposerHasDraft => { - // Both of these modes render the single-line footer style (with - // either the shortcuts hint or the optional queue hint). We still - // want the single-line collapse rules so the mode label can win over - // the context indicator on narrow widths. - Some(single_line_footer_layout( - hint_rect, - right_width, - left_mode_indicator, - show_cycle_hint, - show_shortcuts_hint, - show_queue_hint, - footer_props.key_hints, - )) - } - FooterMode::EscHint - | FooterMode::HistorySearch - | FooterMode::QuitShortcutReminder - | FooterMode::ShortcutOverlay => None, - } - }; + let passive_mode = matches!( + footer_props.mode, + FooterMode::ComposerEmpty | FooterMode::ComposerHasDraft + ); let show_right = if has_override && status_line_active { can_show_left_and_context - } else if matches!( - footer_props.mode, - FooterMode::EscHint - | FooterMode::HistorySearch - | FooterMode::QuitShortcutReminder - | FooterMode::ShortcutOverlay - ) { - false } else { - single_line_layout - .as_ref() - .map(|(_, show_context)| *show_context) - .unwrap_or(can_show_left_and_context) + passive_mode && (!has_override || can_show_left_and_context) }; - if let Some((summary_left, _)) = single_line_layout { - match summary_left { - SummaryLeft::Default => { - render_footer_from_props( - hint_rect, - buf, - &footer_props, - left_mode_indicator, - show_cycle_hint, - show_shortcuts_hint, - show_queue_hint, - ); - } - SummaryLeft::Custom(line) => { - render_footer_line(hint_rect, buf, line); - } - SummaryLeft::None => {} - } - } else if self.footer.flash_visible() { + if self.footer.flash_visible() { if let Some(flash) = self.footer.flash.as_ref() { Widget::render(&flash.line, inset_footer_hint_area(hint_rect), buf); } } else if let Some(items) = active_footer_hint_override { render_footer_hint_items(hint_rect, buf, items); - } else { + } else if !passive_mode { render_footer_from_props( hint_rect, buf, @@ -4878,7 +4835,7 @@ impl ChatComposer { } } } - self.footer.warning_notice_area.set(warning_area); + self.footer.warning_notice_area.set(painted_warning); if let Some((warning_area, line)) = warning_notice { line.render(warning_area, buf); } diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/footer_state.rs b/codex-rs/tui/src/bottom_pane/chat_composer/footer_state.rs index 9951d9a29c..92124a0fbc 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/footer_state.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer/footer_state.rs @@ -293,36 +293,9 @@ impl super::ChatComposer { if options.unfocused || self.shortcuts_above_composer(options) { return 1; } - if self.footer.status_line_enabled - && options.footer.is_none() - && self.custom_footer_height().is_none() - && matches!( - self.footer_mode(), - FooterMode::ComposerEmpty | FooterMode::ComposerHasDraft - ) - { - let mode = self.footer_mode(); - let navigation = mode == FooterMode::ComposerEmpty - && !self.is_in_paste_burst() - && (self.agents_navigation_available() - || self.footer.toggle_shortcuts_key.is_some()); - let queue = mode == FooterMode::ComposerHasDraft - && self.is_task_running - && self.footer.queue_key.is_some(); - return u16::from( - self.warning_notice_layout( - super::inset_footer_hint_area(Rect::new(0, 0, width, 1)), - options, - ) - .is_some() - || navigation - || queue - || self - .mode_indicator_line(/*show_cycle_hint*/ false) - .is_some() - || self.shell_mode_footer_line().is_some() - || self.footer.side_conversation_context_label.is_some(), - ); + if let Some(hints) = self.passive_hints(options) { + return hints + .desired_rows(super::passive_hints::hint_area(Rect::new(0, 0, width, 1)).width); } options.footer.map_or_else( || { diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/passive_hints.rs b/codex-rs/tui/src/bottom_pane/chat_composer/passive_hints.rs new file mode 100644 index 0000000000..4aac82f219 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/chat_composer/passive_hints.rs @@ -0,0 +1,353 @@ +//! Fit whole composer hints with navigation and actions on the left and indicators on the right. +//! Request a second row only when a compact input mode (Shell, Vim, Plan) and a +//! current action (queue or active side conversation) fit separately but not together. +//! In two rows, navigation and modes stay above actions and the remaining indicators. +//! Shorten or drop labels in collapse order. If a row still overflows, refill it +//! in interaction priority order, then paint the surviving labels in reading order. + +use super::*; +use crate::bottom_pane::footer::goal_status_indicator_line; +use crate::bottom_pane::footer::mode_indicator_line; +use crate::line_truncation::line_width; +use crate::style::secondary_text_style; +use ratatui::style::Styled; + +// Declaration order is the reading order within each side. +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +enum Kind { + Agents, + Shortcuts, + Action, + Side, + SideNav, + AgentName, + Shell, + Vim, + Plan, + Goal, + Ide, + Context, + Warning, +} + +use Kind::*; + +impl Kind { + fn is_input_mode(self) -> bool { + matches!(self, Shell | Vim | Plan) + } + + fn left(self) -> bool { + matches!(self, Agents | Shortcuts | Action | Side | SideNav) + } + + fn row(self, two_rows: bool) -> usize { + usize::from( + two_rows && !self.is_input_mode() && !matches!(self, Agents | Shortcuts | SideNav), + ) + } +} + +fn joined_width(first: usize, second: usize) -> usize { + first + second + usize::from(first > 0 && second > 0) * 3 +} + +struct Label { + kind: Kind, + forms: Vec<(usize, Line<'static>)>, + selected: Option, +} + +impl Label { + fn width(&self) -> usize { + self.selected + .and_then(|i| self.forms.get(i)) + .map_or(0, |(cells, _)| *cells) + } +} + +#[derive(Default)] +pub(super) struct Hints { + labels: Vec