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} 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} q 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 = word.chars().count();
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 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 = if status_text.is_empty() {
1
} else {
wrapped_rows(&status_text, size.width).clamp(1, 3)
};
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);
}
}
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 block = titled_block(title).style(ratatui::style::Style::default().fg(Color::Yellow));
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 {
Style::default().fg(Color::Yellow)
} 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 items: Vec<ListItem> = app
.visible
.iter()
.map(|node| flat_node_to_list_item(node))
.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 list = List::new(items)
.block(titled_block("Items"))
.highlight_style(
Style::default()
.bg(Color::DarkGray)
.add_modifier(Modifier::BOLD),
)
.highlight_symbol("\u{276f} ");
frame.render_stateful_widget(list, area, &mut state);
}
fn flat_node_to_list_item(node: &FlatNode) -> ListItem<'_> {
let indent = " ".repeat(node.depth);
let expand_marker = if node.expandable {
if node.expanded {
"\u{25be} " } else {
"\u{25b8} " }
} else {
" "
};
let icon = match &node.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} ", };
let style = match &node.node {
TreeNode::InstalledGroup | TreeNode::AvailableGroup | TreeNode::UnmanagedGroup => {
Style::default().add_modifier(Modifier::BOLD)
}
TreeNode::InstalledItem(_) => Style::default().fg(Color::Green),
TreeNode::AvailableItem(_) => Style::default(),
TreeNode::UnmanagedItem(_) => Style::default().fg(Color::Yellow),
TreeNode::Source(_) => Style::default().fg(Color::Cyan),
TreeNode::KindBucket { .. } => Style::default().fg(Color::Blue),
TreeNode::SuggestedSource(_) => Style::default().fg(Color::Magenta),
TreeNode::DepChild(dep) if dep.is_cycle => Style::default()
.fg(Color::DarkGray)
.add_modifier(Modifier::DIM),
TreeNode::DepChild(_) => Style::default().fg(Color::DarkGray),
};
let label = format!("{indent}{expand_marker}{icon}{}", node.label);
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 color = if is_error { Color::Red } else { Color::Green };
let widget = Paragraph::new(text.to_string())
.style(Style::default().fg(color))
.wrap(Wrap { trim: false });
frame.render_widget(widget, area);
}
fn draw_hints(frame: &mut Frame, area: Rect) {
let widget = Paragraph::new(HINTS)
.style(Style::default().fg(Color::DarkGray))
.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 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)",
Style::default().fg(Color::DarkGray),
)]))]
} else {
app.lobes
.iter()
.enumerate()
.map(|(i, lobe)| {
let style = if i == app.lobes_selected {
Style::default()
.bg(Color::DarkGray)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::Cyan)
};
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(
Style::default()
.bg(Color::DarkGray)
.add_modifier(Modifier::BOLD),
);
frame.render_widget(list, splits[0]);
let hint = Paragraph::new(hint_line)
.style(Style::default().fg(Color::DarkGray))
.wrap(Wrap { trim: false });
frame.render_widget(hint, splits[1]);
}
fn draw_dialog(frame: &mut Frame, dialog: &crate::tui::app::Dialog, area: Rect) {
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 = Paragraph::new(dialog.detail.join("\n"))
.style(Style::default().fg(Color::Gray))
.wrap(Wrap { trim: false });
frame.render_widget(detail, splits[0]);
let items: Vec<ListItem> = dialog
.actions
.iter()
.enumerate()
.map(|(i, a)| {
let marker = if i == dialog.selected {
"\u{276f} "
} else {
" "
};
let style = if i == dialog.selected {
Style::default()
.bg(Color::DarkGray)
.add_modifier(Modifier::BOLD)
} 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(Style::default().fg(Color::DarkGray))
.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(|l| l.chars().count()).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::{confirm_modal_text, modal_width, scroll_offset, wrapped_rows};
use crate::tui::app::{ActionKind, PendingAction};
#[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 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"
);
}
}