From 5ee2bdf1e0e04064bc427ae31a49a01e8f7064df Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Sat, 19 Sep 2026 19:00:20 +0000 Subject: [PATCH] Unify TUI picker styling and improve compact session layouts (#46697) ## What changed - Remove the legacy selection-list appearance and use shared full-width selection, filled tabs, wrapped hints, and separate overflow rows throughout. - Give resume and fork pickers separate toolbar and search rows. Collapse spacing on short screens, keep the focused toolbar control visible on narrow screens, and prioritize labeled primary actions in the footer. - Apply shared selection styling and contrast-aware text colors to session rows and metadata. Clear inherited selection colors and reversal from the transcript loading overlay. - Preserve clipped-header notices alongside results in small approval dialogs. ## Testing Add rendering assertions and snapshots for session selection contrast, reversed selection, loading overlay styles, compact metadata and action labels, and keyboard-focused toolbar visibility. Extend approval clipping snapshots to shorter terminal heights. GitOrigin-RevId: 6e34b14f5528baf33e97ff6cf746107a1bbe69ab --- codex-rs/tui/src/app/agents_overview.rs | 1 - .../tui/src/app/agents_overview_actions.rs | 2 - .../tui/src/app/agents_overview_errors.rs | 1 - codex-rs/tui/src/app/daemon_menu.rs | 2 - codex-rs/tui/src/app/input.rs | 1 - codex-rs/tui/src/app/session_lifecycle.rs | 1 - codex-rs/tui/src/app/session_picker.rs | 1 - .../tui/src/app/tests/disconnect_tests.rs | 1 - codex-rs/tui/src/app/tests/key_chords.rs | 1 - codex-rs/tui/src/app/thread_goal_actions.rs | 1 - codex-rs/tui/src/bottom_pane/app_link_view.rs | 1 - .../tui/src/bottom_pane/approval_overlay.rs | 1 - .../approval_overlay/clipping_tests.rs | 2 +- ...c_approval_opens_the_complete_command.snap | 38 ++ .../src/bottom_pane/async_questions/mod.rs | 1 - .../bottom_pane/experimental_features_view.rs | 1 - codex-rs/tui/src/bottom_pane/feedback_view.rs | 3 - .../src/bottom_pane/list_selection_view.rs | 258 +++-------- .../src/bottom_pane/mcp_server_elicitation.rs | 1 - .../src/bottom_pane/memories_settings_view.rs | 2 - codex-rs/tui/src/bottom_pane/mod.rs | 19 +- .../src/bottom_pane/multi_select_picker.rs | 2 - codex-rs/tui/src/bottom_pane/picker_option.rs | 1 - .../tui/src/bottom_pane/picker_presets.rs | 1 - codex-rs/tui/src/bottom_pane/popup_consts.rs | 9 - .../src/bottom_pane/request_user_input/mod.rs | 3 - .../bottom_pane/selection_picker_layout.rs | 7 +- .../src/bottom_pane/selection_picker_tests.rs | 2 - .../tui/src/bottom_pane/selection_tabs.rs | 112 +---- .../tui/src/bottom_pane/skills_toggle_view.rs | 1 - ...n_col_width_mode_auto_all_rows_scroll.snap | 7 +- ...on_col_width_mode_auto_visible_scroll.snap | 7 +- ...ests__list_selection_empty_searchable.snap | 5 +- ...sts__list_selection_footer_note_wraps.snap | 10 +- ...election_halfwidth_sound_marks_narrow.snap | 29 +- ..._list_selection_model_picker_width_80.snap | 11 +- ...selection_narrow_width_preserves_rows.snap | 15 +- ..._list_selection_spacing_with_subtitle.snap | 3 +- ...st_selection_spacing_without_subtitle.snap | 3 +- .../tests/actionable_banner_tests.rs | 29 +- .../bottom_pane/tests/picker_hint_tests.rs | 2 - .../tui/src/bottom_pane/user_verification.rs | 1 - codex-rs/tui/src/chatwidget.rs | 1 - codex-rs/tui/src/chatwidget/connectors.rs | 3 - codex-rs/tui/src/chatwidget/copy_picker.rs | 1 - codex-rs/tui/src/chatwidget/goal_menu.rs | 1 - .../tui/src/chatwidget/luna_reserve_model.rs | 1 - .../tui/src/chatwidget/misalignment_policy.rs | 2 - codex-rs/tui/src/chatwidget/model_popups.rs | 5 - .../src/chatwidget/permission_discovery.rs | 2 - .../tui/src/chatwidget/permission_popups.rs | 3 - .../tui/src/chatwidget/permissions_menu.rs | 1 - codex-rs/tui/src/chatwidget/pets.rs | 1 - .../tui/src/chatwidget/plan_implementation.rs | 1 - codex-rs/tui/src/chatwidget/plugin_catalog.rs | 14 - codex-rs/tui/src/chatwidget/plugins.rs | 1 - codex-rs/tui/src/chatwidget/rate_limits.rs | 1 - .../tui/src/chatwidget/realtime_settings.rs | 1 - codex-rs/tui/src/chatwidget/review_popups.rs | 3 - .../tui/src/chatwidget/safety_buffering.rs | 2 - codex-rs/tui/src/chatwidget/skills.rs | 1 - codex-rs/tui/src/chatwidget/slash_dispatch.rs | 2 - .../tui/src/chatwidget/tests/permissions.rs | 1 - codex-rs/tui/src/chatwidget/tests/usage.rs | 1 - .../tui/src/chatwidget/transcript_export.rs | 1 - codex-rs/tui/src/chatwidget/usage.rs | 9 - .../src/chatwidget/windows_sandbox_prompts.rs | 2 - .../tui/src/chatwidget/worktree_picker.rs | 6 - codex-rs/tui/src/keymap_setup.rs | 26 +- codex-rs/tui/src/keymap_setup/picker.rs | 1 - codex-rs/tui/src/pets/picker.rs | 1 - codex-rs/tui/src/resume_picker.rs | 415 +++++++++--------- .../tui/src/resume_picker/archive_tests.rs | 2 +- codex-rs/tui/src/resume_picker/layout.rs | 85 ++++ codex-rs/tui/src/resume_picker_color_tests.rs | 330 +++++++++++++- ..._metadata_and_labeled_primary_actions.snap | 70 +++ ...eeps_keyboard_focused_control_visible.snap | 33 ++ ...vive_selection_in_both_picker_layouts.snap | 12 - ..._resume_picker_db_fallback_transition.snap | 4 +- ...icker__tests__resume_picker_dense_all.snap | 2 +- ...sume_picker_dense_all_auto_hidden_cwd.snap | 2 +- ...s__resume_picker_dense_all_forced_cwd.snap | 2 +- ...icker__tests__resume_picker_dense_cwd.snap | 2 +- ...er__tests__resume_picker_dense_narrow.snap | 2 +- ...s__resume_picker_dense_no_blank_lines.snap | 2 +- ..._tests__resume_picker_linked_worktree.snap | 2 +- ..._tests__resume_picker_more_indicators.snap | 4 +- ...__tests__resume_picker_narrow_session.snap | 4 +- ...e_picker_search_line_sort_filter_tabs.snap | 2 +- ...me_picker__tests__resume_picker_table.snap | 4 +- ...ume_picker_transcript_loading_overlay.snap | 2 +- codex-rs/tui/src/startup_hooks_review.rs | 1 - codex-rs/tui/src/theme_picker.rs | 1 - 93 files changed, 922 insertions(+), 755 deletions(-) create mode 100644 codex-rs/tui/src/resume_picker/layout.rs create mode 100644 codex-rs/tui/src/snapshots/codex_tui__resume_picker__color_tests__compact_picker_keeps_metadata_and_labeled_primary_actions.snap create mode 100644 codex-rs/tui/src/snapshots/codex_tui__resume_picker__color_tests__narrow_toolbar_keeps_keyboard_focused_control_visible.snap delete mode 100644 codex-rs/tui/src/snapshots/codex_tui__resume_picker__color_tests__thread_colors_survive_selection_in_both_picker_layouts.snap diff --git a/codex-rs/tui/src/app/agents_overview.rs b/codex-rs/tui/src/app/agents_overview.rs index 23646a33cd..ed198c926d 100644 --- a/codex-rs/tui/src/app/agents_overview.rs +++ b/codex-rs/tui/src/app/agents_overview.rs @@ -74,7 +74,6 @@ impl App { if matches!(self.app_server_target, AppServerTarget::Embedded) { let workload_identity_selected = codex_login::is_workload_identity_selected(); self.chat_widget.show_selection_view(SelectionViewParams { - appearance: crate::bottom_pane::SelectionAppearance::Picker, title: Some("Shared agents unavailable".to_string()), subtitle: Some( if workload_identity_selected { diff --git a/codex-rs/tui/src/app/agents_overview_actions.rs b/codex-rs/tui/src/app/agents_overview_actions.rs index 14ff91a989..dd9cbd77b3 100644 --- a/codex-rs/tui/src/app/agents_overview_actions.rs +++ b/codex-rs/tui/src/app/agents_overview_actions.rs @@ -137,7 +137,6 @@ impl App { ), }; self.chat_widget.show_selection_view(SelectionViewParams { - appearance: crate::bottom_pane::SelectionAppearance::Picker, header: Box::new(LifecycleHeader(vec![ title.bold().into(), description.dim().into(), @@ -308,7 +307,6 @@ impl App { ); } self.chat_widget.show_selection_view(SelectionViewParams { - appearance: crate::bottom_pane::SelectionAppearance::Picker, header: Box::new(LifecycleHeader(header)), items: vec![SelectionItem { name: "Back to agents".to_string(), diff --git a/codex-rs/tui/src/app/agents_overview_errors.rs b/codex-rs/tui/src/app/agents_overview_errors.rs index 554460c287..5dc356b8d1 100644 --- a/codex-rs/tui/src/app/agents_overview_errors.rs +++ b/codex-rs/tui/src/app/agents_overview_errors.rs @@ -33,7 +33,6 @@ impl App { ..Default::default() }]; self.chat_widget.show_selection_view(SelectionViewParams { - appearance: crate::bottom_pane::SelectionAppearance::Picker, header: Box::new(AgentsOverviewErrorHeader(vec![ Line::from("Unable to complete action".bold()), Line::from(message.clone().dim()), diff --git a/codex-rs/tui/src/app/daemon_menu.rs b/codex-rs/tui/src/app/daemon_menu.rs index 7c92ae5cbb..08062f4d3c 100644 --- a/codex-rs/tui/src/app/daemon_menu.rs +++ b/codex-rs/tui/src/app/daemon_menu.rs @@ -79,7 +79,6 @@ impl App { }) .collect(); self.chat_widget.show_selection_view(SelectionViewParams { - appearance: crate::bottom_pane::SelectionAppearance::Picker, header: Box::new(DaemonMenuHeader(header)), items, ..SelectionViewParams::picker() @@ -106,7 +105,6 @@ impl App { let mut header = vec![Line::from("Update daemon and exit Codex?".bold())]; header.extend(explanation.lines().map(|line| Line::from(line.to_owned()))); self.chat_widget.show_selection_view(SelectionViewParams { - appearance: crate::bottom_pane::SelectionAppearance::Picker, header: Box::new(DaemonMenuHeader(header)), items: vec![ SelectionItem { diff --git a/codex-rs/tui/src/app/input.rs b/codex-rs/tui/src/app/input.rs index d46a805f9a..31e358643e 100644 --- a/codex-rs/tui/src/app/input.rs +++ b/codex-rs/tui/src/app/input.rs @@ -385,7 +385,6 @@ impl App { let allow_background = running_side_thread_id.is_none() && !self.chat_widget.has_queued_follow_up_messages(); self.chat_widget.show_selection_view(SelectionViewParams { - appearance: crate::bottom_pane::SelectionAppearance::Picker, title: Some("Task is still running".to_string()), subtitle: Some("Choose what happens to the current task.".to_string()), footer_hint: Some(standard_popup_hint_line()), diff --git a/codex-rs/tui/src/app/session_lifecycle.rs b/codex-rs/tui/src/app/session_lifecycle.rs index 41353b4c14..403f9b79a7 100644 --- a/codex-rs/tui/src/app/session_lifecycle.rs +++ b/codex-rs/tui/src/app/session_lifecycle.rs @@ -213,7 +213,6 @@ impl App { .collect(); SelectionViewParams { - appearance: crate::bottom_pane::SelectionAppearance::Picker, view_id: Some(AGENT_PICKER_VIEW_ID), title: Some("Subagents".to_string()), subtitle: Some(AgentNavigationState::picker_subtitle()), diff --git a/codex-rs/tui/src/app/session_picker.rs b/codex-rs/tui/src/app/session_picker.rs index 499587b290..018a3f5ad1 100644 --- a/codex-rs/tui/src/app/session_picker.rs +++ b/codex-rs/tui/src/app/session_picker.rs @@ -144,7 +144,6 @@ impl App { .is_some() { self.chat_widget.show_selection_view(SelectionViewParams { - appearance: crate::bottom_pane::SelectionAppearance::Picker, title: Some("Unable to resume session".to_string()), subtitle: Some(message.clone()), items: vec![SelectionItem { diff --git a/codex-rs/tui/src/app/tests/disconnect_tests.rs b/codex-rs/tui/src/app/tests/disconnect_tests.rs index 269455babb..b1ed8d1e6b 100644 --- a/codex-rs/tui/src/app/tests/disconnect_tests.rs +++ b/codex-rs/tui/src/app/tests/disconnect_tests.rs @@ -53,7 +53,6 @@ async fn lost_mutation_reply_preserves_work_without_resubmitting() -> Result<()> let overview = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None); app.chat_widget.show_bottom_pane_view(Box::new(overview)); app.chat_widget.show_selection_view(SelectionViewParams { - appearance: crate::bottom_pane::SelectionAppearance::Picker, items: vec![SelectionItem { name: "Stale server action".into(), actions: vec![Box::new(|tx| tx.send(AppEvent::CodexOp(Op::Interrupt)))], diff --git a/codex-rs/tui/src/app/tests/key_chords.rs b/codex-rs/tui/src/app/tests/key_chords.rs index 75d1038f4c..04436675a5 100644 --- a/codex-rs/tui/src/app/tests/key_chords.rs +++ b/codex-rs/tui/src/app/tests/key_chords.rs @@ -496,7 +496,6 @@ async fn physical_dispatch_band_events_are_dropped() -> Result<()> { async fn physical_chords_route_list_and_mixed_request_input_modals() -> Result<()> { let (mut app, mut tui, mut app_server) = chord_app().await?; app.chat_widget.show_selection_view(SelectionViewParams { - appearance: crate::bottom_pane::SelectionAppearance::Picker, view_id: Some("list"), items: ["First", "Second"] .into_iter() diff --git a/codex-rs/tui/src/app/thread_goal_actions.rs b/codex-rs/tui/src/app/thread_goal_actions.rs index cb6c96e3f9..1dd2c9f3e7 100644 --- a/codex-rs/tui/src/app/thread_goal_actions.rs +++ b/codex-rs/tui/src/app/thread_goal_actions.rs @@ -317,7 +317,6 @@ impl App { }, ]; self.chat_widget.show_selection_view(SelectionViewParams { - appearance: crate::bottom_pane::SelectionAppearance::Picker, title: Some("Replace goal?".to_string()), subtitle: Some(format!( "New objective: {}", diff --git a/codex-rs/tui/src/bottom_pane/app_link_view.rs b/codex-rs/tui/src/bottom_pane/app_link_view.rs index 44fae4d28d..a215ff36c0 100644 --- a/codex-rs/tui/src/bottom_pane/app_link_view.rs +++ b/codex-rs/tui/src/bottom_pane/app_link_view.rs @@ -654,7 +654,6 @@ impl AppLinkView { ' ' }; GenericDisplayRow { - category_tag: None, selection_style: Some(crate::bottom_pane::selection_style()), name: format!("{prefix} {}. {label}", index + 1), ..Default::default() diff --git a/codex-rs/tui/src/bottom_pane/approval_overlay.rs b/codex-rs/tui/src/bottom_pane/approval_overlay.rs index 35036d8efe..0c676aae69 100644 --- a/codex-rs/tui/src/bottom_pane/approval_overlay.rs +++ b/codex-rs/tui/src/bottom_pane/approval_overlay.rs @@ -314,7 +314,6 @@ impl ApprovalOverlay { .collect(); let params = SelectionViewParams { - appearance: crate::bottom_pane::SelectionAppearance::Picker, footer_hint: Some(approval_footer_hint(request, approval_keymap, list_keymap)), items, header, diff --git a/codex-rs/tui/src/bottom_pane/approval_overlay/clipping_tests.rs b/codex-rs/tui/src/bottom_pane/approval_overlay/clipping_tests.rs index 47ea8f6713..5413d50db6 100644 --- a/codex-rs/tui/src/bottom_pane/approval_overlay/clipping_tests.rs +++ b/codex-rs/tui/src/bottom_pane/approval_overlay/clipping_tests.rs @@ -40,7 +40,7 @@ fn clipped_exec_approval_opens_the_complete_command() { keymap.approval, keymap.list, ); - for (width, height) in [(80, 14), (40, 10)] { + for (width, height) in [(80, 14), (40, 10), (40, 8), (40, 7)] { let area = Rect::new(/*x*/ 0, /*y*/ 0, width, height); let mut buffer = Buffer::empty(area); view.render(area, &mut buffer); diff --git a/codex-rs/tui/src/bottom_pane/approval_overlay/snapshots/codex_tui__bottom_pane__approval_overlay__clipping_tests__clipped_exec_approval_opens_the_complete_command.snap b/codex-rs/tui/src/bottom_pane/approval_overlay/snapshots/codex_tui__bottom_pane__approval_overlay__clipping_tests__clipped_exec_approval_opens_the_complete_command.snap index 0d91e7714d..4747b441fb 100644 --- a/codex-rs/tui/src/bottom_pane/approval_overlay/snapshots/codex_tui__bottom_pane__approval_overlay__clipping_tests__clipped_exec_approval_opens_the_complete_command.snap +++ b/codex-rs/tui/src/bottom_pane/approval_overlay/snapshots/codex_tui__bottom_pane__approval_overlay__clipping_tests__clipped_exec_approval_opens_the_complete_command.snap @@ -31,6 +31,25 @@ bound=true 40x10 Press enter to confirm or esc to cancel +bound=true 40x8 + + Would you like to run the following + [… 43 lines] ctrl+g view all +› 1. Yes, proceed (y) + 2. No, and tell Codex what to do + differently (esc) + Press enter to confirm or esc to + cancel + +bound=true 40x7 + + [… 43 lines] ctrl+g view all +› 1. Yes, proceed (y) + 2. No, and tell Codex what to do + differently (esc) + Press enter to confirm or esc to + cancel + bound=false 80x14 Would you like to run the following command? @@ -58,3 +77,22 @@ bound=false 40x10 differently (esc) Press enter to confirm or esc to cancel + +bound=false 40x8 + + Would you like to run the following + [… 43 lines] +› 1. Yes, proceed (y) + 2. No, and tell Codex what to do + differently (esc) + Press enter to confirm or esc to + cancel + +bound=false 40x7 + + [… 43 lines] +› 1. Yes, proceed (y) + 2. No, and tell Codex what to do + differently (esc) + Press enter to confirm or esc to + cancel diff --git a/codex-rs/tui/src/bottom_pane/async_questions/mod.rs b/codex-rs/tui/src/bottom_pane/async_questions/mod.rs index c3143ab9f5..063c556a3c 100644 --- a/codex-rs/tui/src/bottom_pane/async_questions/mod.rs +++ b/codex-rs/tui/src/bottom_pane/async_questions/mod.rs @@ -184,7 +184,6 @@ impl AsyncQuestions { let number = index + 1; let prefix = format!("{prefix} {number}. "); GenericDisplayRow { - category_tag: None, // Other stays an inline editor with foreground-only focus. selection_style: (index < self.options().len()) .then(super::picker_style::selection_style), diff --git a/codex-rs/tui/src/bottom_pane/experimental_features_view.rs b/codex-rs/tui/src/bottom_pane/experimental_features_view.rs index a51f04b937..63a5bd85e1 100644 --- a/codex-rs/tui/src/bottom_pane/experimental_features_view.rs +++ b/codex-rs/tui/src/bottom_pane/experimental_features_view.rs @@ -151,7 +151,6 @@ impl ExperimentalFeaturesView { let read_only = if item.writable { "" } else { " (read-only)" }; let name = format!("{prefix} [{marker}] {}{read_only}", item.name); rows.push(GenericDisplayRow { - category_tag: None, selection_style: Some(super::picker_style::selection_style()), wrap_indent: Some(6), name, diff --git a/codex-rs/tui/src/bottom_pane/feedback_view.rs b/codex-rs/tui/src/bottom_pane/feedback_view.rs index a200a5a6e0..3a4965a6a4 100644 --- a/codex-rs/tui/src/bottom_pane/feedback_view.rs +++ b/codex-rs/tui/src/bottom_pane/feedback_view.rs @@ -145,7 +145,6 @@ pub(crate) fn feedback_selection_params( app_event_tx: AppEventSender, ) -> super::SelectionViewParams { super::SelectionViewParams { - appearance: crate::bottom_pane::SelectionAppearance::Picker, title: Some("How was this?".to_string()), items: vec![ make_feedback_item( @@ -186,7 +185,6 @@ pub(crate) fn feedback_selection_params( /// Build the selection popup params shown when feedback is disabled. pub(crate) fn feedback_disabled_params() -> super::SelectionViewParams { super::SelectionViewParams { - appearance: crate::bottom_pane::SelectionAppearance::Picker, title: Some("Sending feedback is disabled".to_string()), subtitle: Some("This action is disabled by configuration.".to_string()), footer_hint: Some(standard_popup_hint_line()), @@ -310,7 +308,6 @@ pub(crate) fn feedback_upload_consent_params( } super::SelectionViewParams { - appearance: crate::bottom_pane::SelectionAppearance::Picker, footer_hint: Some(standard_popup_hint_line()), items: vec![ super::SelectionItem { 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 84e73fe493..575a7ea332 100644 --- a/codex-rs/tui/src/bottom_pane/list_selection_view.rs +++ b/codex-rs/tui/src/bottom_pane/list_selection_view.rs @@ -1,5 +1,6 @@ -//! Render selectable lists while existing callers retain the legacy presentation. -//! Picker appearance opts into stable controls, full-width selection, and separate overflow rows. +//! Render selectable lists with shared picker controls and full-width focus rows. +//! Selection and scrolling use filtered row indices; actions map back to source items. +//! Controls and overflow indicators keep their own rows outside the result viewport. use crossterm::event::KeyCode; use crossterm::event::KeyEvent; @@ -19,7 +20,6 @@ use ratatui::widgets::Wrap; use super::selection_picker_layout::PickerLayoutSizes; use super::selection_picker_layout::picker_areas; -use super::selection_popup_common::render_menu_surface; use super::selection_popup_common::wrap_styled_line; use crate::app_event_sender::AppEventSender; use crate::clipboard_paste::normalize_pasted_search_query; @@ -49,7 +49,6 @@ 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::TabAppearance; use super::selection_tabs::render_tab_bar; use super::selection_tabs::tab_bar_height; use unicode_width::UnicodeWidthStr; @@ -118,15 +117,6 @@ pub(crate) enum SelectionRowDisplay { SingleLine, } -/// Visual treatment is opt-in while existing workflows retain their current layout. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub(crate) enum SelectionAppearance { - #[default] - Legacy, - /// Full-width focus and separate overflow rows on a terminal or panel surface. - Picker, -} - /// One selectable item in the generic selection list. pub(crate) type SelectionAction = Box; @@ -197,12 +187,9 @@ pub(crate) struct SelectionItem { /// `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 /// `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. +/// `description_layout` optionally hides descriptions when their column would become too narrow. pub(crate) struct SelectionViewParams { - pub appearance: SelectionAppearance, pub picker_surface: PickerSurface, /// Upper row budget; compact completion menus retain the default of eight. pub max_visible_rows: usize, @@ -225,6 +212,7 @@ pub(crate) struct SelectionViewParams { /// Rendered left-column width to use for auto-sized rows. pub name_column_width: Option, pub header: Box, + /// Optional shortcut to the complete header, shown when its text is clipped. pub header_view_all_hint: Option, /// Blank rows after the header; defaults to one. Inline banners use zero. pub header_gap: u16, @@ -263,7 +251,6 @@ pub(crate) struct SelectionViewParams { impl Default for SelectionViewParams { fn default() -> Self { Self { - appearance: SelectionAppearance::default(), picker_surface: PickerSurface::default(), max_visible_rows: MAX_POPUP_ROWS, reserve_result_rows: false, @@ -304,7 +291,6 @@ impl Default for SelectionViewParams { /// visible rows and source items and for preserving selection while filters /// change. pub(crate) struct ListSelectionView { - appearance: SelectionAppearance, picker_surface: PickerSurface, max_visible_rows: usize, reserve_result_rows: bool, @@ -423,20 +409,13 @@ impl ListSelectionView { if params.title.is_some() || params.subtitle.is_some() { let title = params.title.map(|title| Line::from(title.bold())); let subtitle = params.subtitle.map(|subtitle| Line::from(subtitle.dim())); - header = match params.appearance { - SelectionAppearance::Legacy => Box::new(ColumnRenderable::with([ - header, - Box::new(title), - Box::new(subtitle), - ])), - SelectionAppearance::Picker => Box::new(ColumnRenderable::with([ - header, - Box::new( - Paragraph::new(title.into_iter().chain(subtitle).collect::>()) - .wrap(Wrap { trim: false }), - ), - ])), - }; + header = Box::new(ColumnRenderable::with([ + header, + Box::new( + Paragraph::new(title.into_iter().chain(subtitle).collect::>()) + .wrap(Wrap { trim: false }), + ), + ])); } let active_tab_idx = params.initial_tab_id.as_ref().and_then(|initial_tab_id| { params @@ -451,7 +430,6 @@ impl ListSelectionView { }; let has_initial_selected_idx = params.initial_selected_idx.is_some(); let mut s = Self { - appearance: params.appearance, picker_surface: params.picker_surface, max_visible_rows: params.max_visible_rows.max(/*other*/ 1), reserve_result_rows: params.reserve_result_rows, @@ -528,17 +506,14 @@ impl ListSelectionView { fn active_header(&self, width: u16) -> &dyn Renderable { self.active_tab_idx .and_then(|idx| self.tabs.get(idx)) - .filter(|tab| { - self.appearance == SelectionAppearance::Legacy - || tab.header.desired_height(width) > 0 - }) + .filter(|tab| tab.header.desired_height(width) > 0) .map(|tab| tab.header.as_ref()) .unwrap_or(self.header.as_ref()) } fn header_height(&self, width: u16) -> u16 { let active_height = self.active_header(width).desired_height(width); - if self.appearance == SelectionAppearance::Picker && self.reserve_result_rows { + if self.reserve_result_rows { self.tabs .iter() .map(|tab| tab.header.desired_height(width)) @@ -575,7 +550,7 @@ impl ListSelectionView { fn visible_rows(&self, len: usize) -> usize { let rendered = self.rendered_item_count.get(); - let limit = if self.appearance == SelectionAppearance::Picker && rendered > 0 { + let limit = if rendered > 0 { self.max_visible_rows.min(rendered) } else { self.max_visible_rows @@ -675,8 +650,7 @@ impl ListSelectionView { .to_string() .len(); let mut enabled_row_number = 0; - let row_selection_style = - (self.appearance == SelectionAppearance::Picker).then(selection_style); + let row_selection_style = Some(selection_style()); self.filtered_indices .iter() .enumerate() @@ -726,13 +700,13 @@ impl ListSelectionView { let wrap_indent = (description.is_none() && item.disabled_reason.is_none()) .then_some(wrap_prefix_width); GenericDisplayRow { + category_tag: None, selection_style: row_selection_style, name: name_with_marker, name_prefix_spans, display_shortcut: item.display_shortcut, match_indices: None, description, - category_tag: None, wrap_indent, is_disabled, disabled_reason: item.disabled_reason.clone(), @@ -758,9 +732,7 @@ impl ListSelectionView { }; self.active_tab_idx = Some(next_idx); self.search_query.clear(); - if self.appearance == SelectionAppearance::Picker { - self.rendered_item_count.set(/*val*/ 0); - } + self.rendered_item_count.set(/*val*/ 0); self.state.reset(); self.apply_filter(); if self.state.selected_idx.is_none() { @@ -948,20 +920,10 @@ impl ListSelectionView { self.last_selected_actual_idx.take() } - fn tab_appearance(&self) -> TabAppearance { - match self.appearance { - SelectionAppearance::Legacy => TabAppearance::Legacy, - SelectionAppearance::Picker => TabAppearance::Filled, - } - } - fn footer_hint_lines(&self, width: u16) -> Vec> { let Some(hint) = self.active_footer_hint() else { return Vec::new(); }; - if self.appearance == SelectionAppearance::Legacy { - return vec![hint.clone()]; - } let mut lines = wrap_styled_line(hint, width); if self.reserve_result_rows { let height = self @@ -981,7 +943,7 @@ impl ListSelectionView { } fn result_area_height(&self, measured_height: u16) -> u16 { - if self.appearance == SelectionAppearance::Picker && self.reserve_result_rows { + if self.reserve_result_rows { let reserved_height = self.max_visible_rows.min(usize::from(u16::MAX)) as u16; measured_height.max(reserved_height) } else { @@ -989,13 +951,6 @@ impl ListSelectionView { } } - fn rows_width(&self, total_width: u16) -> u16 { - match self.appearance { - SelectionAppearance::Legacy => total_width.saturating_sub(/*rhs*/ 2), - SelectionAppearance::Picker => total_width, - } - } - fn clear_to_terminal_bg(buf: &mut Buffer, area: Rect) { let buf_area = buf.area(); let min_x = area.x.max(buf_area.x); @@ -1315,7 +1270,7 @@ impl Renderable for ListSelectionView { // Leave the preview's right inset outside the full-width focus row. width.saturating_sub(MENU_SURFACE_HORIZONTAL_INSET / 2 + SIDE_CONTENT_GAP + side_w) } else { - self.rows_width(width) + width }; // Measure at the same width as the painted rows, including any side panel. @@ -1336,27 +1291,13 @@ impl Renderable for ListSelectionView { }; let rows_height = self.result_area_height(rows_height); - let tab_height = tab_bar_height( - &self.tabs, - self.active_tab_idx.unwrap_or(/*default*/ 0), - inner_width, - self.tab_appearance(), - ); + let tab_height = tab_bar_height(&self.tabs, inner_width); let mut height = self.header_height(inner_width); - let header_gap = if self.appearance == SelectionAppearance::Picker && height == 0 { - 0 - } else { - self.header_gap - }; - let tab_gap = u16::from( - tab_height > 0 - && (self.appearance == SelectionAppearance::Legacy || self.is_searchable), - ); + let header_gap = if height == 0 { 0 } else { self.header_gap }; + let tab_gap = u16::from(tab_height > 0 && self.is_searchable); height = height.saturating_add(tab_height + tab_gap); height = height.saturating_add(rows_height + 2 + header_gap); - if self.appearance == SelectionAppearance::Picker - && self.picker_surface == PickerSurface::Panel - { + if self.picker_surface == PickerSurface::Panel { height = height.saturating_add(/*rhs*/ 1); } if self.is_searchable { @@ -1403,62 +1344,50 @@ impl Renderable for ListSelectionView { let hint_height = hint_lines.len() as u16; let inner_width = popup_content_width(area.width); let header_height = self.header_height(inner_width); - let tab_height = tab_bar_height( - &self.tabs, - self.active_tab_idx.unwrap_or(/*default*/ 0), - inner_width, - self.tab_appearance(), - ); + let tab_height = tab_bar_height(&self.tabs, inner_width); let mut footer_rows = note_height + hint_height; - if self.appearance == SelectionAppearance::Picker { - // Wrapped hints yield before the title, controls, and selected result. - let minimum_content = u16::from(self.picker_surface == PickerSurface::Panel) - + header_height.min(/*other*/ 1) - + tab_height - + u16::from(self.is_searchable) - + 1; - footer_rows = footer_rows.min(area.height.saturating_sub(minimum_content)); - } + // Wrapped hints yield before the title, controls, and selected result. + let minimum_content = u16::from(self.picker_surface == PickerSurface::Panel) + + header_height.min(/*other*/ 1) + + tab_height + + u16::from(self.is_searchable) + + 1; + footer_rows = footer_rows.min(area.height.saturating_sub(minimum_content)); let [content_area, footer_area] = Layout::vertical([Constraint::Fill(1), Constraint::Length(footer_rows)]).areas(area); let outer_content_area = content_area; - // Paint the shared menu surface and then layout inside the returned inset. - let content_area = match self.appearance { - SelectionAppearance::Legacy => render_menu_surface(outer_content_area, buf), - SelectionAppearance::Picker => { - Self::clear_to_terminal_bg(buf, outer_content_area); - if self.picker_surface == PickerSurface::Panel { - ratatui::widgets::Block::default() - .style(crate::style::user_message_style()) - .render(outer_content_area, buf); - } - // The list owns its vertical spacer rows so hints remain adjacent - // to results even when search, tabs, or a header are present. - let area = outer_content_area.inset(Insets::vh(/*v*/ 0, /*h*/ 2)); - if self.picker_surface == PickerSurface::Panel { - Rect::new( - area.x, - area.y.saturating_add(/*rhs*/ 1), - area.width, - area.height.saturating_sub(/*rhs*/ 1), - ) - } else { - area - } - } + Self::clear_to_terminal_bg(buf, outer_content_area); + if self.picker_surface == PickerSurface::Panel { + ratatui::widgets::Block::default() + .style(crate::style::user_message_style()) + .render(outer_content_area, buf); + } + // The list owns its vertical spacer rows so hints remain adjacent + // to results even when search, tabs, or a header are present. + let content_area = outer_content_area.inset(Insets::vh(/*v*/ 0, /*h*/ 2)); + let content_area = if self.picker_surface == PickerSurface::Panel { + // At tiny heights, leave room for both the clipped header and selected row. + let top_gap = u16::from(content_area.height > 2); + Rect::new( + content_area.x, + content_area.y.saturating_add(top_gap), + content_area.width, + content_area.height.saturating_sub(top_gap), + ) + } else { + content_area }; let side_w = self.side_layout_width(inner_width); // When side-by-side is active, shrink the list to make room. - let full_rows_width = self.rows_width(outer_content_area.width); let effective_rows_width = if let Some(sw) = side_w { outer_content_area .width .saturating_sub(MENU_SURFACE_HORIZONTAL_INSET / 2 + SIDE_CONTENT_GAP + sw) } else { - full_rows_width + outer_content_area.width }; let header = self.active_header(inner_width); @@ -1486,7 +1415,6 @@ impl Renderable for ListSelectionView { } else { 0 }; - let stacked_gap = if stacked_side_h > 0 { 1 } else { 0 }; let [ header_area, @@ -1499,52 +1427,17 @@ impl Renderable for ListSelectionView { below_area, _, stacked_side_area, - ] = if self.appearance == SelectionAppearance::Picker { - picker_areas( - content_area, - PickerLayoutSizes { - header: header_height, - header_gap: self.header_gap, - tabs: tab_height, - search: u16::from(self.is_searchable), - rows: rows_height, - side: stacked_side_h, - }, - ) - } else { - let [ - header, - header_gap, - tabs, - tab_gap, - search, - rows, - side_gap, - side, - ] = Layout::vertical([ - Constraint::Max(header_height), - Constraint::Max(self.header_gap), - Constraint::Length(tab_height), - Constraint::Length(u16::from(tab_height > 0)), - Constraint::Length(if self.is_searchable { 1 } else { 0 }), - Constraint::Length(rows_height), - Constraint::Length(stacked_gap), - Constraint::Length(stacked_side_h), - ]) - .areas(content_area); - [ - header, - header_gap, - tabs, - tab_gap, - search, - Rect::default(), - rows, - Rect::default(), - side_gap, - side, - ] - }; + ] = picker_areas( + content_area, + PickerLayoutSizes { + header: header_height, + header_gap: self.header_gap, + tabs: tab_height, + search: u16::from(self.is_searchable), + rows: rows_height, + side: stacked_side_h, + }, + ); if !self.is_searchable && header_area.height < header_height { let [header_area, elision_area] = @@ -1568,7 +1461,6 @@ impl Renderable for ListSelectionView { self.active_tab_idx.unwrap_or(/*default*/ 0), tabs_area, buf, - self.tab_appearance(), ); } @@ -1589,11 +1481,7 @@ impl Renderable for ListSelectionView { // -- List rows -- if list_area.height > 0 { let render_area = Rect { - x: if rows.is_empty() && self.appearance == SelectionAppearance::Legacy { - list_area.x - } else { - list_area.x.saturating_sub(2) - }, + x: list_area.x.saturating_sub(/*rhs*/ 2), y: list_area.y, width: effective_rows_width.max(1), height: list_area.height, @@ -1619,10 +1507,7 @@ impl Renderable for ListSelectionView { ), }; self.rendered_item_count.set(rendered_rows.items); - if self.appearance == SelectionAppearance::Picker - && above_area.height > 0 - && below_area.height > 0 - { + if above_area.height > 0 && below_area.height > 0 { render_scroll_indicators( Rect::new( render_area.x, @@ -1720,14 +1605,7 @@ impl Renderable for ListSelectionView { width: hint_area.width.saturating_sub(2), height: hint_area.height, }; - match self.appearance { - SelectionAppearance::Legacy => { - hint_lines[0].clone().dim().render(hint_area, buf) - } - SelectionAppearance::Picker => { - Paragraph::new(hint_lines).dim().render(hint_area, buf); - } - } + Paragraph::new(hint_lines).dim().render(hint_area, buf); } } } @@ -2108,7 +1986,7 @@ mod tests { } #[test] - fn empty_searchable_list_aligns_message_with_search() { + fn empty_searchable_list_keeps_search_and_no_matches_visible() { let (tx_raw, _rx) = unbounded_channel::(); let tx = AppEventSender::new(tx_raw); let view = new_view( diff --git a/codex-rs/tui/src/bottom_pane/mcp_server_elicitation.rs b/codex-rs/tui/src/bottom_pane/mcp_server_elicitation.rs index 07d3a74cec..568097e214 100644 --- a/codex-rs/tui/src/bottom_pane/mcp_server_elicitation.rs +++ b/codex-rs/tui/src/bottom_pane/mcp_server_elicitation.rs @@ -921,7 +921,6 @@ impl McpServerElicitationOverlay { let prefix_label = format!("{prefix} {number}. "); let wrap_indent = UnicodeWidthStr::width(prefix_label.as_str()); GenericDisplayRow { - category_tag: None, selection_style: Some(crate::bottom_pane::selection_style()), name: format!("{prefix_label}{}", option.label), description: option.description.clone(), diff --git a/codex-rs/tui/src/bottom_pane/memories_settings_view.rs b/codex-rs/tui/src/bottom_pane/memories_settings_view.rs index 561e581147..00b5882a59 100644 --- a/codex-rs/tui/src/bottom_pane/memories_settings_view.rs +++ b/codex-rs/tui/src/bottom_pane/memories_settings_view.rs @@ -167,7 +167,6 @@ impl MemoriesSettingsView { .into_iter() .enumerate() .map(|(idx, name)| GenericDisplayRow { - category_tag: None, selection_style: Some(super::picker_style::selection_style()), wrap_indent: Some(2), name: if state.selected_idx == Some(idx) { @@ -210,7 +209,6 @@ impl MemoriesSettingsView { } => (format!("{prefix} {name}"), description), }; GenericDisplayRow { - category_tag: None, selection_style: Some(super::picker_style::selection_style()), wrap_indent: Some(if matches!(item, MemoriesMenuItem::Setting { .. }) { 6 diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 19ce128b68..e8a8402008 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -144,7 +144,6 @@ 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::PickerSurface; -pub(crate) use list_selection_view::SelectionAppearance; pub(crate) use list_selection_view::SelectionDescriptionLayout; pub(crate) use list_selection_view::SelectionRowDisplay; pub(crate) use list_selection_view::SelectionToggle; @@ -153,6 +152,7 @@ pub(crate) use list_selection_view::SideContentWidth; pub(crate) use list_selection_view::popup_content_width; pub(crate) use list_selection_view::side_by_side_layout_widths; pub(crate) use memories_settings_view::MemoriesSettingsView; +pub(crate) use picker_style::selection_style; use slash_commands::ServiceTierCommand; mod feedback_note_view; mod feedback_view; @@ -178,11 +178,11 @@ mod pending_input_preview; mod pending_thread_approvals; mod picker_option; mod picker_presets; +mod picker_rows; pub(crate) use picker_option::picker_option_list; pub(crate) use picker_option::picker_option_row; -mod picker_rows; mod picker_style; -pub(crate) use picker_style::selection_style; +pub(crate) use picker_style::active_tab_style; pub(crate) mod popup_consts; mod scroll_state; mod selection_picker_layout; @@ -1343,12 +1343,7 @@ impl BottomPane { if params.footer_hint.is_none() || params.footer_hint.as_ref() == Some(&popup_consts::standard_popup_hint_line()) { - params.footer_hint = Some(match params.appearance { - SelectionAppearance::Picker => { - popup_consts::picker_hint_line_for_keymap(&self.keymap.list) - } - SelectionAppearance::Legacy => self.standard_popup_hint_line(), - }); + params.footer_hint = Some(popup_consts::picker_hint_line_for_keymap(&self.keymap.list)); } } @@ -1408,10 +1403,6 @@ impl BottomPane { true } - pub(crate) fn standard_popup_hint_line(&self) -> Line<'static> { - popup_consts::standard_popup_hint_line_for_keymap(&self.keymap.list) - } - pub(crate) fn replace_view_if_present( &mut self, view_id: &'static str, @@ -3371,7 +3362,6 @@ mod tests { pane.set_task_running(/*running*/ true); pane.show_selection_view(SelectionViewParams { - appearance: crate::bottom_pane::SelectionAppearance::Picker, title: Some("Agents".to_string()), items: vec![SelectionItem { name: "Main".to_string(), @@ -3475,7 +3465,6 @@ mod tests { keymap.list.cancel = vec![crate::key_hint::plain(KeyCode::Char('q'))]; pane.set_keymap_bindings(&keymap); pane.show_selection_view(SelectionViewParams { - appearance: crate::bottom_pane::SelectionAppearance::Picker, title: Some("Agents".to_string()), items: vec![SelectionItem { name: "Main".to_string(), diff --git a/codex-rs/tui/src/bottom_pane/multi_select_picker.rs b/codex-rs/tui/src/bottom_pane/multi_select_picker.rs index fba6779e79..d6fe7d58fc 100644 --- a/codex-rs/tui/src/bottom_pane/multi_select_picker.rs +++ b/codex-rs/tui/src/bottom_pane/multi_select_picker.rs @@ -319,7 +319,6 @@ impl MultiSelectPicker { let item_name = truncate_text(&item.name, ITEM_NAME_TRUNCATE_LEN); let name = format!("{prefix} [{marker}] {item_name}"); rows.push(GenericDisplayRow { - category_tag: None, name, description: item.description.clone(), selection_style: Some(picker_style::selection_style()), @@ -328,7 +327,6 @@ impl MultiSelectPicker { if item.section_break_after && visible_idx + 1 < self.filtered_indices.len() { rows.push(GenericDisplayRow { - category_tag: None, name: SECTION_BREAK_ROW.to_string(), is_disabled: true, ..Default::default() diff --git a/codex-rs/tui/src/bottom_pane/picker_option.rs b/codex-rs/tui/src/bottom_pane/picker_option.rs index b136e8eabc..620fa1eb01 100644 --- a/codex-rs/tui/src/bottom_pane/picker_option.rs +++ b/codex-rs/tui/src/bottom_pane/picker_option.rs @@ -57,7 +57,6 @@ fn numbered_row(index: usize, label: Line<'static>, is_selected: bool) -> Generi span })); GenericDisplayRow { - category_tag: None, name_prefix_spans: spans, selection_style: Some(selection_style()), wrap_indent: Some(prefix_width), diff --git a/codex-rs/tui/src/bottom_pane/picker_presets.rs b/codex-rs/tui/src/bottom_pane/picker_presets.rs index 8e10cc9ccb..26db4f5588 100644 --- a/codex-rs/tui/src/bottom_pane/picker_presets.rs +++ b/codex-rs/tui/src/bottom_pane/picker_presets.rs @@ -12,7 +12,6 @@ impl SelectionViewParams { /// Titles and subtitles wrap; standard hints follow the active list keymap. pub(crate) fn picker() -> Self { Self { - appearance: super::SelectionAppearance::Picker, picker_surface: PickerSurface::Panel, col_width_mode: ColumnWidthMode::AutoAllRows, description_layout: SelectionDescriptionLayout::HideWhenNarrow { diff --git a/codex-rs/tui/src/bottom_pane/popup_consts.rs b/codex-rs/tui/src/bottom_pane/popup_consts.rs index 876ce1f441..12baeb5a86 100644 --- a/codex-rs/tui/src/bottom_pane/popup_consts.rs +++ b/codex-rs/tui/src/bottom_pane/popup_consts.rs @@ -24,15 +24,6 @@ pub(crate) fn standard_popup_hint_line() -> Line<'static> { ]) } -pub(crate) fn standard_popup_hint_line_for_keymap(list_keymap: &ListKeymap) -> Line<'static> { - accept_cancel_hint_line( - list_keymap.primary_hint(ListAction::Accept), - "to confirm", - list_keymap.primary_hint(ListAction::Cancel), - "to go back", - ) -} - /// Compact footer for shared pickers, using only the configured list actions. pub(crate) fn picker_hint_line_for_keymap(list_keymap: &ListKeymap) -> Line<'static> { let mut spans = Vec::new(); diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs b/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs index b835dd69e2..39168deb89 100644 --- a/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs +++ b/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs @@ -424,7 +424,6 @@ impl RequestUserInputOverlay { let prefix_label = format!("{prefix} {number}. "); let wrap_indent = UnicodeWidthStr::width(prefix_label.as_str()); GenericDisplayRow { - category_tag: None, selection_style: Some(crate::bottom_pane::selection_style()), name: format!("{prefix_label}{label}"), description: Some(opt.description.clone()), @@ -442,7 +441,6 @@ impl RequestUserInputOverlay { let prefix_label = format!("{prefix} {number}. "); let wrap_indent = UnicodeWidthStr::width(prefix_label.as_str()); rows.push(GenericDisplayRow { - category_tag: None, selection_style: Some(crate::bottom_pane::selection_style()), name: format!("{prefix_label}{OTHER_OPTION_LABEL}"), description: Some(OTHER_OPTION_DESCRIPTION.to_string()), @@ -983,7 +981,6 @@ impl RequestUserInputOverlay { let prefix = if idx == selected { '›' } else { ' ' }; let number = idx + 1; GenericDisplayRow { - category_tag: None, selection_style: Some(crate::bottom_pane::selection_style()), name: format!("{prefix} {number}. {label}"), description: Some(description.clone()), diff --git a/codex-rs/tui/src/bottom_pane/selection_picker_layout.rs b/codex-rs/tui/src/bottom_pane/selection_picker_layout.rs index 40a1a0baa8..c80fc33c5e 100644 --- a/codex-rs/tui/src/bottom_pane/selection_picker_layout.rs +++ b/codex-rs/tui/src/bottom_pane/selection_picker_layout.rs @@ -19,9 +19,14 @@ pub(super) fn picker_areas(area: Rect, sizes: PickerLayoutSizes) -> [Rect; 10] { let tabs = sizes.tabs.min(area.height); let search = sizes.search.min(area.height.saturating_sub(tabs)); let minimum_rows = sizes.rows.min(if tabs > 0 { 1 } else { 3 }); + // Keep the elision notice when it fits alongside at least one result row. + let minimum_header = u16::from( + sizes.header > 0 && area.height.saturating_sub(tabs + search) > u16::from(sizes.rows > 0), + ); let header = sizes .header - .min(area.height.saturating_sub(tabs + search + minimum_rows)); + .min(area.height.saturating_sub(tabs + search + minimum_rows)) + .max(minimum_header); let mut remaining = area.height.saturating_sub(header + tabs + search); let indicators = u16::from(remaining >= minimum_rows + 2); remaining = remaining.saturating_sub(indicators * 2); diff --git a/codex-rs/tui/src/bottom_pane/selection_picker_tests.rs b/codex-rs/tui/src/bottom_pane/selection_picker_tests.rs index 99caf8e8b5..afb130b72b 100644 --- a/codex-rs/tui/src/bottom_pane/selection_picker_tests.rs +++ b/codex-rs/tui/src/bottom_pane/selection_picker_tests.rs @@ -10,7 +10,6 @@ fn browser() -> ListSelectionView { let (tx, _rx) = unbounded_channel::(); ListSelectionView::new( SelectionViewParams { - appearance: crate::bottom_pane::SelectionAppearance::Picker, picker_surface: PickerSurface::Panel, max_visible_rows: 24, title: Some("Keymap".to_owned()), @@ -65,7 +64,6 @@ fn shared_menu_presentation_at_wide_and_narrow_sizes() { let (tx, _rx) = unbounded_channel(); let view = ListSelectionView::new( SelectionViewParams { - appearance: crate::bottom_pane::SelectionAppearance::Picker, title: Some("Choose an option".into()), subtitle: Some("A menu with descriptions and disabled choices.".into()), items: vec![ diff --git a/codex-rs/tui/src/bottom_pane/selection_tabs.rs b/codex-rs/tui/src/bottom_pane/selection_tabs.rs index b7dc8b6f37..a13b8a674f 100644 --- a/codex-rs/tui/src/bottom_pane/selection_tabs.rs +++ b/codex-rs/tui/src/bottom_pane/selection_tabs.rs @@ -1,30 +1,19 @@ -//! Tab headers for selection lists, including a single-row filled picker variant. +//! Single-row filled tab headers for selection lists. //! //! Filled tabs always retain the active tab and reserve arrows for hidden neighbors. use ratatui::buffer::Buffer; use ratatui::layout::Rect; -use ratatui::style::Styled; use ratatui::style::Stylize; use ratatui::text::Line; -use ratatui::text::Span; use ratatui::widgets::Widget; use crate::line_truncation::truncate_line_with_ellipsis_if_overflow; use crate::render::renderable::Renderable; -use crate::style::accent_style; use super::SelectionItem; use super::picker_style::active_tab_style; -const TAB_GAP_WIDTH: usize = 2; - -#[derive(Clone, Copy)] -pub(super) enum TabAppearance { - Legacy, - Filled, -} - pub(crate) struct SelectionTab { pub(crate) id: String, pub(crate) label: String, @@ -32,22 +21,8 @@ pub(crate) struct SelectionTab { pub(crate) items: Vec, } -pub(super) fn tab_bar_height( - tabs: &[SelectionTab], - active_idx: usize, - width: u16, - appearance: TabAppearance, -) -> u16 { - if tabs.is_empty() { - return 0; - } - if matches!(appearance, TabAppearance::Filled) { - return u16::from(width > 0); - } - tab_bar_lines(tabs, active_idx, width) - .len() - .try_into() - .unwrap_or(u16::MAX) +pub(super) fn tab_bar_height(tabs: &[SelectionTab], width: u16) -> u16 { + u16::from(!tabs.is_empty() && width > 0) } pub(super) fn render_tab_bar( @@ -55,31 +30,12 @@ pub(super) fn render_tab_bar( active_idx: usize, area: Rect, buf: &mut Buffer, - appearance: TabAppearance, ) { - if matches!(appearance, TabAppearance::Filled) { - let labels = tabs - .iter() - .map(|tab| tab.label.as_str()) - .collect::>(); - render_filled_tab_bar(&labels, active_idx, area, buf); - return; - } - for (offset, line) in tab_bar_lines(tabs, active_idx, area.width) - .into_iter() - .take(area.height as usize) - .enumerate() - { - line.render( - Rect { - x: area.x, - y: area.y.saturating_add(offset as u16), - width: area.width, - height: 1, - }, - buf, - ); - } + let labels = tabs + .iter() + .map(|tab| tab.label.as_str()) + .collect::>(); + render_filled_tab_bar(&labels, active_idx, area, buf); } /// Render a fixed-height tab strip and return the visible tab hit targets. @@ -151,58 +107,6 @@ pub(crate) fn render_filled_tab_bar( regions } -fn tab_bar_lines(tabs: &[SelectionTab], active_idx: usize, width: u16) -> Vec> { - if tabs.is_empty() { - return Vec::new(); - } - - let max_width = width.max(1) as usize; - let mut lines = Vec::new(); - let mut current_spans: Vec> = Vec::new(); - let mut current_width = 0usize; - - for (idx, tab) in tabs.iter().enumerate() { - let unit = tab_unit(tab.label.as_str(), idx == active_idx); - let unit_width = Line::from(unit.clone()).width(); - let gap_width = if current_spans.is_empty() { - 0 - } else { - TAB_GAP_WIDTH - }; - - if !current_spans.is_empty() && current_width + gap_width + unit_width > max_width { - lines.push(Line::from(current_spans)); - current_spans = Vec::new(); - current_width = 0; - } - - if !current_spans.is_empty() { - current_spans.push(" ".into()); - current_width += TAB_GAP_WIDTH; - } - current_width += unit_width; - current_spans.extend(unit); - } - - if !current_spans.is_empty() { - lines.push(Line::from(current_spans)); - } - lines -} - -fn tab_unit(label: &str, active: bool) -> Vec> { - if active { - let style = accent_style(); - vec![ - "[".set_style(style), - label.to_string().set_style(style), - "]".set_style(style), - ] - } else { - vec![label.to_string().dim()] - } -} - #[cfg(test)] #[path = "selection_tabs_tests.rs"] mod tests; diff --git a/codex-rs/tui/src/bottom_pane/skills_toggle_view.rs b/codex-rs/tui/src/bottom_pane/skills_toggle_view.rs index d1532d44a9..a6d6572599 100644 --- a/codex-rs/tui/src/bottom_pane/skills_toggle_view.rs +++ b/codex-rs/tui/src/bottom_pane/skills_toggle_view.rs @@ -152,7 +152,6 @@ impl SkillsToggleView { let item_name = &item.name; let name = format!("{prefix} [{marker}] {item_name}"); GenericDisplayRow { - category_tag: None, selection_style: Some(super::picker_style::selection_style()), name, description: Some(item.description.clone()), 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 index 1ef9490f8a..8d3aff6f37 100644 --- 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 @@ -1,11 +1,12 @@ --- source: tui/src/bottom_pane/list_selection_view.rs +assertion_line: 2903 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 @@ -14,12 +15,12 @@ before scroll: 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 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 index 30bdc4bce1..ccd8d9ee46 100644 --- 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 @@ -1,11 +1,12 @@ --- source: tui/src/bottom_pane/list_selection_view.rs +assertion_line: 2895 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 @@ -14,12 +15,12 @@ before scroll: 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 diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_empty_searchable.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_empty_searchable.snap index a69e588ec5..0ffd647d57 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_empty_searchable.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_empty_searchable.snap @@ -1,11 +1,12 @@ --- source: tui/src/bottom_pane/list_selection_view.rs +assertion_line: 2046 expression: rendered --- - Select a base branch Type to search branches - no matches + +no matches Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_footer_note_wraps.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_footer_note_wraps.snap index 8eb41f417f..dbb6abcc70 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_footer_note_wraps.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_footer_note_wraps.snap @@ -1,13 +1,15 @@ --- source: tui/src/bottom_pane/list_selection_view.rs +assertion_line: 1989 expression: "render_lines_with_width(&view, 40)" --- - Select Approval Mode -› 1. Read Only (current) Codex can - read files + +› 1. Read Only (current) Codex can read + files Note: Use /setup-default-sandbox to allow network access. - Press enter to confirm or esc to go ba + Press enter to confirm or esc to go + back diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_halfwidth_sound_marks_narrow.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_halfwidth_sound_marks_narrow.snap index 81badbe243..7a5d266169 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_halfwidth_sound_marks_narrow.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_halfwidth_sound_marks_narrow.snap @@ -1,30 +1,29 @@ --- source: tui/src/bottom_pane/list_selection_view.rs +assertion_line: 2890 expression: rendered --- width 20: - Halfwidth sound + marks -› 1. abガc - dakute - n - descri - ption + +› 1. abガc dakuten + descript + ion 2. aパc - handak - uten - descri - ption + handakut + en + descript + ion width 24: + Halfwidth sound + marks - Halfwidth sound mark › 1. abガc dakuten - descriptio - n + description 2. aパc handakuten - descriptio - n + description diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_model_picker_width_80.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_model_picker_width_80.snap index be81978c89..0c0351d938 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_model_picker_width_80.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_model_picker_width_80.snap @@ -1,13 +1,14 @@ --- source: tui/src/bottom_pane/list_selection_view.rs +assertion_line: 2825 expression: "render_lines_with_width(&view, 80)" --- - Select Model and Effort -› 1. gpt-5.1-codex (current) Optimized for Codex. Balance of reasoning - quality and coding ability. + +› 1. gpt-5.1-codex (current) Optimized for Codex. Balance of reasoning quality + and coding ability. 2. gpt-5.1-codex-mini Optimized for Codex. Cheaper, faster, but less capable. - 3. gpt-4.1-codex Legacy model. Use when you need compatibility - with older automations. + 3. gpt-4.1-codex Legacy model. Use when you need compatibility with + older automations. diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_narrow_width_preserves_rows.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_narrow_width_preserves_rows.snap index 3ce6a3c45f..4a7f9b99df 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_narrow_width_preserves_rows.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_narrow_width_preserves_rows.snap @@ -1,16 +1,11 @@ --- source: tui/src/bottom_pane/list_selection_view.rs +assertion_line: 2852 expression: "render_lines_with_width(&view, 24)" --- - Debug -› 1. Item 1 - xxxxxxxxx - x - 2. Item 2 - xxxxxxxxx - x - 3. Item 3 - xxxxxxxxx - x + +› 1. Item 1 xxxxxxxxxx + 2. Item 2 xxxxxxxxxx + 3. Item 3 xxxxxxxxxx diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_spacing_with_subtitle.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_spacing_with_subtitle.snap index 512f6bbca6..052039e505 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_spacing_with_subtitle.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_spacing_with_subtitle.snap @@ -1,11 +1,12 @@ --- source: tui/src/bottom_pane/list_selection_view.rs +assertion_line: 1887 expression: render_lines(&view) --- - Select Approval Mode Switch between Codex approval presets + › 1. Read Only (current) Codex can read files 2. Full Access Codex can edit files diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_spacing_without_subtitle.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_spacing_without_subtitle.snap index ddd0f90cd8..3e7bbd0d5b 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_spacing_without_subtitle.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_spacing_without_subtitle.snap @@ -1,10 +1,11 @@ --- source: tui/src/bottom_pane/list_selection_view.rs +assertion_line: 1878 expression: render_lines(&view) --- - Select Approval Mode + › 1. Read Only (current) Codex can read files 2. Full Access Codex can edit files diff --git a/codex-rs/tui/src/bottom_pane/tests/actionable_banner_tests.rs b/codex-rs/tui/src/bottom_pane/tests/actionable_banner_tests.rs index 470c5f2303..855220962c 100644 --- a/codex-rs/tui/src/bottom_pane/tests/actionable_banner_tests.rs +++ b/codex-rs/tui/src/bottom_pane/tests/actionable_banner_tests.rs @@ -135,15 +135,21 @@ fn partially_clipped_banner_preserves_hidden_action_shortcuts() { ); pane.set_inline_banner(Some(ActionableBanner { title: "Usage limit reached".into(), - actions: vec![SelectionItem { - name: "View usage".into(), - actions: vec![Box::new(|tx| { - tx.send(AppEvent::OpenUrlInBrowser { - url: "https://example.com/usage".into(), - }); - })], - ..Default::default() - }], + actions: vec![ + SelectionItem { + name: "Keep working".into(), + ..Default::default() + }, + SelectionItem { + name: "View usage".into(), + actions: vec![Box::new(|tx| { + tx.send(AppEvent::OpenUrlInBrowser { + url: "https://example.com/usage".into(), + }); + })], + ..Default::default() + }, + ], ..Default::default() })); let width = 70; @@ -155,9 +161,10 @@ fn partially_clipped_banner_preserves_hidden_action_shortcuts() { ); let rendered = render_snapshot(&pane, footer_only_area); + assert!(rendered.contains("Keep working")); assert!(!rendered.contains("View usage")); - pane.handle_key_event(KeyEvent::new(KeyCode::Char('1'), KeyModifiers::NONE)); - assert_eq!(pane.composer_text(), "1"); + pane.handle_key_event(KeyEvent::new(KeyCode::Char('2'), KeyModifiers::NONE)); + assert_eq!(pane.composer_text(), "2"); assert!(rx.try_recv().is_err()); } diff --git a/codex-rs/tui/src/bottom_pane/tests/picker_hint_tests.rs b/codex-rs/tui/src/bottom_pane/tests/picker_hint_tests.rs index 5e709191cd..f34e6586ab 100644 --- a/codex-rs/tui/src/bottom_pane/tests/picker_hint_tests.rs +++ b/codex-rs/tui/src/bottom_pane/tests/picker_hint_tests.rs @@ -16,7 +16,6 @@ fn popup_hints_follow_configured_bindings() { keymap.list.cancel = vec![crate::key_hint::plain(KeyCode::F(/*n*/ 2))]; pane.set_keymap_bindings(&keymap); pane.show_selection_view(SelectionViewParams { - appearance: crate::bottom_pane::SelectionAppearance::Picker, title: Some("Choose an action".into()), items: vec![SelectionItem { name: "Continue".into(), @@ -59,7 +58,6 @@ fn custom_and_noncancelable_popup_hints_keep_their_policy() { ), ] { let mut params = SelectionViewParams { - appearance: crate::bottom_pane::SelectionAppearance::Picker, allow_cancel, footer_hint: supplied, ..Default::default() diff --git a/codex-rs/tui/src/bottom_pane/user_verification.rs b/codex-rs/tui/src/bottom_pane/user_verification.rs index 8369d6663b..436613a939 100644 --- a/codex-rs/tui/src/bottom_pane/user_verification.rs +++ b/codex-rs/tui/src/bottom_pane/user_verification.rs @@ -90,7 +90,6 @@ impl UserVerificationView { .collect(); let header = prompt_header(&request); let params = SelectionViewParams { - appearance: crate::bottom_pane::SelectionAppearance::Picker, footer_note: Some( accept_cancel_hint_line( list_keymap.primary_hint(ListAction::Accept), diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index c04a606c88..825da453e8 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -1088,7 +1088,6 @@ impl ChatWidget { _ => return, }; self.bottom_pane.show_selection_view(SelectionViewParams { - appearance: crate::bottom_pane::SelectionAppearance::Picker, title: Some(format!("Enable {name}?")), subtitle: Some(format!("{label} are disabled in this TUI session.")), footer_note: (feature == Feature::MemoryTool).then(|| { diff --git a/codex-rs/tui/src/chatwidget/connectors.rs b/codex-rs/tui/src/chatwidget/connectors.rs index edc7af43fb..b21b4511ec 100644 --- a/codex-rs/tui/src/chatwidget/connectors.rs +++ b/codex-rs/tui/src/chatwidget/connectors.rs @@ -214,7 +214,6 @@ impl ChatWidget { fn connectors_loading_popup_params(&self) -> SelectionViewParams { SelectionViewParams { - appearance: crate::bottom_pane::SelectionAppearance::Picker, view_id: Some(CONNECTORS_SELECTION_VIEW_ID), title: Some("Apps".to_string()), subtitle: Some("Loading installed and available apps...".to_string()), @@ -230,7 +229,6 @@ impl ChatWidget { fn connectors_error_popup_params(&self) -> SelectionViewParams { SelectionViewParams { - appearance: crate::bottom_pane::SelectionAppearance::Picker, view_id: Some(CONNECTORS_SELECTION_VIEW_ID), title: Some("Apps".to_string()), subtitle: Some("Failed to load apps.".to_string()), @@ -344,7 +342,6 @@ impl ChatWidget { } SelectionViewParams { - appearance: crate::bottom_pane::SelectionAppearance::Picker, view_id: Some(CONNECTORS_SELECTION_VIEW_ID), header: Box::new(header), items, diff --git a/codex-rs/tui/src/chatwidget/copy_picker.rs b/codex-rs/tui/src/chatwidget/copy_picker.rs index 762906a99e..538688e43c 100644 --- a/codex-rs/tui/src/chatwidget/copy_picker.rs +++ b/codex-rs/tui/src/chatwidget/copy_picker.rs @@ -97,7 +97,6 @@ impl ChatWidget { .collect(); self.show_selection_view(SelectionViewParams { - appearance: crate::bottom_pane::SelectionAppearance::Picker, title: Some("Copy to clipboard".into()), items, ..SelectionViewParams::picker() diff --git a/codex-rs/tui/src/chatwidget/goal_menu.rs b/codex-rs/tui/src/chatwidget/goal_menu.rs index b84cb06473..151ac79a14 100644 --- a/codex-rs/tui/src/chatwidget/goal_menu.rs +++ b/codex-rs/tui/src/chatwidget/goal_menu.rs @@ -48,7 +48,6 @@ impl ChatWidget { }); })]; self.show_selection_view(SelectionViewParams { - appearance: crate::bottom_pane::SelectionAppearance::Picker, title: Some("Resume paused goal?".to_string()), subtitle: Some(format!("Goal: {objective}")), footer_hint: Some(standard_popup_hint_line()), diff --git a/codex-rs/tui/src/chatwidget/luna_reserve_model.rs b/codex-rs/tui/src/chatwidget/luna_reserve_model.rs index 3ebd839e8d..e1c98e36a2 100644 --- a/codex-rs/tui/src/chatwidget/luna_reserve_model.rs +++ b/codex-rs/tui/src/chatwidget/luna_reserve_model.rs @@ -47,7 +47,6 @@ impl ChatWidget { self.show_model_selection_view( vec![LUNA_RESERVE_MODEL.to_string()], SelectionViewParams { - appearance: crate::bottom_pane::SelectionAppearance::Picker, view_id: Some(view_id), header, footer_hint: Some(picker_hint_line_for_keymap(&self.bottom_pane.list_keymap())), diff --git a/codex-rs/tui/src/chatwidget/misalignment_policy.rs b/codex-rs/tui/src/chatwidget/misalignment_policy.rs index 7191b085f4..eb7283fa19 100644 --- a/codex-rs/tui/src/chatwidget/misalignment_policy.rs +++ b/codex-rs/tui/src/chatwidget/misalignment_policy.rs @@ -166,7 +166,6 @@ impl ChatWidget { ); } self.bottom_pane.show_selection_view(SelectionViewParams { - appearance: crate::bottom_pane::SelectionAppearance::Picker, view_id: Some(PRECAUTION_VIEW), header: Box::new( Paragraph::new(vec![ @@ -204,7 +203,6 @@ impl ChatWidget { let can_continue = review.continuation_message().is_some(); self.bottom_pane.dismiss_view_by_id(PRECAUTION_VIEW); self.bottom_pane.show_selection_view(SelectionViewParams { - appearance: crate::bottom_pane::SelectionAppearance::Picker, view_id: Some(PRECAUTION_VIEW), title: Some("Chat paused as a precaution".to_string()), items: vec![ diff --git a/codex-rs/tui/src/chatwidget/model_popups.rs b/codex-rs/tui/src/chatwidget/model_popups.rs index 66cd36942d..7e2df92ae9 100644 --- a/codex-rs/tui/src/chatwidget/model_popups.rs +++ b/codex-rs/tui/src/chatwidget/model_popups.rs @@ -190,7 +190,6 @@ impl ChatWidget { self.show_model_selection_view( model_ids, SelectionViewParams { - appearance: crate::bottom_pane::SelectionAppearance::Picker, view_id: Some(MODEL_SELECTION_VIEW_ID), items, header, @@ -283,7 +282,6 @@ impl ChatWidget { self.show_model_selection_view( model_ids, SelectionViewParams { - appearance: crate::bottom_pane::SelectionAppearance::Picker, view_id: Some(view_id), items, header, @@ -450,7 +448,6 @@ impl ChatWidget { })]; self.bottom_pane.show_selection_view(SelectionViewParams { - appearance: crate::bottom_pane::SelectionAppearance::Picker, title: Some(PLAN_MODE_REASONING_SCOPE_TITLE.to_string()), subtitle: Some(subtitle), items: vec![ @@ -644,7 +641,6 @@ impl ChatWidget { .wrap(Wrap { trim: false }); self.bottom_pane.show_selection_view(SelectionViewParams { - appearance: crate::bottom_pane::SelectionAppearance::Picker, header: Box::new(header), items, initial_selected_idx, @@ -712,7 +708,6 @@ impl ChatWidget { ]) .wrap(Wrap { trim: false }); self.bottom_pane.show_selection_view(SelectionViewParams { - appearance: crate::bottom_pane::SelectionAppearance::Picker, header: Box::new(header), items, ..SelectionViewParams::picker() diff --git a/codex-rs/tui/src/chatwidget/permission_discovery.rs b/codex-rs/tui/src/chatwidget/permission_discovery.rs index 52ef53445f..09f7f418d5 100644 --- a/codex-rs/tui/src/chatwidget/permission_discovery.rs +++ b/codex-rs/tui/src/chatwidget/permission_discovery.rs @@ -18,7 +18,6 @@ impl ChatWidget { let request_id = uuid::Uuid::new_v4(); self.permission_popup_request_id = Some(request_id); self.bottom_pane.show_selection_view(SelectionViewParams { - appearance: crate::bottom_pane::SelectionAppearance::Picker, view_id: Some(VIEW_ID), title: Some("Update Model Permissions".into()), items: vec![SelectionItem { @@ -65,7 +64,6 @@ impl ChatWidget { self.open_permission_profiles_popup(discovery); } Err(message) => self.bottom_pane.show_selection_view(SelectionViewParams { - appearance: crate::bottom_pane::SelectionAppearance::Picker, view_id: Some(VIEW_ID), title: Some("Update Model Permissions".to_string()), subtitle: Some(message), diff --git a/codex-rs/tui/src/chatwidget/permission_popups.rs b/codex-rs/tui/src/chatwidget/permission_popups.rs index e2caea2cf7..b4798efe9c 100644 --- a/codex-rs/tui/src/chatwidget/permission_popups.rs +++ b/codex-rs/tui/src/chatwidget/permission_popups.rs @@ -173,7 +173,6 @@ impl ChatWidget { }); self.bottom_pane.show_selection_view(SelectionViewParams { - appearance: crate::bottom_pane::SelectionAppearance::Picker, footer_note, items, title: Some("Update Model Permissions".into()), @@ -230,7 +229,6 @@ impl ChatWidget { ); self.bottom_pane.show_selection_view(SelectionViewParams { - appearance: crate::bottom_pane::SelectionAppearance::Picker, header: Box::new( Paragraph::new(vec![ Line::from("Auto-review Denials".bold()), @@ -512,7 +510,6 @@ impl ChatWidget { ]; self.bottom_pane.show_selection_view(SelectionViewParams { - appearance: crate::bottom_pane::SelectionAppearance::Picker, items, header: Box::new(header), ..SelectionViewParams::picker() diff --git a/codex-rs/tui/src/chatwidget/permissions_menu.rs b/codex-rs/tui/src/chatwidget/permissions_menu.rs index 4c497144b4..da80735a76 100644 --- a/codex-rs/tui/src/chatwidget/permissions_menu.rs +++ b/codex-rs/tui/src/chatwidget/permissions_menu.rs @@ -163,7 +163,6 @@ impl ChatWidget { )); } self.bottom_pane.show_selection_view(SelectionViewParams { - appearance: crate::bottom_pane::SelectionAppearance::Picker, view_id: Some(super::permission_discovery::VIEW_ID), items, title: Some("Update Model Permissions".to_string()), diff --git a/codex-rs/tui/src/chatwidget/pets.rs b/codex-rs/tui/src/chatwidget/pets.rs index 92cfc2af3f..0d688fde9a 100644 --- a/codex-rs/tui/src/chatwidget/pets.rs +++ b/codex-rs/tui/src/chatwidget/pets.rs @@ -294,7 +294,6 @@ impl ChatWidget { self.pet_picker_preview_state.clear(); self.pet_picker_preview_pet = None; self.bottom_pane.show_selection_view(SelectionViewParams { - appearance: crate::bottom_pane::SelectionAppearance::Picker, view_id: Some(PET_SELECTION_LOADING_VIEW_ID), title: Some("Loading Pet".to_string()), subtitle: Some("Preparing the terminal pet.".to_string()), diff --git a/codex-rs/tui/src/chatwidget/plan_implementation.rs b/codex-rs/tui/src/chatwidget/plan_implementation.rs index 6698416972..4354de95ed 100644 --- a/codex-rs/tui/src/chatwidget/plan_implementation.rs +++ b/codex-rs/tui/src/chatwidget/plan_implementation.rs @@ -75,7 +75,6 @@ pub(super) fn selection_view_params( ); SelectionViewParams { - appearance: crate::bottom_pane::SelectionAppearance::Picker, title: Some(PLAN_IMPLEMENTATION_TITLE.to_string()), subtitle: None, footer_hint: Some(standard_popup_hint_line()), diff --git a/codex-rs/tui/src/chatwidget/plugin_catalog.rs b/codex-rs/tui/src/chatwidget/plugin_catalog.rs index 8a233c80ff..91e7844538 100644 --- a/codex-rs/tui/src/chatwidget/plugin_catalog.rs +++ b/codex-rs/tui/src/chatwidget/plugin_catalog.rs @@ -340,7 +340,6 @@ impl Renderable for PluginDisclosureLine { impl ChatWidget { pub(super) fn plugins_loading_popup_params(&self) -> SelectionViewParams { SelectionViewParams { - appearance: crate::bottom_pane::SelectionAppearance::Picker, view_id: Some(PLUGINS_SELECTION_VIEW_ID), header: Box::new(DelayedLoadingHeader::new( self.frame_requester.clone(), @@ -360,7 +359,6 @@ impl ChatWidget { pub(super) fn marketplace_add_loading_popup_params(&self) -> SelectionViewParams { SelectionViewParams { - appearance: crate::bottom_pane::SelectionAppearance::Picker, view_id: Some(PLUGINS_SELECTION_VIEW_ID), header: Box::new(DelayedLoadingHeader::new( self.frame_requester.clone(), @@ -400,7 +398,6 @@ impl ChatWidget { let plugins_response_for_on_cancel = plugins_response.clone(); SelectionViewParams { - appearance: crate::bottom_pane::SelectionAppearance::Picker, view_id: Some(PLUGINS_SELECTION_VIEW_ID), header: Box::new(header), footer_hint: Some(Line::from(vec![ @@ -459,7 +456,6 @@ impl ChatWidget { marketplace_display_name: &str, ) -> SelectionViewParams { SelectionViewParams { - appearance: crate::bottom_pane::SelectionAppearance::Picker, view_id: Some(PLUGINS_SELECTION_VIEW_ID), title: Some("Plugins".to_string()), subtitle: Some(format!("Removing {marketplace_display_name}...")), @@ -481,7 +477,6 @@ impl ChatWidget { .map(|name| format!("Upgrading {name} marketplace...")) .unwrap_or_else(|| "Upgrading marketplaces...".to_string()); SelectionViewParams { - appearance: crate::bottom_pane::SelectionAppearance::Picker, view_id: Some(PLUGINS_SELECTION_VIEW_ID), header: Box::new(DelayedLoadingHeader::new( self.frame_requester.clone(), @@ -504,7 +499,6 @@ impl ChatWidget { plugin_display_name: &str, ) -> SelectionViewParams { SelectionViewParams { - appearance: crate::bottom_pane::SelectionAppearance::Picker, view_id: Some(PLUGINS_SELECTION_VIEW_ID), header: Box::new(DelayedLoadingHeader::new( self.frame_requester.clone(), @@ -527,7 +521,6 @@ impl ChatWidget { plugin_display_name: &str, ) -> SelectionViewParams { SelectionViewParams { - appearance: crate::bottom_pane::SelectionAppearance::Picker, view_id: Some(PLUGINS_SELECTION_VIEW_ID), title: Some("Plugins".to_string()), subtitle: Some(format!("Installing {plugin_display_name}...")), @@ -546,7 +539,6 @@ impl ChatWidget { plugin_display_name: &str, ) -> SelectionViewParams { SelectionViewParams { - appearance: crate::bottom_pane::SelectionAppearance::Picker, view_id: Some(PLUGINS_SELECTION_VIEW_ID), title: Some("Plugins".to_string()), subtitle: Some(format!("Uninstalling {plugin_display_name}...")), @@ -562,7 +554,6 @@ impl ChatWidget { pub(super) fn plugins_error_popup_params(&self, err: &str) -> SelectionViewParams { SelectionViewParams { - appearance: crate::bottom_pane::SelectionAppearance::Picker, view_id: Some(PLUGINS_SELECTION_VIEW_ID), title: Some("Plugins".to_string()), subtitle: Some("Failed to load plugins.".to_string()), @@ -614,7 +605,6 @@ impl ChatWidget { } SelectionViewParams { - appearance: crate::bottom_pane::SelectionAppearance::Picker, view_id: Some(PLUGINS_SELECTION_VIEW_ID), title: Some("Plugins".to_string()), subtitle: Some("Failed to add marketplace.".to_string()), @@ -669,7 +659,6 @@ impl ChatWidget { } SelectionViewParams { - appearance: crate::bottom_pane::SelectionAppearance::Picker, view_id: Some(PLUGINS_SELECTION_VIEW_ID), title: Some("Plugins".to_string()), subtitle: Some("Failed to remove marketplace.".to_string()), @@ -707,7 +696,6 @@ impl ChatWidget { } SelectionViewParams { - appearance: crate::bottom_pane::SelectionAppearance::Picker, view_id: Some(PLUGINS_SELECTION_VIEW_ID), title: Some("Plugins".to_string()), subtitle: Some("Failed to load plugin details.".to_string()), @@ -934,7 +922,6 @@ impl ChatWidget { active_tab_id.and_then(|tab_id| plugin_tab_id_matching_saved_id(&tab_id, &tabs)); SelectionViewParams { - appearance: crate::bottom_pane::SelectionAppearance::Picker, view_id: Some(PLUGINS_SELECTION_VIEW_ID), header: Box::new(()), footer_hint: Some(plugins_popup_hint_line( @@ -1151,7 +1138,6 @@ impl ChatWidget { }); SelectionViewParams { - appearance: crate::bottom_pane::SelectionAppearance::Picker, view_id: Some(PLUGINS_SELECTION_VIEW_ID), header: Box::new(header), footer_hint: Some(plugin_detail_hint_line()), diff --git a/codex-rs/tui/src/chatwidget/plugins.rs b/codex-rs/tui/src/chatwidget/plugins.rs index dcb4bc1066..5462f9f58e 100644 --- a/codex-rs/tui/src/chatwidget/plugins.rs +++ b/codex-rs/tui/src/chatwidget/plugins.rs @@ -955,7 +955,6 @@ impl ChatWidget { }); Some(SelectionViewParams { - appearance: crate::bottom_pane::SelectionAppearance::Picker, view_id: Some(PLUGINS_SELECTION_VIEW_ID), header: Box::new(header), footer_hint: Some(plugin_detail_hint_line()), diff --git a/codex-rs/tui/src/chatwidget/rate_limits.rs b/codex-rs/tui/src/chatwidget/rate_limits.rs index 9542c5cfa2..05eca3ce4e 100644 --- a/codex-rs/tui/src/chatwidget/rate_limits.rs +++ b/codex-rs/tui/src/chatwidget/rate_limits.rs @@ -507,7 +507,6 @@ impl ChatWidget { ]; self.bottom_pane.show_selection_view(SelectionViewParams { - appearance: crate::bottom_pane::SelectionAppearance::Picker, view_id: Some(RATE_LIMIT_SWITCH_PROMPT_VIEW_ID), title: Some("Approaching rate limits".to_string()), subtitle: Some(format!("Switch to {switch_model} for lower credit usage?")), diff --git a/codex-rs/tui/src/chatwidget/realtime_settings.rs b/codex-rs/tui/src/chatwidget/realtime_settings.rs index 66e83f706c..3057764af5 100644 --- a/codex-rs/tui/src/chatwidget/realtime_settings.rs +++ b/codex-rs/tui/src/chatwidget/realtime_settings.rs @@ -25,7 +25,6 @@ impl ChatWidget { }) .collect(); self.bottom_pane.show_selection_view(SelectionViewParams { - appearance: crate::bottom_pane::SelectionAppearance::Picker, title: Some("Select voice".to_string()), subtitle: Some("Applies to your next voice conversation.".to_string()), footer_hint: Some(standard_popup_hint_line()), diff --git a/codex-rs/tui/src/chatwidget/review_popups.rs b/codex-rs/tui/src/chatwidget/review_popups.rs index 00ea85b854..8adc58ae13 100644 --- a/codex-rs/tui/src/chatwidget/review_popups.rs +++ b/codex-rs/tui/src/chatwidget/review_popups.rs @@ -62,7 +62,6 @@ impl ChatWidget { }); self.bottom_pane.show_selection_view(SelectionViewParams { - appearance: crate::bottom_pane::SelectionAppearance::Picker, title: Some("Select a review preset".into()), items, ..SelectionViewParams::picker() @@ -92,7 +91,6 @@ impl ChatWidget { } self.bottom_pane.show_selection_view(SelectionViewParams { - appearance: crate::bottom_pane::SelectionAppearance::Picker, picker_surface: PickerSurface::Panel, title: Some("Select a base branch".to_string()), subtitle: Some(format!("Current branch: {current_branch}")), @@ -130,7 +128,6 @@ impl ChatWidget { } self.bottom_pane.show_selection_view(SelectionViewParams { - appearance: crate::bottom_pane::SelectionAppearance::Picker, picker_surface: PickerSurface::Panel, title: Some("Select a commit to review".into()), items, diff --git a/codex-rs/tui/src/chatwidget/safety_buffering.rs b/codex-rs/tui/src/chatwidget/safety_buffering.rs index 18488307ef..dacfbc5db0 100644 --- a/codex-rs/tui/src/chatwidget/safety_buffering.rs +++ b/codex-rs/tui/src/chatwidget/safety_buffering.rs @@ -227,7 +227,6 @@ impl ChatWidget { }, ]); self.bottom_pane.show_selection_view(SelectionViewParams { - appearance: crate::bottom_pane::SelectionAppearance::Picker, view_id: Some(SAFETY_BUFFERING_PROMPT_VIEW_ID), header: Box::new(SafetyBufferingHeader(header)), footer_note: Some(Line::from(SAFETY_BUFFERING_FOOTER).dim()), @@ -259,7 +258,6 @@ impl ChatWidget { self.bottom_pane .dismiss_view_by_id(SAFETY_BUFFERING_PROMPT_VIEW_ID); self.bottom_pane.show_selection_view(SelectionViewParams { - appearance: crate::bottom_pane::SelectionAppearance::Picker, view_id: Some(SAFETY_BUFFERING_PROMPT_VIEW_ID), header: Box::new(SafetyBufferingHeader(vec![ "Stop this attempt and retry?".bold().into(), diff --git a/codex-rs/tui/src/chatwidget/skills.rs b/codex-rs/tui/src/chatwidget/skills.rs index e59bcf07ee..680675bcd9 100644 --- a/codex-rs/tui/src/chatwidget/skills.rs +++ b/codex-rs/tui/src/chatwidget/skills.rs @@ -49,7 +49,6 @@ impl ChatWidget { ]; self.bottom_pane.show_selection_view(SelectionViewParams { - appearance: crate::bottom_pane::SelectionAppearance::Picker, title: Some("Skills".to_string()), subtitle: Some("Choose an action".to_string()), footer_hint: Some(standard_popup_hint_line()), diff --git a/codex-rs/tui/src/chatwidget/slash_dispatch.rs b/codex-rs/tui/src/chatwidget/slash_dispatch.rs index 222a5a0f56..a07662d0db 100644 --- a/codex-rs/tui/src/chatwidget/slash_dispatch.rs +++ b/codex-rs/tui/src/chatwidget/slash_dispatch.rs @@ -206,7 +206,6 @@ impl ChatWidget { } SlashCommand::Archive => { self.bottom_pane.show_selection_view(SelectionViewParams { - appearance: crate::bottom_pane::SelectionAppearance::Picker, title: Some("Archive this session?".to_string()), subtitle: Some( "Are you sure? This will archive the current session".to_string(), @@ -235,7 +234,6 @@ impl ChatWidget { } SlashCommand::Delete => { self.bottom_pane.show_selection_view(SelectionViewParams { - appearance: crate::bottom_pane::SelectionAppearance::Picker, title: Some("Delete this session?".to_string()), subtitle: Some( "Cannot be undone. Subagent threads will also be deleted.".to_string(), diff --git a/codex-rs/tui/src/chatwidget/tests/permissions.rs b/codex-rs/tui/src/chatwidget/tests/permissions.rs index a79f9d89b7..c0d90956a5 100644 --- a/codex-rs/tui/src/chatwidget/tests/permissions.rs +++ b/codex-rs/tui/src/chatwidget/tests/permissions.rs @@ -114,7 +114,6 @@ async fn permission_discovery_discards_stale_results_and_preserves_covering_moda chat.open_permissions_popup(); let request_id = chat.permission_popup_request_id.unwrap(); chat.bottom_pane.show_selection_view(SelectionViewParams { - appearance: crate::bottom_pane::SelectionAppearance::Picker, view_id: Some("other-modal"), title: Some("Other modal".to_string()), ..Default::default() diff --git a/codex-rs/tui/src/chatwidget/tests/usage.rs b/codex-rs/tui/src/chatwidget/tests/usage.rs index 703d61b174..6bea412c31 100644 --- a/codex-rs/tui/src/chatwidget/tests/usage.rs +++ b/codex-rs/tui/src/chatwidget/tests/usage.rs @@ -1110,7 +1110,6 @@ fn dismiss_popup(chat: &mut ChatWidget) { fn show_usage_test_overlay(chat: &mut ChatWidget) { chat.bottom_pane.show_selection_view(SelectionViewParams { - appearance: crate::bottom_pane::SelectionAppearance::Picker, view_id: Some(TEST_OVERLAY_VIEW_ID), title: Some("Covering overlay".to_string()), items: vec![SelectionItem { diff --git a/codex-rs/tui/src/chatwidget/transcript_export.rs b/codex-rs/tui/src/chatwidget/transcript_export.rs index 452f0cb412..e99e9795bc 100644 --- a/codex-rs/tui/src/chatwidget/transcript_export.rs +++ b/codex-rs/tui/src/chatwidget/transcript_export.rs @@ -25,7 +25,6 @@ impl ChatWidget { pub(super) fn show_transcript_export_popup(&mut self) { self.show_selection_view(SelectionViewParams { - appearance: crate::bottom_pane::SelectionAppearance::Picker, header: Box::new( Paragraph::new(vec![ Line::from("Export conversation".bold()), diff --git a/codex-rs/tui/src/chatwidget/usage.rs b/codex-rs/tui/src/chatwidget/usage.rs index 8563a3bdf3..a45aee4a88 100644 --- a/codex-rs/tui/src/chatwidget/usage.rs +++ b/codex-rs/tui/src/chatwidget/usage.rs @@ -47,7 +47,6 @@ impl ChatWidget { }; SelectionViewParams { - appearance: crate::bottom_pane::SelectionAppearance::Picker, view_id: Some(USAGE_MENU_VIEW_ID), title: Some("Usage".to_string()), subtitle: Some("View account usage or redeem an earned reset.".to_string()), @@ -109,7 +108,6 @@ impl ChatWidget { let request_id = self.take_next_rate_limit_reset_request_id(); self.pending_rate_limit_reset_request_id = Some(request_id); self.bottom_pane.show_selection_view(SelectionViewParams { - appearance: crate::bottom_pane::SelectionAppearance::Picker, view_id: Some(RATE_LIMIT_RESET_VIEW_ID), title: Some("Usage limit resets".to_string()), subtitle: Some("Checking your available resets...".to_string()), @@ -209,7 +207,6 @@ impl ChatWidget { ..Default::default() }); SelectionViewParams { - appearance: crate::bottom_pane::SelectionAppearance::Picker, view_id: Some(RATE_LIMIT_RESET_VIEW_ID), title: Some("Usage limit resets".to_string()), subtitle: Some(format!( @@ -250,7 +247,6 @@ impl ChatWidget { |reset_detail| format!("{reset_title} · {reset_detail}"), ); self.bottom_pane.show_selection_view(SelectionViewParams { - appearance: crate::bottom_pane::SelectionAppearance::Picker, view_id: Some(RATE_LIMIT_RESET_CONFIRMATION_VIEW_ID), title: Some("Use this reset?".to_string()), subtitle: Some(subtitle), @@ -299,7 +295,6 @@ impl ChatWidget { fn rate_limit_reset_message_params(message: &str) -> SelectionViewParams { SelectionViewParams { - appearance: crate::bottom_pane::SelectionAppearance::Picker, view_id: Some(RATE_LIMIT_RESET_VIEW_ID), title: Some("Usage limit resets".to_string()), subtitle: Some(message.to_string()), @@ -314,7 +309,6 @@ impl ChatWidget { fn reset_refresh_params(message: &str) -> SelectionViewParams { SelectionViewParams { - appearance: crate::bottom_pane::SelectionAppearance::Picker, view_id: Some(RATE_LIMIT_RESET_VIEW_ID), title: Some("Usage limit resets".to_string()), subtitle: Some(message.to_string()), @@ -348,7 +342,6 @@ impl ChatWidget { self.bottom_pane .dismiss_view_by_id(RATE_LIMIT_RESET_VIEW_ID); self.bottom_pane.show_selection_view(SelectionViewParams { - appearance: crate::bottom_pane::SelectionAppearance::Picker, view_id: Some(RATE_LIMIT_RESET_VIEW_ID), title: Some("Usage limit resets".to_string()), subtitle: Some("Resetting your usage...".to_string()), @@ -414,7 +407,6 @@ impl ChatWidget { self.pending_rate_limit_reset_request_id = None; self.pending_rate_limit_reset_idempotency_key = Some(idempotency_key.clone()); self.replace_rate_limit_reset_popup(SelectionViewParams { - appearance: crate::bottom_pane::SelectionAppearance::Picker, view_id: Some(RATE_LIMIT_RESET_VIEW_ID), title: Some("Usage limit resets".to_string()), subtitle: Some("Couldn't reset usage. Please try again.".to_string()), @@ -474,7 +466,6 @@ impl ChatWidget { fn rate_limit_reset_success_loading_params() -> SelectionViewParams { SelectionViewParams { - appearance: crate::bottom_pane::SelectionAppearance::Picker, view_id: Some(RATE_LIMIT_RESET_VIEW_ID), title: Some("Usage limit resets".to_string()), subtitle: Some("Usage reset. Checking your remaining resets...".to_string()), diff --git a/codex-rs/tui/src/chatwidget/windows_sandbox_prompts.rs b/codex-rs/tui/src/chatwidget/windows_sandbox_prompts.rs index 6a48824bb8..bb3d63988b 100644 --- a/codex-rs/tui/src/chatwidget/windows_sandbox_prompts.rs +++ b/codex-rs/tui/src/chatwidget/windows_sandbox_prompts.rs @@ -114,7 +114,6 @@ impl ChatWidget { }); self.bottom_pane.show_selection_view(SelectionViewParams { - appearance: crate::bottom_pane::SelectionAppearance::Picker, title: None, footer_hint: Some(standard_popup_hint_line()), items, @@ -249,7 +248,6 @@ impl ChatWidget { }); self.bottom_pane.show_selection_view(SelectionViewParams { - appearance: crate::bottom_pane::SelectionAppearance::Picker, title: None, footer_hint: Some(standard_popup_hint_line()), items, diff --git a/codex-rs/tui/src/chatwidget/worktree_picker.rs b/codex-rs/tui/src/chatwidget/worktree_picker.rs index fa4b456a03..cdff579b36 100644 --- a/codex-rs/tui/src/chatwidget/worktree_picker.rs +++ b/codex-rs/tui/src/chatwidget/worktree_picker.rs @@ -43,7 +43,6 @@ impl ChatWidget { }; let current_name = name.clone(); self.bottom_pane.show_selection_view(SelectionViewParams { - appearance: crate::bottom_pane::SelectionAppearance::Picker, title: Some(title.into()), items: vec![ SelectionItem { @@ -95,7 +94,6 @@ impl ChatWidget { } self.bottom_pane.show_selection_view(SelectionViewParams { - appearance: crate::bottom_pane::SelectionAppearance::Picker, title: Some("Worktrees".into()), items: vec![ SelectionItem { @@ -149,7 +147,6 @@ impl ChatWidget { self.bottom_pane.dismiss_view_by_id(BROWSER_VIEW_ID); self.worktree_popup_request_id = Some(request.id); self.bottom_pane.show_selection_view(SelectionViewParams { - appearance: crate::bottom_pane::SelectionAppearance::Picker, view_id: Some(BROWSER_VIEW_ID), picker_surface: PickerSurface::Panel, title: Some("Managed worktrees".into()), @@ -194,7 +191,6 @@ impl ChatWidget { } }; self.bottom_pane.show_selection_view(SelectionViewParams { - appearance: crate::bottom_pane::SelectionAppearance::Picker, title: Some("Managed worktrees".to_string()), subtitle: Some( if entries.is_empty() { @@ -344,7 +340,6 @@ impl ChatWidget { Owner::None | Owner::Unavailable(_) => "Worktree".to_string(), }; self.bottom_pane.show_selection_view(SelectionViewParams { - appearance: crate::bottom_pane::SelectionAppearance::Picker, title: Some(title), subtitle: Some(entry.cwd.display().to_string()), items, @@ -357,7 +352,6 @@ impl ChatWidget { return; } self.bottom_pane.show_selection_view(SelectionViewParams { - appearance: crate::bottom_pane::SelectionAppearance::Picker, title: Some("Delete this worktree?".to_string()), subtitle: Some(root.display().to_string()), items: vec![ diff --git a/codex-rs/tui/src/keymap_setup.rs b/codex-rs/tui/src/keymap_setup.rs index 4ed00954ed..adc4e946fd 100644 --- a/codex-rs/tui/src/keymap_setup.rs +++ b/codex-rs/tui/src/keymap_setup.rs @@ -297,7 +297,6 @@ pub(crate) fn build_keymap_action_menu_params( }); SelectionViewParams { - appearance: crate::bottom_pane::SelectionAppearance::Picker, view_id: Some(KEYMAP_ACTION_MENU_VIEW_ID), header: Box::new(Paragraph::new(header).wrap(Wrap { trim: false })), footer_note: Some(Line::from(vec![ @@ -363,7 +362,6 @@ pub(crate) fn build_keymap_replace_binding_menu_params( .collect(); SelectionViewParams { - appearance: crate::bottom_pane::SelectionAppearance::Picker, view_id: Some(KEYMAP_REPLACE_BINDING_MENU_VIEW_ID), header: Box::new(Paragraph::new(header).wrap(Wrap { trim: false })), footer_hint: Some(picker_hint_line_for_keymap(&runtime_keymap.list)), @@ -386,7 +384,6 @@ pub(crate) fn build_keymap_conflict_params( KeymapCaptureMode::SingleKey }; SelectionViewParams { - appearance: crate::bottom_pane::SelectionAppearance::Picker, header: Box::new( Paragraph::new(vec![ Line::from("Shortcut Conflict".bold()), @@ -722,11 +719,6 @@ mod tests { use tokio::sync::mpsc::UnboundedReceiver; use tokio::sync::mpsc::unbounded_channel; - // Keep layout fixtures portable while runtime defaults remain platform-specific. - fn snapshot_runtime(runtime: RuntimeKeymap) -> RuntimeKeymap { - runtime - } - fn app_event_sender() -> AppEventSender { let (tx, _rx) = unbounded_channel(); AppEventSender::new(tx) @@ -901,7 +893,7 @@ mod tests { #[test] fn keymap_picker_fast_mode_enabled_snapshot() { - let runtime = snapshot_runtime(RuntimeKeymap::defaults()); + let runtime = RuntimeKeymap::defaults(); let params = build_keymap_picker_params_with_filter( &runtime, &TuiKeymap::default(), @@ -995,7 +987,7 @@ mod tests { #[test] fn picker_content_snapshot() { - let runtime = snapshot_runtime(RuntimeKeymap::defaults()); + let runtime = RuntimeKeymap::defaults(); let params = build_keymap_picker_params(&runtime, &TuiKeymap::default()); let all_tab = selection_tab(¶ms, KEYMAP_ALL_TAB_ID); let snapshot = params @@ -1190,7 +1182,7 @@ mod tests { #[test] fn picker_question_actions_snapshot() { - let runtime = snapshot_runtime(RuntimeKeymap::defaults()); + let runtime = RuntimeKeymap::defaults(); let params = build_keymap_picker_params_for_selected_action( &runtime, &TuiKeymap::default(), @@ -1223,7 +1215,7 @@ mod tests { #[test] fn picker_all_tab_items_remain_searchable() { - let runtime = snapshot_runtime(RuntimeKeymap::defaults()); + let runtime = RuntimeKeymap::defaults(); let params = build_keymap_picker_params(&runtime, &TuiKeymap::default()); let all_tab = selection_tab(¶ms, KEYMAP_ALL_TAB_ID); let snapshot = all_tab @@ -1246,7 +1238,7 @@ mod tests { #[test] fn picker_wide_render_snapshot() { - let runtime = snapshot_runtime(RuntimeKeymap::defaults()); + let runtime = RuntimeKeymap::defaults(); let params = build_keymap_picker_params(&runtime, &TuiKeymap::default()); assert_snapshot!("keymap_picker_wide", render_picker(params, /*width*/ 120)); @@ -1254,7 +1246,7 @@ mod tests { #[test] fn picker_narrow_render_snapshot() { - let runtime = snapshot_runtime(RuntimeKeymap::defaults()); + let runtime = RuntimeKeymap::defaults(); let params = build_keymap_picker_params(&runtime, &TuiKeymap::default()); assert_snapshot!("keymap_picker_narrow", render_picker(params, /*width*/ 78)); @@ -1265,8 +1257,7 @@ mod tests { let keymap = keymap_with_replacement(&TuiKeymap::default(), "composer", "submit", "ctrl-enter") .expect("replace binding"); - let runtime = - snapshot_runtime(RuntimeKeymap::from_config(&keymap).expect("runtime keymap")); + let runtime = RuntimeKeymap::from_config(&keymap).expect("runtime keymap"); let params = build_keymap_picker_params(&runtime, &keymap); assert_snapshot!("keymap_picker_custom", render_picker(params, /*width*/ 120)); @@ -1281,8 +1272,7 @@ mod tests { &["ctrl-t".to_string(), "ctrl-x ctrl-t".to_string()], ) .expect("global chord binding"); - let runtime = - snapshot_runtime(RuntimeKeymap::from_config(&keymap).expect("runtime keymap")); + let runtime = RuntimeKeymap::from_config(&keymap).expect("runtime keymap"); let params = build_keymap_picker_params(&runtime, &keymap); assert_snapshot!( diff --git a/codex-rs/tui/src/keymap_setup/picker.rs b/codex-rs/tui/src/keymap_setup/picker.rs index 623a86157e..6ad29c5ff6 100644 --- a/codex-rs/tui/src/keymap_setup/picker.rs +++ b/codex-rs/tui/src/keymap_setup/picker.rs @@ -297,7 +297,6 @@ fn build_keymap_picker_params_for_action( tabs.push(keymap_debug_tab()); SelectionViewParams { - appearance: crate::bottom_pane::SelectionAppearance::Picker, view_id: Some(KEYMAP_PICKER_VIEW_ID), picker_surface: PickerSurface::Panel, max_visible_rows: 24, diff --git a/codex-rs/tui/src/pets/picker.rs b/codex-rs/tui/src/pets/picker.rs index a5c1490bab..72251d7776 100644 --- a/codex-rs/tui/src/pets/picker.rs +++ b/codex-rs/tui/src/pets/picker.rs @@ -117,7 +117,6 @@ pub(crate) fn build_pet_picker_params( .collect(); SelectionViewParams { - appearance: crate::bottom_pane::SelectionAppearance::Picker, view_id: Some(PET_PICKER_VIEW_ID), title: Some("Select Pet".to_string()), subtitle: Some("Choose a pet to wake in the terminal.".to_string()), diff --git a/codex-rs/tui/src/resume_picker.rs b/codex-rs/tui/src/resume_picker.rs index b054e8db4c..3e37641be1 100644 --- a/codex-rs/tui/src/resume_picker.rs +++ b/codex-rs/tui/src/resume_picker.rs @@ -21,6 +21,8 @@ use crate::markdown_render::render_streaming_markdown_lines_with_width_and_cwd a use crate::pager_overlay::Overlay; use crate::status::format_directory_display; use crate::style::footer_hint_label_style; +use crate::style::readable_color_on; +use crate::style::secondary_text_style; use crate::terminal_palette::best_color; use crate::terminal_palette::default_bg; use crate::text_formatting::truncate_text; @@ -54,8 +56,6 @@ use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyEventKind; use crossterm::event::KeyModifiers; -use ratatui::layout::Constraint; -use ratatui::layout::Layout; use ratatui::layout::Rect; use ratatui::layout::Size; use ratatui::style::Color; @@ -75,6 +75,7 @@ use unicode_width::UnicodeWidthStr; use uuid::Uuid; mod archive; +mod layout; mod page_loading; #[cfg(test)] @@ -102,7 +103,6 @@ const SESSION_META_CWD_ICON: &str = "⌁"; const FOOTER_COMPACT_BREAKPOINT: u16 = 120; const FOOTER_HINT_LEFT_PADDING: usize = 1; const FOOTER_HINT_GAP: usize = 3; -const PICKER_CHROME_HEIGHT: u16 = 8; const PICKER_LIST_HORIZONTAL_INSET: u16 = 4; #[derive(Debug, Clone)] @@ -578,7 +578,7 @@ async fn run_session_picker_with_loader( state.request_frame(); if let Ok(size) = alt.tui.terminal.size() { - let list_height = size.height.saturating_sub(PICKER_CHROME_HEIGHT) as usize; + let list_height = usize::from(layout::areas(Rect::from(size)).list.height); state.update_viewport(list_height, list_viewport_width(size.width)); state.ensure_minimum_rows_for_view(list_height); } @@ -620,7 +620,7 @@ async fn run_session_picker_with_loader( TuiEvent::Draw | TuiEvent::Resume | TuiEvent::Resize(_) | TuiEvent::FocusGained => { let list_width = list_viewport_width(screen_size.width); let list_height = - usize::from(screen_size.height.saturating_sub(PICKER_CHROME_HEIGHT)); + usize::from(layout::areas(Rect::from(screen_size)).list.height); state.update_viewport(list_height, list_width); state.ensure_minimum_rows_for_view(list_height); draw_picker(alt.tui, &state, screen_size)?; @@ -2110,54 +2110,7 @@ fn parse_timestamp_str(ts: &str) -> Option> { } fn draw_picker(tui: &mut Tui, state: &PickerState, screen_size: Size) -> std::io::Result<()> { - // Render full-screen overlay - tui.draw(screen_size.height, |frame| { - let area = frame.area(); - let [header, _header_gap, search, _search_gap, list, footer] = Layout::vertical([ - Constraint::Length(1), - Constraint::Length(1), - Constraint::Length(1), - Constraint::Length(1), - Constraint::Min(area.height.saturating_sub(PICKER_CHROME_HEIGHT)), - Constraint::Length(4), - ]) - .areas(area); - - let chrome = |area: Rect| { - Rect::new( - area.x.saturating_add(1), - area.y, - area.width.saturating_sub(2), - area.height, - ) - }; - - // Header - let header_title = if default_bg().is_some_and(is_light) { - state.action.title().bold().fg(best_color((0, 100, 0))) - } else { - state.action.title().bold().cyan() - }; - let header_line: Line = vec![header_title].into(); - frame.render_widget_ref(&header_line, chrome(header)); - - // Search line - let search = chrome(search); - frame.render_widget_ref(&search_line(state, search.width), search); - - let list = Rect::new( - list.x.saturating_add(2), - list.y, - list_viewport_width(list.width), - list.height, - ); - render_list(frame, list, state); - if state.is_transcript_loading() { - render_transcript_loading_overlay(frame, list); - } - - render_picker_footer(frame, footer, state, list.height); - }) + tui.draw(screen_size.height, |frame| layout::render(frame, state)) } fn list_viewport_width(width: u16) -> u16 { @@ -2166,38 +2119,40 @@ fn list_viewport_width(width: u16) -> u16 { fn search_line(state: &PickerState, width: u16) -> Line<'_> { if let Some(error) = state.inline_error.as_deref() { - return Line::from(error.red()); + return Line::from(truncate_text(error, usize::from(width)).red()); } - let search = if state.query.is_empty() { - "Type to search".dim() + if state.query.is_empty() { + "Type to search".set_style(secondary_text_style()).into() } else { - format!("Search: {}", state.query).into() - }; - let search_width = UnicodeWidthStr::width(search.content.as_ref()); - let mut toolbar = toolbar_line(state, /*compact*/ false); - if search_width.saturating_add(toolbar.width()) > usize::from(width.saturating_sub(2)) { - toolbar = toolbar_line(state, /*compact*/ true); + truncate_text(&format!("Search: {}", state.query), usize::from(width)).into() } - let toolbar_width = toolbar.width(); - let spacer_width = width - .saturating_sub((search_width + toolbar_width) as u16) - .max(2) as usize; - let available_search_width = width - .saturating_sub(toolbar_width as u16) - .saturating_sub(spacer_width as u16) as usize; - let search = if search_width > available_search_width { - let truncated = truncate_text(search.content.as_ref(), available_search_width); - if state.query.is_empty() { - truncated.dim() - } else { - truncated.into() - } - } else { - search - }; +} - let mut spans = vec![search, " ".repeat(spacer_width).into()]; - spans.extend(toolbar.spans); +fn toolbar_for_width(state: &PickerState, width: u16) -> Line<'static> { + let toolbar = toolbar_line(state, /*compact*/ false); + if toolbar.width() <= usize::from(width) { + return toolbar; + } + let toolbar = toolbar_line(state, /*compact*/ true); + if toolbar.width() <= usize::from(width) { + return toolbar; + } + // Keep the control affected by the arrow keys visible when the compact strip cannot fit. + let spans = match state.toolbar_focus { + ToolbarControl::Filter => filter_control_spans(state, /*compact*/ true), + ToolbarControl::Sort => sort_control_spans(state, /*compact*/ true), + ToolbarControl::Status => vec![ + "Status:".set_style(secondary_text_style()), + toolbar_value( + match state.status { + SessionStatus::Active => "Active", + SessionStatus::Archived => "Archived", + }, + /*active*/ true, + /*focused*/ true, + ), + ], + }; spans.into() } @@ -2209,7 +2164,7 @@ fn toolbar_line(state: &PickerState, compact: bool) -> Line<'static> { " " }; spans.extend(filter_control_spans(state, compact)); - spans.push(separator.dim()); + spans.push(separator.set_style(secondary_text_style())); if matches!(state.action, SessionPickerAction::Resume) { let status_focused = state.toolbar_focus == ToolbarControl::Status; if compact { @@ -2223,7 +2178,7 @@ fn toolbar_line(state: &PickerState, compact: bool) -> Line<'static> { status_focused, )); } else { - spans.push("Status: ".dim()); + spans.push("Status: ".set_style(secondary_text_style())); spans.push(toolbar_value( "Active", state.status == SessionStatus::Active, @@ -2235,7 +2190,7 @@ fn toolbar_line(state: &PickerState, compact: bool) -> Line<'static> { status_focused, )); } - spans.push(separator.dim()); + spans.push(separator.set_style(secondary_text_style())); } spans.extend(sort_control_spans(state, compact)); spans.into() @@ -2245,7 +2200,7 @@ fn sort_control_spans(state: &PickerState, compact: bool) -> Vec> let sort_focused = state.toolbar_focus == ToolbarControl::Sort; if compact { return vec![ - "Sort:".dim(), + "Sort:".set_style(secondary_text_style()), toolbar_value( sort_key_label(state.sort_key), /*active*/ true, @@ -2254,7 +2209,7 @@ fn sort_control_spans(state: &PickerState, compact: bool) -> Vec> ]; } vec![ - "Sort: ".dim(), + "Sort: ".set_style(secondary_text_style()), toolbar_value( sort_key_label(ThreadSortKey::UpdatedAt), state.sort_key == ThreadSortKey::UpdatedAt, @@ -2272,7 +2227,7 @@ fn filter_control_spans(state: &PickerState, compact: bool) -> Vec let filter_focused = state.toolbar_focus == ToolbarControl::Filter; if compact || state.filter_cwd.is_none() { return vec![ - "Filter:".dim(), + "Filter:".set_style(secondary_text_style()), toolbar_value( filter_mode_label(state.filter_mode), /*active*/ true, @@ -2281,7 +2236,7 @@ fn filter_control_spans(state: &PickerState, compact: bool) -> Vec ]; } vec![ - "Filter: ".dim(), + "Filter: ".set_style(secondary_text_style()), toolbar_value( filter_mode_label(SessionFilterMode::Cwd), state.filter_mode == SessionFilterMode::Cwd, @@ -2296,15 +2251,16 @@ fn filter_control_spans(state: &PickerState, compact: bool) -> Vec } fn toolbar_value(label: &'static str, active: bool, focused: bool) -> Span<'static> { + let value = format!(" {label} "); if active { - let value = format!("[{label}]"); - if focused { - value.magenta() + value.set_style(if focused { + crate::bottom_pane::active_tab_style() } else { - value.into() - } + let style = crate::style::user_message_style().bold().not_dim(); + style.fg(readable_color_on(Color::Reset, style.bg)) + }) } else { - format!(" {label} ").dim() + value.set_style(secondary_text_style()) } } @@ -2369,7 +2325,10 @@ fn render_picker_footer_separator( progress_width, 1, ); - frame.render_widget_ref(&Line::from(progress_label.dim()), percent_area); + frame.render_widget_ref( + &Line::from(progress_label.set_style(secondary_text_style())), + percent_area, + ); } } @@ -2587,21 +2546,19 @@ fn hint_line_for_row(hints: &[PickerFooterHint], width: u16) -> Line<'static> { if let Some(line) = fit_footer_hints(hints, FooterHintLabelMode::Compact, width) { return line; } - if let Some(line) = fit_footer_hints(hints, FooterHintLabelMode::KeyOnly, width) { - return line; - } - let mut retained = (0..hints.len()).collect::>(); retained.sort_by_key(|idx| hints[*idx].priority); - for retain_count in (1..=retained.len()).rev() { - let mut candidate_indices = retained[..retain_count].to_vec(); - candidate_indices.sort_unstable(); - let candidate = candidate_indices - .iter() - .map(|idx| &hints[*idx]) - .collect::>(); - if let Some(line) = fit_footer_hint_refs(&candidate, FooterHintLabelMode::KeyOnly, width) { - return line; + for mode in [FooterHintLabelMode::Compact, FooterHintLabelMode::KeyOnly] { + for retain_count in (1..=retained.len()).rev() { + let mut candidate_indices = retained[..retain_count].to_vec(); + candidate_indices.sort_unstable(); + let candidate = candidate_indices + .iter() + .map(|idx| &hints[*idx]) + .collect::>(); + if let Some(line) = fit_footer_hint_refs(&candidate, mode, width) { + return line; + } } } Line::default() @@ -2646,14 +2603,23 @@ fn render_transcript_loading_overlay(frame: &mut crate::custom_terminal::Frame, fn transcript_loading_overlay_style() -> Style { let Some(bg) = default_bg() else { - return Style::default().bg(Color::DarkGray); + return Style::default() + .fg(Color::Reset) + .bg(Color::Reset) + .not_dim() + .not_reversed(); }; let (overlay, alpha) = if is_light(bg) { ((0, 0, 0), 0.08) } else { ((255, 255, 255), 0.14) }; - Style::default().bg(best_color(blend(overlay, bg, alpha))) + let fill = best_color(blend(overlay, bg, alpha)); + Style::default() + .bg(fill) + .fg(readable_color_on(Color::Reset, Some(fill))) + .not_dim() + .not_reversed() } #[derive(Clone, Copy)] @@ -2791,7 +2757,13 @@ fn render_list(frame: &mut crate::custom_terminal::Frame, area: Rect, state: &Pi } if state.pagination.is_loading() && y < content_area.y.saturating_add(content_area.height) { - let loading_line: Line = vec![" ".into(), "Loading older sessions…".italic().dim()].into(); + let loading_line: Line = vec![ + " ".into(), + "Loading older sessions…" + .set_style(secondary_text_style()) + .italic(), + ] + .into(); let rect = Rect::new(area.x, y, area.width, 1); frame.render_widget_ref(&loading_line, rect); } @@ -2814,7 +2786,12 @@ fn render_list(frame: &mut crate::custom_terminal::Frame, area: Rect, state: &Pi } fn more_line(label: &'static str) -> Line<'static> { - vec![label.dim()].into() + vec![ + label + .fg(crate::style::accent_color_on(/*background*/ None)) + .not_dim(), + ] + .into() } fn render_session_lines( @@ -2849,15 +2826,13 @@ fn render_comfortable_session_lines( let title_line = Line::from(vec![marker, title]); let mut lines = vec![title_line]; let row_style = if is_selected { - Some(dense_selected_style()) + dense_selected_style() } else if is_zebra { - Some(dense_zebra_style()) + dense_zebra_style() } else { - None + Style::default() }; - if let Some(style) = row_style { - lines = apply_session_row_background(lines, style, width); - } + lines = apply_session_row_background(lines, row_style, width); if is_expanded { lines.extend(render_transcript_preview_lines(row, state, width)); return lines; @@ -2887,11 +2862,7 @@ fn render_comfortable_session_lines( show_cwd, width, ); - if let Some(style) = row_style { - lines.extend(apply_session_row_background(footer_lines, style, width)); - } else { - lines.extend(footer_lines); - } + lines.extend(apply_session_row_background(footer_lines, row_style, width)); lines } @@ -2913,8 +2884,21 @@ fn apply_line_background(mut line: Line<'static>, style: Style, width: u16) -> L } line.style = line.style.patch(style); for span in &mut line.spans { - // Keep identity accents while inheriting the selected row's background. + // Preserve each role's hue and weight, then resolve it against the painted row. span.style = style.patch(span.style); + if style + .add_modifier + .contains(ratatui::style::Modifier::REVERSED) + { + span.style.fg = style.fg; + span.style = span.style.reversed(); + } else { + span.style.fg = Some(readable_color_on( + span.style.fg.unwrap_or(Color::Reset), + span.style.bg, + )); + span.style = span.style.not_reversed(); + } } line } @@ -2977,26 +2961,18 @@ fn dense_summary_line(input: DenseSummaryInput<'_>) -> Line<'static> { let spans = vec![ input.marker, - dense_column_text(input.date, columns.date_width).dim(), + dense_column_text(input.date, columns.date_width).set_style(secondary_text_style()), title, ]; - let mut line = Line::from(spans); - if input.is_selected { - let padding = (input.width as usize).saturating_sub(line.width()); - if padding > 0 { - line.spans - .push(" ".repeat(padding).set_style(dense_selected_style())); - } - line = line.style(dense_selected_style()); + let line = Line::from(spans); + let row_style = if input.is_selected { + dense_selected_style() } else if input.is_zebra { - let padding = (input.width as usize).saturating_sub(line.width()); - if padding > 0 { - line.spans - .push(" ".repeat(padding).set_style(dense_zebra_style())); - } - line = line.style(dense_zebra_style()); - } - line + dense_zebra_style() + } else { + Style::default() + }; + apply_line_background(line, row_style, input.width) } struct DenseColumns { @@ -3017,7 +2993,7 @@ fn dense_zebra_style() -> Style { } fn dense_selected_style() -> Style { - selected_session_style().patch(dense_row_background_style(/*selected*/ true)) + selected_session_style() } fn dense_row_background_style(selected: bool) -> Style { @@ -3041,17 +3017,13 @@ fn dense_column_text(text: &str, width: usize) -> String { fn selection_marker(is_selected: bool, is_expanded: bool) -> Span<'static> { match (is_selected, is_expanded) { (true, true) => "⌄ ".set_style(selected_session_style().bold()), - (true, false) => "❯ ".set_style(selected_session_style().bold()), + (true, false) => "› ".set_style(selected_session_style()), (false, _) => " ".into(), } } fn selected_session_style() -> Style { - if default_bg().is_some_and(is_light) { - Style::default().fg(Color::Magenta) - } else { - Style::default().fg(Color::Yellow) - } + crate::style::selection_style() } fn session_title_span( @@ -3060,13 +3032,12 @@ fn session_title_span( use_theme_colors: bool, is_selected: bool, ) -> Span<'static> { - if use_theme_colors && let Some(thread_id) = thread_id { + let title = if use_theme_colors && let Some(thread_id) = thread_id { title.fg(crate::thread_color::thread_color(thread_id)) - } else if is_selected { - title.set_style(selected_session_style()) } else { title.into() - } + }; + if is_selected { title.bold() } else { title } } fn render_footer_lines( @@ -3185,7 +3156,7 @@ fn footer_line(parts: Vec, width: usize, cwd_width: usize) -> Line<' if idx > 0 { let gap_width = SESSION_META_FIELD_GAP_WIDTH.min(remaining_width); if gap_width > 0 { - spans.push(" ".repeat(gap_width).dim()); + spans.push(" ".repeat(gap_width).set_style(secondary_text_style())); remaining_width = remaining_width.saturating_sub(gap_width); } } @@ -3200,7 +3171,7 @@ fn footer_line(parts: Vec, width: usize, cwd_width: usize) -> Line<' if let Some(target_width) = target_width { let padding = target_width.saturating_sub(used_width); if padding > 0 { - spans.push(" ".repeat(padding).dim()); + spans.push(" ".repeat(padding).set_style(secondary_text_style())); remaining_width = remaining_width.saturating_sub(padding); } } @@ -3218,7 +3189,7 @@ fn push_footer_part( let Some(prefix) = part.prefix() else { let text = truncate_text(&text, available_width); let width = UnicodeWidthStr::width(text.as_str()); - spans.push(text.dim()); + spans.push(text.set_style(secondary_text_style())); return width; }; @@ -3226,14 +3197,14 @@ fn push_footer_part( if available_width <= prefix_width { let prefix = truncate_text(prefix, available_width); let width = UnicodeWidthStr::width(prefix.as_str()); - spans.push(prefix.dim()); + spans.push(prefix.set_style(secondary_text_style())); return width; } - spans.push(prefix.dim()); + spans.push(prefix.set_style(secondary_text_style())); let mut used_width = prefix_width; if !text.is_empty() && used_width < available_width { - spans.push(" ".dim()); + spans.push(" ".set_style(secondary_text_style())); used_width += 1; } let text_width = target_width @@ -3243,8 +3214,10 @@ fn push_footer_part( let text = truncate_text(&text, text_width); let rendered_text_width = UnicodeWidthStr::width(text.as_str()); match part { - FooterPart::Branch(None) | FooterPart::Cwd(None) => spans.push(text.dim().italic()), - _ => spans.push(text.dim()), + FooterPart::Branch(None) | FooterPart::Cwd(None) => { + spans.push(text.set_style(secondary_text_style()).italic()) + } + _ => spans.push(text.set_style(secondary_text_style())), } used_width + rendered_text_width } @@ -3260,12 +3233,24 @@ fn render_transcript_preview_lines( }; let preview_lines = match state.transcript_previews.get(&thread_id) { Some(TranscriptPreviewState::Loading) => { - vec![vec![" │ ".dim(), "Loading recent transcript...".italic().dim()].into()] + vec![ + vec![ + " │ ".set_style(secondary_text_style()), + "Loading recent transcript..." + .set_style(secondary_text_style()) + .italic(), + ] + .into(), + ] } Some(TranscriptPreviewState::Failed) => vec![ vec![ - " │ ".dim(), - "Could not load transcript preview".italic().red(), + " │ ".set_style(secondary_text_style()), + "Could not load transcript preview" + .set_style(crate::style::status_style( + crate::style::StatusTone::Failure, + )) + .italic(), ] .into(), ], @@ -3295,11 +3280,10 @@ fn render_expanded_session_details( .as_ref() .map(|path| format_directory_display(path, /*max_width*/ None)) .unwrap_or_else(|| "-".to_string()); - let branch = row - .git_branch - .as_ref() - .map(|branch| format!("{SESSION_META_BRANCH_ICON} {branch}")) - .unwrap_or_else(|| format!("{SESSION_META_BRANCH_ICON} no branch")); + let branch = format!( + "{SESSION_META_BRANCH_ICON} {}", + row.git_branch.as_deref().unwrap_or("no branch") + ); vec![ expanded_detail_line("Session:", &session, width), @@ -3312,8 +3296,12 @@ fn render_expanded_session_details( ), expanded_detail_line("Directory:", &directory, width), expanded_detail_line("Branch:", &branch, width), - vec![" │".dim()].into(), - vec![" │ ".dim(), "Conversation:".dim()].into(), + vec![" │".set_style(secondary_text_style())].into(), + vec![ + " │ ".set_style(secondary_text_style()), + "Conversation:".set_style(secondary_text_style()), + ] + .into(), ] } @@ -3325,8 +3313,10 @@ fn render_conversation_preview_lines( if lines.is_empty() { return vec![ vec![ - " └ ".dim(), - "No transcript preview available".italic().dim(), + " └ ".set_style(secondary_text_style()), + "No transcript preview available" + .set_style(secondary_text_style()) + .italic(), ] .into(), ]; @@ -3410,19 +3400,14 @@ fn connector_style_from_content(style: Style) -> Style { } fn conversation_assistant_style() -> Style { - if default_bg().is_some_and(is_light) { - Style::default().fg(Color::Gray) - } else { - Style::default().fg(Color::DarkGray) - } + secondary_text_style() } fn conversation_user_style() -> Style { - if default_bg().is_some_and(is_light) { - Style::default().fg(Color::DarkGray).italic() - } else { - Style::default().fg(Color::Gray).italic() - } + Style::default() + .fg(readable_color_on(Color::Reset, /*background*/ None)) + .not_dim() + .italic() } fn expanded_detail_line(label: &'static str, value: &str, width: u16) -> Line<'static> { @@ -3433,9 +3418,9 @@ fn expanded_detail_line(label: &'static str, value: &str, width: u16) -> Line<'s .saturating_sub(prefix_width + LABEL_WIDTH + gap_width) .max(1); vec![ - " │ ".dim(), - format!("{label: Line<'static> { if state.search_state.is_active() || (state.pagination.is_loading() && state.pagination.next_cursor.is_some()) { - return vec!["Searching…".italic().dim()].into(); + return vec!["Searching…".set_style(secondary_text_style()).italic()].into(); } if state.pagination.reached_scan_cap { let msg = format!( "Search scanned first {} sessions; more may exist", state.pagination.num_scanned_files ); - return vec![Span::from(msg).italic().dim()].into(); + return vec![Span::from(msg).set_style(secondary_text_style()).italic()].into(); } - return vec!["No results for your search".italic().dim()].into(); + return vec![ + "No results for your search" + .set_style(secondary_text_style()) + .italic(), + ] + .into(); } if state.pagination.is_loading() { if state.all_rows.is_empty() && state.pagination.num_scanned_files == 0 { - return vec!["Loading sessions…".italic().dim()].into(); + return vec![ + "Loading sessions…" + .set_style(secondary_text_style()) + .italic(), + ] + .into(); } - return vec!["Loading older sessions…".italic().dim()].into(); + return vec![ + "Loading older sessions…" + .set_style(secondary_text_style()) + .italic(), + ] + .into(); } - vec!["No sessions yet".italic().dim()].into() + vec!["No sessions yet".set_style(secondary_text_style()).italic()].into() } #[cfg(test)] @@ -4620,7 +4620,7 @@ mod tests { } #[test] - fn hint_line_prioritizes_keybinds_when_very_narrow() { + fn hint_line_preserves_primary_action_labels_when_very_narrow() { let loader = page_only_loader(|_| {}); let mut state = PickerState::new( FrameRequester::test_dummy(), @@ -4641,13 +4641,10 @@ mod tests { .join("\n"); assert!(lines.iter().all(|line| line.width() <= width as usize)); - assert!(rendered.contains("enter")); - assert!(rendered.contains("esc")); - assert!(rendered.contains("ctrl+c")); - assert!(rendered.contains("ctrl+o")); - assert!(rendered.contains("ctrl+t")); - assert!(rendered.contains("ctrl+e")); - assert!(rendered.contains("↑/↓")); + assert_eq!( + rendered, + " enter resume esc new ctrl+c quit\n ctrl+o comfy ctrl+t preview" + ); } #[test] @@ -5417,7 +5414,7 @@ session_picker_view = "dense" } #[test] - fn search_line_renders_sort_and_filter_tabs() { + fn toolbar_renders_sort_and_filter_tabs() { use crate::custom_terminal::Terminal; use crate::test_backend::VT100Backend; @@ -5438,7 +5435,7 @@ session_picker_view = "dense" { let mut frame = terminal.get_frame(); - let line = search_line(&state, frame.area().width); + let line = toolbar_for_width(&state, frame.area().width); frame.render_widget_ref(&line, frame.area()); } terminal.flush().expect("flush"); @@ -5450,7 +5447,7 @@ session_picker_view = "dense" } #[test] - fn search_line_compacts_toolbar_on_narrow_width() { + fn toolbar_compacts_on_narrow_width() { let loader = page_only_loader(|_| {}); let state = PickerState::new( FrameRequester::test_dummy(), @@ -5461,12 +5458,12 @@ session_picker_view = "dense" SessionPickerAction::Resume, ); - let line = search_line(&state, /*width*/ 40).to_string(); + let line = toolbar_for_width(&state, /*width*/ 40).to_string(); - assert!(line.contains("Filter:[Cwd]")); - assert!(line.contains("[Active]")); - assert!(line.contains("Sort:[Updated]")); - assert!(line.find("Filter:[Cwd]") < line.find("Sort:[Updated]")); + assert!(line.contains("Filter: Cwd ")); + assert!(line.contains(" Active ")); + assert!(line.contains("Sort: Updated ")); + assert!(line.find("Filter: Cwd ") < line.find("Sort: Updated ")); } fn dense_snapshot_row() -> Row { @@ -5626,7 +5623,7 @@ session_picker_view = "dense" assert_eq!(line.width(), 80); assert_eq!(line.style.fg, selected_session_style().fg); - assert_eq!(line.spans[0].content, "❯ "); + assert_eq!(line.spans[0].content, "› "); } #[test] @@ -6249,7 +6246,7 @@ session_picker_view = "dense" ); assert_eq!( - search_line(&state, /*width*/ 80) + toolbar_for_width(&state, /*width*/ 80) .to_string() .matches("Cwd") .count(), diff --git a/codex-rs/tui/src/resume_picker/archive_tests.rs b/codex-rs/tui/src/resume_picker/archive_tests.rs index f4d232feb1..5923f50152 100644 --- a/codex-rs/tui/src/resume_picker/archive_tests.rs +++ b/codex-rs/tui/src/resume_picker/archive_tests.rs @@ -213,7 +213,7 @@ fn archived_status_preserves_directory_filter_and_hides_archive_shortcut() { "); insta::assert_snapshot!( super::super::toolbar_line(&state, /*compact*/ true).to_string(), - @"Filter:[Cwd] [Archived] Sort:[Updated]" + @"Filter: Cwd Archived Sort: Updated " ); state.toggle_filter_mode(); diff --git a/codex-rs/tui/src/resume_picker/layout.rs b/codex-rs/tui/src/resume_picker/layout.rs new file mode 100644 index 0000000000..25aed489b7 --- /dev/null +++ b/codex-rs/tui/src/resume_picker/layout.rs @@ -0,0 +1,85 @@ +//! Session-picker chrome shares its geometry with paging and rendering. +//! Decorative gaps collapse before the list loses room for two comfortable rows. + +use ratatui::layout::Constraint; +use ratatui::layout::Layout; +use ratatui::layout::Rect; +use ratatui::style::Stylize; +use ratatui::text::Line; + +use super::PickerState; +use super::list_viewport_width; +use super::render_list; +use super::render_picker_footer; +use super::render_transcript_loading_overlay; +use super::search_line; +use super::toolbar_for_width; + +pub(super) struct PickerAreas { + header: Rect, + toolbar: Rect, + search: Rect, + pub(super) list: Rect, + footer: Rect, +} + +pub(super) fn areas(area: Rect) -> PickerAreas { + const FIXED_CHROME_ROWS: u16 = 7; + const MIN_LIST_ROWS: u16 = 6; + let gaps = area + .height + .saturating_sub(FIXED_CHROME_ROWS + MIN_LIST_ROWS) + .min(/*other*/ 3); + let [header, _, toolbar, _, search, _, list, footer] = Layout::vertical([ + Constraint::Length(1), + Constraint::Length(u16::from(gaps >= 3)), + Constraint::Length(1), + Constraint::Length(u16::from(gaps >= 2)), + Constraint::Length(1), + Constraint::Length(u16::from(gaps >= 1)), + Constraint::Min(area.height.saturating_sub(FIXED_CHROME_ROWS + gaps)), + Constraint::Length(4), + ]) + .areas(area); + PickerAreas { + header, + toolbar, + search, + list, + footer, + } +} + +pub(super) fn render(frame: &mut crate::custom_terminal::Frame, state: &PickerState) { + let PickerAreas { + header, + toolbar, + search, + list, + footer, + } = areas(frame.area()); + let chrome = |area: Rect| { + Rect::new( + area.x.saturating_add(/*rhs*/ 1), + area.y, + area.width.saturating_sub(/*rhs*/ 2), + area.height, + ) + }; + frame.render_widget_ref(&Line::from(state.action.title().bold()), chrome(header)); + let toolbar = chrome(toolbar); + frame.render_widget_ref(&toolbar_for_width(state, toolbar.width), toolbar); + let search = chrome(search); + frame.render_widget_ref(&search_line(state, search.width), search); + let list = Rect::new( + list.x.saturating_add(/*rhs*/ 2), + list.y, + list_viewport_width(list.width), + list.height, + ); + render_list(frame, list, state); + if state.is_transcript_loading() { + render_transcript_loading_overlay(frame, list); + } + render_picker_footer(frame, footer, state, list.height); +} diff --git a/codex-rs/tui/src/resume_picker_color_tests.rs b/codex-rs/tui/src/resume_picker_color_tests.rs index 81e6caa316..7a61988c84 100644 --- a/codex-rs/tui/src/resume_picker_color_tests.rs +++ b/codex-rs/tui/src/resume_picker_color_tests.rs @@ -1,11 +1,22 @@ use super::*; +use crate::terminal_palette::rgb_color; +use crate::terminal_palette::with_test_default_colors; +use crate::terminal_probe::DefaultColors; +use pretty_assertions::assert_eq; +use ratatui::buffer::Buffer; +use ratatui::style::Modifier; #[test] -fn thread_colors_survive_selection_in_both_picker_layouts() { - let id = ThreadId::from_u128(/*value*/ 42); +fn thread_colors_and_selection_contrast_in_both_picker_layouts() { + let original_theme = crate::render::highlight::current_syntax_theme(); + crate::render::highlight::set_syntax_theme( + two_face::theme::extra() + .get(two_face::theme::EmbeddedThemeName::CatppuccinMocha) + .clone(), + ); let row = Row { path: None, - thread_id: Some(id), + thread_id: Some(ThreadId::from_u128(/*value*/ 42)), thread_name: Some("Named task".into()), preview: "Original prompt".into(), created_at: None, @@ -21,22 +32,307 @@ fn thread_colors_survive_selection_in_both_picker_layouts() { /*filter_cwd*/ None, SessionPickerAction::Resume, ); - let mut snapshot = Vec::new(); - for density in [SessionListDensity::Comfortable, SessionListDensity::Dense] { - state.density = density; - for use_colors in [true, false] { - state.use_theme_colors = use_colors; - for selected in [true, false] { - let lines = render_session_lines( - &row, &state, selected, /*is_expanded*/ false, /*is_zebra*/ false, - /*width*/ 32, + let (fg, bg) = ((0, 0, 0), (255, 255, 255)); + with_test_default_colors(DefaultColors { fg, bg }, || { + for density in [SessionListDensity::Comfortable, SessionListDensity::Dense] { + state.density = density; + for use_colors in [true, false] { + state.use_theme_colors = use_colors; + for (selected, zebra) in [(true, false), (false, false), (false, true)] { + let lines = render_session_lines( + &row, &state, selected, /*is_expanded*/ false, zebra, + /*width*/ 40, + ); + let area = Rect::new( + /*x*/ 0, + /*y*/ 0, + /*width*/ 40, + lines.len() as u16, + ); + let mut buffer = Buffer::empty(area); + buffer.set_style(area, Style::default().fg(rgb_color(fg)).bg(rgb_color(bg))); + for (y, line) in lines.into_iter().enumerate() { + line.render( + Rect::new(/*x*/ 0, y as u16, /*width*/ 40, /*height*/ 1), + &mut buffer, + ); + } + let title_x = match density { + SessionListDensity::Comfortable => 2, + SessionListDensity::Dense => 14, + }; + let title_cell = &buffer[(title_x, 0)]; + if use_colors { + assert_eq!( + title_cell.fg, + readable_color_on( + crate::thread_color::thread_color(row.thread_id.unwrap()), + Some(title_cell.bg), + ) + ); + } else { + let expected = if selected { + selected_session_style().fg.unwrap() + } else { + rgb_color(fg) + }; + assert_eq!(title_cell.fg, expected); + } + if selected { + let style = selected_session_style(); + let metadata_y = match density { + SessionListDensity::Comfortable => 1, + SessionListDensity::Dense => 0, + }; + let metadata = &buffer[(2, metadata_y)]; + assert_eq!( + ( + buffer + .content + .iter() + .map(|cell| cell.bg) + .collect::>(), + title_cell.modifier.contains(Modifier::BOLD), + metadata.modifier.contains(Modifier::BOLD), + ), + (vec![style.bg.unwrap(); buffer.content.len()], true, false) + ); + } + } + } + } + }); + crate::render::highlight::set_syntax_theme(original_theme); +} + +#[test] +fn loading_overlay_clears_inherited_selection_colors_and_reversal() { + use crate::custom_terminal::Terminal; + use crate::test_backend::VT100Backend; + + let width = UnicodeWidthStr::width("Loading transcript…") as u16; + let area = Rect::new(/*x*/ 0, /*y*/ 0, width, /*height*/ 1); + let backend = VT100Backend::new(width, /*height*/ 1); + let mut terminal = Terminal::with_options(backend).expect("terminal"); + terminal.set_viewport_area(area); + let mut frame = terminal.get_frame(); + frame.buffer.set_style( + area, + Style::default().red().bg(Color::Yellow).dim().reversed(), + ); + + render_transcript_loading_overlay(&mut frame, area); + + let style = transcript_loading_overlay_style(); + assert_eq!( + frame + .buffer + .content + .iter() + .map(|cell| (cell.fg, cell.bg, cell.modifier)) + .collect::>(), + vec![ + ( + style.fg.unwrap_or(Color::Reset), + style.bg.unwrap_or(Color::Reset), + Modifier::BOLD + ); + usize::from(width) + ] + ); +} + +#[test] +fn compact_picker_keeps_metadata_and_labeled_primary_actions() { + use crate::custom_terminal::Terminal; + use crate::test_backend::VT100Backend; + + let timestamp = parse_timestamp_str("2026-09-17T12:00:00Z"); + let mut state = PickerState::new( + FrameRequester::test_dummy(), + Arc::new(|_| {}), + ProviderFilter::Any, + /*show_all*/ false, + Some(PathBuf::from("/tmp/codex")), + SessionPickerAction::Resume, + ); + state.relative_time_reference = timestamp; + state.filtered_rows = (1..=6) + .map(|id| Row { + path: None, + thread_id: Some(ThreadId::from_u128(id)), + thread_name: Some(format!("Session {id}")), + preview: String::new(), + created_at: timestamp, + updated_at: timestamp, + cwd: Some(PathBuf::from("/tmp/codex")), + git_branch: Some("fcoury/contrast".into()), + }) + .collect(); + let mut snapshots = Vec::new(); + with_test_default_colors( + DefaultColors { + fg: (32, 32, 32), + bg: (255, 255, 255), + }, + || { + for (action, width, height) in [ + (SessionPickerAction::Resume, 118, 30), + (SessionPickerAction::Resume, 70, 12), + (SessionPickerAction::Resume, 38, 12), + (SessionPickerAction::Fork, 38, 12), + ] { + state.action = action; + let backend = VT100Backend::new(width, height); + let mut terminal = Terminal::with_options(backend).expect("terminal"); + let area = Rect::new(/*x*/ 0, /*y*/ 0, width, height); + terminal.set_viewport_area(area); + let list = layout::areas(area).list; + state.update_viewport(usize::from(list.height), list_viewport_width(width)); + { + let mut frame = terminal.get_frame(); + layout::render(&mut frame, &state); + let progress = frame + .buffer + .content + .iter() + .filter(|cell| cell.symbol() == "%") + .collect::>(); + assert_eq!(progress.len(), 1); + assert!(!progress[0].modifier.contains(Modifier::DIM)); + } + terminal.flush().expect("flush"); + let text = terminal.backend().to_string(); + assert!( + text.contains(" fcoury/contrast"), + "{width}x{height}: {text}" ); - snapshot.push(format!( - "{density:?} colors={use_colors} selected={selected}: {:?}", - lines[0] - )); + assert!(text.contains("now"), "{width}x{height}: {text}"); + assert!( + text.contains(&format!("enter {}", action.action_label())), + "{action:?} {width}x{height}: {text}" + ); + assert!( + text.contains(if width >= FOOTER_COMPACT_BREAKPOINT { + "esc start new" + } else { + "esc new" + }), + "{width}x{height}: {text}" + ); + snapshots.push(format!("{action:?} {width}x{height}\n{text}")); + } + }, + ); + insta::assert_snapshot!(snapshots.join("\n")); +} + +#[test] +fn reversed_selection_overrides_thread_color() { + let line = apply_line_background( + Line::from("Title".fg(rgb_color((150, 50, 170)))), + Style::default() + .fg(Color::Reset) + .bg(Color::Reset) + .reversed(), + /*width*/ 12, + ); + assert_eq!( + ( + line.spans[0].style.fg, + line.spans[0] + .style + .add_modifier + .contains(Modifier::REVERSED) + ), + (Some(Color::Reset), true) + ); +} + +#[tokio::test] +async fn narrow_toolbar_keeps_keyboard_focused_control_visible() { + use crate::custom_terminal::Terminal; + use crate::test_backend::VT100Backend; + + let mut snapshots = Vec::new(); + for action in [SessionPickerAction::Resume, SessionPickerAction::Fork] { + for (width, height) in [(38, 12), (24, 9)] { + let mut state = PickerState::new( + FrameRequester::test_dummy(), + Arc::new(|_| {}), + ProviderFilter::Any, + /*show_all*/ false, + Some(PathBuf::from("/tmp/codex")), + action, + ); + if matches!(action, SessionPickerAction::Resume) { + state.status = SessionStatus::Archived; + } + let controls: &[(&str, &str)] = match action { + SessionPickerAction::Resume => &[ + ("Filter: Cwd", "Cwd"), + ("Status: Archived", "Archived"), + ("Sort: Updated", "Updated"), + ], + SessionPickerAction::Fork => { + &[("Filter: Cwd", "Cwd"), ("Sort: Updated", "Updated")] + } + }; + for &(label, value) in controls { + with_test_default_colors( + DefaultColors { + fg: (32, 32, 32), + bg: (130, 130, 130), + }, + || { + let backend = VT100Backend::new(width, height); + let mut terminal = Terminal::with_options(backend).expect("terminal"); + let area = Rect::new(/*x*/ 0, /*y*/ 0, width, height); + terminal.set_viewport_area(area); + let list = layout::areas(area).list; + state.update_viewport(usize::from(list.height), list_viewport_width(width)); + let mut frame = terminal.get_frame(); + layout::render(&mut frame, &state); + let rows = frame + .buffer + .content + .chunks(usize::from(width)) + .map(|row| { + row.iter() + .map(ratatui::buffer::Cell::symbol) + .collect::() + }) + .collect::>(); + let y = rows + .iter() + .position(|row| row.contains(label)) + .expect("keyboard-focused toolbar control stays visible"); + let x = rows[y].find(value).unwrap(); + let focused_style = crate::bottom_pane::active_tab_style(); + let cells = (x..x + value.len()) + .map(|x| &frame.buffer[(x as u16, y as u16)]) + .collect::>(); + assert_eq!( + cells.iter().map(|cell| cell.bg).collect::>(), + vec![focused_style.bg.unwrap(); value.len()] + ); + assert!( + cells + .iter() + .all(|cell| !cell.modifier.contains(Modifier::DIM)) + ); + snapshots.push(format!( + "{action:?} {width}x{height} {label}\n{}", + rows[y].trim_end() + )); + }, + ); + state + .handle_key(KeyEvent::from(KeyCode::Tab)) + .await + .unwrap(); } } } - insta::assert_snapshot!(snapshot.join("\n")); + insta::assert_snapshot!(snapshots.join("\n\n")); } diff --git a/codex-rs/tui/src/snapshots/codex_tui__resume_picker__color_tests__compact_picker_keeps_metadata_and_labeled_primary_actions.snap b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__color_tests__compact_picker_keeps_metadata_and_labeled_primary_actions.snap new file mode 100644 index 0000000000..8e384a2567 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__color_tests__compact_picker_keeps_metadata_and_labeled_primary_actions.snap @@ -0,0 +1,70 @@ +--- +source: tui/src/resume_picker_color_tests.rs +expression: "snapshots.join(\"\\n\")" +--- +Resume 118x30 + Resume a previous session + + Filter: Cwd All Status: Active Archived Sort: Updated Created + + Type to search + + › Session 1 + now  fcoury/contrast + + Session 2 + now  fcoury/contrast + + Session 3 + now  fcoury/contrast + + Session 4 + now  fcoury/contrast + + Session 5 + now  fcoury/contrast + + Session 6 + now  fcoury/contrast + + + +─────────────────────────────────────────────────────────────────────────────────────────────────────── 1 / 6 · 100% ─ + enter resume ctrl+a archive esc new ctrl+c quit tab focus ←/→ option + ctrl+o dense ctrl+t preview ctrl+e exp ↑/↓ browse +Resume 70x12 + Resume a previous session + Filter: Cwd Active Sort: Updated + Type to search + › Session 1 + now  fcoury/contrast + + Session 2 + ↓ more +───────────────────────────────────────────────────────── 1 / 6 · 0% ─ + enter resume ctrl+a archive esc new ctrl+c quit tab focus + ctrl+o dense ctrl+t preview ctrl+e exp ↑/↓ browse +Resume 38x12 + Resume a previous session + Filter: Cwd Active Sort: Updated + Type to search + › Session 1 + now  fcoury/contrast + + Session 2 + ↓ more +───────────────────────── 1 / 6 · 0% ─ + enter resume esc new + ctrl+o dense ctrl+t preview +Fork 38x12 + Fork a previous session + Filter: Cwd Sort: Updated + Type to search + › Session 1 + now  fcoury/contrast + + Session 2 + ↓ more +───────────────────────── 1 / 6 · 0% ─ + enter fork esc new ctrl+c quit + ctrl+o dense ctrl+t preview diff --git a/codex-rs/tui/src/snapshots/codex_tui__resume_picker__color_tests__narrow_toolbar_keeps_keyboard_focused_control_visible.snap b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__color_tests__narrow_toolbar_keeps_keyboard_focused_control_visible.snap new file mode 100644 index 0000000000..56d0f524c0 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__color_tests__narrow_toolbar_keeps_keyboard_focused_control_visible.snap @@ -0,0 +1,33 @@ +--- +source: tui/src/resume_picker_color_tests.rs +expression: "snapshots.join(\"\\n\\n\")" +--- +Resume 38x12 Filter: Cwd + Filter: Cwd + +Resume 38x12 Status: Archived + Status: Archived + +Resume 38x12 Sort: Updated + Sort: Updated + +Resume 24x9 Filter: Cwd + Filter: Cwd + +Resume 24x9 Status: Archived + Status: Archived + +Resume 24x9 Sort: Updated + Sort: Updated + +Fork 38x12 Filter: Cwd + Filter: Cwd Sort: Updated + +Fork 38x12 Sort: Updated + Filter: Cwd Sort: Updated + +Fork 24x9 Filter: Cwd + Filter: Cwd + +Fork 24x9 Sort: Updated + Sort: Updated diff --git a/codex-rs/tui/src/snapshots/codex_tui__resume_picker__color_tests__thread_colors_survive_selection_in_both_picker_layouts.snap b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__color_tests__thread_colors_survive_selection_in_both_picker_layouts.snap deleted file mode 100644 index 45c71c8c15..0000000000 --- a/codex-rs/tui/src/snapshots/codex_tui__resume_picker__color_tests__thread_colors_survive_selection_in_both_picker_layouts.snap +++ /dev/null @@ -1,12 +0,0 @@ ---- -source: tui/src/resume_picker_color_tests.rs -expression: "snapshot.join(\"\\n\")" ---- -Comfortable colors=true selected=true: Line::from_iter([Span::from("❯ ").yellow().bold(), Span::from("Named task").fg(Color::Rgb(242, 205, 205)), Span::from(" ").yellow()]).yellow() -Comfortable colors=true selected=false: Line::from_iter([Span::from(" "), Span::from("Named task").fg(Color::Rgb(242, 205, 205))]) -Comfortable colors=false selected=true: Line::from_iter([Span::from("❯ ").yellow().bold(), Span::from("Named task").yellow(), Span::from(" ").yellow()]).yellow() -Comfortable colors=false selected=false: Line::from_iter([Span::from(" "), Span::from("Named task")]) -Dense colors=true selected=true: Line::from_iter([Span::from("❯ ").yellow().bold(), Span::from("- ").dim(), Span::from("Named task ").fg(Color::Rgb(242, 205, 205))]).yellow() -Dense colors=true selected=false: Line::from_iter([Span::from(" "), Span::from("- ").dim(), Span::from("Named task ").fg(Color::Rgb(242, 205, 205))]) -Dense colors=false selected=true: Line::from_iter([Span::from("❯ ").yellow().bold(), Span::from("- ").dim(), Span::from("Named task ").yellow()]).yellow() -Dense colors=false selected=false: Line::from_iter([Span::from(" "), Span::from("- ").dim(), Span::from("Named task ")]) diff --git a/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_db_fallback_transition.snap b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_db_fallback_transition.snap index 61158efc1a..538c644b64 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_db_fallback_transition.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_db_fallback_transition.snap @@ -5,8 +5,8 @@ expression: "format!(\"---- loading fallback ----\\n{loading_snapshot}\\n---- re ---- loading fallback ---- Loading sessions… ---- repaired results ---- -❯ first store result - 1d ago ⌁ no cwd  no branch +› first store result + 1d ago ⌁ no cwd  no branch repaired store result 1d ago ⌁ no cwd  no branch diff --git a/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_dense_all.snap b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_dense_all.snap index 62cb8d199b..6ebc20a8a2 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_dense_all.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_dense_all.snap @@ -2,4 +2,4 @@ source: tui/src/resume_picker.rs expression: "render_dense_row_snapshot(true, None, 120,)" --- -❯ 15m ago Propose session picker redesign with enough title text to exercise truncation +› 15m ago Propose session picker redesign with enough title text to exercise truncation diff --git a/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_dense_all_auto_hidden_cwd.snap b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_dense_all_auto_hidden_cwd.snap index 94f74b55f3..89b2ec29cd 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_dense_all_auto_hidden_cwd.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_dense_all_auto_hidden_cwd.snap @@ -2,4 +2,4 @@ source: tui/src/resume_picker.rs expression: "render_dense_row_snapshot(true, None, 100,)" --- -❯ 15m ago Propose session picker redesign with enough title text to exercise truncation +› 15m ago Propose session picker redesign with enough title text to exercise truncation diff --git a/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_dense_all_forced_cwd.snap b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_dense_all_forced_cwd.snap index 9b8c39ef50..3b25001697 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_dense_all_forced_cwd.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_dense_all_forced_cwd.snap @@ -2,4 +2,4 @@ source: tui/src/resume_picker.rs expression: "render_dense_row_snapshot(true, None, 48,)" --- -❯ 15m ago Propose session picker redesig... +› 15m ago Propose session picker redesig... diff --git a/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_dense_cwd.snap b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_dense_cwd.snap index 64120ef26a..d68f265a30 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_dense_cwd.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_dense_cwd.snap @@ -2,4 +2,4 @@ source: tui/src/resume_picker.rs expression: "render_dense_row_snapshot(false,\nSome(PathBuf::from(\"/Users/felipe.coury/code/codex.fcoury-session-picker/codex-rs\")),\n100,)" --- -❯ 15m ago Propose session picker redesign with enough title text to exercise truncation +› 15m ago Propose session picker redesign with enough title text to exercise truncation diff --git a/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_dense_narrow.snap b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_dense_narrow.snap index 9b8c39ef50..3b25001697 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_dense_narrow.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_dense_narrow.snap @@ -2,4 +2,4 @@ source: tui/src/resume_picker.rs expression: "render_dense_row_snapshot(true, None, 48,)" --- -❯ 15m ago Propose session picker redesig... +› 15m ago Propose session picker redesig... diff --git a/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_dense_no_blank_lines.snap b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_dense_no_blank_lines.snap index 50e37a46b9..9ab736d3c1 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_dense_no_blank_lines.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_dense_no_blank_lines.snap @@ -3,4 +3,4 @@ source: tui/src/resume_picker.rs expression: terminal.backend().to_string() --- 15m ago First dense row -❯ 15m ago Second dense row +› 15m ago Second dense row diff --git a/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_linked_worktree.snap b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_linked_worktree.snap index 12b962d6e9..9232324d38 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_linked_worktree.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_linked_worktree.snap @@ -2,5 +2,5 @@ source: tui/src/resume_picker.rs expression: "render_picker_list(&state, 100, 12)" --- -❯ Linked session +› Linked session 1d ago ⌁ /repo/linked  no branch diff --git a/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_more_indicators.snap b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_more_indicators.snap index 76a645aa07..a107938c7a 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_more_indicators.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_more_indicators.snap @@ -3,8 +3,8 @@ source: tui/src/resume_picker.rs expression: terminal.backend().to_string() --- ↑ more -❯ item-2 - 10m ago ⌁ no cwd  no branch +› item-2 + 10m ago ⌁ no cwd  no branch item-3 ↓ more diff --git a/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_narrow_session.snap b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_narrow_session.snap index db583b9957..a8ef2f3a7d 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_narrow_session.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_narrow_session.snap @@ -2,6 +2,6 @@ source: tui/src/resume_picker.rs expression: terminal.backend().to_string() --- -❯ Investigate picker expansion - 15m ago ⌁ /tmp/codex +› Investigate picker expansion + 15m ago ⌁ /tmp/codex  fcoury/session-picker diff --git a/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_search_line_sort_filter_tabs.snap b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_search_line_sort_filter_tabs.snap index 7d9be2926d..3e250ca77f 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_search_line_sort_filter_tabs.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_search_line_sort_filter_tabs.snap @@ -2,4 +2,4 @@ source: tui/src/resume_picker.rs expression: terminal.backend().to_string() --- -Type to search Filter: [Cwd] All Status: [Active] Archived Sort: [Updated] Created +Filter: Cwd All Status: Active Archived Sort: Updated Created diff --git a/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_table.snap b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_table.snap index b882050d58..85103e4c63 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_table.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_table.snap @@ -5,8 +5,8 @@ expression: snapshot Fix resume picker timestamps 42s ago ⌁ no cwd  no branch -❯ Investigate lazy pagination cap - 35m ago ⌁ no cwd  no branch +› Investigate lazy pagination cap + 35m ago ⌁ no cwd  no branch Explain the codebase 2h ago ⌁ no cwd  no branch diff --git a/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_transcript_loading_overlay.snap b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_transcript_loading_overlay.snap index fa6b47cbbd..470e992163 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_transcript_loading_overlay.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_transcript_loading_overlay.snap @@ -2,7 +2,7 @@ source: tui/src/resume_picker.rs expression: snapshot --- -❯ Find pending threads and emails +› Find pending threads and emails - ⌁ no cwd  no branch Plan raw scrollback mod Loading transcript… diff --git a/codex-rs/tui/src/startup_hooks_review.rs b/codex-rs/tui/src/startup_hooks_review.rs index a1ab4b3cb8..160253586e 100644 --- a/codex-rs/tui/src/startup_hooks_review.rs +++ b/codex-rs/tui/src/startup_hooks_review.rs @@ -262,7 +262,6 @@ fn selection_view_params( } SelectionViewParams { - appearance: crate::bottom_pane::SelectionAppearance::Picker, footer_hint: Some(hints.into()), items: vec![ selection_item("Review hooks", trusting_all), diff --git a/codex-rs/tui/src/theme_picker.rs b/codex-rs/tui/src/theme_picker.rs index 8cbabdaa5f..562997efee 100644 --- a/codex-rs/tui/src/theme_picker.rs +++ b/codex-rs/tui/src/theme_picker.rs @@ -388,7 +388,6 @@ pub(crate) fn build_theme_picker_params( }) as Box, ); SelectionViewParams { - appearance: crate::bottom_pane::SelectionAppearance::Picker, title: Some("Select Syntax Theme".to_string()), subtitle: Some(theme_picker_subtitle( codex_home_owned.as_deref(),