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
This commit is contained in:
Eric Traut
2026-09-19 19:21:35 +00:00
committed by copyberry
parent c9d13e8c75
commit 5ee2bdf1e0
93 changed files with 922 additions and 755 deletions
-1
View File
@@ -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 {
@@ -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(),
@@ -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()),
-2
View File
@@ -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 {
-1
View File
@@ -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()),
@@ -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()),
-1
View File
@@ -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 {
@@ -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)))],
-1
View File
@@ -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()
@@ -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: {}",
@@ -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()
@@ -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,
@@ -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);
@@ -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
@@ -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),
@@ -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,
@@ -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 {
@@ -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<dyn Fn(&AppEventSender) + Send + Sync>;
@@ -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<usize>,
pub header: Box<dyn Renderable>,
/// Optional shortcut to the complete header, shown when its text is clipped.
pub header_view_all_hint: Option<ShortcutHint>,
/// 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::<Vec<_>>())
.wrap(Wrap { trim: false }),
),
])),
};
header = Box::new(ColumnRenderable::with([
header,
Box::new(
Paragraph::new(title.into_iter().chain(subtitle).collect::<Vec<_>>())
.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<Line<'_>> {
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::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let view = new_view(
@@ -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(),
@@ -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
+4 -15
View File
@@ -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(),
@@ -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()
@@ -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),
@@ -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 {
@@ -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();
@@ -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()),
@@ -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);
@@ -10,7 +10,6 @@ fn browser() -> ListSelectionView {
let (tx, _rx) = unbounded_channel::<AppEvent>();
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![
+8 -104
View File
@@ -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<SelectionItem>,
}
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::<Vec<_>>();
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::<Vec<_>>();
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<Line<'static>> {
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<Span<'static>> = 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<Span<'static>> {
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;
@@ -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()),
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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.
@@ -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
@@ -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
@@ -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
@@ -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());
}
@@ -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()
@@ -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),
-1
View File
@@ -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(|| {
@@ -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,
@@ -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()
-1
View File
@@ -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()),
@@ -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())),
@@ -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![
@@ -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()
@@ -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),
@@ -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()
@@ -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()),
-1
View File
@@ -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()),
@@ -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()),
@@ -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()),
-1
View File
@@ -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()),
@@ -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?")),
@@ -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()),
@@ -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,
@@ -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(),
-1
View File
@@ -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()),
@@ -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(),
@@ -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()
@@ -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 {
@@ -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()),
-9
View File
@@ -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()),
@@ -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,
@@ -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![
+8 -18
View File
@@ -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(&params, 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(&params, 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!(
-1
View File
@@ -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,
-1
View File
@@ -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()),
+206 -209
View File
@@ -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<DateTime<Utc>> {
}
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<Span<'static>>
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<Span<'static>>
];
}
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<Span<'static>
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<Span<'static>
];
}
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<Span<'static>
}
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::<Vec<_>>();
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::<Vec<_>>();
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::<Vec<_>>();
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<FooterPart>, 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<FooterPart>, 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:<LABEL_WIDTH$}").dim(),
" ".dim(),
" │ ".set_style(secondary_text_style()),
format!("{label:<LABEL_WIDTH$}").set_style(secondary_text_style()),
" ".set_style(secondary_text_style()),
truncate_text(value, value_width).into(),
]
.into()
@@ -3517,26 +3502,41 @@ fn render_empty_state_line(state: &PickerState) -> 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(),
@@ -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();
+85
View File
@@ -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);
}
+313 -17
View File
@@ -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::<Vec<_>>(),
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<_>>(),
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::<Vec<_>>();
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::<String>()
})
.collect::<Vec<_>>();
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::<Vec<_>>();
assert_eq!(
cells.iter().map(|cell| cell.bg).collect::<Vec<_>>(),
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"));
}
@@ -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
@@ -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
@@ -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 ")])
@@ -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
@@ -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
@@ -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
@@ -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...
@@ -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
@@ -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...
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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…
-1
View File
@@ -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),
-1
View File
@@ -388,7 +388,6 @@ pub(crate) fn build_theme_picker_params(
}) as Box<dyn Fn(&crate::app_event_sender::AppEventSender) + Send + Sync>,
);
SelectionViewParams {
appearance: crate::bottom_pane::SelectionAppearance::Picker,
title: Some("Select Syntax Theme".to_string()),
subtitle: Some(theme_picker_subtitle(
codex_home_owned.as_deref(),