diff --git a/codex-rs/tui/src/bottom_pane/list_selection_view.rs b/codex-rs/tui/src/bottom_pane/list_selection_view.rs index 75d11ae91f83..ee8051a72e96 100644 --- a/codex-rs/tui/src/bottom_pane/list_selection_view.rs +++ b/codex-rs/tui/src/bottom_pane/list_selection_view.rs @@ -34,6 +34,7 @@ use super::selection_popup_common::GenericDisplayRow; use super::selection_popup_common::measure_rows_height_with_col_width_mode; use super::selection_popup_common::render_rows_single_line_with_col_width_mode; use super::selection_popup_common::render_rows_with_col_width_mode; +pub(crate) use super::selection_row_layout::SelectionDescriptionLayout; use super::selection_tabs::SelectionTab; use super::selection_tabs::render_tab_bar; use super::selection_tabs::tab_bar_height; @@ -145,6 +146,8 @@ pub(crate) struct SelectionItem { pub dismiss_parent_on_child_accept: bool, pub search_value: Option, pub disabled_reason: Option, + /// Optional marker rendered in place of the number for a disabled row. + pub disabled_gutter_marker: Option<&'static str>, } /// Construction-time configuration for [`ListSelectionView`]. @@ -158,6 +161,8 @@ pub(crate) struct SelectionItem { /// `AutoAllRows` measures all rows to ensure stable column widths as the user scrolls /// `Fixed` used a fixed 30/70 split between columns /// `row_display` controls whether rows can wrap or stay single-line with ellipsis truncation +/// `description_layout` optionally moves descriptions below labels when their +/// column would become too narrow. pub(crate) struct SelectionViewParams { pub view_id: Option<&'static str>, pub title: Option, @@ -172,6 +177,7 @@ pub(crate) struct SelectionViewParams { pub search_placeholder: Option, pub col_width_mode: ColumnWidthMode, pub row_display: SelectionRowDisplay, + pub description_layout: SelectionDescriptionLayout, /// Rendered left-column width to use for auto-sized rows. pub name_column_width: Option, pub header: Box, @@ -223,6 +229,7 @@ impl Default for SelectionViewParams { search_placeholder: None, col_width_mode: ColumnWidthMode::AutoVisible, row_display: SelectionRowDisplay::Wrapped, + description_layout: SelectionDescriptionLayout::Columns, name_column_width: None, header: Box::new(()), initial_selected_idx: None, @@ -260,6 +267,7 @@ pub(crate) struct ListSelectionView { search_placeholder: Option, col_width_mode: ColumnWidthMode, row_display: SelectionRowDisplay, + description_layout: SelectionDescriptionLayout, name_column_width: Option, filtered_indices: Vec, last_selected_actual_idx: Option, @@ -394,6 +402,7 @@ impl ListSelectionView { }, col_width_mode: params.col_width_mode, row_display: params.row_display, + description_layout: params.description_layout, name_column_width: params.name_column_width, filtered_indices: Vec::new(), last_selected_actual_idx: None, @@ -581,7 +590,14 @@ impl ListSelectionView { // numbers be used for the search query). format!("{prefix} ") } else if is_disabled { - format!("{prefix} {}", " ".repeat(enabled_row_number_width + 2)) + if let Some(disabled_gutter_marker) = item.disabled_gutter_marker { + let marker_width = UnicodeWidthStr::width(disabled_gutter_marker); + let marker_padding = + " ".repeat(enabled_row_number_width.saturating_sub(marker_width)); + format!("{prefix} {marker_padding}{disabled_gutter_marker} ") + } else { + format!("{prefix} {}", " ".repeat(enabled_row_number_width + 2)) + } } else { enabled_row_number += 1; let n = enabled_row_number; @@ -1115,7 +1131,8 @@ impl Renderable for ListSelectionView { // Measure wrapped height for up to MAX_POPUP_ROWS items. let rows = self.build_rows(); - let column_width = ColumnWidthConfig::new(self.col_width_mode, self.name_column_width); + let column_width = ColumnWidthConfig::new(self.col_width_mode, self.name_column_width) + .with_description_layout(self.description_layout); let rows_height = match self.row_display { SelectionRowDisplay::Wrapped => measure_rows_height_with_col_width_mode( &rows, @@ -1193,7 +1210,8 @@ impl Renderable for ListSelectionView { let header_height = header.desired_height(inner_width); let tab_height = tab_bar_height(&self.tabs, self.active_tab_idx.unwrap_or(0), inner_width); let rows = self.build_rows(); - let column_width = ColumnWidthConfig::new(self.col_width_mode, self.name_column_width); + let column_width = ColumnWidthConfig::new(self.col_width_mode, self.name_column_width) + .with_description_layout(self.description_layout); let rows_height = match self.row_display { SelectionRowDisplay::Wrapped => measure_rows_height_with_col_width_mode( &rows, diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 72cdc8961982..102ff5620992 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -120,6 +120,7 @@ pub(crate) use footer::goal_status_indicator_line; pub(crate) use list_selection_view::ColumnWidthMode; pub(crate) use list_selection_view::ListSelectionView; pub(crate) use list_selection_view::OnSelectionChangedCallback; +pub(crate) use list_selection_view::SelectionDescriptionLayout; pub(crate) use list_selection_view::SelectionRowDisplay; pub(crate) use list_selection_view::SelectionToggle; pub(crate) use list_selection_view::SelectionViewParams; @@ -152,6 +153,7 @@ mod pending_thread_approvals; pub(crate) mod popup_consts; mod scroll_state; mod selection_popup_common; +mod selection_row_layout; mod selection_tabs; mod textarea; mod unified_exec_footer; diff --git a/codex-rs/tui/src/bottom_pane/selection_popup_common.rs b/codex-rs/tui/src/bottom_pane/selection_popup_common.rs index 91d155b507a7..5964e4727769 100644 --- a/codex-rs/tui/src/bottom_pane/selection_popup_common.rs +++ b/codex-rs/tui/src/bottom_pane/selection_popup_common.rs @@ -7,8 +7,6 @@ use ratatui::text::Line; use ratatui::text::Span; use ratatui::widgets::Block; use ratatui::widgets::Widget; -use std::borrow::Cow; -use unicode_width::UnicodeWidthChar; use unicode_width::UnicodeWidthStr; use crate::key_hint::KeyBinding; @@ -19,6 +17,10 @@ use crate::style::accent_style; use crate::style::user_message_style; use super::scroll_state::ScrollState; +use super::selection_row_layout::SelectionDescriptionLayout; +use super::selection_row_layout::build_full_line; +use super::selection_row_layout::line_to_owned; +use super::selection_row_layout::wrap_stacked_row; /// Render-ready representation of one row in a selection popup. /// @@ -59,6 +61,7 @@ pub(crate) enum ColumnWidthMode { pub(crate) struct ColumnWidthConfig { pub mode: ColumnWidthMode, pub name_column_width: Option, + pub description_layout: SelectionDescriptionLayout, } impl ColumnWidthConfig { @@ -66,8 +69,17 @@ impl ColumnWidthConfig { Self { mode, name_column_width, + description_layout: SelectionDescriptionLayout::Columns, } } + + pub(crate) const fn with_description_layout( + mut self, + description_layout: SelectionDescriptionLayout, + ) -> Self { + self.description_layout = description_layout; + self + } } // Fixed split used by explicitly fixed column mode: 30% label, 70% @@ -121,21 +133,6 @@ pub(crate) fn wrap_styled_line<'a>(line: &'a Line<'a>, width: u16) -> Vec) -> Line<'static> { - Line { - style: line.style, - alignment: line.alignment, - spans: line - .spans - .into_iter() - .map(|span| Span { - style: span.style, - content: Cow::Owned(span.content.into_owned()), - }) - .collect(), - } -} - fn compute_desc_col( rows_all: &[GenericDisplayRow], start_idx: usize, @@ -287,11 +284,16 @@ fn wrap_two_column_row(row: &GenericDisplayRow, desc_col: usize, width: u16) -> out } -fn wrap_standard_row(row: &GenericDisplayRow, desc_col: usize, width: u16) -> Vec> { +fn wrap_standard_row( + row: &GenericDisplayRow, + desc_col: usize, + width: u16, + description_layout: SelectionDescriptionLayout, +) -> Vec> { use crate::wrapping::RtOptions; use crate::wrapping::word_wrap_line; - let full_line = build_full_line(row, desc_col); + let full_line = build_full_line(row, desc_col, description_layout); let continuation_indent = wrap_indent(row, desc_col, width); let options = RtOptions::new(width.max(1) as usize) .initial_indent(Line::from("")) @@ -302,7 +304,15 @@ fn wrap_standard_row(row: &GenericDisplayRow, desc_col: usize, width: u16) -> Ve .collect() } -fn wrap_row_lines(row: &GenericDisplayRow, desc_col: usize, width: u16) -> Vec> { +fn wrap_row_lines( + row: &GenericDisplayRow, + desc_col: usize, + width: u16, + description_layout: SelectionDescriptionLayout, +) -> Vec> { + if description_layout.should_stack(width, desc_col) { + return wrap_stacked_row(row, width); + } if should_wrap_name_in_column(row) { let wrapped = wrap_two_column_row(row, desc_col, width); if !wrapped.is_empty() { @@ -310,7 +320,7 @@ fn wrap_row_lines(row: &GenericDisplayRow, desc_col: usize, width: u16) -> Vec], selected: bool, is_disabled: bool) { @@ -353,23 +363,31 @@ fn compute_item_window_start( start_idx } +#[derive(Clone, Copy)] +struct WrappedViewport { + width: u16, + height: u16, + description_layout: SelectionDescriptionLayout, +} + fn is_selected_visible_in_wrapped_viewport( rows_all: &[GenericDisplayRow], start_idx: usize, max_items: usize, selected_idx: usize, desc_col: usize, - width: u16, - viewport_height: u16, + viewport: WrappedViewport, ) -> bool { - if viewport_height == 0 { + if viewport.height == 0 { return false; } let mut used_lines = 0usize; - let viewport_height = viewport_height as usize; + let viewport_height = viewport.height as usize; for (idx, row) in rows_all.iter().enumerate().skip(start_idx).take(max_items) { - let row_lines = wrap_row_lines(row, desc_col, width).len().max(1); + let row_lines = wrap_row_lines(row, desc_col, viewport.width, viewport.description_layout) + .len() + .max(1); // Keep rendering semantics in sync: always show the first row, even if // it overflows the viewport. if used_lines > 0 && used_lines.saturating_add(row_lines) > viewport_height { @@ -414,8 +432,11 @@ fn adjust_start_for_wrapped_selection_visibility( max_items, sel, desc_col, - width, - viewport_height, + WrappedViewport { + width, + height: viewport_height, + description_layout: column_width.description_layout, + }, ) { break; } @@ -424,92 +445,6 @@ fn adjust_start_for_wrapped_selection_visibility( start_idx } -/// Build the full display line for a row with the description padded to start -/// at `desc_col`. Applies fuzzy-match bolding when indices are present and -/// dims the description. -fn build_full_line(row: &GenericDisplayRow, desc_col: usize) -> Line<'static> { - let combined_description = match (&row.description, &row.disabled_reason) { - (Some(desc), Some(reason)) => Some(format!("{desc} (disabled: {reason})")), - (Some(desc), None) => Some(desc.clone()), - (None, Some(reason)) => Some(format!("disabled: {reason}")), - (None, None) => None, - }; - - // Enforce single-line name: allow at most desc_col - 2 cells for name, - // reserving two spaces before the description column. - let name_prefix_width = Line::from(row.name_prefix_spans.clone()).width(); - let name_limit = combined_description - .as_ref() - .map(|_| desc_col.saturating_sub(2).saturating_sub(name_prefix_width)) - .unwrap_or(usize::MAX); - - let mut name_spans: Vec = Vec::with_capacity(row.name.len()); - let mut used_width = 0usize; - let mut truncated = false; - - if let Some(idxs) = row.match_indices.as_ref() { - let mut idx_iter = idxs.iter().peekable(); - for (char_idx, ch) in row.name.chars().enumerate() { - let ch_w = UnicodeWidthChar::width(ch).unwrap_or(0); - let next_width = used_width.saturating_add(ch_w); - if next_width > name_limit { - truncated = true; - break; - } - used_width = next_width; - - if idx_iter.peek().is_some_and(|next| **next == char_idx) { - idx_iter.next(); - name_spans.push(ch.to_string().bold()); - } else { - name_spans.push(ch.to_string().into()); - } - } - } else { - for ch in row.name.chars() { - let ch_w = UnicodeWidthChar::width(ch).unwrap_or(0); - let next_width = used_width.saturating_add(ch_w); - if next_width > name_limit { - truncated = true; - break; - } - used_width = next_width; - name_spans.push(ch.to_string().into()); - } - } - - if truncated { - // If there is at least one cell available, add an ellipsis. - // When name_limit is 0, we still show an ellipsis to indicate truncation. - name_spans.push("…".into()); - } - - if row.disabled_reason.is_some() { - name_spans.push(" (disabled)".dim()); - } - - let this_name_width = name_prefix_width + Line::from(name_spans.clone()).width(); - let mut full_spans: Vec = row.name_prefix_spans.clone(); - full_spans.extend(name_spans); - if let Some(display_shortcut) = row.display_shortcut { - full_spans.push(" (".into()); - full_spans.push(display_shortcut.into()); - full_spans.push(")".into()); - } - if let Some(desc) = combined_description.as_ref() { - let gap = desc_col.saturating_sub(this_name_width); - if gap > 0 { - full_spans.push(" ".repeat(gap).into()); - } - full_spans.push(desc.clone().dim()); - } - if let Some(tag) = row.category_tag.as_deref().filter(|tag| !tag.is_empty()) { - full_spans.push(" ".into()); - full_spans.push(tag.to_string().dim()); - } - Line::from(full_spans) -} - /// Render a list of rows using the provided ScrollState, with shared styling /// and behavior for selection popups. /// Returns the number of terminal lines actually rendered (including the @@ -566,7 +501,8 @@ fn render_rows_inner( break; } - let mut wrapped = wrap_row_lines(row, desc_col, area.width); + let mut wrapped = + wrap_row_lines(row, desc_col, area.width, column_width.description_layout); apply_row_state_style( &mut wrapped, Some(i) == state.selected_idx && !row.is_disabled, @@ -721,7 +657,7 @@ pub(crate) fn render_rows_single_line_with_col_width_mode( break; } - let mut full_line = build_full_line(row, desc_col); + let mut full_line = build_full_line(row, desc_col, column_width.description_layout); if Some(i) == state.selected_idx && !row.is_disabled { full_line.spans.iter_mut().for_each(|span| { span.style = accent_style(); @@ -828,7 +764,13 @@ fn measure_rows_height_inner( .take(visible_items) .map(|(_, r)| r) { - let wrapped_lines = wrap_row_lines(row, desc_col, content_width).len(); + let wrapped_lines = wrap_row_lines( + row, + desc_col, + content_width, + column_width.description_layout, + ) + .len(); total = total.saturating_add(wrapped_lines as u16); } total.max(1) diff --git a/codex-rs/tui/src/bottom_pane/selection_row_layout.rs b/codex-rs/tui/src/bottom_pane/selection_row_layout.rs new file mode 100644 index 000000000000..692e086171a7 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/selection_row_layout.rs @@ -0,0 +1,203 @@ +use std::borrow::Cow; + +use ratatui::style::Stylize; +use ratatui::text::Line; +use ratatui::text::Span; +use unicode_width::UnicodeWidthChar; + +use super::selection_popup_common::GenericDisplayRow; +use crate::wrapping::RtOptions; +use crate::wrapping::word_wrap_line; + +/// Controls whether selection-row descriptions stay in a column or move below +/// their labels when the description column becomes too narrow to read. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(crate) enum SelectionDescriptionLayout { + #[default] + Columns, + StackBelowWhenNarrow { + min_description_width: u16, + }, +} + +impl SelectionDescriptionLayout { + pub(super) fn should_stack(self, width: u16, desc_col: usize) -> bool { + let Self::StackBelowWhenNarrow { + min_description_width, + } = self + else { + return false; + }; + let desc_col = desc_col.min(width as usize) as u16; + width.saturating_sub(desc_col) < min_description_width + } +} + +pub(super) fn line_to_owned(line: Line<'_>) -> Line<'static> { + Line { + style: line.style, + alignment: line.alignment, + spans: line + .spans + .into_iter() + .map(|span| Span { + style: span.style, + content: Cow::Owned(span.content.into_owned()), + }) + .collect(), + } +} + +fn combined_description( + row: &GenericDisplayRow, + description_layout: SelectionDescriptionLayout, +) -> Option { + match (&row.description, &row.disabled_reason) { + (Some(desc), Some(reason)) => Some(format!("{desc} (disabled: {reason})")), + (Some(desc), None) => Some(desc.clone()), + (None, Some(reason)) + if matches!( + description_layout, + SelectionDescriptionLayout::StackBelowWhenNarrow { .. } + ) => + { + Some(reason.clone()) + } + (None, Some(reason)) => Some(format!("disabled: {reason}")), + (None, None) => None, + } +} + +fn stacked_description(row: &GenericDisplayRow) -> Option { + match (&row.description, &row.disabled_reason) { + (Some(desc), Some(reason)) => Some(format!("{desc} (disabled: {reason})")), + (Some(desc), None) => Some(desc.clone()), + (None, Some(reason)) => Some(reason.clone()), + (None, None) => None, + } +} + +fn build_name_spans(row: &GenericDisplayRow, name_limit: usize) -> Vec> { + let mut name_spans = Vec::with_capacity(row.name.len()); + let mut used_width = 0usize; + let mut truncated = false; + + if let Some(idxs) = row.match_indices.as_ref() { + let mut idx_iter = idxs.iter().peekable(); + for (char_idx, ch) in row.name.chars().enumerate() { + let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0); + let next_width = used_width.saturating_add(ch_width); + if next_width > name_limit { + truncated = true; + break; + } + used_width = next_width; + + if idx_iter.peek().is_some_and(|next| **next == char_idx) { + idx_iter.next(); + name_spans.push(ch.to_string().bold()); + } else { + name_spans.push(ch.to_string().into()); + } + } + } else { + for ch in row.name.chars() { + let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0); + let next_width = used_width.saturating_add(ch_width); + if next_width > name_limit { + truncated = true; + break; + } + used_width = next_width; + name_spans.push(ch.to_string().into()); + } + } + + if truncated { + name_spans.push("…".into()); + } + if row.disabled_reason.is_some() { + name_spans.push(" (disabled)".dim()); + } + name_spans +} + +fn append_shortcut(row: &GenericDisplayRow, spans: &mut Vec>) { + if let Some(display_shortcut) = row.display_shortcut { + spans.push(" (".into()); + spans.push(display_shortcut.into()); + spans.push(")".into()); + } +} + +fn append_category_tag(row: &GenericDisplayRow, spans: &mut Vec>) { + if let Some(tag) = row.category_tag.as_deref().filter(|tag| !tag.is_empty()) { + spans.push(" ".into()); + spans.push(tag.to_string().dim()); + } +} + +/// Build the full display line for a row with the description padded to start +/// at `desc_col`. +pub(super) fn build_full_line( + row: &GenericDisplayRow, + desc_col: usize, + description_layout: SelectionDescriptionLayout, +) -> Line<'static> { + let description = combined_description(row, description_layout); + let name_prefix_width = Line::from(row.name_prefix_spans.clone()).width(); + let name_limit = description + .as_ref() + .map(|_| desc_col.saturating_sub(2).saturating_sub(name_prefix_width)) + .unwrap_or(usize::MAX); + let name_spans = build_name_spans(row, name_limit); + let name_width = name_prefix_width + Line::from(name_spans.clone()).width(); + + let mut spans = row.name_prefix_spans.clone(); + spans.extend(name_spans); + append_shortcut(row, &mut spans); + if let Some(description) = description { + let gap = desc_col.saturating_sub(name_width); + if gap > 0 { + spans.push(" ".repeat(gap).into()); + } + spans.push(description.dim()); + } + append_category_tag(row, &mut spans); + Line::from(spans) +} + +/// Render a row as a full-width label followed by an indented description. +pub(super) fn wrap_stacked_row(row: &GenericDisplayRow, width: u16) -> Vec> { + let width = width.max(1); + let prefix_width = Line::from(row.name_prefix_spans.clone()) + .width() + .min(width.saturating_sub(1) as usize); + let indent = " ".repeat(prefix_width); + + let mut label_spans = row.name_prefix_spans.clone(); + label_spans.extend(build_name_spans(row, usize::MAX)); + append_shortcut(row, &mut label_spans); + append_category_tag(row, &mut label_spans); + let label = Line::from(label_spans); + let label_options = RtOptions::new(width as usize) + .initial_indent(Line::from("")) + .subsequent_indent(Line::from(indent.clone())); + let mut lines = word_wrap_line(&label, label_options) + .into_iter() + .map(line_to_owned) + .collect::>(); + + if let Some(description) = stacked_description(row) { + let description = Line::from(description.dim()); + let description_options = RtOptions::new(width as usize) + .initial_indent(Line::from(indent.clone())) + .subsequent_indent(Line::from(indent)); + lines.extend( + word_wrap_line(&description, description_options) + .into_iter() + .map(line_to_owned), + ); + } + lines +} diff --git a/codex-rs/tui/src/keymap_setup.rs b/codex-rs/tui/src/keymap_setup.rs index 759938fd3270..a88f52495172 100644 --- a/codex-rs/tui/src/keymap_setup.rs +++ b/codex-rs/tui/src/keymap_setup.rs @@ -52,6 +52,7 @@ use crate::app_event_sender::AppEventSender; use crate::bottom_pane::BottomPaneView; use crate::bottom_pane::CancellationEvent; use crate::bottom_pane::ColumnWidthMode; +use crate::bottom_pane::SelectionDescriptionLayout; use crate::bottom_pane::SelectionItem; use crate::bottom_pane::SelectionViewParams; use crate::bottom_pane::popup_consts::standard_popup_hint_line; @@ -69,6 +70,7 @@ use debug::KeymapDebugView; pub(crate) const KEYMAP_ACTION_MENU_VIEW_ID: &str = "keymap-action-menu"; pub(crate) const KEYMAP_REPLACE_BINDING_MENU_VIEW_ID: &str = "keymap-replace-binding-menu"; +const KEYMAP_ACTION_MENU_MIN_DESCRIPTION_WIDTH: u16 = 24; #[derive(Debug, PartialEq, Eq)] pub(crate) enum KeymapEditOutcome { @@ -166,8 +168,8 @@ pub(crate) fn build_keymap_action_menu_params( let description = descriptor .map(|descriptor| descriptor.description) .unwrap_or("Configure this shortcut."); - let remove_disabled_reason = (!custom_binding) - .then(|| "There is no custom root binding for this action to remove.".to_string()); + let remove_disabled_reason = + (!custom_binding).then(|| "No custom root override to remove.".to_string()); let label = action_label(&action); let remove_context = context.clone(); let remove_action = action.clone(); @@ -263,15 +265,11 @@ pub(crate) fn build_keymap_action_menu_params( } items.push(SelectionItem { name: "Remove custom binding".to_string(), - description: Some(if custom_binding { - "Restore the default keymap binding.".to_string() - } else { - "No root override to remove.".to_string() - }), - selected_description: Some( - "Delete the root override and use the default keymap again.".to_string(), - ), + description: custom_binding.then(|| "Restore the default keymap binding.".to_string()), + selected_description: custom_binding + .then(|| "Delete the root override and use the default keymap again.".to_string()), disabled_reason: remove_disabled_reason, + disabled_gutter_marker: Some("–"), actions: vec![Box::new(move |tx| { tx.send(AppEvent::KeymapCleared { context: remove_context.clone(), @@ -297,7 +295,10 @@ pub(crate) fn build_keymap_action_menu_params( ])), footer_hint: Some(keymap_action_menu_hint_line()), items, - col_width_mode: ColumnWidthMode::Fixed, + col_width_mode: ColumnWidthMode::AutoAllRows, + description_layout: SelectionDescriptionLayout::StackBelowWhenNarrow { + min_description_width: KEYMAP_ACTION_MENU_MIN_DESCRIPTION_WIDTH, + }, ..Default::default() } } @@ -1297,6 +1298,33 @@ mod tests { assert_snapshot!("keymap_action_menu", snapshot); } + #[test] + fn action_menu_responsive_render_snapshot() { + let runtime = RuntimeKeymap::defaults(); + let render_at = |width| { + let params = build_keymap_action_menu_params( + "global".to_string(), + "open_transcript".to_string(), + &runtime, + &TuiKeymap::default(), + ); + render_picker(params, width) + }; + let snapshot = [ + "48 columns:", + &render_at(/*width*/ 48), + "", + "64 columns:", + &render_at(/*width*/ 64), + "", + "96 columns:", + &render_at(/*width*/ 96), + ] + .join("\n"); + + assert_snapshot!("keymap_action_menu_responsive", snapshot); + } + #[test] fn action_menu_disables_clear_when_action_has_no_custom_binding() { let runtime = RuntimeKeymap::defaults(); @@ -1314,7 +1342,7 @@ mod tests { let back = selection_item(¶ms, "Back to shortcuts"); assert_eq!( remove.disabled_reason.as_deref(), - Some("There is no custom root binding for this action to remove.") + Some("No custom root override to remove.") ); assert!( !replace.dismiss_on_select, diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_action_menu_responsive.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_action_menu_responsive.snap new file mode 100644 index 000000000000..776375c26491 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_action_menu_responsive.snap @@ -0,0 +1,61 @@ +--- +source: tui/src/keymap_setup.rs +expression: snapshot +--- +48 columns: + + Edit Shortcut + Open Transcript · Global + Current ctrl-t · Default keymap + Config `tui.keymap.global.open_transcript` + Open the transcript overlay. + +› 1. Replace binding + Capture one key and replace `ctrl-t`. + 2. Add alternate binding + Keep the current binding and add another + key. + – Remove custom binding (disabled) + No custom root override to remove. + 3. Back to shortcuts + Return to the shortcut list. + + Changes write the root `tui.keymap.*` + override. + enter select · esc back + +64 columns: + + Edit Shortcut + Open Transcript · Global + Current ctrl-t · Default keymap + Config `tui.keymap.global.open_transcript` + Open the transcript overlay. + +› 1. Replace binding + Capture one key and replace `ctrl-t`. + 2. Add alternate binding + Keep the current binding and add another key. + – Remove custom binding (disabled) + No custom root override to remove. + 3. Back to shortcuts + Return to the shortcut list. + + Changes write the root `tui.keymap.*` override. + enter select · esc back + +96 columns: + + Edit Shortcut + Open Transcript · Global + Current ctrl-t · Default keymap + Config `tui.keymap.global.open_transcript` + Open the transcript overlay. + +› 1. Replace binding Capture one key and replace `ctrl-t`. + 2. Add alternate binding Keep the current binding and add another key. + – Remove custom binding (disabled) No custom root override to remove. + 3. Back to shortcuts Return to the shortcut list. + + Changes write the root `tui.keymap.*` override. + enter select · esc back