442 lines
15 KiB
Rust
442 lines
15 KiB
Rust
//! TUI rendering: frame layout, conversation display, and scroll management.
|
|
|
|
use ratatui::Frame;
|
|
use ratatui::layout::{Constraint, Layout, Rect};
|
|
use ratatui::style::{Color, Modifier, Style};
|
|
use ratatui::text::{Line, Span};
|
|
use ratatui::widgets::{Block, Clear, Paragraph, Wrap};
|
|
|
|
use super::{AppState, Mode};
|
|
use crate::core::types::Role;
|
|
|
|
/// Estimate the total rendered line count for all messages and update `state.scroll`
|
|
/// so the bottom of the content is visible.
|
|
///
|
|
/// When content fits within the viewport, `state.scroll` is set to 0.
|
|
///
|
|
/// Line estimation per message:
|
|
/// - 1 line for the role header
|
|
/// - `ceil(chars / width).max(1)` lines per newline-separated content line
|
|
/// - 1 blank separator line
|
|
pub(super) fn update_scroll(state: &mut AppState, area: Rect) {
|
|
// 4 = input pane (3: border top + content + border bottom) + status bar (1)
|
|
let viewport_height = area.height.saturating_sub(4);
|
|
state.viewport_height = viewport_height;
|
|
let width = area.width.max(1) as usize;
|
|
|
|
let mut total_lines: u16 = 0;
|
|
for (_, content) in &state.messages {
|
|
total_lines = total_lines.saturating_add(1); // role header
|
|
for line in content.lines() {
|
|
let chars = line.chars().count();
|
|
let wrapped = chars.div_ceil(width).max(1) as u16;
|
|
total_lines = total_lines.saturating_add(wrapped);
|
|
}
|
|
total_lines = total_lines.saturating_add(1); // blank separator
|
|
}
|
|
|
|
let max_scroll = total_lines.saturating_sub(viewport_height);
|
|
|
|
match state.mode {
|
|
// In Insert mode, auto-follow the bottom of the conversation.
|
|
Mode::Insert => {
|
|
state.scroll = max_scroll;
|
|
}
|
|
// In Normal/Command mode, the user controls scroll -- just clamp to bounds.
|
|
_ => {
|
|
state.scroll = state.scroll.min(max_scroll);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Compute the overlay rectangle for the command palette, centered on `output_area`.
|
|
fn command_overlay_rect(output_area: Rect) -> Rect {
|
|
let overlay_w = (output_area.width / 2).max(80).min(output_area.width);
|
|
let overlay_h: u16 = 3; // border + content + border
|
|
let overlay_x = output_area.x + (output_area.width.saturating_sub(overlay_w)) / 2;
|
|
let overlay_y = output_area.y + 2;
|
|
Rect {
|
|
x: overlay_x,
|
|
y: overlay_y,
|
|
width: overlay_w,
|
|
height: overlay_h.min(output_area.height),
|
|
}
|
|
}
|
|
|
|
/// Render the full TUI into `frame`.
|
|
///
|
|
/// Layout (top to bottom):
|
|
/// ```text
|
|
/// +--------------------------------+
|
|
/// | conversation history | Fill(1)
|
|
/// | |
|
|
/// +--------------------------------+
|
|
/// | Input | Length(3)
|
|
/// | > _ |
|
|
/// +--------------------------------+
|
|
/// | NORMAL tokens: -- | Length(1)
|
|
/// +--------------------------------+
|
|
/// ```
|
|
///
|
|
/// Role headers are coloured: `"You:"` in cyan, `"Assistant:"` in green.
|
|
/// In Command mode, a one-line overlay appears at row 1 of the output pane.
|
|
pub(super) fn render(frame: &mut Frame, state: &AppState) {
|
|
let chunks = Layout::vertical([
|
|
Constraint::Fill(1),
|
|
Constraint::Length(3),
|
|
Constraint::Length(1),
|
|
])
|
|
.split(frame.area());
|
|
|
|
// --- Output pane ---
|
|
let mut lines: Vec<Line> = Vec::new();
|
|
for (role, content) in &state.messages {
|
|
let (label, color) = match role {
|
|
Role::User => ("You:", Color::Cyan),
|
|
Role::Assistant => ("Assistant:", Color::Green),
|
|
};
|
|
lines.push(Line::from(Span::styled(label, Style::default().fg(color))));
|
|
for body_line in content.lines() {
|
|
lines.push(Line::from(body_line.to_string()));
|
|
}
|
|
lines.push(Line::from("")); // blank separator
|
|
}
|
|
|
|
let output = Paragraph::new(lines)
|
|
.wrap(Wrap { trim: false })
|
|
.scroll((state.scroll, 0));
|
|
let output_area = chunks[0];
|
|
frame.render_widget(output, output_area);
|
|
|
|
// --- Tool approval overlay ---
|
|
if let Some(ref approval) = state.pending_approval {
|
|
let overlay_w = (output_area.width / 2).max(60).min(output_area.width);
|
|
let overlay_h: u16 = 5;
|
|
let overlay_x = output_area.x + (output_area.width.saturating_sub(overlay_w)) / 2;
|
|
let overlay_y = output_area.y + output_area.height.saturating_sub(overlay_h) / 2;
|
|
let overlay_area = Rect {
|
|
x: overlay_x,
|
|
y: overlay_y,
|
|
width: overlay_w,
|
|
height: overlay_h.min(output_area.height),
|
|
};
|
|
frame.render_widget(Clear, overlay_area);
|
|
let text = format!(
|
|
"{}: {}\n\ny = approve, n = deny",
|
|
approval.tool_name, approval.input_summary
|
|
);
|
|
let overlay = Paragraph::new(text).block(
|
|
Block::bordered()
|
|
.border_style(Style::default().fg(Color::Yellow))
|
|
.title("Tool Approval"),
|
|
);
|
|
frame.render_widget(overlay, overlay_area);
|
|
}
|
|
|
|
// --- Command overlay (floating box centered on output pane) ---
|
|
if state.mode == Mode::Command {
|
|
let overlay_area = command_overlay_rect(output_area);
|
|
// Clear the area behind the overlay so it appears floating.
|
|
frame.render_widget(Clear, overlay_area);
|
|
let overlay = Paragraph::new(format!(":{}", state.command_buffer)).block(
|
|
Block::bordered()
|
|
.border_style(Style::default().fg(Color::Yellow))
|
|
.title("Command"),
|
|
);
|
|
frame.render_widget(overlay, overlay_area);
|
|
}
|
|
|
|
// --- Input pane ---
|
|
let (input_title, input_style) = match state.mode {
|
|
Mode::Normal | Mode::Command => (
|
|
"Input (normal)",
|
|
Style::default()
|
|
.fg(Color::DarkGray)
|
|
.add_modifier(Modifier::ITALIC),
|
|
),
|
|
Mode::Insert => ("Input", Style::default()),
|
|
};
|
|
let input_text = format!("> {}", state.input);
|
|
let input_widget = Paragraph::new(input_text)
|
|
.style(input_style)
|
|
.block(Block::bordered().title(input_title));
|
|
frame.render_widget(input_widget, chunks[1]);
|
|
|
|
// --- Cursor positioning ---
|
|
match state.mode {
|
|
Mode::Insert => {
|
|
// Cursor at end of input text: border(1) + "> " (2) + input len
|
|
let cursor_x = chunks[1].x + 1 + 2 + state.input.len() as u16;
|
|
let cursor_y = chunks[1].y + 1; // inside the border
|
|
frame.set_cursor_position((cursor_x, cursor_y));
|
|
}
|
|
Mode::Command => {
|
|
// Cursor in the floating overlay
|
|
let overlay = command_overlay_rect(output_area);
|
|
// border(1) + ":" (1) + buf len
|
|
let cursor_x = overlay.x + 1 + 1 + state.command_buffer.len() as u16;
|
|
let cursor_y = overlay.y + 1; // inside the border
|
|
frame.set_cursor_position((cursor_x, cursor_y));
|
|
}
|
|
Mode::Normal => {} // no cursor
|
|
}
|
|
|
|
// --- Status bar ---
|
|
let (mode_label, mode_style) = match state.mode {
|
|
Mode::Normal => (
|
|
" NORMAL ",
|
|
Style::default().bg(Color::Blue).fg(Color::White),
|
|
),
|
|
Mode::Insert => (
|
|
" INSERT ",
|
|
Style::default().bg(Color::Green).fg(Color::White),
|
|
),
|
|
Mode::Command => (
|
|
" COMMAND ",
|
|
Style::default().bg(Color::Yellow).fg(Color::Black),
|
|
),
|
|
};
|
|
|
|
let right_text = if let Some(ref err) = state.status_error {
|
|
err.clone()
|
|
} else {
|
|
"tokens: --".to_string()
|
|
};
|
|
|
|
let right_style = if state.status_error.is_some() {
|
|
Style::default().fg(Color::Red)
|
|
} else {
|
|
Style::default()
|
|
};
|
|
|
|
let bar_width = chunks[2].width as usize;
|
|
let left_len = mode_label.len();
|
|
let right_len = right_text.len();
|
|
let pad = bar_width.saturating_sub(left_len + right_len);
|
|
|
|
let status_line = Line::from(vec![
|
|
Span::styled(mode_label, mode_style),
|
|
Span::raw(" ".repeat(pad)),
|
|
Span::styled(right_text, right_style),
|
|
]);
|
|
let status_widget = Paragraph::new(status_line);
|
|
frame.render_widget(status_widget, chunks[2]);
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use ratatui::Terminal;
|
|
use ratatui::backend::TestBackend;
|
|
|
|
#[test]
|
|
fn render_smoke_test() {
|
|
let backend = TestBackend::new(80, 24);
|
|
let mut terminal = Terminal::new(backend).unwrap();
|
|
let state = AppState::new();
|
|
terminal.draw(|frame| render(frame, &state)).unwrap();
|
|
// no panic is the assertion
|
|
}
|
|
|
|
#[test]
|
|
fn render_shows_role_prefixes() {
|
|
let backend = TestBackend::new(80, 24);
|
|
let mut terminal = Terminal::new(backend).unwrap();
|
|
let mut state = AppState::new();
|
|
state.messages.push((Role::User, "hi".to_string()));
|
|
state.messages.push((Role::Assistant, "hello".to_string()));
|
|
terminal.draw(|frame| render(frame, &state)).unwrap();
|
|
let buf = terminal.backend().buffer().clone();
|
|
let all_text: String = buf
|
|
.content()
|
|
.iter()
|
|
.map(|c| c.symbol().to_string())
|
|
.collect();
|
|
assert!(
|
|
all_text.contains("You:"),
|
|
"expected 'You:' in buffer: {all_text:.100}"
|
|
);
|
|
assert!(
|
|
all_text.contains("Assistant:"),
|
|
"expected 'Assistant:' in buffer"
|
|
);
|
|
}
|
|
|
|
// --- update_scroll tests ---
|
|
|
|
#[test]
|
|
fn update_scroll_zero_when_fits() {
|
|
let mut state = AppState::new();
|
|
state.messages.push((Role::User, "hello".to_string()));
|
|
let area = Rect::new(0, 0, 80, 24);
|
|
update_scroll(&mut state, area);
|
|
assert_eq!(state.scroll, 0);
|
|
}
|
|
|
|
#[test]
|
|
fn update_scroll_positive_when_overflow() {
|
|
let mut state = AppState::new();
|
|
for i in 0..50 {
|
|
state.messages.push((Role::User, format!("message {i}")));
|
|
}
|
|
let area = Rect::new(0, 0, 80, 24);
|
|
update_scroll(&mut state, area);
|
|
assert!(state.scroll > 0, "expected scroll > 0 with 50 messages");
|
|
}
|
|
|
|
// --- render snapshot tests ---
|
|
|
|
#[test]
|
|
fn render_status_bar_normal_mode() {
|
|
let backend = TestBackend::new(80, 24);
|
|
let mut terminal = Terminal::new(backend).unwrap();
|
|
let mut state = AppState::new();
|
|
state.mode = Mode::Normal;
|
|
terminal.draw(|frame| render(frame, &state)).unwrap();
|
|
let buf = terminal.backend().buffer().clone();
|
|
let all_text: String = buf
|
|
.content()
|
|
.iter()
|
|
.map(|c| c.symbol().to_string())
|
|
.collect();
|
|
assert!(all_text.contains("NORMAL"), "expected 'NORMAL' in buffer");
|
|
assert!(
|
|
all_text.contains("tokens: --"),
|
|
"expected 'tokens: --' in buffer"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn render_status_bar_insert_mode() {
|
|
let backend = TestBackend::new(80, 24);
|
|
let mut terminal = Terminal::new(backend).unwrap();
|
|
let state = AppState::new(); // starts in Insert
|
|
terminal.draw(|frame| render(frame, &state)).unwrap();
|
|
let buf = terminal.backend().buffer().clone();
|
|
let all_text: String = buf
|
|
.content()
|
|
.iter()
|
|
.map(|c| c.symbol().to_string())
|
|
.collect();
|
|
assert!(all_text.contains("INSERT"), "expected 'INSERT' in buffer");
|
|
}
|
|
|
|
#[test]
|
|
fn render_status_bar_command_mode() {
|
|
let backend = TestBackend::new(80, 24);
|
|
let mut terminal = Terminal::new(backend).unwrap();
|
|
let mut state = AppState::new();
|
|
state.mode = Mode::Command;
|
|
terminal.draw(|frame| render(frame, &state)).unwrap();
|
|
let buf = terminal.backend().buffer().clone();
|
|
let all_text: String = buf
|
|
.content()
|
|
.iter()
|
|
.map(|c| c.symbol().to_string())
|
|
.collect();
|
|
assert!(all_text.contains("COMMAND"), "expected 'COMMAND' in buffer");
|
|
}
|
|
|
|
#[test]
|
|
fn render_command_overlay_visible_in_command_mode() {
|
|
let backend = TestBackend::new(80, 24);
|
|
let mut terminal = Terminal::new(backend).unwrap();
|
|
let mut state = AppState::new();
|
|
state.mode = Mode::Command;
|
|
state.command_buffer = "quit".to_string();
|
|
terminal.draw(|frame| render(frame, &state)).unwrap();
|
|
let buf = terminal.backend().buffer().clone();
|
|
let all_text: String = buf
|
|
.content()
|
|
.iter()
|
|
.map(|c| c.symbol().to_string())
|
|
.collect();
|
|
assert!(
|
|
all_text.contains(":quit"),
|
|
"expected ':quit' overlay in buffer"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn render_no_overlay_in_normal_mode() {
|
|
let backend = TestBackend::new(80, 24);
|
|
let mut terminal = Terminal::new(backend).unwrap();
|
|
let mut state = AppState::new();
|
|
state.mode = Mode::Normal;
|
|
terminal.draw(|frame| render(frame, &state)).unwrap();
|
|
let buf = terminal.backend().buffer().clone();
|
|
// Row 1 should not have a ":" prefix from the overlay
|
|
let row1: String = (0..80)
|
|
.map(|x| buf.cell((x, 1)).unwrap().symbol().to_string())
|
|
.collect();
|
|
assert!(
|
|
!row1.starts_with(':'),
|
|
"overlay should not appear in Normal mode"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn render_input_pane_dimmed_in_normal() {
|
|
let backend = TestBackend::new(80, 24);
|
|
let mut terminal = Terminal::new(backend).unwrap();
|
|
let mut state = AppState::new();
|
|
state.mode = Mode::Normal;
|
|
terminal.draw(|frame| render(frame, &state)).unwrap();
|
|
let buf = terminal.backend().buffer().clone();
|
|
let all_text: String = buf
|
|
.content()
|
|
.iter()
|
|
.map(|c| c.symbol().to_string())
|
|
.collect();
|
|
assert!(
|
|
all_text.contains("Input (normal)"),
|
|
"expected 'Input (normal)' title in Normal mode"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn render_status_bar_shows_error() {
|
|
let backend = TestBackend::new(80, 24);
|
|
let mut terminal = Terminal::new(backend).unwrap();
|
|
let mut state = AppState::new();
|
|
state.status_error = Some("Unknown command: foo".to_string());
|
|
terminal.draw(|frame| render(frame, &state)).unwrap();
|
|
let buf = terminal.backend().buffer().clone();
|
|
let all_text: String = buf
|
|
.content()
|
|
.iter()
|
|
.map(|c| c.symbol().to_string())
|
|
.collect();
|
|
assert!(
|
|
all_text.contains("Unknown command: foo"),
|
|
"expected error in status bar"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn render_approval_overlay_visible() {
|
|
let backend = TestBackend::new(80, 24);
|
|
let mut terminal = Terminal::new(backend).unwrap();
|
|
let mut state = AppState::new();
|
|
state.pending_approval = Some(super::super::events::PendingApproval {
|
|
tool_use_id: "t1".to_string(),
|
|
tool_name: "write_file".to_string(),
|
|
input_summary: "path: foo.txt".to_string(),
|
|
});
|
|
terminal.draw(|frame| render(frame, &state)).unwrap();
|
|
let buf = terminal.backend().buffer().clone();
|
|
let all_text: String = buf
|
|
.content()
|
|
.iter()
|
|
.map(|c| c.symbol().to_string())
|
|
.collect();
|
|
assert!(
|
|
all_text.contains("Tool Approval"),
|
|
"expected 'Tool Approval' overlay"
|
|
);
|
|
assert!(
|
|
all_text.contains("write_file"),
|
|
"expected tool name in overlay"
|
|
);
|
|
}
|
|
}
|