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 095a09550..9bc046f01 100644 --- a/codex-rs/tui/src/bottom_pane/list_selection_view.rs +++ b/codex-rs/tui/src/bottom_pane/list_selection_view.rs @@ -23,14 +23,25 @@ use super::CancellationEvent; use super::bottom_pane_view::BottomPaneView; use super::popup_consts::MAX_POPUP_ROWS; use super::scroll_state::ScrollState; +pub(crate) use super::selection_popup_common::ColumnWidthMode; use super::selection_popup_common::GenericDisplayRow; use super::selection_popup_common::measure_rows_height; +use super::selection_popup_common::measure_rows_height_stable_col_widths; +use super::selection_popup_common::measure_rows_height_with_col_width_mode; use super::selection_popup_common::render_rows; +use super::selection_popup_common::render_rows_stable_col_widths; +use super::selection_popup_common::render_rows_with_col_width_mode; use unicode_width::UnicodeWidthStr; /// One selectable item in the generic selection list. pub(crate) type SelectionAction = Box; +/// One row in a [`ListSelectionView`] selection list. +/// +/// This is the source-of-truth model for row state before filtering and +/// formatting into render rows. A row is treated as disabled when either +/// `is_disabled` is true or `disabled_reason` is present; disabled rows cannot +/// be accepted and are skipped by keyboard navigation. #[derive(Default)] pub(crate) struct SelectionItem { pub name: String, @@ -46,6 +57,16 @@ pub(crate) struct SelectionItem { pub disabled_reason: Option, } +/// Construction-time configuration for [`ListSelectionView`]. +/// +/// This config is consumed once by [`ListSelectionView::new`]. After +/// construction, mutable interaction state (filtering, scrolling, and selected +/// row) lives on the view itself. +/// +/// `col_width_mode` controls column width mode in selection lists: +/// `AutoVisible` (default) measures only rows visible in the viewport +/// `AutoAllRows` measures all rows to ensure stable column widths as the user scrolls +/// `Fixed` used a fixed 30/70 split between columns pub(crate) struct SelectionViewParams { pub title: Option, pub subtitle: Option, @@ -54,6 +75,7 @@ pub(crate) struct SelectionViewParams { pub items: Vec, pub is_searchable: bool, pub search_placeholder: Option, + pub col_width_mode: ColumnWidthMode, pub header: Box, pub initial_selected_idx: Option, } @@ -68,12 +90,18 @@ impl Default for SelectionViewParams { items: Vec::new(), is_searchable: false, search_placeholder: None, + col_width_mode: ColumnWidthMode::AutoVisible, header: Box::new(()), initial_selected_idx: None, } } } +/// Runtime state for rendering and interacting with a list-based selection popup. +/// +/// This type is the single authority for filtered index mapping between +/// visible rows and source items and for preserving selection while filters +/// change. pub(crate) struct ListSelectionView { footer_note: Option>, footer_hint: Option>, @@ -84,6 +112,7 @@ pub(crate) struct ListSelectionView { is_searchable: bool, search_query: String, search_placeholder: Option, + col_width_mode: ColumnWidthMode, filtered_indices: Vec, last_selected_actual_idx: Option, header: Box, @@ -91,6 +120,13 @@ pub(crate) struct ListSelectionView { } impl ListSelectionView { + /// Create a selection popup view with filtering, scrolling, and callbacks wired. + /// + /// The constructor normalizes header/title composition and immediately + /// applies filtering so `ScrollState` starts in a valid visible range. + /// When search is enabled, rows without `search_value` will disappear as + /// soon as the query is non-empty, which can look like dropped data unless + /// callers intentionally populate that field. pub fn new(params: SelectionViewParams, app_event_tx: AppEventSender) -> Self { let mut header = params.header; if params.title.is_some() || params.subtitle.is_some() { @@ -116,6 +152,7 @@ impl ListSelectionView { } else { None }, + col_width_mode: params.col_width_mode, filtered_indices: Vec::new(), last_selected_actual_idx: None, header, @@ -434,12 +471,27 @@ impl Renderable for ListSelectionView { // Build the same display rows used by the renderer so wrapping math matches. let rows = self.build_rows(); let rows_width = Self::rows_width(width); - let rows_height = measure_rows_height( - &rows, - &self.state, - MAX_POPUP_ROWS, - rows_width.saturating_add(1), - ); + let rows_height = match self.col_width_mode { + ColumnWidthMode::AutoVisible => measure_rows_height( + &rows, + &self.state, + MAX_POPUP_ROWS, + rows_width.saturating_add(1), + ), + ColumnWidthMode::AutoAllRows => measure_rows_height_stable_col_widths( + &rows, + &self.state, + MAX_POPUP_ROWS, + rows_width.saturating_add(1), + ), + ColumnWidthMode::Fixed => measure_rows_height_with_col_width_mode( + &rows, + &self.state, + MAX_POPUP_ROWS, + rows_width.saturating_add(1), + ColumnWidthMode::Fixed, + ), + }; // Subtract 4 for the padding on the left and right of the header. let mut height = self.header.desired_height(width.saturating_sub(4)); @@ -483,12 +535,27 @@ impl Renderable for ListSelectionView { .desired_height(outer_content_area.width.saturating_sub(4)); let rows = self.build_rows(); let rows_width = Self::rows_width(outer_content_area.width); - let rows_height = measure_rows_height( - &rows, - &self.state, - MAX_POPUP_ROWS, - rows_width.saturating_add(1), - ); + let rows_height = match self.col_width_mode { + ColumnWidthMode::AutoVisible => measure_rows_height( + &rows, + &self.state, + MAX_POPUP_ROWS, + rows_width.saturating_add(1), + ), + ColumnWidthMode::AutoAllRows => measure_rows_height_stable_col_widths( + &rows, + &self.state, + MAX_POPUP_ROWS, + rows_width.saturating_add(1), + ), + ColumnWidthMode::Fixed => measure_rows_height_with_col_width_mode( + &rows, + &self.state, + MAX_POPUP_ROWS, + rows_width.saturating_add(1), + ColumnWidthMode::Fixed, + ), + }; let [header_area, _, search_area, list_area] = Layout::vertical([ Constraint::Max(header_height), Constraint::Max(1), @@ -529,14 +596,33 @@ impl Renderable for ListSelectionView { width: rows_width.max(1), height: list_area.height, }; - render_rows( - render_area, - buf, - &rows, - &self.state, - render_area.height as usize, - "no matches", - ); + match self.col_width_mode { + ColumnWidthMode::AutoVisible => render_rows( + render_area, + buf, + &rows, + &self.state, + render_area.height as usize, + "no matches", + ), + ColumnWidthMode::AutoAllRows => render_rows_stable_col_widths( + render_area, + buf, + &rows, + &self.state, + render_area.height as usize, + "no matches", + ), + ColumnWidthMode::Fixed => render_rows_with_col_width_mode( + render_area, + buf, + &rows, + &self.state, + render_area.height as usize, + "no matches", + ColumnWidthMode::Fixed, + ), + } } if footer_area.height > 0 { @@ -585,7 +671,9 @@ mod tests { use super::*; use crate::app_event::AppEvent; use crate::bottom_pane::popup_consts::standard_popup_hint_line; + use crossterm::event::KeyCode; use insta::assert_snapshot; + use pretty_assertions::assert_eq; use ratatui::layout::Rect; use tokio::sync::mpsc::unbounded_channel; @@ -647,6 +735,55 @@ mod tests { lines.join("\n") } + fn description_col(rendered: &str, item_marker: &str, description: &str) -> usize { + let line = rendered + .lines() + .find(|line| line.contains(item_marker) && line.contains(description)) + .expect("expected rendered line to contain row marker and description"); + line.find(description) + .expect("expected rendered line to contain description") + } + + fn make_scrolling_width_items() -> Vec { + let mut items: Vec = (1..=8) + .map(|idx| SelectionItem { + name: format!("Item {idx}"), + description: Some(format!("desc {idx}")), + dismiss_on_select: true, + ..Default::default() + }) + .collect(); + items.push(SelectionItem { + name: "Item 9 with an intentionally much longer name".to_string(), + description: Some("desc 9".to_string()), + dismiss_on_select: true, + ..Default::default() + }); + items + } + + fn render_before_after_scroll_snapshot(col_width_mode: ColumnWidthMode, width: u16) -> String { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut view = ListSelectionView::new( + SelectionViewParams { + title: Some("Debug".to_string()), + items: make_scrolling_width_items(), + col_width_mode, + ..Default::default() + }, + tx, + ); + + let before_scroll = render_lines_with_width(&view, width); + for _ in 0..8 { + view.handle_key_event(KeyEvent::from(KeyCode::Down)); + } + let after_scroll = render_lines_with_width(&view, width); + + format!("before scroll:\n{before_scroll}\n\nafter scroll:\n{after_scroll}") + } + #[test] fn renders_blank_line_between_title_and_items_without_subtitle() { let view = make_selection_view(None); @@ -921,4 +1058,96 @@ mod tests { render_lines_with_width(&view, 24) ); } + + #[test] + fn snapshot_auto_visible_col_width_mode_scroll_behavior() { + assert_snapshot!( + "list_selection_col_width_mode_auto_visible_scroll", + render_before_after_scroll_snapshot(ColumnWidthMode::AutoVisible, 96) + ); + } + + #[test] + fn snapshot_auto_all_rows_col_width_mode_scroll_behavior() { + assert_snapshot!( + "list_selection_col_width_mode_auto_all_rows_scroll", + render_before_after_scroll_snapshot(ColumnWidthMode::AutoAllRows, 96) + ); + } + + #[test] + fn snapshot_fixed_col_width_mode_scroll_behavior() { + assert_snapshot!( + "list_selection_col_width_mode_fixed_scroll", + render_before_after_scroll_snapshot(ColumnWidthMode::Fixed, 96) + ); + } + + #[test] + fn auto_all_rows_col_width_does_not_shift_when_scrolling() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + + let mut view = ListSelectionView::new( + SelectionViewParams { + title: Some("Debug".to_string()), + items: make_scrolling_width_items(), + col_width_mode: ColumnWidthMode::AutoAllRows, + ..Default::default() + }, + tx, + ); + + let before_scroll = render_lines_with_width(&view, 96); + for _ in 0..8 { + view.handle_key_event(KeyEvent::from(KeyCode::Down)); + } + let after_scroll = render_lines_with_width(&view, 96); + + assert!( + after_scroll.contains("9. Item 9 with an intentionally much longer name"), + "expected the scrolled view to include the longer row:\n{after_scroll}" + ); + + let before_col = description_col(&before_scroll, "8. Item 8", "desc 8"); + let after_col = description_col(&after_scroll, "8. Item 8", "desc 8"); + assert_eq!( + before_col, after_col, + "description column changed across scroll:\nbefore:\n{before_scroll}\nafter:\n{after_scroll}" + ); + } + + #[test] + fn fixed_col_width_is_30_70_and_does_not_shift_when_scrolling() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let width = 96; + let mut view = ListSelectionView::new( + SelectionViewParams { + title: Some("Debug".to_string()), + items: make_scrolling_width_items(), + col_width_mode: ColumnWidthMode::Fixed, + ..Default::default() + }, + tx, + ); + + let before_scroll = render_lines_with_width(&view, width); + let before_col = description_col(&before_scroll, "8. Item 8", "desc 8"); + let expected_desc_col = ((width.saturating_sub(2) as usize) * 3) / 10; + assert_eq!( + before_col, expected_desc_col, + "fixed mode should place description column at a 30/70 split:\n{before_scroll}" + ); + + for _ in 0..8 { + view.handle_key_event(KeyEvent::from(KeyCode::Down)); + } + let after_scroll = render_lines_with_width(&view, width); + let after_col = description_col(&after_scroll, "8. Item 8", "desc 8"); + assert_eq!( + before_col, after_col, + "fixed description column changed across scroll:\nbefore:\n{before_scroll}\nafter:\n{after_scroll}" + ); + } } diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index cd5e2adb8..b251beb80 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -65,6 +65,7 @@ mod skill_popup; mod skills_toggle_view; mod slash_commands; pub(crate) use footer::CollaborationModeIndicator; +pub(crate) use list_selection_view::ColumnWidthMode; pub(crate) use list_selection_view::SelectionViewParams; mod feedback_view; pub(crate) use feedback_view::FeedbackAudience; 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 89ef8b3b3..f8e1ad890 100644 --- a/codex-rs/tui/src/bottom_pane/selection_popup_common.rs +++ b/codex-rs/tui/src/bottom_pane/selection_popup_common.rs @@ -19,7 +19,11 @@ use crate::style::user_message_style; use super::scroll_state::ScrollState; -/// A generic representation of a display row for selection popups. +/// Render-ready representation of one row in a selection popup. +/// +/// This type contains presentation-focused fields that are intentionally more +/// concrete than source domain models. `match_indices` are character offsets +/// into `name`, and `wrap_indent` is interpreted in terminal cell columns. #[derive(Default)] pub(crate) struct GenericDisplayRow { pub name: String, @@ -31,6 +35,25 @@ pub(crate) struct GenericDisplayRow { pub wrap_indent: Option, // optional indent for wrapped lines } +/// Controls how selection rows choose the split between left/right name/description columns. +/// +/// Callers should use the same mode for both measurement and rendering, or the +/// popup can reserve the wrong number of lines and clip content. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +#[cfg_attr(not(test), allow(dead_code))] +pub(crate) enum ColumnWidthMode { + /// Derive column placement from only the visible viewport rows. + #[default] + AutoVisible, + /// Derive column placement from all rows so scrolling does not shift columns. + AutoAllRows, + /// Use a fixed two-column split: 30% left (name), 70% right (description). + Fixed, +} + +const FIXED_LEFT_COLUMN_NUMERATOR: usize = 3; +const FIXED_LEFT_COLUMN_DENOMINATOR: usize = 10; + const MENU_SURFACE_INSET_V: u16 = 1; const MENU_SURFACE_INSET_H: u16 = 2; @@ -50,7 +73,8 @@ pub(crate) const fn menu_surface_padding_height() -> u16 { /// Paint the shared menu background and return the inset content area. /// /// This keeps the surface treatment consistent across selection-style overlays -/// (for example `/model`, approvals, and request-user-input). +/// (for example `/model`, approvals, and request-user-input). Callers should +/// render all inner content in the returned rect, not the original area. pub(crate) fn render_menu_surface(area: Rect, buf: &mut Buffer) -> Rect { if area.is_empty() { return area; @@ -61,6 +85,10 @@ pub(crate) fn render_menu_surface(area: Rect, buf: &mut Buffer) -> Rect { menu_surface_inset(area) } +/// Wrap a styled line while preserving span styles. +/// +/// The function clamps `width` to at least one terminal cell so callers can use +/// it safely with narrow layouts. pub(crate) fn wrap_styled_line<'a>(line: &'a Line<'a>, width: u16) -> Vec> { use crate::wrapping::RtOptions; use crate::wrapping::word_wrap_line; @@ -143,34 +171,60 @@ fn truncate_line_with_ellipsis_if_overflow(line: Line<'static>, max_width: usize Line::from(spans) } -/// Compute a shared description-column start based on the widest visible name -/// plus two spaces of padding. Ensures at least one column is left for the -/// description. +/// Computes the shared start column used for descriptions in selection rows. +/// The column is derived from the widest row name plus two spaces of padding +/// while always leaving at least one terminal cell for description content. +/// [`ColumnWidthMode::AutoAllRows`] computes width across the full dataset so +/// the description column does not shift as the user scrolls. fn compute_desc_col( rows_all: &[GenericDisplayRow], start_idx: usize, visible_items: usize, content_width: u16, + col_width_mode: ColumnWidthMode, ) -> usize { - let visible_range = start_idx..(start_idx + visible_items); - let max_name_width = rows_all - .iter() - .enumerate() - .filter(|(i, _)| visible_range.contains(i)) - .map(|(_, r)| { - let mut spans: Vec = vec![r.name.clone().into()]; - if r.disabled_reason.is_some() { - spans.push(" (disabled)".dim()); - } - Line::from(spans).width() - }) - .max() - .unwrap_or(0); - let mut desc_col = max_name_width.saturating_add(2); - if (desc_col as u16) >= content_width { - desc_col = content_width.saturating_sub(1) as usize; + if content_width <= 1 { + return 0; + } + + let max_desc_col = content_width.saturating_sub(1) as usize; + match col_width_mode { + ColumnWidthMode::Fixed => ((content_width as usize * FIXED_LEFT_COLUMN_NUMERATOR) + / FIXED_LEFT_COLUMN_DENOMINATOR) + .clamp(1, max_desc_col), + ColumnWidthMode::AutoVisible | ColumnWidthMode::AutoAllRows => { + let max_name_width = match col_width_mode { + ColumnWidthMode::AutoVisible => rows_all + .iter() + .enumerate() + .skip(start_idx) + .take(visible_items) + .map(|(_, row)| { + let mut spans: Vec = vec![row.name.clone().into()]; + if row.disabled_reason.is_some() { + spans.push(" (disabled)".dim()); + } + Line::from(spans).width() + }) + .max() + .unwrap_or(0), + ColumnWidthMode::AutoAllRows => rows_all + .iter() + .map(|row| { + let mut spans: Vec = vec![row.name.clone().into()]; + if row.disabled_reason.is_some() { + spans.push(" (disabled)".dim()); + } + Line::from(spans).width() + }) + .max() + .unwrap_or(0), + ColumnWidthMode::Fixed => 0, + }; + + max_name_width.saturating_add(2).min(max_desc_col) + } } - desc_col } /// Determine how many spaces to indent wrapped lines for a row. @@ -268,13 +322,14 @@ fn build_full_line(row: &GenericDisplayRow, desc_col: usize) -> Line<'static> { /// Render a list of rows using the provided ScrollState, with shared styling /// and behavior for selection popups. -pub(crate) fn render_rows( +fn render_rows_inner( area: Rect, buf: &mut Buffer, rows_all: &[GenericDisplayRow], state: &ScrollState, max_results: usize, empty_message: &str, + col_width_mode: ColumnWidthMode, ) { if rows_all.is_empty() { if area.height > 0 { @@ -301,7 +356,13 @@ pub(crate) fn render_rows( } } - let desc_col = compute_desc_col(rows_all, start_idx, visible_items, area.width); + let desc_col = compute_desc_col( + rows_all, + start_idx, + visible_items, + area.width, + col_width_mode, + ); // Render items, wrapping descriptions and aligning wrapped lines under the // shared description column. Stop when we run out of vertical space. @@ -358,7 +419,88 @@ pub(crate) fn render_rows( } } +/// Render a list of rows using the provided ScrollState, with shared styling +/// and behavior for selection popups. +/// Description alignment is computed from visible rows only, which allows the +/// layout to adapt tightly to the current viewport. +/// +/// This function should be paired with [`measure_rows_height`] when reserving +/// space; pairing it with a different measurement mode can cause clipping. +pub(crate) fn render_rows( + area: Rect, + buf: &mut Buffer, + rows_all: &[GenericDisplayRow], + state: &ScrollState, + max_results: usize, + empty_message: &str, +) { + render_rows_inner( + area, + buf, + rows_all, + state, + max_results, + empty_message, + ColumnWidthMode::AutoVisible, + ); +} + +/// Render a list of rows using the provided ScrollState, with shared styling +/// and behavior for selection popups. +/// This mode keeps column placement stable while scrolling by sizing the +/// description column against the full dataset. +/// +/// This function should be paired with +/// [`measure_rows_height_stable_col_widths`] so reserved and rendered heights +/// stay in sync. +pub(crate) fn render_rows_stable_col_widths( + area: Rect, + buf: &mut Buffer, + rows_all: &[GenericDisplayRow], + state: &ScrollState, + max_results: usize, + empty_message: &str, +) { + render_rows_inner( + area, + buf, + rows_all, + state, + max_results, + empty_message, + ColumnWidthMode::AutoAllRows, + ); +} + +/// Render a list of rows using the provided ScrollState and explicit +/// [`ColumnWidthMode`] behavior. +/// +/// This is the low-level entry point for callers that need to thread a mode +/// through higher-level configuration. +pub(crate) fn render_rows_with_col_width_mode( + area: Rect, + buf: &mut Buffer, + rows_all: &[GenericDisplayRow], + state: &ScrollState, + max_results: usize, + empty_message: &str, + col_width_mode: ColumnWidthMode, +) { + render_rows_inner( + area, + buf, + rows_all, + state, + max_results, + empty_message, + col_width_mode, + ); +} + /// Render rows as a single line each (no wrapping), truncating overflow with an ellipsis. +/// +/// This path always uses viewport-local width alignment and is best for dense +/// list UIs where multi-line descriptions would add too much vertical churn. pub(crate) fn render_rows_single_line( area: Rect, buf: &mut Buffer, @@ -390,7 +532,13 @@ pub(crate) fn render_rows_single_line( } } - let desc_col = compute_desc_col(rows_all, start_idx, visible_items, area.width); + let desc_col = compute_desc_col( + rows_all, + start_idx, + visible_items, + area.width, + ColumnWidthMode::AutoVisible, + ); let mut cur_y = area.y; for (i, row) in rows_all @@ -433,11 +581,62 @@ pub(crate) fn render_rows_single_line( /// items from `rows_all` given the current scroll/selection state and the /// available `width`. Accounts for description wrapping and alignment so the /// caller can allocate sufficient vertical space. +/// +/// This function matches [`render_rows`] semantics (`AutoVisible` column +/// sizing). Mixing it with stable or fixed render modes can under- or +/// over-estimate required height. pub(crate) fn measure_rows_height( rows_all: &[GenericDisplayRow], state: &ScrollState, max_results: usize, width: u16, +) -> u16 { + measure_rows_height_inner( + rows_all, + state, + max_results, + width, + ColumnWidthMode::AutoVisible, + ) +} + +/// Measures selection-row height while using full-dataset column alignment. +/// This should be paired with [`render_rows_stable_col_widths`] so layout +/// reservation matches rendering behavior. +pub(crate) fn measure_rows_height_stable_col_widths( + rows_all: &[GenericDisplayRow], + state: &ScrollState, + max_results: usize, + width: u16, +) -> u16 { + measure_rows_height_inner( + rows_all, + state, + max_results, + width, + ColumnWidthMode::AutoAllRows, + ) +} + +/// Measure selection-row height using explicit [`ColumnWidthMode`] behavior. +/// +/// This is the low-level companion to [`render_rows_with_col_width_mode`]. +pub(crate) fn measure_rows_height_with_col_width_mode( + rows_all: &[GenericDisplayRow], + state: &ScrollState, + max_results: usize, + width: u16, + col_width_mode: ColumnWidthMode, +) -> u16 { + measure_rows_height_inner(rows_all, state, max_results, width, col_width_mode) +} + +fn measure_rows_height_inner( + rows_all: &[GenericDisplayRow], + state: &ScrollState, + max_results: usize, + width: u16, + col_width_mode: ColumnWidthMode, ) -> u16 { if rows_all.is_empty() { return 1; // placeholder "no matches" line @@ -458,7 +657,13 @@ pub(crate) fn measure_rows_height( } } - let desc_col = compute_desc_col(rows_all, start_idx, visible_items, content_width); + let desc_col = compute_desc_col( + rows_all, + start_idx, + visible_items, + content_width, + col_width_mode, + ); use crate::wrapping::RtOptions; use crate::wrapping::word_wrap_line; diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_col_width_mode_auto_all_rows_scroll.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_col_width_mode_auto_all_rows_scroll.snap new file mode 100644 index 000000000..6aaf439a9 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_col_width_mode_auto_all_rows_scroll.snap @@ -0,0 +1,31 @@ +--- +source: tui/src/bottom_pane/list_selection_view.rs +assertion_line: 1054 +expression: "render_before_after_scroll_snapshot(ColumnWidthMode::AutoAllRows, 96)" +--- +before scroll: + + Debug + +› 1. Item 1 desc 1 + 2. Item 2 desc 2 + 3. Item 3 desc 3 + 4. Item 4 desc 4 + 5. Item 5 desc 5 + 6. Item 6 desc 6 + 7. Item 7 desc 7 + 8. Item 8 desc 8 + + +after scroll: + + Debug + + 2. Item 2 desc 2 + 3. Item 3 desc 3 + 4. Item 4 desc 4 + 5. Item 5 desc 5 + 6. Item 6 desc 6 + 7. Item 7 desc 7 + 8. Item 8 desc 8 +› 9. Item 9 with an intentionally much longer name desc 9 diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_col_width_mode_auto_visible_scroll.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_col_width_mode_auto_visible_scroll.snap new file mode 100644 index 000000000..6875fb543 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_col_width_mode_auto_visible_scroll.snap @@ -0,0 +1,31 @@ +--- +source: tui/src/bottom_pane/list_selection_view.rs +assertion_line: 1046 +expression: "render_before_after_scroll_snapshot(ColumnWidthMode::AutoVisible, 96)" +--- +before scroll: + + Debug + +› 1. Item 1 desc 1 + 2. Item 2 desc 2 + 3. Item 3 desc 3 + 4. Item 4 desc 4 + 5. Item 5 desc 5 + 6. Item 6 desc 6 + 7. Item 7 desc 7 + 8. Item 8 desc 8 + + +after scroll: + + Debug + + 2. Item 2 desc 2 + 3. Item 3 desc 3 + 4. Item 4 desc 4 + 5. Item 5 desc 5 + 6. Item 6 desc 6 + 7. Item 7 desc 7 + 8. Item 8 desc 8 +› 9. Item 9 with an intentionally much longer name desc 9 diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_col_width_mode_fixed_scroll.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_col_width_mode_fixed_scroll.snap new file mode 100644 index 000000000..4672ab7f2 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_col_width_mode_fixed_scroll.snap @@ -0,0 +1,31 @@ +--- +source: tui/src/bottom_pane/list_selection_view.rs +assertion_line: 1062 +expression: "render_before_after_scroll_snapshot(ColumnWidthMode::Fixed, 96)" +--- +before scroll: + + Debug + +› 1. Item 1 desc 1 + 2. Item 2 desc 2 + 3. Item 3 desc 3 + 4. Item 4 desc 4 + 5. Item 5 desc 5 + 6. Item 6 desc 6 + 7. Item 7 desc 7 + 8. Item 8 desc 8 + + +after scroll: + + Debug + + 2. Item 2 desc 2 + 3. Item 3 desc 3 + 4. Item 4 desc 4 + 5. Item 5 desc 5 + 6. Item 6 desc 6 + 7. Item 7 desc 7 + 8. Item 8 desc 8 +› 9. Item 9 with an intent… desc 9 diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 52a1b5440..58ffcc37f 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -144,6 +144,7 @@ use crate::bottom_pane::BottomPane; use crate::bottom_pane::BottomPaneParams; use crate::bottom_pane::CancellationEvent; use crate::bottom_pane::CollaborationModeIndicator; +use crate::bottom_pane::ColumnWidthMode; use crate::bottom_pane::DOUBLE_PRESS_QUIT_SHORTCUT_ENABLED; use crate::bottom_pane::ExperimentalFeatureItem; use crate::bottom_pane::ExperimentalFeaturesView; @@ -5742,6 +5743,7 @@ impl ChatWidget { items, is_searchable: true, search_placeholder: Some("Type to search apps".to_string()), + col_width_mode: ColumnWidthMode::AutoAllRows, ..Default::default() }); }