use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Modifier, Style, Stylize};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, BorderType, Borders, Clear, List, ListItem, Paragraph};
use super::app::{App, ConfirmationPrompt, EffortPicker, ModelPicker, Picker};
use super::event::Mode;
use super::inline_terminal::Frame;
use super::slash_popup::{MAX_VISIBLE_ROWS as SLASH_POPUP_MAX_ROWS, SlashPopup};
use crate::tools::utils::ConfirmationType;
const ACCENT: Color = Color::Rgb(0xFF, 0x99, 0x33);
const BORDER_IDLE: Color = Color::Rgb(120, 120, 120);
const TITLE_FG: Color = Color::Rgb(180, 180, 180);
const HINT_KEY: Color = Color::Rgb(120, 180, 120);
const QUEUE_FG: Color = Color::Rgb(0xCC, 0xCC, 0x66);
const MODEL_FG: Color = Color::Rgb(110, 110, 110);
const STATUS_KEY: Color = Color::Rgb(150, 150, 150);
const STATUS_VAL: Color = Color::Rgb(200, 200, 200);
const SAFE_MODE_FG: Color = Color::Rgb(0xFF, 0xA5, 0x00);
const PICKER_BORDER: Color = Color::Rgb(140, 140, 140);
const SLASH_POPUP_BORDER: Color = Color::Rgb(120, 120, 120);
const SLASH_POPUP_NAME_FG: Color = Color::Rgb(200, 200, 200);
const SLASH_POPUP_DESC_FG: Color = Color::Rgb(130, 130, 130);
const SEP: &str = " · ";
const SLASH_POPUP_CHROME_ROWS: u16 = 2;
const SLASH_POPUP_COLUMN_GAP: usize = 2;
const ROW_MARKER_SELECTED: &str = "❯ ";
const ROW_MARKER_PLAIN: &str = " ";
pub const MAX_INPUT_CONTENT_ROWS: u16 = 6;
const INPUT_BORDER_ROWS: u16 = 2;
const HINT_ROW_HEIGHT: u16 = 1;
const STATUS_ROW_HEIGHT: u16 = 1;
pub fn draw(frame: &mut Frame, app: &mut App) {
let area = frame.area();
let input_height = input_box_height(app, area.width);
let popup_height = slash_popup_height(&app.slash_popup);
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(HINT_ROW_HEIGHT),
Constraint::Length(popup_height),
Constraint::Length(input_height),
Constraint::Length(STATUS_ROW_HEIGHT),
])
.split(area);
draw_hint(frame, rows[0], app);
if popup_height > 0 {
draw_slash_popup(frame, rows[1], &app.slash_popup);
}
draw_input(frame, rows[2], app);
draw_status(frame, rows[3], app);
if let Some(picker) = &app.picker {
draw_picker(frame, area, picker);
}
if let Some(picker) = &app.model_picker {
draw_model_picker(frame, area, picker);
}
if let Some(picker) = &app.effort_picker {
draw_effort_picker(frame, area, picker);
}
if let Some(confirmation) = &app.confirmation {
draw_confirmation(frame, area, confirmation);
}
}
const CONFIRMATION_CHROME_ROWS: u16 = 6;
const PICKER_CHROME_ROWS: u16 = 2;
const PICKER_MAX_VISIBLE_ENTRIES: u16 = 12;
const PICKER_POPUP_WIDTH_PCT: u16 = 80;
pub fn desired_viewport_height(app: &mut App, area_width: u16) -> u16 {
let popup_rows = slash_popup_height(&app.slash_popup);
let base = HINT_ROW_HEIGHT + popup_rows + input_box_height(app, area_width) + STATUS_ROW_HEIGHT;
let modal_height = if let Some(confirmation) = &app.confirmation {
let rows = u16::try_from(confirmation.choices.len()).unwrap_or(u16::MAX);
CONFIRMATION_CHROME_ROWS.saturating_add(rows)
} else if let Some(picker) = &app.picker {
let rows = u16::try_from(picker.sessions.len())
.unwrap_or(u16::MAX)
.min(PICKER_MAX_VISIBLE_ENTRIES);
PICKER_CHROME_ROWS.saturating_add(rows)
} else if let Some(picker) = &app.model_picker {
let rows = u16::try_from(picker.entries.len())
.unwrap_or(u16::MAX)
.min(PICKER_MAX_VISIBLE_ENTRIES);
PICKER_CHROME_ROWS.saturating_add(rows)
} else if let Some(picker) = &app.effort_picker {
let rows = u16::try_from(picker.entries.len())
.unwrap_or(u16::MAX)
.min(PICKER_MAX_VISIBLE_ENTRIES);
PICKER_CHROME_ROWS.saturating_add(rows)
} else {
0
};
base.max(modal_height)
}
fn slash_popup_height(popup: &SlashPopup) -> u16 {
if !popup.is_visible() {
return 0;
}
let visible = popup.matches().len().clamp(1, SLASH_POPUP_MAX_ROWS);
let visible = u16::try_from(visible).unwrap_or(u16::MAX);
visible.saturating_add(SLASH_POPUP_CHROME_ROWS)
}
fn input_box_height(app: &mut App, area_width: u16) -> u16 {
let content_width = area_width.saturating_sub(2);
let measure = app.textarea.measure(content_width);
let content_rows = measure.content_rows.clamp(1, MAX_INPUT_CONTENT_ROWS);
content_rows + INPUT_BORDER_ROWS
}
fn draw_input(frame: &mut Frame, area: Rect, app: &App) {
let border_color = if app.busy() {
Color::DarkGray
} else {
BORDER_IDLE
};
let safe = app.is_safe_mode();
let prompt_glyph = if safe { " : " } else { " > " };
let content_width = area.width.saturating_sub(2);
let mut textarea = app.textarea.clone();
let measure = textarea.measure(content_width);
let overflow_rows = measure.content_rows.saturating_sub(MAX_INPUT_CONTENT_ROWS);
let mut title_spans = vec![Span::styled(
prompt_glyph,
Style::default()
.fg(if safe { Color::Yellow } else { TITLE_FG })
.add_modifier(Modifier::BOLD),
)];
if overflow_rows > 0 {
title_spans.push(Span::styled(
format!(" … +{} more ", overflow_rows),
Style::default().fg(Color::DarkGray),
));
}
let title = Line::from(title_spans);
if safe {
textarea.set_cursor_style(
Style::default()
.fg(SAFE_MODE_FG)
.add_modifier(Modifier::UNDERLINED),
);
} else {
textarea.set_cursor_style(Style::default().add_modifier(Modifier::REVERSED));
}
textarea.set_block(
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(border_color))
.title(title),
);
textarea.set_style(Style::default().fg(Color::White));
frame.render_widget(&textarea, area);
}
fn draw_hint(frame: &mut Frame, area: Rect, app: &App) {
let mut spans: Vec<Span> = Vec::new();
if app.confirmation.is_some() {
spans.push(Span::styled(
" ⏸ ",
Style::default().fg(ACCENT).add_modifier(Modifier::BOLD),
));
spans.push(Span::styled(
"awaiting confirmation",
Style::default().fg(ACCENT),
));
spans.push(Span::styled(SEP, Style::default().fg(Color::DarkGray)));
spans.push(Span::styled(
"↑↓ ⏎ answer · esc cancel",
Style::default().fg(Color::DarkGray),
));
if !app.queue.is_empty() {
spans.push(Span::styled(SEP, Style::default().fg(Color::DarkGray)));
spans.push(Span::styled(
format!("queued: {}", app.queue.len()),
Style::default().fg(QUEUE_FG).add_modifier(Modifier::BOLD),
));
}
frame.render_widget(Paragraph::new(Line::from(spans)), area);
return;
}
if app.picker.is_some() || app.model_picker.is_some() || app.effort_picker.is_some() {
let label = if app.model_picker.is_some() {
"pick a model"
} else if app.effort_picker.is_some() {
"pick an effort"
} else {
"pick a session"
};
spans.push(Span::styled(" ❯ ", Style::default().fg(HINT_KEY)));
spans.push(Span::styled(label, Style::default().fg(Color::Gray)));
spans.push(Span::styled(SEP, Style::default().fg(Color::DarkGray)));
spans.push(Span::styled(
"↑↓ ⏎ select · esc cancel",
Style::default().fg(Color::DarkGray),
));
frame.render_widget(Paragraph::new(Line::from(spans)), area);
return;
}
if app.busy() {
let frame_ch = app.spinner_frame();
spans.push(Span::styled(
format!(" {} ", frame_ch),
Style::default().fg(ACCENT).add_modifier(Modifier::BOLD),
));
let label = if app.busy_label.is_empty() {
"working"
} else {
app.busy_label.as_str()
};
let elapsed = app.busy_since.map(|t| t.elapsed().as_secs()).unwrap_or(0);
let elapsed_str = if elapsed >= 60 {
format!("{}m {}s", elapsed / 60, elapsed % 60)
} else {
format!("{}s", elapsed)
};
spans.push(Span::styled(
format!("{}… {}", label, elapsed_str),
Style::default().fg(ACCENT),
));
spans.push(Span::styled(SEP, Style::default().fg(Color::DarkGray)));
spans.push(Span::styled(
"esc to interrupt",
Style::default().fg(Color::DarkGray),
));
} else {
spans.push(Span::styled(" ⏎ ", Style::default().fg(HINT_KEY)));
spans.push(Span::styled(
"send · ",
Style::default().fg(Color::DarkGray),
));
spans.push(Span::styled("shift+⏎ ", Style::default().fg(HINT_KEY)));
spans.push(Span::styled(
"newline · ",
Style::default().fg(Color::DarkGray),
));
spans.push(Span::styled("/ ", Style::default().fg(HINT_KEY)));
spans.push(Span::styled(
"commands",
Style::default().fg(Color::DarkGray),
));
}
if !app.queue.is_empty() {
spans.push(Span::styled(SEP, Style::default().fg(Color::DarkGray)));
spans.push(Span::styled(
format!("queued: {}", app.queue.len()),
Style::default().fg(QUEUE_FG).add_modifier(Modifier::BOLD),
));
}
let line = Line::from(spans);
frame.render_widget(Paragraph::new(line), area);
}
fn draw_status(frame: &mut Frame, area: Rect, app: &App) {
let (model, mode, reasoning, in_tok, out_tok, cache_read, cache_create) = match &app.status {
Some(s) => (
s.model.as_str(),
s.mode,
s.reasoning.as_str(),
s.input_tokens,
s.output_tokens,
s.cache_read_tokens,
s.cache_creation_tokens,
),
None => (
app.model_label.as_str(),
Mode::Normal,
"",
0u32,
0u32,
0u32,
0u32,
),
};
let mode_style = match mode {
Mode::Safe => Style::default()
.fg(SAFE_MODE_FG)
.add_modifier(Modifier::BOLD),
Mode::Normal => Style::default().fg(STATUS_VAL),
};
let mut spans: Vec<Span> = vec![
Span::styled(" model: ", Style::default().fg(STATUS_KEY)),
Span::styled(
model,
Style::default().fg(STATUS_VAL).add_modifier(Modifier::BOLD),
),
Span::styled(SEP, Style::default().fg(Color::DarkGray)),
Span::styled("mode: ", Style::default().fg(STATUS_KEY)),
Span::styled(mode.label(), mode_style),
];
if !reasoning.is_empty() {
spans.push(Span::styled(SEP, Style::default().fg(Color::DarkGray)));
spans.push(Span::styled(reasoning, Style::default().fg(MODEL_FG)));
}
if in_tok > 0 || out_tok > 0 {
spans.push(Span::styled(SEP, Style::default().fg(Color::DarkGray)));
spans.push(Span::styled(
format!("tokens: {}↑ {}↓", in_tok, out_tok),
Style::default().fg(MODEL_FG),
));
}
if cache_read > 0 || cache_create > 0 {
spans.push(Span::styled(SEP, Style::default().fg(Color::DarkGray)));
spans.push(Span::styled(
format!("cache: {}r {}w", cache_read, cache_create),
Style::default().fg(MODEL_FG),
));
}
frame.render_widget(Paragraph::new(Line::from(spans)), area);
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct ConfirmationLayout {
include_full_chrome: bool,
window_start: u16,
window_end: u16,
}
fn confirmation_layout(interior: u16, choices: u16, cursor: u16) -> ConfirmationLayout {
if choices == 0 {
return ConfirmationLayout {
include_full_chrome: false,
window_start: 0,
window_end: 0,
};
}
const PROMPT_ROWS: u16 = 1;
const LEADING_SEP_ROWS: u16 = 1;
const TRAILING_CHROME_ROWS: u16 = 2; const FULL_NON_CHOICE_ROWS: u16 = PROMPT_ROWS + LEADING_SEP_ROWS + TRAILING_CHROME_ROWS;
let include_full_chrome = interior >= FULL_NON_CHOICE_ROWS.saturating_add(choices);
let leading_sep: u16 = if include_full_chrome {
LEADING_SEP_ROWS
} else {
0
};
let trailing_chrome: u16 = if include_full_chrome {
TRAILING_CHROME_ROWS
} else {
0
};
let choice_budget = interior
.saturating_sub(PROMPT_ROWS)
.saturating_sub(leading_sep)
.saturating_sub(trailing_chrome);
let visible = choice_budget.min(choices).max(1);
let start = if visible >= choices {
0
} else {
let half = visible / 2;
let clamped_cursor = cursor.min(choices.saturating_sub(1));
clamped_cursor
.saturating_sub(half)
.min(choices.saturating_sub(visible))
};
let end = start.saturating_add(visible).min(choices);
ConfirmationLayout {
include_full_chrome,
window_start: start,
window_end: end,
}
}
fn draw_confirmation(frame: &mut Frame, area: Rect, prompt: &ConfirmationPrompt) {
const HINT_LINE: &str = " ↑↓ navigate · ⏎ select · esc cancel";
const MIN_WIDTH: u16 = 40;
const HORIZONTAL_PADDING: u16 = 6; const MIN_INTERIOR: u16 = 2;
let longest_choice = prompt
.choices
.iter()
.map(|c| c.chars().count())
.max()
.unwrap_or(0);
let prompt_chars = prompt.prompt.chars().count();
let content_cols = prompt_chars
.max(longest_choice + 4) .max(HINT_LINE.chars().count());
let ideal = u16::try_from(content_cols)
.unwrap_or(u16::MAX)
.saturating_add(HORIZONTAL_PADDING);
let width = ideal.max(MIN_WIDTH).min(area.width);
let choices_rows = u16::try_from(prompt.choices.len()).unwrap_or(u16::MAX);
let full_height: u16 = 2 + 1 + 1 + choices_rows + 1 + 1;
let min_height: u16 = 2 + MIN_INTERIOR;
let height = full_height
.min(area.height)
.max(min_height.min(area.height));
let x = area.x + area.width.saturating_sub(width) / 2;
let y = area.y + area.height.saturating_sub(height) / 2;
let popup = Rect {
x,
y,
width,
height,
};
frame.render_widget(Clear, popup);
let (title, accent) = match prompt.kind {
ConfirmationType::Destructive => (" Confirm destructive action ", ACCENT),
ConfirmationType::Permission => (" Permission required ", Color::Yellow),
ConfirmationType::Info => (" Confirm ", HINT_KEY),
};
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(accent))
.title(Line::from(vec![Span::styled(
title,
Style::default().fg(accent).add_modifier(Modifier::BOLD),
)]));
let interior = popup.height.saturating_sub(2);
let layout = confirmation_layout(
interior,
choices_rows,
u16::try_from(prompt.cursor).unwrap_or(u16::MAX),
);
let include_full_chrome = layout.include_full_chrome;
let start = layout.window_start;
let end = layout.window_end;
let mut lines: Vec<Line> = Vec::with_capacity(usize::from(interior));
lines.push(Line::from(Span::styled(
format!(" {}", prompt.prompt),
Style::default().fg(Color::White),
)));
if include_full_chrome {
lines.push(Line::from(Span::raw("")));
}
let cursor_u16 = u16::try_from(prompt.cursor).unwrap_or(u16::MAX);
let needs_top_cue = start > 0;
let needs_bottom_cue = end < choices_rows;
let cursor_at_top = cursor_u16 == start;
let cursor_at_bottom = cursor_u16 + 1 == end;
let top_cue_row: Option<u16> = if !needs_top_cue {
None
} else if cursor_at_top && end.saturating_sub(start) >= 2 {
Some(start + 1)
} else {
Some(start)
};
let bottom_cue_row: Option<u16> = if !needs_bottom_cue {
None
} else if cursor_at_bottom && end.saturating_sub(start) >= 2 {
Some(end - 2)
} else {
Some(end - 1)
};
for i in start..end {
let idx = usize::from(i);
let choice = &prompt.choices[idx];
let selected = idx == prompt.cursor;
let at_top_cue = Some(i) == top_cue_row && !selected;
let at_bottom_cue = Some(i) == bottom_cue_row && !selected;
let marker = if selected {
ROW_MARKER_SELECTED
} else if at_top_cue && at_bottom_cue {
"⇅ "
} else if at_top_cue {
"▴ "
} else if at_bottom_cue {
"▾ "
} else {
ROW_MARKER_PLAIN
};
let style = if selected {
Style::default().fg(accent).add_modifier(Modifier::BOLD)
} else if at_top_cue || at_bottom_cue {
Style::default().fg(Color::DarkGray)
} else {
Style::default().fg(Color::Gray)
};
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(marker, style),
Span::styled(choice.clone(), style),
]));
}
if include_full_chrome {
lines.push(Line::from(Span::raw("")));
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled("↑↓", Style::default().fg(HINT_KEY)),
Span::styled(" navigate · ", Style::default().fg(Color::DarkGray)),
Span::styled("⏎", Style::default().fg(HINT_KEY)),
Span::styled(" select · ", Style::default().fg(Color::DarkGray)),
Span::styled("esc", Style::default().fg(HINT_KEY)),
Span::styled(" cancel", Style::default().fg(Color::DarkGray)),
]));
}
let paragraph = Paragraph::new(lines).block(block);
frame.render_widget(paragraph, popup);
}
fn draw_slash_popup(frame: &mut Frame, area: Rect, popup: &SlashPopup) {
if area.height <= SLASH_POPUP_CHROME_ROWS || area.width == 0 {
return;
}
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(SLASH_POPUP_BORDER))
.title(Line::from(Span::styled(
" commands ",
Style::default().fg(TITLE_FG).add_modifier(Modifier::BOLD),
)));
let interior_rows = area.height.saturating_sub(SLASH_POPUP_CHROME_ROWS);
let matches = popup.matches();
let visible = (interior_rows as usize).min(matches.len());
let start = popup
.scroll_top()
.min(matches.len().saturating_sub(visible.max(1)));
let end = (start + visible).min(matches.len());
let name_col_width = matches
.iter()
.skip(start)
.take(visible)
.map(|entry| entry.name.chars().count())
.max()
.unwrap_or(0);
let accent_bold = Style::default().fg(ACCENT).add_modifier(Modifier::BOLD);
let unselected_name = Style::default().fg(SLASH_POPUP_NAME_FG);
let unselected_desc = Style::default().fg(SLASH_POPUP_DESC_FG);
let unselected_marker = Style::default().fg(Color::DarkGray);
let selected_desc = Style::default().fg(ACCENT);
let mut lines: Vec<Line> = Vec::with_capacity(visible);
for (offset, entry) in matches[start..end].iter().enumerate() {
let idx = start + offset;
let selected = idx == popup.cursor();
let (marker, marker_style, name_style, desc_style) = if selected {
(ROW_MARKER_SELECTED, accent_bold, accent_bold, selected_desc)
} else {
(
ROW_MARKER_PLAIN,
unselected_marker,
unselected_name,
unselected_desc,
)
};
let name_padding = name_col_width.saturating_sub(entry.name.chars().count());
lines.push(Line::from(vec![
Span::styled(marker, marker_style),
Span::styled(entry.name, name_style),
Span::raw(" ".repeat(name_padding + SLASH_POPUP_COLUMN_GAP)),
Span::styled(entry.description, desc_style),
]));
}
let paragraph = Paragraph::new(lines).block(block);
frame.render_widget(paragraph, area);
}
fn picker_popup_rect(area: Rect) -> Rect {
let width = area.width.saturating_mul(PICKER_POPUP_WIDTH_PCT) / 100;
let x = area.x + area.width.saturating_sub(width) / 2;
Rect {
x,
y: area.y,
width,
height: area.height,
}
}
fn picker_block(title: &'static str) -> Block<'static> {
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(PICKER_BORDER))
.title(Line::from(vec![Span::styled(
title,
Style::default().fg(ACCENT).add_modifier(Modifier::BOLD),
)]))
}
fn picker_visible_window(popup_height: u16, cursor: usize, total: usize) -> (usize, usize) {
let visible_slots = popup_height.saturating_sub(PICKER_CHROME_ROWS) as usize;
let scroll = scroll_offset(cursor, total, visible_slots);
let visible = visible_slots.min(total.saturating_sub(scroll));
(scroll, visible)
}
fn scroll_offset(cursor: usize, total: usize, visible: usize) -> usize {
if visible == 0 || visible >= total || cursor < visible / 2 {
0
} else if cursor + visible / 2 >= total {
total.saturating_sub(visible)
} else {
cursor.saturating_sub(visible / 2)
}
}
fn draw_model_picker(frame: &mut Frame, area: Rect, picker: &ModelPicker) {
let popup = picker_popup_rect(area);
frame.render_widget(Clear, popup);
let (scroll, visible) =
picker_visible_window(popup.height, picker.cursor, picker.entries.len());
let items: Vec<ListItem> = picker
.entries
.iter()
.enumerate()
.skip(scroll)
.take(visible)
.map(|(i, entry)| {
let selected = i == picker.cursor;
let marker = if selected {
ROW_MARKER_SELECTED
} else {
ROW_MARKER_PLAIN
};
let (name_style, desc_style) = if !entry.is_available {
(
Style::default().fg(Color::DarkGray),
Style::default().fg(Color::DarkGray),
)
} else if selected {
(
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD),
Style::default().fg(ACCENT),
)
} else {
(
Style::default().fg(Color::Gray),
Style::default().fg(SLASH_POPUP_DESC_FG),
)
};
let mut spans: Vec<Span> =
vec![Span::raw(marker), Span::styled(entry.name, name_style)];
if entry.is_current {
spans.push(Span::styled(
" (current)",
Style::default().fg(HINT_KEY).add_modifier(Modifier::ITALIC),
));
}
if !entry.is_available {
spans.push(Span::styled(
" (re-launch session to activate)",
Style::default()
.fg(Color::DarkGray)
.add_modifier(Modifier::ITALIC),
));
}
spans.push(Span::raw(" "));
spans.push(Span::styled(entry.description, desc_style));
ListItem::new(Line::from(spans))
})
.collect();
let list = List::new(items).block(picker_block(" Select model "));
frame.render_widget(list, popup);
}
fn draw_effort_picker(frame: &mut Frame, area: Rect, picker: &EffortPicker) {
let popup = picker_popup_rect(area);
frame.render_widget(Clear, popup);
let (scroll, visible) =
picker_visible_window(popup.height, picker.cursor, picker.entries.len());
let items: Vec<ListItem> = picker
.entries
.iter()
.enumerate()
.skip(scroll)
.take(visible)
.map(|(i, entry)| {
let selected = i == picker.cursor;
let marker = if selected {
ROW_MARKER_SELECTED
} else {
ROW_MARKER_PLAIN
};
let name_style = if selected {
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::Gray)
};
let mut spans: Vec<Span> = vec![
Span::raw(marker),
Span::styled(entry.effort.as_label(), name_style),
];
if entry.is_current {
spans.push(Span::styled(
" (current)",
Style::default().fg(HINT_KEY).add_modifier(Modifier::ITALIC),
));
}
ListItem::new(Line::from(spans))
})
.collect();
let list = List::new(items).block(picker_block(" Select effort "));
frame.render_widget(list, popup);
}
fn draw_picker(frame: &mut Frame, area: Rect, picker: &Picker) {
let popup = picker_popup_rect(area);
frame.render_widget(Clear, popup);
let (scroll, visible) =
picker_visible_window(popup.height, picker.cursor, picker.sessions.len());
let items: Vec<ListItem> = picker
.sessions
.iter()
.enumerate()
.skip(scroll)
.take(visible)
.map(|(i, s)| {
let selected = i == picker.cursor;
let marker = if selected {
ROW_MARKER_SELECTED
} else {
ROW_MARKER_PLAIN
};
let style = if selected {
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::Gray)
};
let meta = format!(" ({} msgs)", s.message_count).dim();
ListItem::new(Line::from(vec![
Span::raw(marker),
Span::styled(s.preview.clone(), style),
meta,
]))
})
.collect();
let list = List::new(items).block(picker_block(" Resume session "));
frame.render_widget(list, popup);
}
#[cfg(test)]
mod tests {
use super::*;
fn app() -> App {
App::new("test-model".into())
}
#[test]
fn empty_input_height_is_one_content_row_plus_border() {
let mut a = app();
assert_eq!(input_box_height(&mut a, 80), 1 + INPUT_BORDER_ROWS);
}
#[test]
fn long_line_grows_height_via_soft_wrap() {
let mut a = app();
a.textarea.insert_str("a".repeat(60));
let h = input_box_height(&mut a, 20);
assert!(
h > 1 + INPUT_BORDER_ROWS,
"expected wrapped height > {}, got {}",
1 + INPUT_BORDER_ROWS,
h
);
assert!(
h <= MAX_INPUT_CONTENT_ROWS + INPUT_BORDER_ROWS,
"expected height clamped to {}, got {}",
MAX_INPUT_CONTENT_ROWS + INPUT_BORDER_ROWS,
h
);
}
#[test]
fn very_long_content_is_clamped_to_max() {
let mut a = app();
a.textarea.insert_str("x".repeat(10_000));
assert_eq!(
input_box_height(&mut a, 40),
MAX_INPUT_CONTENT_ROWS + INPUT_BORDER_ROWS
);
}
#[test]
fn multi_logical_lines_count_each_row() {
let mut a = app();
a.textarea.insert_str("a\nb\nc");
assert_eq!(input_box_height(&mut a, 80), 3 + INPUT_BORDER_ROWS);
}
#[test]
fn tiny_width_does_not_panic() {
let mut a = app();
a.textarea.insert_str("hello world");
for w in [0u16, 1, 2, 3] {
let h = input_box_height(&mut a, w);
assert!(h > INPUT_BORDER_ROWS);
assert!(h <= MAX_INPUT_CONTENT_ROWS + INPUT_BORDER_ROWS);
}
}
#[test]
fn height_reflows_when_width_changes() {
let mut a = app();
a.textarea.insert_str("a".repeat(50));
let narrow = input_box_height(&mut a, 20);
let wide = input_box_height(&mut a, 80);
assert!(
narrow > wide,
"expected narrower width to wrap to more rows: narrow={narrow}, wide={wide}"
);
assert_eq!(input_box_height(&mut a, 200), 1 + INPUT_BORDER_ROWS);
}
#[test]
fn confirmation_layout_shows_all_choices_when_interior_is_generous() {
let layout = confirmation_layout(
12, 4, 2,
);
assert!(layout.include_full_chrome);
assert_eq!(layout.window_start, 0);
assert_eq!(layout.window_end, 4);
}
#[test]
fn confirmation_layout_scrolls_on_short_terminal() {
for cursor in 0..4 {
let layout = confirmation_layout(4, 4, cursor);
assert!(!layout.include_full_chrome, "cursor={cursor}");
let window_size = layout.window_end - layout.window_start;
assert!(window_size >= 1, "cursor={cursor}: empty window {layout:?}");
assert!(
cursor >= layout.window_start && cursor < layout.window_end,
"cursor={cursor} fell outside window {layout:?}",
);
}
}
#[test]
fn confirmation_layout_keeps_cursor_inside_window() {
for cursor in [0u16, 1, 2, 5] {
let layout = confirmation_layout(1, 6, cursor);
assert_eq!(layout.window_end - layout.window_start, 1);
let clamped = cursor.min(5);
assert!(clamped >= layout.window_start && clamped < layout.window_end);
}
}
#[test]
fn confirmation_layout_handles_zero_choices() {
let layout = confirmation_layout(10, 0, 0);
assert_eq!(layout.window_start, 0);
assert_eq!(layout.window_end, 0);
}
#[test]
fn confirmation_layout_full_chrome_boundary_is_four_plus_choices() {
assert!(confirmation_layout(8, 4, 0).include_full_chrome);
assert!(!confirmation_layout(7, 4, 0).include_full_chrome);
assert!(confirmation_layout(6, 2, 0).include_full_chrome);
assert!(!confirmation_layout(5, 2, 0).include_full_chrome);
}
#[test]
fn scroll_offset_keeps_cursor_inside_window() {
assert_eq!(scroll_offset(0, 5, 10), 0);
assert_eq!(scroll_offset(4, 5, 10), 0);
assert_eq!(scroll_offset(0, 20, 6), 0);
assert_eq!(scroll_offset(2, 20, 6), 0);
assert_eq!(scroll_offset(10, 20, 6), 10 - 3);
assert_eq!(scroll_offset(8, 20, 6), 8 - 3);
assert_eq!(scroll_offset(19, 20, 6), 20 - 6);
assert_eq!(scroll_offset(18, 20, 6), 20 - 6);
assert_eq!(scroll_offset(0, 0, 6), 0);
assert_eq!(scroll_offset(5, 10, 0), 0);
}
#[test]
fn scroll_offset_keeps_cursor_visible_for_every_position() {
for total in [1usize, 5, 7, 12, 30] {
for visible in [1usize, 2, 3, 6, total] {
for cursor in 0..total {
let s = scroll_offset(cursor, total, visible);
let window_end = s + visible.min(total.saturating_sub(s));
assert!(
cursor >= s && cursor < window_end,
"cursor={cursor} not in [{s}, {window_end}) (total={total}, visible={visible})"
);
}
}
}
}
#[test]
fn confirmation_layout_full_chrome_shows_every_choice() {
let layout = confirmation_layout(8, 4, 3);
assert!(layout.include_full_chrome);
assert_eq!(layout.window_start, 0);
assert_eq!(layout.window_end, 4);
}
}