mirror of
https://github.com/openai/codex.git
synced 2026-10-20 17:38:45 +00:00
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:
@@ -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()),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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)))],
|
||||
|
||||
@@ -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);
|
||||
|
||||
+38
@@ -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
|
||||
|
||||
@@ -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![
|
||||
|
||||
@@ -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()),
|
||||
|
||||
+4
-3
@@ -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
|
||||
|
||||
+4
-3
@@ -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
|
||||
|
||||
+3
-2
@@ -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
|
||||
|
||||
+6
-4
@@ -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
|
||||
|
||||
+14
-15
@@ -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
|
||||
|
||||
+6
-5
@@ -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.
|
||||
|
||||
+5
-10
@@ -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
|
||||
|
||||
+2
-1
@@ -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
|
||||
|
||||
|
||||
+2
-1
@@ -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),
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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![
|
||||
|
||||
@@ -297,7 +297,6 @@ pub(crate) fn build_keymap_action_menu_params(
|
||||
});
|
||||
|
||||
SelectionViewParams {
|
||||
appearance: crate::bottom_pane::SelectionAppearance::Picker,
|
||||
view_id: Some(KEYMAP_ACTION_MENU_VIEW_ID),
|
||||
header: Box::new(Paragraph::new(header).wrap(Wrap { trim: false })),
|
||||
footer_note: Some(Line::from(vec![
|
||||
@@ -363,7 +362,6 @@ pub(crate) fn build_keymap_replace_binding_menu_params(
|
||||
.collect();
|
||||
|
||||
SelectionViewParams {
|
||||
appearance: crate::bottom_pane::SelectionAppearance::Picker,
|
||||
view_id: Some(KEYMAP_REPLACE_BINDING_MENU_VIEW_ID),
|
||||
header: Box::new(Paragraph::new(header).wrap(Wrap { trim: false })),
|
||||
footer_hint: Some(picker_hint_line_for_keymap(&runtime_keymap.list)),
|
||||
@@ -386,7 +384,6 @@ pub(crate) fn build_keymap_conflict_params(
|
||||
KeymapCaptureMode::SingleKey
|
||||
};
|
||||
SelectionViewParams {
|
||||
appearance: crate::bottom_pane::SelectionAppearance::Picker,
|
||||
header: Box::new(
|
||||
Paragraph::new(vec![
|
||||
Line::from("Shortcut Conflict".bold()),
|
||||
@@ -722,11 +719,6 @@ mod tests {
|
||||
use tokio::sync::mpsc::UnboundedReceiver;
|
||||
use tokio::sync::mpsc::unbounded_channel;
|
||||
|
||||
// Keep layout fixtures portable while runtime defaults remain platform-specific.
|
||||
fn snapshot_runtime(runtime: RuntimeKeymap) -> RuntimeKeymap {
|
||||
runtime
|
||||
}
|
||||
|
||||
fn app_event_sender() -> AppEventSender {
|
||||
let (tx, _rx) = unbounded_channel();
|
||||
AppEventSender::new(tx)
|
||||
@@ -901,7 +893,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn keymap_picker_fast_mode_enabled_snapshot() {
|
||||
let runtime = snapshot_runtime(RuntimeKeymap::defaults());
|
||||
let runtime = RuntimeKeymap::defaults();
|
||||
let params = build_keymap_picker_params_with_filter(
|
||||
&runtime,
|
||||
&TuiKeymap::default(),
|
||||
@@ -995,7 +987,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn picker_content_snapshot() {
|
||||
let runtime = snapshot_runtime(RuntimeKeymap::defaults());
|
||||
let runtime = RuntimeKeymap::defaults();
|
||||
let params = build_keymap_picker_params(&runtime, &TuiKeymap::default());
|
||||
let all_tab = selection_tab(¶ms, KEYMAP_ALL_TAB_ID);
|
||||
let snapshot = params
|
||||
@@ -1190,7 +1182,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn picker_question_actions_snapshot() {
|
||||
let runtime = snapshot_runtime(RuntimeKeymap::defaults());
|
||||
let runtime = RuntimeKeymap::defaults();
|
||||
let params = build_keymap_picker_params_for_selected_action(
|
||||
&runtime,
|
||||
&TuiKeymap::default(),
|
||||
@@ -1223,7 +1215,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn picker_all_tab_items_remain_searchable() {
|
||||
let runtime = snapshot_runtime(RuntimeKeymap::defaults());
|
||||
let runtime = RuntimeKeymap::defaults();
|
||||
let params = build_keymap_picker_params(&runtime, &TuiKeymap::default());
|
||||
let all_tab = selection_tab(¶ms, KEYMAP_ALL_TAB_ID);
|
||||
let snapshot = all_tab
|
||||
@@ -1246,7 +1238,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn picker_wide_render_snapshot() {
|
||||
let runtime = snapshot_runtime(RuntimeKeymap::defaults());
|
||||
let runtime = RuntimeKeymap::defaults();
|
||||
let params = build_keymap_picker_params(&runtime, &TuiKeymap::default());
|
||||
|
||||
assert_snapshot!("keymap_picker_wide", render_picker(params, /*width*/ 120));
|
||||
@@ -1254,7 +1246,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn picker_narrow_render_snapshot() {
|
||||
let runtime = snapshot_runtime(RuntimeKeymap::defaults());
|
||||
let runtime = RuntimeKeymap::defaults();
|
||||
let params = build_keymap_picker_params(&runtime, &TuiKeymap::default());
|
||||
|
||||
assert_snapshot!("keymap_picker_narrow", render_picker(params, /*width*/ 78));
|
||||
@@ -1265,8 +1257,7 @@ mod tests {
|
||||
let keymap =
|
||||
keymap_with_replacement(&TuiKeymap::default(), "composer", "submit", "ctrl-enter")
|
||||
.expect("replace binding");
|
||||
let runtime =
|
||||
snapshot_runtime(RuntimeKeymap::from_config(&keymap).expect("runtime keymap"));
|
||||
let runtime = RuntimeKeymap::from_config(&keymap).expect("runtime keymap");
|
||||
let params = build_keymap_picker_params(&runtime, &keymap);
|
||||
|
||||
assert_snapshot!("keymap_picker_custom", render_picker(params, /*width*/ 120));
|
||||
@@ -1281,8 +1272,7 @@ mod tests {
|
||||
&["ctrl-t".to_string(), "ctrl-x ctrl-t".to_string()],
|
||||
)
|
||||
.expect("global chord binding");
|
||||
let runtime =
|
||||
snapshot_runtime(RuntimeKeymap::from_config(&keymap).expect("runtime keymap"));
|
||||
let runtime = RuntimeKeymap::from_config(&keymap).expect("runtime keymap");
|
||||
let params = build_keymap_picker_params(&runtime, &keymap);
|
||||
|
||||
assert_snapshot!(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
+70
@@ -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
|
||||
+33
@@ -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
|
||||
-12
@@ -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 ")])
|
||||
+2
-2
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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...
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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...
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+2
-2
@@ -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
-2
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
+1
-1
@@ -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…
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user