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, Picker};
use super::event::Mode;
use super::inline_terminal::Frame;
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 SEP: &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 rows = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(HINT_ROW_HEIGHT),
Constraint::Length(input_height),
Constraint::Length(STATUS_ROW_HEIGHT),
])
.split(area);
draw_hint(frame, rows[0], app);
draw_input(frame, rows[1], app);
draw_status(frame, rows[2], app);
if let Some(picker) = &app.picker {
draw_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;
pub fn desired_viewport_height(app: &mut App, area_width: u16) -> u16 {
let base = HINT_ROW_HEIGHT + 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 {
0
};
base.max(modal_height)
}
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 title = Line::from(vec![Span::styled(
prompt_glyph,
Style::default()
.fg(if safe { Color::Yellow } else { TITLE_FG })
.add_modifier(Modifier::BOLD),
)]);
let mut textarea = app.textarea.clone();
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() {
spans.push(Span::styled(" ❯ ", Style::default().fg(HINT_KEY)));
spans.push(Span::styled(
"pick a session",
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("/exit ", Style::default().fg(HINT_KEY)));
spans.push(Span::styled("quit", 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) = match &app.status {
Some(s) => (
s.model.as_str(),
s.mode,
s.reasoning.as_str(),
s.input_tokens,
s.output_tokens,
),
None => (app.model_label.as_str(), Mode::Normal, "", 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),
));
}
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 {
"❯ "
} else if at_top_cue && at_bottom_cue {
"⇅ "
} else if at_top_cue {
"▴ "
} else if at_bottom_cue {
"▾ "
} else {
" "
};
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_picker(frame: &mut Frame, area: Rect, picker: &Picker) {
let width = area.width.saturating_mul(8) / 10;
let height = area.height.saturating_mul(6) / 10;
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 items: Vec<ListItem> = picker
.sessions
.iter()
.enumerate()
.map(|(i, s)| {
let selected = i == picker.cursor;
let marker = if selected { "❯ " } else { " " };
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 block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(PICKER_BORDER))
.title(Line::from(vec![Span::styled(
" Resume session ",
Style::default().fg(ACCENT).add_modifier(Modifier::BOLD),
)]));
let list = List::new(items).block(block);
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 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);
}
}