use pretty_assertions::assert_eq;
use ratatui::{layout::Rect, text::Line};
use super::super::{
PickerAction, PickerBadge, PickerBadgePlacement, PickerBadgeTone, PickerItem, PickerLayout,
UiPicker,
};
use super::*;
fn line_text(line: &Line<'_>) -> String {
line.spans
.iter()
.map(|span| span.content.as_ref())
.collect()
}
fn sample_picker(detail_a: &str, detail_b: &str) -> UiPicker {
UiPicker::new(
"loaded agents",
"help",
vec![
PickerItem {
section: None,
label: "explorer".into(),
detail: Some(detail_a.into()),
preview: None,
badge: Some(PickerBadge {
text: "internal".into(),
tone: PickerBadgeTone::Internal,
}),
value: "explorer".into(),
},
PickerItem {
section: None,
label: "worker".into(),
detail: Some(detail_b.into()),
preview: None,
badge: None,
value: "worker".into(),
},
],
PickerAction::ViewAgent,
)
.with_layout(PickerLayout::Overlay)
.with_overlay_chrome(OverlayChrome {
nav_label: " AGENTS".into(),
detail_label: Some(" DETAILS".into()),
nav_keys_hint: "↑↓ agents".into(),
})
}
fn tree_like_picker() -> UiPicker {
UiPicker::new(
"Conversation tree",
"help",
vec![
PickerItem {
section: None,
label: "◆ root turn".into(),
detail: None,
preview: None,
badge: None,
value: "root".into(),
},
PickerItem {
section: None,
label: "└─ ◆ branch turn".into(),
detail: None,
preview: None,
badge: Some(PickerBadge {
text: "active".into(),
tone: PickerBadgeTone::Selected,
}),
value: "branch".into(),
},
],
PickerAction::SelectTreeNode,
)
.with_layout(PickerLayout::Overlay)
.with_overlay_chrome(OverlayChrome {
nav_label: " TREE".into(),
detail_label: None,
nav_keys_hint: "↑↓ turns".into(),
})
}
fn long_detail() -> String {
(0..40)
.map(|index| format!("detail line {index:02}"))
.collect::<Vec<_>>()
.join("\n")
}
fn nav_and_detail_panes(layout: &OverlayLayout) -> OverlayPanes {
match layout.panes {
panes @ OverlayPanes::NavAndDetail { .. } => panes,
OverlayPanes::NavOnly { .. } => panic!("expected nav+detail panes, got nav-only"),
}
}
#[test]
fn section_headers_follow_filtered_items_without_becoming_selectable() {
let mut picker = sample_picker("agent detail", "worker detail");
picker.items[0].section = Some("INTERNAL".into());
picker.items[1].section = Some("CUSTOM".into());
picker.filter = "custom".into();
picker.select_first_match();
let frame = render_picker_overlay(&picker, Rect::new(0, 0, 100, 30));
let rendered = frame.lines.iter().map(line_text).collect::<Vec<_>>();
assert!(rendered.iter().any(|line| line.contains("CUSTOM")));
assert!(rendered.iter().all(|line| !line.contains("INTERNAL")));
assert_eq!(picker.selected_item().unwrap().label, "worker");
}
#[test]
fn detail_badges_move_status_out_of_navigation_rows() {
let picker = sample_picker("agent detail", "worker detail")
.with_badge_placement(PickerBadgePlacement::Detail);
let frame = render_picker_overlay(&picker, Rect::new(0, 0, 100, 30));
let selected_row = frame
.lines
.iter()
.map(line_text)
.find(|line| line.contains("→ explorer"))
.unwrap();
let (navigation, detail) = selected_row.split_once(SEPARATOR).unwrap();
assert!(!navigation.contains("internal"), "{selected_row}");
assert!(detail.contains("Status internal"), "{selected_row}");
}
#[test]
fn detail_badge_rows_never_exceed_narrow_overlay_widths() {
let picker = sample_picker("agent detail", "worker detail")
.with_badge_placement(PickerBadgePlacement::Detail);
let mut long_badge = picker;
long_badge.items[0].badge = Some(PickerBadge {
text: "healthy-and-also-very-long-status-label".into(),
tone: PickerBadgeTone::Healthy,
});
for width in [8_u16, 12, 18, 24, 36, 48] {
let frame = render_picker_overlay(&long_badge, Rect::new(0, 0, width, 20));
for line in &frame.lines {
let text = line_text(line);
let measured = super::super::display_width(&text);
assert!(
measured <= width as usize,
"width {width}: measured {measured} for {text:?}"
);
}
assert!(
frame
.lines
.iter()
.map(line_text)
.any(|line| line.contains("Status")
|| line.contains("…")
|| line.contains("healthy")),
"expected a detail badge row at width {width}"
);
}
}
#[test]
fn tiny_stacked_layout_keeps_viewports_within_the_body() {
let layout = picker_overlay_layout(Rect::new(0, 0, 20, 1), true);
let OverlayPanes::NavAndDetail {
orientation,
detail_viewport_rows,
nav_viewport_rows,
..
} = nav_and_detail_panes(&layout)
else {
unreachable!()
};
assert_eq!(orientation, OverlayOrientation::Stacked);
let separator_rows = usize::from(layout.body_rows > 2);
assert!(detail_viewport_rows + nav_viewport_rows + separator_rows <= layout.body_rows);
assert_eq!(nav_viewport_rows, 1);
}
#[test]
fn popup_uses_most_of_the_terminal_and_stays_centered() {
let area = Rect::new(0, 0, 100, 40);
let layout = picker_overlay_layout(area, true);
let outer = layout.outer;
assert!(outer.width >= 90);
assert!(outer.height >= 34);
assert_eq!(outer.x, (area.width - outer.width) / 2);
assert_eq!(outer.y, (area.height - outer.height) / 2);
}
#[test]
fn side_by_side_layout_keeps_stable_height_and_shows_selected_detail() {
let area = Rect::new(0, 0, 80, 24);
let layout = picker_overlay_layout(area, true);
let mut picker = sample_picker(
"Description\nread-only investigation\n\nTools\nlist_dir, read_file",
"Description\nimplementation work",
);
let first = render_picker_overlay(&picker, area);
let first_text = first
.lines
.iter()
.map(line_text)
.collect::<Vec<_>>()
.join("\n");
picker.selected = 1;
picker.reset_detail_scroll();
let second = render_picker_overlay(&picker, area);
let second_text = second
.lines
.iter()
.map(line_text)
.collect::<Vec<_>>()
.join("\n");
let OverlayPanes::NavAndDetail { orientation, .. } = nav_and_detail_panes(&layout) else {
unreachable!()
};
let divider_columns = first_text
.lines()
.skip(3)
.take(layout.body_rows)
.map(|line| {
let divider = line
.find(" │ ")
.expect("each body row should keep its divider");
line[..divider].chars().count()
})
.collect::<Vec<_>>();
assert_eq!(orientation, OverlayOrientation::SideBySide);
assert!(divider_columns
.iter()
.all(|column| *column == divider_columns[0]));
assert_eq!(first.lines.len(), layout.outer.height as usize);
assert_eq!(second.lines.len(), first.lines.len());
assert!(first_text.contains('│'));
assert!(first_text.contains('┬'), "{first_text}");
assert!(first_text.contains('┴'), "{first_text}");
let top_junction = first_text
.lines()
.find(|line| line.contains('┬'))
.expect("top junction");
let bottom_junction = first_text
.lines()
.find(|line| line.contains('┴'))
.expect("bottom junction");
let body_divider = first_text
.lines()
.find(|line| line.contains(" │ ") && line.contains('→'))
.expect("body divider");
let char_col = |line: &str, needle: char| line.chars().position(|ch| ch == needle);
let pane_divider_col = |line: &str| -> Option<usize> {
let chars = line.chars().collect::<Vec<_>>();
chars
.windows(3)
.position(|window| window == [' ', '│', ' '])
.map(|index| index + 1)
};
assert_eq!(char_col(top_junction, '┬'), pane_divider_col(body_divider));
assert_eq!(
char_col(bottom_junction, '┴'),
pane_divider_col(body_divider)
);
let split = pane_divider_col(body_divider);
let divider_rows = first
.lines
.iter()
.map(line_text)
.filter(|line| pane_divider_col(line) == split)
.count();
assert!(
divider_rows > layout.body_rows,
"expected continuous divider through header/body, got {divider_rows}"
);
assert!(first_text.contains("read-only investigation"));
assert!(first_text.contains("internal"));
assert!(!first_text.contains("implementation work"));
assert!(second_text.contains("implementation work"));
assert!(first_text.contains("↑↓"));
assert!(first_text.contains("PgUp/PgDn"));
assert!(first_text.contains("AGENTS"));
assert!(first_text.contains("DETAILS"));
assert_eq!(
first.cursor,
ratatui::layout::Position {
x: layout
.outer
.x
.saturating_add(1)
.saturating_add(filter_cursor_x("", layout.inner_width)),
y: layout.outer.y.saturating_add(1),
}
);
}
#[test]
fn stacked_layout_places_detail_above_navigation() {
let area = Rect::new(0, 0, 48, 24);
let layout = picker_overlay_layout(area, true);
let picker = sample_picker(
"Description\nread-only investigation across many files",
"Description\nimplementation work",
);
let frame = render_picker_overlay(&picker, area);
let text_lines = frame.lines.iter().map(line_text).collect::<Vec<_>>();
let detail_row = text_lines
.iter()
.position(|line| line.contains("read-only investigation"))
.unwrap();
let selected_row = text_lines
.iter()
.position(|line| line.contains("explorer"))
.unwrap();
let OverlayPanes::NavAndDetail { orientation, .. } = nav_and_detail_panes(&layout) else {
unreachable!()
};
assert_eq!(orientation, OverlayOrientation::Stacked);
assert!(!text_lines.iter().any(|line| line.contains(" │ ")));
assert!(detail_row < selected_row, "{text_lines:#?}");
let has_stacked_separator = text_lines.iter().enumerate().any(|(index, line)| {
index > detail_row
&& index < selected_row
&& line.starts_with('├')
&& line.chars().all(|ch| matches!(ch, '├' | '─' | '┤'))
});
assert!(
has_stacked_separator,
"stacked layout should draw a rule between detail and nav: {text_lines:#?}"
);
}
#[test]
fn missing_item_details_uses_full_width_navigation() {
let area = Rect::new(0, 0, 80, 24);
let layout = picker_overlay_layout(area, false);
let picker = tree_like_picker();
let frame = render_picker_overlay(&picker, area);
let text = frame
.lines
.iter()
.map(line_text)
.collect::<Vec<_>>()
.join("\n");
assert!(!picker.has_item_details());
assert!(!picker.has_scrollable_detail());
assert_eq!(
layout.panes,
OverlayPanes::NavOnly {
nav_width: layout.inner_width,
nav_viewport_rows: layout.body_rows,
}
);
assert_eq!(
layout.page_target(),
OverlayPageTarget::Nav {
rows: layout.body_rows.max(1)
}
);
assert!(text.contains(" TREE"));
assert!(text.contains("root turn"));
assert!(text.contains("branch turn"));
assert!(!text.contains(" DETAILS"));
assert!(!text.contains(" │ "));
assert!(!text.contains("PgUp/PgDn details"));
assert!(text.contains("PgUp/PgDn"));
assert!(text.contains("↑↓ turns"));
}
#[test]
fn detail_scroll_reveals_content_below_the_viewport() {
let area = Rect::new(0, 0, 80, 16);
let layout = picker_overlay_layout(area, true);
let mut picker = sample_picker(&long_detail(), "other");
let viewport = layout.detail_viewport().expect("detail viewport");
let hidden_index = viewport.rows.saturating_add(5);
let hidden_marker = format!("detail line {hidden_index:02}");
let unscroll = render_picker_overlay(&picker, area);
picker.scroll_detail_by(hidden_index as isize, viewport);
let scrolled = render_picker_overlay(&picker, area);
let unscroll_text = unscroll
.lines
.iter()
.map(line_text)
.collect::<Vec<_>>()
.join("\n");
let scrolled_text = scrolled
.lines
.iter()
.map(line_text)
.collect::<Vec<_>>()
.join("\n");
assert!(unscroll_text.contains("detail line 00"));
assert!(!unscroll_text.contains(&hidden_marker));
assert!(scrolled_text.contains(&hidden_marker));
assert!(!scrolled_text.contains("detail line 00"));
assert_eq!(unscroll.lines.len(), scrolled.lines.len());
}
#[test]
fn clamp_detail_scroll_respects_viewport() {
assert_eq!(clamp_detail_scroll(100, 12, 5), 7);
assert_eq!(clamp_detail_scroll(0, 3, 5), 0);
assert_eq!(clamp_detail_scroll(2, 10, 10), 0);
}
#[test]
fn overlay_detail_end_scroll_uses_max_without_sentinel() {
let area = Rect::new(0, 0, 80, 16);
let layout = picker_overlay_layout(area, true);
let mut picker = sample_picker(&long_detail(), "other");
let viewport = layout.detail_viewport().expect("detail viewport");
picker.scroll_detail_end(viewport);
let line_count = overlay_detail_lines(picker.selected_detail(), viewport.width).len();
let expected = line_count.saturating_sub(viewport.rows.max(1));
assert_eq!(picker.detail_scroll, expected);
}
#[test]
fn overlay_uses_single_line_box_chrome() {
let frame = render_picker_overlay(
&sample_picker("agent detail", "worker detail"),
Rect::new(0, 0, 80, 24),
);
let text = frame
.lines
.iter()
.map(line_text)
.collect::<Vec<_>>()
.join("\n");
assert!(text.contains('┌'), "{text}");
assert!(text.contains('└'), "{text}");
assert!(text.contains('│'), "{text}");
assert!(!text.contains('╔'), "{text}");
assert!(!text.contains('║'), "{text}");
assert!(!text.contains('═'), "{text}");
}
#[test]
fn overlay_footer_stays_compact_and_collapses_identical_exits() {
let mut picker = sample_picker("agent detail", "worker detail").with_confirm_verb("close");
let frame = render_picker_overlay(&picker, Rect::new(0, 0, 90, 24));
let footer = frame
.lines
.iter()
.rev()
.map(line_text)
.find(|line| line.contains("PgUp/PgDn"))
.expect("footer");
assert!(footer.contains("Enter/Esc close"), "{footer}");
assert!(!footer.contains("Enter close · Esc close"), "{footer}");
assert!(!footer.contains("Type search"), "{footer}");
assert!(!footer.contains("PgUp/PgDn details"), "{footer}");
assert!(footer.contains("1/2"), "{footer}");
assert!(
!footer.contains('…'),
"footer should fit common terminal widths without truncating: {footer}"
);
picker = sample_picker("agent detail", "worker detail");
assert_eq!(picker.action_footer(), "Enter configure · Esc close");
}
#[test]
fn overlay_footer_drops_detail_range_before_hard_truncation() {
let essential = [
"↑↓ agents",
"PgUp/PgDn",
"Enter configure · Esc close",
"1/5",
];
let detail = DetailFooterStatus {
range: "lines 1-12 of 40".into(),
overflow: Some(DetailOverflow::Below),
};
let full = fit_overlay_footer(&essential, Some(&detail), 120);
assert!(full.contains("lines 1-12 of 40"), "{full}");
assert!(full.contains("↓ more"), "{full}");
assert!(!full.contains('…'), "{full}");
let elided = fit_overlay_footer(&essential, Some(&detail), 70);
assert!(elided.contains("Enter configure · Esc close"), "{elided}");
assert!(elided.contains("1/5"), "{elided}");
assert!(elided.contains("↓ more"), "{elided}");
assert!(!elided.contains("lines "), "{elided}");
assert!(!elided.contains('…'), "{elided}");
let essential_only = fit_overlay_footer(&essential, Some(&detail), 58);
assert!(
essential_only.contains("Enter configure · Esc close"),
"{essential_only}"
);
assert!(essential_only.contains("1/5"), "{essential_only}");
assert!(!essential_only.contains("↓ more"), "{essential_only}");
assert!(!essential_only.contains("lines "), "{essential_only}");
assert!(!essential_only.contains('…'), "{essential_only}");
}
#[test]
fn overlay_footer_elides_on_narrow_rendered_frames() {
let mut picker = sample_picker(&long_detail(), "other").with_confirm_verb("configure");
let frame = render_picker_overlay(&picker, Rect::new(0, 0, 80, 24));
let footer = frame
.lines
.iter()
.rev()
.map(line_text)
.find(|line| line.contains("PgUp/PgDn"))
.expect("footer");
assert!(footer.contains("Enter configure"), "{footer}");
assert!(footer.contains("Esc close"), "{footer}");
assert!(
!footer.contains('…'),
"priority elision should beat hard truncation at common widths: {footer}"
);
if footer.contains("lines ") {
let content = footer
.trim_end()
.strip_prefix('│')
.and_then(|line| line.strip_suffix('│'))
.unwrap_or(footer.trim_end());
assert!(
display_width(content)
<= picker_overlay_layout(Rect::new(0, 0, 80, 24), true).inner_width,
"{footer}"
);
}
picker.scroll_detail_by(
20,
DetailViewport {
width: 40,
rows: 10,
},
);
let scrolled = render_picker_overlay(&picker, Rect::new(0, 0, 80, 24));
let scrolled_footer = scrolled
.lines
.iter()
.rev()
.map(line_text)
.find(|line| line.contains("PgUp/PgDn"))
.expect("footer");
assert!(
!scrolled_footer.contains('…'),
"scrolled detail footer should also avoid hard truncation: {scrolled_footer}"
);
}