use ratatui::Frame;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, BorderType, Borders, List, ListItem, ListState, Paragraph, Wrap};
use crate::error::Result;
use crate::tui::app::{App, FlatNode};
use crate::tui::tree::TreeNode;
const HINTS: &str = " j/k move \u{b7} h/l collapse/expand \u{b7} PgUp/PgDn page \u{b7} / search \u{b7} Enter details \u{b7} Space expand \u{b7} i install \u{b7} d delete \u{b7} s sync \u{b7} u upgrade \u{b7} m meld \u{b7} M unmeld \u{b7} C lobes \u{b7} ? help \u{b7} q quit";
const HELP_TEXT: &str = "Navigation\n\
j/k, Up/Down move selection\n\
h/l, Left/Right collapse / expand\n\
Space toggle expand\n\
Ctrl-u/Ctrl-d page up/down\n\
/ jump to search (Esc clears, Enter/Tab submits)\n\
Enter open details dialog\n\
\n\
Actions\n\
i install selected item\n\
d uninstall (forget) selected item\n\
s sync all sources\n\
u upgrade pending items\n\
m meld a source\n\
M unmeld selected source\n\
C agent homes (lobes)\n\
\n\
General\n\
y / n confirm / cancel a pending action\n\
Esc cancel / close the active overlay\n\
? toggle this help\n\
q, Ctrl-C x2 quit";
fn wrapped_rows(text: &str, width: u16) -> u16 {
let w = width.max(1) as usize;
let mut rows: u16 = 0;
for para in text.split('\n') {
rows = rows.saturating_add(line_rows(para, w));
}
rows.max(1)
}
fn line_rows(line: &str, w: usize) -> u16 {
let mut rows: u16 = 1;
let mut col: usize = 0;
let place = |word_len: usize, rows: &mut u16, col: &mut usize| {
if word_len <= w {
*col += word_len;
} else {
let extra = (word_len - 1) / w;
*rows = rows.saturating_add(extra as u16);
*col = word_len - extra * w;
}
};
for word in line.split(' ') {
let wl = crate::render::visible_width(word);
if col == 0 {
place(wl, &mut rows, &mut col);
} else if col + 1 + wl <= w {
col += 1 + wl; } else {
rows = rows.saturating_add(1);
col = 0;
place(wl, &mut rows, &mut col);
}
}
rows
}
fn status_height(status_text: &str, width: u16, term_height: u16) -> u16 {
if status_text.is_empty() {
return 1;
}
const RESERVED: u16 = 3 + 1 + 1; let cap = term_height.saturating_sub(RESERVED).max(3);
wrapped_rows(status_text, width).clamp(1, cap)
}
fn modal_width(desired: u16, min: u16, avail: u16) -> u16 {
desired.max(min).min(avail.max(1))
}
fn scroll_offset(prev: usize, selected: usize, len: usize, rows: usize) -> usize {
if rows == 0 || len <= rows {
return 0;
}
let max_off = len - rows;
let margin = rows / 6;
let lo = (selected + margin + 1).saturating_sub(rows); let hi = selected.saturating_sub(margin).min(max_off); let lo = lo.min(hi); prev.clamp(lo, hi)
}
pub fn draw(app: &App) -> Result<()> {
let mut terminal = crate::tui::term::get_terminal();
terminal
.draw(|frame| draw_frame(frame, app))
.map_err(|e| crate::error::MindError::io("<terminal>", e))?;
Ok(())
}
fn draw_frame(frame: &mut Frame, app: &App) {
let size = frame.area();
let status_text = status_text(app);
let status_h = status_height(&status_text, size.width, size.height);
let hint_h = wrapped_rows(HINTS, size.width).clamp(1, 3);
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), Constraint::Min(1), Constraint::Length(status_h), Constraint::Length(hint_h), ])
.split(size);
draw_search_bar(frame, app, layout[0]);
draw_tree(frame, app, layout[1]);
draw_status(frame, &status_text, app.error.is_some(), layout[2]);
draw_hints(frame, layout[3]);
if app.modal_visible {
draw_modal(frame, app, size);
}
if app.spec_input_active {
draw_spec_input(frame, app, size);
}
if app.lobes_modal_visible {
draw_lobes_modal(frame, app, size);
}
if app.lobe_input_active {
draw_lobe_input(frame, app, size);
}
if let Some(dialog) = &app.dialog {
draw_dialog(frame, dialog, size);
}
if app.namespace_input_active {
draw_namespace_input(frame, app, size);
}
if app.help_visible {
draw_help(frame, size);
}
}
fn draw_help(frame: &mut Frame, area: Rect) {
let w = modal_width(60, 40, area.width);
let inner_w = w.saturating_sub(2).max(1);
let content_h = wrapped_rows(HELP_TEXT, inner_w);
let h = content_h
.saturating_add(2)
.clamp(5.min(area.height.max(1)), area.height.max(1));
let inner = overlay(frame, area, "Keymap Help (any key closes)", w, h);
let widget = Paragraph::new(HELP_TEXT).wrap(Wrap { trim: false });
frame.render_widget(widget, inner);
}
fn titled_block(title: &str) -> Block<'_> {
Block::default()
.title(title)
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
}
fn overlay(frame: &mut Frame, area: Rect, title: &str, w: u16, h: u16) -> Rect {
let w = w.min(area.width.max(1));
let h = h.min(area.height.max(1));
let x = (area.width.saturating_sub(w)) / 2;
let y = (area.height.saturating_sub(h)) / 2;
let modal_area = Rect::new(x, y, w, h);
let border_style = if crate::render::ctx().color {
ratatui::style::Style::default().fg(Color::Yellow)
} else {
ratatui::style::Style::default()
};
let block = titled_block(title).style(border_style);
let inner = block.inner(modal_area);
frame.render_widget(ratatui::widgets::Clear, modal_area);
frame.render_widget(block, modal_area);
inner
}
fn draw_search_bar(frame: &mut Frame, app: &App, area: Rect) {
let title = if app.search_focused {
"Search (ESC to clear)"
} else {
"Search (/) to focus"
};
let style = if app.search_focused {
if crate::render::ctx().color {
Style::default().fg(Color::Yellow)
} else {
Style::default().add_modifier(Modifier::BOLD)
}
} else {
Style::default()
};
let text = Paragraph::new(app.search.as_str())
.block(titled_block(title))
.style(style);
frame.render_widget(text, area);
}
fn draw_tree(frame: &mut Frame, app: &App, area: Rect) {
let rc = crate::render::ctx();
let items: Vec<ListItem> = app
.visible
.iter()
.map(|node| flat_node_to_list_item(node, rc))
.collect();
let rows = area.height.saturating_sub(2) as usize;
let offset = scroll_offset(app.scroll.get(), app.selected, app.visible.len(), rows);
app.scroll.set(offset);
let mut state = ListState::default();
state.select(Some(app.selected));
*state.offset_mut() = offset;
let highlight_style = if rc.color {
Style::default()
.bg(Color::DarkGray)
.add_modifier(Modifier::BOLD)
} else {
Style::default().add_modifier(Modifier::REVERSED)
};
let highlight_symbol = if rc.unicode { "\u{276f} " } else { "> " };
let list = List::new(items)
.block(titled_block("Items"))
.highlight_style(highlight_style)
.highlight_symbol(highlight_symbol);
frame.render_stateful_widget(list, area, &mut state);
}
fn expand_marker(expandable: bool, expanded: bool, unicode: bool) -> &'static str {
if !expandable {
return " ";
}
match (expanded, unicode) {
(true, true) => "\u{25be} ", (false, true) => "\u{25b8} ", (true, false) => "v ",
(false, false) => "> ",
}
}
fn node_icon(node: &TreeNode, unicode: bool) -> &'static str {
if unicode {
match node {
TreeNode::InstalledGroup | TreeNode::AvailableGroup | TreeNode::UnmanagedGroup => "",
TreeNode::Source(_) => "\u{25c6} ", TreeNode::KindBucket { .. } => "\u{25aa} ", TreeNode::InstalledItem(_) => "\u{25cf} ", TreeNode::AvailableItem(_) => "\u{25cb} ", TreeNode::UnmanagedItem(_) => "\u{25cb} ", TreeNode::SuggestedSource(_) => "\u{25c7} ", TreeNode::DepChild(dep) if dep.is_cycle => "\u{21ba} ", TreeNode::DepChild(_) => "\u{21b3} ", TreeNode::EmptyState(_) => "",
}
} else {
match node {
TreeNode::InstalledGroup | TreeNode::AvailableGroup | TreeNode::UnmanagedGroup => "",
TreeNode::Source(_) => "* ",
TreeNode::KindBucket { .. } => "- ",
TreeNode::InstalledItem(_) => "+ ",
TreeNode::AvailableItem(_) => "o ",
TreeNode::UnmanagedItem(_) => "o ",
TreeNode::SuggestedSource(_) => "? ",
TreeNode::DepChild(dep) if dep.is_cycle => "^ ",
TreeNode::DepChild(_) => "> ",
TreeNode::EmptyState(_) => "",
}
}
}
fn node_style(node: &TreeNode, color: bool) -> Style {
match node {
TreeNode::InstalledGroup | TreeNode::AvailableGroup | TreeNode::UnmanagedGroup => {
Style::default().add_modifier(Modifier::BOLD)
}
TreeNode::InstalledItem(_) => {
if color {
Style::default().fg(Color::Green)
} else {
Style::default()
}
}
TreeNode::AvailableItem(_) => Style::default(),
TreeNode::UnmanagedItem(_) => {
if color {
Style::default().fg(Color::Yellow)
} else {
Style::default()
}
}
TreeNode::Source(_) => {
if color {
Style::default().fg(Color::Cyan)
} else {
Style::default()
}
}
TreeNode::KindBucket { .. } => {
if color {
Style::default().fg(Color::Blue)
} else {
Style::default()
}
}
TreeNode::SuggestedSource(_) => {
if color {
Style::default().fg(Color::Magenta)
} else {
Style::default()
}
}
TreeNode::DepChild(dep) if dep.is_cycle => {
let s = Style::default().add_modifier(Modifier::DIM);
if color { s.fg(Color::DarkGray) } else { s }
}
TreeNode::DepChild(_) => {
if color {
Style::default().fg(Color::DarkGray)
} else {
Style::default()
}
}
TreeNode::EmptyState(_) => Style::default().add_modifier(Modifier::DIM),
}
}
fn selection_style(color: bool) -> Style {
if color {
Style::default()
.bg(Color::DarkGray)
.add_modifier(Modifier::BOLD)
} else {
Style::default().add_modifier(Modifier::REVERSED)
}
}
fn dim_style(color: bool) -> Style {
if color {
Style::default().fg(Color::DarkGray)
} else {
Style::default()
}
}
fn stale_suffix(node: &TreeNode, unicode: bool) -> Option<&'static str> {
match node {
TreeNode::InstalledItem(info) if info.stale => {
Some(if unicode { " \u{2191}" } else { " ^" })
}
_ => None,
}
}
fn flat_node_to_list_item(node: &FlatNode, rc: crate::render::OutputCtx) -> ListItem<'_> {
let indent = " ".repeat(node.depth);
let expand_marker = expand_marker(node.expandable, node.expanded, rc.unicode);
let icon = node_icon(&node.node, rc.unicode);
let style = node_style(&node.node, rc.color);
let mut label = format!("{indent}{expand_marker}{icon}{}", node.label);
if let Some(suffix) = stale_suffix(&node.node, rc.unicode) {
label.push_str(suffix);
}
ListItem::new(Line::from(vec![Span::styled(label, style)]))
}
fn status_text(app: &App) -> String {
if let Some(err) = &app.error {
format!("ERROR: {err}")
} else if let Some(msg) = &app.status {
msg.clone()
} else {
String::new()
}
}
fn draw_status(frame: &mut Frame, text: &str, is_error: bool, area: Rect) {
let rc = crate::render::ctx();
let style = if rc.color {
let color = if is_error { Color::Red } else { Color::Green };
Style::default().fg(color)
} else if is_error {
Style::default().add_modifier(Modifier::BOLD)
} else {
Style::default()
};
let widget = Paragraph::new(text.to_string())
.style(style)
.wrap(Wrap { trim: false });
frame.render_widget(widget, area);
}
fn draw_hints(frame: &mut Frame, area: Rect) {
let style = if crate::render::ctx().color {
Style::default().fg(Color::DarkGray)
} else {
Style::default()
};
let widget = Paragraph::new(HINTS)
.style(style)
.wrap(Wrap { trim: false });
frame.render_widget(widget, area);
}
fn draw_spec_input(frame: &mut Frame, app: &App, area: Rect) {
let hint = "Enter a repo spec then press Enter. Esc to cancel.\n\
Examples: /path/to/repo | file:///path/to/repo | owner/repo | https://github.com/owner/repo | git@github.com:owner/repo";
let input = format!("\u{276f} {}", app.spec_input_text);
draw_input_modal(frame, area, "Meld: enter repo spec", hint, &input);
}
fn draw_lobes_modal(frame: &mut Frame, app: &App, area: Rect) {
let rc = crate::render::ctx();
let w = modal_width(area.width * 2 / 3, 50, area.width);
let h = (app.lobes.len() as u16 + 8)
.min(area.height.saturating_sub(4).max(1))
.max(8.min(area.height.max(1)));
let inner = overlay(frame, area, "Agent Homes (Lobes)", w, h);
let items: Vec<ListItem> = if app.lobes.is_empty() {
vec![ListItem::new(Line::from(vec![Span::styled(
" (none configured - using default)",
dim_style(rc.color),
)]))]
} else {
app.lobes
.iter()
.enumerate()
.map(|(i, lobe)| {
let style = if i == app.lobes_selected {
selection_style(rc.color)
} else if rc.color {
Style::default().fg(Color::Cyan)
} else {
Style::default()
};
ListItem::new(Line::from(vec![Span::styled(format!(" {lobe}"), style)]))
})
.collect()
};
let hint_line = " [a] add lobe [D] remove selected [Esc/q] close";
let hint_h = 1u16;
let list_h = inner.height.saturating_sub(hint_h);
let splits = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(list_h), Constraint::Length(hint_h)])
.split(inner);
let list = List::new(items).highlight_style(selection_style(rc.color));
frame.render_widget(list, splits[0]);
let hint = Paragraph::new(hint_line)
.style(dim_style(rc.color))
.wrap(Wrap { trim: false });
frame.render_widget(hint, splits[1]);
}
fn draw_dialog(frame: &mut Frame, dialog: &crate::tui::app::Dialog, area: Rect) {
let rc = crate::render::ctx();
let w = modal_width(area.width * 2 / 3, 40, area.width);
let detail_h = dialog.detail.len() as u16;
let actions_h = (dialog.actions.len() as u16).max(1);
let content_h = detail_h
.saturating_add(1)
.saturating_add(actions_h)
.saturating_add(1);
let h = content_h
.saturating_add(2)
.clamp(6.min(area.height.max(1)), area.height.max(1));
let inner = overlay(frame, area, &dialog.title, w, h);
let splits = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(detail_h),
Constraint::Length(1), Constraint::Min(actions_h),
Constraint::Length(1), ])
.split(inner);
let detail_style = if rc.color {
Style::default().fg(Color::Gray)
} else {
Style::default()
};
let detail = Paragraph::new(dialog.detail.join("\n"))
.style(detail_style)
.wrap(Wrap { trim: false });
frame.render_widget(detail, splits[0]);
let marker_glyph = if rc.unicode { "\u{276f} " } else { "> " };
let items: Vec<ListItem> = dialog
.actions
.iter()
.enumerate()
.map(|(i, a)| {
let marker = if i == dialog.selected {
marker_glyph
} else {
" "
};
let style = if i == dialog.selected {
selection_style(rc.color)
} else {
Style::default()
};
ListItem::new(Line::from(vec![Span::styled(
format!("{marker}{}", a.label),
style,
)]))
})
.collect();
frame.render_widget(List::new(items), splits[2]);
let hint = Paragraph::new(" [j/k] move [Enter/y] run [Esc/n] close")
.style(dim_style(rc.color))
.wrap(Wrap { trim: false });
frame.render_widget(hint, splits[3]);
}
fn draw_lobe_input(frame: &mut Frame, app: &App, area: Rect) {
let hint = "Enter the agent home path (e.g. ~/.other-ai) then press Enter. Esc to cancel.";
let input = format!("\u{276f} {}", app.lobe_input_text);
draw_input_modal(frame, area, "Add Agent Home (Lobe)", hint, &input);
}
fn draw_namespace_input(frame: &mut Frame, app: &App, area: Rect) {
let hint = "Enter a namespace prefix (e.g. jk) to install items as jk:<name>. Leave empty for no prefix. Enter to save, Esc to cancel.";
let input = format!("\u{276f} {}", app.namespace_input_text);
draw_input_modal(frame, area, "Set Namespace", hint, &input);
}
fn draw_input_modal(frame: &mut Frame, area: Rect, title: &str, hint: &str, input: &str) {
let w = modal_width(area.width / 2, 50, area.width);
let inner_w = w.saturating_sub(2).max(1); let body = format!("{hint}\n\n{input}");
let content_h = wrapped_rows(hint, inner_w)
.saturating_add(1)
.saturating_add(wrapped_rows(input, inner_w));
let h = content_h
.saturating_add(2)
.clamp(5.min(area.height.max(1)), area.height.max(1));
let inner = overlay(frame, area, title, w, h);
let widget = Paragraph::new(body).wrap(Wrap { trim: false });
frame.render_widget(widget, inner);
}
fn confirm_modal_text(action: &crate::tui::app::PendingAction) -> String {
match action.dep_tree.as_deref() {
Some(tree) => format!(
"{}\n\n{}\n [y] confirm [n/Esc] cancel",
action.description,
tree.trim_end_matches('\n')
),
None => format!("{}\n\n [y] confirm [n/Esc] cancel", action.description),
}
}
fn draw_modal(frame: &mut Frame, app: &App, area: Rect) {
let Some(action) = &app.pending_action else {
return;
};
let text = confirm_modal_text(action);
let content_w = text
.lines()
.map(crate::render::visible_width)
.max()
.unwrap_or(0) as u16;
let w = (content_w + 4)
.max(area.width / 2)
.max(40)
.min(area.width.max(1));
let inner_w = w.saturating_sub(2).max(1);
let h = wrapped_rows(&text, inner_w)
.saturating_add(2)
.clamp(5.min(area.height.max(1)), area.height.max(1));
let inner = overlay(frame, area, "Confirm", w, h);
let widget = Paragraph::new(text).wrap(Wrap { trim: false });
frame.render_widget(widget, inner);
}
#[cfg(test)]
mod tests {
use super::{
HELP_TEXT, HINTS, confirm_modal_text, dim_style, expand_marker, modal_width, node_icon,
node_style, scroll_offset, selection_style, stale_suffix, status_height, wrapped_rows,
};
use crate::tui::app::{ActionKind, PendingAction};
use crate::tui::tree::{InstalledInfo, SourceInfo, TreeNode};
use ratatui::style::Modifier;
fn installed_node(stale: bool) -> TreeNode {
TreeNode::InstalledItem(InstalledInfo {
key: "skill:review".to_string(),
name: "review".to_string(),
source: "local/agents".to_string(),
kind: crate::error::ItemKind::Skill,
commit: "abc12345".to_string(),
description: None,
deps: vec![],
stale,
})
}
#[test]
fn expand_marker_ascii_fallback_has_no_unicode() {
for expanded in [true, false] {
let m = expand_marker(true, expanded, false);
assert!(
m.is_ascii(),
"ASCII-mode expand marker must be pure ASCII: {m:?}"
);
}
assert_eq!(expand_marker(false, false, true), " ");
assert_eq!(expand_marker(false, false, false), " ");
}
#[test]
fn expand_marker_unicode_mode_uses_triangles() {
assert_eq!(expand_marker(true, true, true), "\u{25be} ");
assert_eq!(expand_marker(true, false, true), "\u{25b8} ");
}
#[test]
fn node_icon_ascii_fallback_has_no_unicode() {
let node = installed_node(false);
let icon = node_icon(&node, false);
assert!(
icon.is_ascii(),
"ASCII-mode icon must be pure ASCII: {icon:?}"
);
assert_eq!(icon, "+ ");
}
#[test]
fn node_icon_unicode_mode_uses_geometric_marker() {
let node = installed_node(false);
assert_eq!(node_icon(&node, true), "\u{25cf} ");
}
#[test]
fn node_style_drops_fg_color_under_no_color() {
let installed = installed_node(false);
let src = TreeNode::Source(SourceInfo {
name: "local/agents".to_string(),
installed: true,
});
assert_eq!(
node_style(&installed, false).fg,
None,
"installed item must carry no fg color under NO_COLOR"
);
assert_eq!(
node_style(&src, false).fg,
None,
"source node must carry no fg color under NO_COLOR"
);
assert!(
node_style(&installed, true).fg.is_some(),
"installed item must carry a fg color when color is on"
);
}
#[test]
fn status_height_empty_text_is_one_row() {
assert_eq!(status_height("", 80, 40), 1);
}
#[test]
fn status_height_grows_past_three_rows_on_a_tall_terminal() {
let long_error = "ERROR: ".to_string() + &"word ".repeat(60); let h = status_height(&long_error, 20, 40);
assert!(
h > 3,
"a long error on a tall terminal must get more than 3 rows, got {h}"
);
}
#[test]
fn status_height_never_starves_the_tree_pane_on_a_short_terminal() {
let long_error = "x ".repeat(500);
let h = status_height(&long_error, 10, 12);
assert!(
h <= 12,
"status height must never exceed the terminal height: {h}"
);
assert!(
h < 12 - 3,
"some rows must remain for search/tree/hints on a 12-row terminal: {h}"
);
}
#[test]
fn selection_style_uses_reversed_not_bg_color_under_no_color() {
let mono = selection_style(false);
assert_eq!(mono.bg, None, "no bg color under NO_COLOR: {mono:?}");
assert!(
mono.add_modifier.contains(Modifier::REVERSED),
"must use REVERSED under NO_COLOR: {mono:?}"
);
let colored = selection_style(true);
assert!(colored.bg.is_some(), "color mode sets a bg: {colored:?}");
}
#[test]
fn dim_style_drops_fg_under_no_color() {
assert_eq!(dim_style(false).fg, None);
assert!(dim_style(true).fg.is_some());
}
#[test]
fn stale_suffix_ascii_and_unicode_glyphs() {
assert_eq!(stale_suffix(&installed_node(true), true), Some(" \u{2191}"));
assert_eq!(stale_suffix(&installed_node(true), false), Some(" ^"));
assert_eq!(stale_suffix(&installed_node(false), true), None);
assert_eq!(stale_suffix(&installed_node(false), false), None);
}
#[test]
fn hints_line_covers_search_collapse_paging_and_help() {
assert!(
HINTS.contains("/ search"),
"HINTS must mention search: {HINTS:?}"
);
assert!(
HINTS.contains("h/l"),
"HINTS must mention h/l collapse/expand: {HINTS:?}"
);
assert!(
HINTS.to_lowercase().contains("page"),
"HINTS must mention paging: {HINTS:?}"
);
assert!(
HINTS.contains("? help"),
"HINTS must mention the ? help key: {HINTS:?}"
);
}
#[test]
fn help_text_lists_every_normal_mode_key() {
for key in [
"j/k", "h/l", "Space", "Enter", "i ", "d ", "s ", "u ", "m ", "M ", "C ", "y / n",
"Esc", "q,",
] {
assert!(
HELP_TEXT.contains(key),
"help overlay must mention {key:?}: {HELP_TEXT:?}"
);
}
}
#[test]
fn scroll_offset_keeps_selection_in_middle_band() {
let rows = 12;
let len = 100;
assert_eq!(scroll_offset(0, 5, 8, 12), 0, "list shorter than viewport");
assert_eq!(scroll_offset(0, 0, len, rows), 0);
assert_eq!(scroll_offset(0, 9, len, rows), 0, "still inside the band");
let off = scroll_offset(0, 10, len, rows);
assert!(
off >= 1,
"must scroll once selection passes the bottom margin: {off}"
);
assert!(
(off..off + rows).contains(&10),
"selection stays visible after scroll"
);
let margin = rows / 6;
assert!(
10 >= off + margin && 10 <= off + rows - 1 - margin,
"selection stays within the middle band [{}, {}], off={off}",
off + margin,
off + rows - 1 - margin
);
}
#[test]
fn scroll_offset_clamps_at_list_end() {
let rows = 10;
let len = 30;
let off = scroll_offset(0, len - 1, len, rows);
assert_eq!(off, len - rows, "offset clamps to the last full page");
assert!((off..off + rows).contains(&(len - 1)));
}
#[test]
fn scroll_offset_zero_rows_returns_zero_without_panic() {
assert_eq!(scroll_offset(0, 0, 0, 0), 0, "empty list, zero rows");
assert_eq!(scroll_offset(5, 50, 100, 0), 0, "nonempty list, zero rows");
}
#[test]
fn scroll_offset_tiny_viewport_margin_zero_never_inverts_bounds() {
let len = 20;
for rows in 1..=5usize {
let margin = rows / 6;
assert_eq!(margin, 0, "margin is 0 below 6 rows");
for selected in 0..len {
let off = scroll_offset(0, selected, len, rows);
assert!(off <= len - rows, "offset within page range, rows={rows}");
assert!(
(off..off + rows).contains(&selected),
"selection {selected} must stay visible on a {rows}-row viewport, off={off}"
);
}
}
}
#[test]
fn scroll_offset_scrolls_up_when_selection_rises_above_the_band() {
let rows = 12;
let len = 100;
let margin = rows / 6; let prev = 50;
let selected = prev + margin - 1; let off = scroll_offset(prev, selected, len, rows);
assert!(off < prev, "view must scroll up: off={off} prev={prev}");
assert!(
selected >= off + margin && selected <= off + rows - 1 - margin,
"selection stays within the middle band [{}, {}] after scrolling up, off={off}",
off + margin,
off + rows - 1 - margin
);
}
#[test]
fn scroll_offset_is_stable_within_the_band() {
let rows = 12;
let len = 100;
assert_eq!(scroll_offset(35, 40, len, rows), 35);
}
#[test]
fn wrapped_rows_counts_word_wrap_and_hard_splits() {
assert_eq!(wrapped_rows("hello world", 40), 1);
assert_eq!(wrapped_rows("hello world", 7), 2);
assert_eq!(wrapped_rows("a\n\nb", 40), 3);
assert_eq!(wrapped_rows("abcdefghij", 4), 3); assert!(wrapped_rows("anything", 0) >= 1);
assert_eq!(wrapped_rows("", 10), 1);
}
#[test]
fn wrapped_rows_measures_wide_chars_by_display_width_not_char_count() {
let cjk = "\u{4e2d}\u{6587}\u{5b57}\u{7b26}"; assert_eq!(
wrapped_rows(cjk, 4),
2,
"4 wide chars (8 columns) at width 4 must wrap to 2 rows"
);
assert_eq!(wrapped_rows("abcdefgh", 4), 2);
}
#[test]
fn modal_width_clamps_to_the_terminal() {
assert_eq!(modal_width(40, 50, 80), 50);
assert_eq!(modal_width(60, 50, 80), 60);
assert_eq!(modal_width(40, 50, 45), 45);
assert_eq!(modal_width(40, 50, 10), 10);
}
#[test]
fn confirm_modal_includes_dependency_tree_for_learn() {
let tree = "review (selected)\n dev (dependency)\n test (already installed)";
let mut action = PendingAction::new(
ActionKind::Learn {
item_key: "skill:review".to_string(),
source: "local/agents".to_string(),
},
"Install skill:review from local/agents?".to_string(),
);
action.dep_tree = Some(tree.to_string());
let body = confirm_modal_text(&action);
for line in tree.lines() {
assert!(
body.contains(line),
"confirm modal must show the dependency tree line {line:?}; body was:\n{body}"
);
}
assert!(
body.contains("Install skill:review from local/agents?"),
"modal must keep the action description"
);
assert!(
body.contains("[y] confirm"),
"modal must keep the confirm hint"
);
}
#[test]
fn confirm_modal_places_tree_between_prompt_and_key_hint() {
let tree = "- skill:review [selected]\n - agent:dev [dep]\n - skill:build [installed]";
let mut action = PendingAction::new(
ActionKind::Learn {
item_key: "skill:review".to_string(),
source: "local/agents".to_string(),
},
"Install skill:review from local/agents?".to_string(),
);
action.dep_tree = Some(tree.to_string());
let body = confirm_modal_text(&action);
let lines: Vec<&str> = body.lines().collect();
assert_eq!(
lines.first().copied(),
Some("Install skill:review from local/agents?"),
"the prompt must be the first line of the modal body: {body:?}"
);
let prompt_idx = 0usize;
let tree_first_idx = lines
.iter()
.position(|l| l.contains("skill:review [selected]"))
.expect("tree root line must be present");
let tree_last_idx = lines
.iter()
.position(|l| l.contains("skill:build [installed]"))
.expect("tree leaf line must be present");
let hint_idx = lines
.iter()
.position(|l| l.contains("[y] confirm"))
.expect("key-hint line must be present");
assert!(
prompt_idx < tree_first_idx,
"the tree must come AFTER the prompt line: {body:?}"
);
assert!(
tree_last_idx < hint_idx,
"the key hint must come AFTER the whole tree: {body:?}"
);
assert!(
tree_first_idx < tree_last_idx,
"tree lines must keep their source order (root before leaf): {body:?}"
);
assert_eq!(
lines.len(),
6,
"prompt + blank + 3 tree lines + blank + hint = 6 lines; got {}: {body:?}",
lines.len()
);
for row in tree.lines() {
assert!(
lines.contains(&row),
"tree row {row:?} must appear verbatim (no truncation): {body:?}"
);
}
}
#[test]
fn confirm_modal_omits_tree_when_no_dependencies() {
let action = PendingAction::new(ActionKind::Sync, "Sync all sources?".to_string());
let body = confirm_modal_text(&action);
assert_eq!(
body, "Sync all sources?\n\n [y] confirm [n/Esc] cancel",
"a treeless confirm must be exactly the prompt plus the key hint"
);
}
}