skate/src/tui/mod.rs
Drew Galbraith 3fd448d431 Add modal editing to the agent TUI. (#2)
Adds a status line indicating which mode the user is in.
Adds a "normal" mode with keyboard shortcuts (including a chorded shortcut 'gg').
Adds a command mode with several basic commands that can be entered into an overlay.

Chores:
- Cleans up design/claude/plan.md to avoid confusing claude.
- Adds some TODOs based on claude feedback.`

Reviewed-on: #2
Co-authored-by: Drew Galbraith <drew@tiramisu.one>
Co-committed-by: Drew Galbraith <drew@tiramisu.one>
2026-02-25 01:16:16 +00:00

1135 lines
39 KiB
Rust

//! TUI frontend: terminal lifecycle, rendering, and input handling.
//!
//! All communication with the core orchestrator flows through channels:
//! - [`UserAction`] sent via `action_tx` when the user submits input or quits
//! - [`UIEvent`] received via `event_rx` to display streaming assistant responses
//!
//! The terminal lifecycle follows the standard crossterm pattern:
//! 1. Enable raw mode
//! 2. Enter alternate screen
//! 3. On exit (or panic), disable raw mode and leave the alternate screen
use std::io::{self, Stdout};
use std::time::Duration;
use crossterm::event::{Event, EventStream, KeyCode, KeyEvent, KeyModifiers};
use crossterm::execute;
use crossterm::terminal::{
EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
};
use futures::StreamExt;
use ratatui::backend::CrosstermBackend;
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 ratatui::{Frame, Terminal};
use tokio::sync::mpsc;
use tracing::debug;
use crate::core::types::{Role, UIEvent, UserAction};
/// Errors that can occur in the TUI layer.
#[derive(Debug, thiserror::Error)]
pub enum TuiError {
/// An underlying terminal I/O error.
#[error("terminal IO error: {0}")]
Io(#[from] std::io::Error),
}
/// Vim-style editing mode for the TUI.
///
/// The TUI starts in Insert mode so first-time users can type immediately.
/// Mode transitions:
/// Insert --Esc--> Normal
/// Normal --i--> Insert
/// Normal --:--> Command
/// Command --Esc/Enter--> Normal
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Mode {
/// Navigation and command entry. Keys are interpreted as commands, not text.
Normal,
/// Text input. Printable keys append to the input buffer.
Insert,
/// Ex-style command entry. The command buffer lives in `AppState::command_buffer`.
Command,
}
/// The UI-layer view of a conversation: rendered messages and the current input buffer.
pub struct AppState {
/// All conversation turns rendered as (role, content) pairs.
pub messages: Vec<(Role, String)>,
/// The current contents of the input box.
pub input: String,
/// Vertical scroll offset for the output pane (lines from top).
pub scroll: u16,
/// Current vim-style editing mode.
pub mode: Mode,
/// Partial command text in Command mode (without the leading `:`).
pub command_buffer: String,
/// Buffered keystrokes for multi-key chord resolution in Normal mode (e.g. `gg`).
pub pending_keys: Vec<char>,
/// Last-known viewport height (output pane lines). Updated each frame by `update_scroll`.
pub viewport_height: u16,
/// Transient error message shown in the status bar, cleared on next keypress.
pub status_error: Option<String>,
}
impl AppState {
fn new() -> Self {
Self {
messages: Vec::new(),
input: String::new(),
scroll: 0,
mode: Mode::Insert,
command_buffer: String::new(),
pending_keys: Vec::new(),
viewport_height: 0,
status_error: None,
}
}
}
/// Initialise the terminal: enable raw mode and switch to the alternate screen.
///
/// Callers must pair this with [`restore_terminal`] (and [`install_panic_hook`]) to
/// guarantee cleanup even on abnormal exit.
pub fn init_terminal() -> Result<Terminal<CrosstermBackend<Stdout>>, TuiError> {
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen)?;
let backend = CrosstermBackend::new(stdout);
let terminal = Terminal::new(backend)?;
Ok(terminal)
}
/// Restore the terminal to its pre-launch state: disable raw mode and leave the
/// alternate screen.
pub fn restore_terminal() -> io::Result<()> {
disable_raw_mode()?;
execute!(io::stdout(), LeaveAlternateScreen)?;
Ok(())
}
/// Install a panic hook that restores the terminal before printing the panic message.
///
/// Without this, a panic leaves the terminal in raw mode with the alternate screen
/// active, making the shell unusable until the user runs `reset`.
pub fn install_panic_hook() {
let original = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
// Best-effort restore; if it fails the original hook still runs.
let _ = restore_terminal();
original(info);
}));
}
/// Internal control flow signal returned by [`handle_key`].
enum LoopControl {
/// The user pressed Enter with non-empty input; send this message to the core.
SendMessage(String),
/// The user pressed Ctrl+C or Ctrl+D; exit the event loop.
Quit,
/// The user ran `:clear`; wipe the conversation.
ClearHistory,
}
/// Map a key event to a [`LoopControl`] signal, mutating `state` as a side-effect.
///
/// Ctrl+C / Ctrl+D quit from any mode. All other keys are dispatched to the
/// handler for the current [`Mode`].
fn handle_key(key: Option<KeyEvent>, state: &mut AppState) -> Option<LoopControl> {
let key = key?;
// Clear any transient status error on the next keypress.
state.status_error = None;
// Ctrl+C quits from any mode.
if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
return Some(LoopControl::Quit);
}
// Ctrl+D quits only from Insert/Command mode. In Normal mode it scrolls.
if key.modifiers.contains(KeyModifiers::CONTROL)
&& key.code == KeyCode::Char('d')
&& !matches!(state.mode, Mode::Normal)
{
return Some(LoopControl::Quit);
}
match &state.mode {
Mode::Normal => handle_normal_key(key, state),
Mode::Insert => handle_insert_key(key, state),
Mode::Command => handle_command_key(key, state),
}
}
enum ChordResult {
/// The chord was fully matched; the returned closure applies the effect.
Executed,
/// The keys so far are a valid prefix of at least one chord.
Prefix,
/// The keys don't match any chord or prefix.
NoMatch,
}
/// Check `keys` against known multi-key chords in Normal mode.
///
/// Currently only `gg` (scroll to top) is defined. Returns the match status
/// without mutating state -- the caller applies effects for `Executed`.
fn resolve_chord(keys: &[char]) -> ChordResult {
match keys {
[.., 'g', 'g'] => ChordResult::Executed,
[.., 'g'] => ChordResult::Prefix,
_ => ChordResult::NoMatch,
}
}
/// Handle a keypress in Normal mode.
///
/// | Key | Effect |
/// |-----|-------------------------------------------|
/// | `i` | Switch to Insert mode |
/// | `:` | Switch to Command mode (empty buffer) |
/// | `j` | Scroll down one line |
/// | `k` | Scroll up one line |
/// | `G` | Scroll to bottom |
/// | `g` | Begin chord; `gg` scrolls to top |
/// | Ctrl+d | Scroll down half a page |
/// | Ctrl+u | Scroll up half a page |
fn handle_normal_key(key: KeyEvent, state: &mut AppState) -> Option<LoopControl> {
let is_ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
match key.code {
KeyCode::Char('d') if is_ctrl => {
let half = (state.viewport_height / 2).max(1);
state.scroll = state.scroll.saturating_add(half);
state.pending_keys.clear();
}
KeyCode::Char('u') if is_ctrl => {
let half = (state.viewport_height / 2).max(1);
state.scroll = state.scroll.saturating_sub(half);
state.pending_keys.clear();
}
KeyCode::Char('i') if !is_ctrl && state.pending_keys.is_empty() => {
state.mode = Mode::Insert;
}
KeyCode::Char(':') if !is_ctrl && state.pending_keys.is_empty() => {
state.command_buffer.clear();
state.mode = Mode::Command;
}
KeyCode::Char('j') if !is_ctrl && state.pending_keys.is_empty() => {
state.scroll = state.scroll.saturating_add(1);
}
KeyCode::Char('k') if !is_ctrl && state.pending_keys.is_empty() => {
state.scroll = state.scroll.saturating_sub(1);
}
KeyCode::Char('G') if !is_ctrl && state.pending_keys.is_empty() => {
state.scroll = u16::MAX;
}
KeyCode::Char(c) => {
state.pending_keys.push(c);
match resolve_chord(&state.pending_keys) {
ChordResult::Executed => {
// Apply the chord effect. Currently only `gg`.
state.scroll = 0;
state.pending_keys.clear();
}
ChordResult::NoMatch => {
state.pending_keys.clear();
}
ChordResult::Prefix => {} // wait for more keys
}
}
_ => {
state.pending_keys.clear();
}
}
None
}
/// Handle a keypress in Insert mode (the default text-entry mode).
///
/// | Key | Effect |
/// |------------------|-------------------------------------------------|
/// | Esc | Switch to Normal mode |
/// | Printable char | Append to input buffer |
/// | Backspace | Pop last char from input buffer |
/// | Enter (non-empty)| Take input, push User message, return `SendMessage` |
/// | Enter (empty) | No-op |
fn handle_insert_key(key: KeyEvent, state: &mut AppState) -> Option<LoopControl> {
match key.code {
KeyCode::Esc => {
state.mode = Mode::Normal;
None
}
KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
state.input.push(c);
None
}
KeyCode::Backspace => {
state.input.pop();
None
}
KeyCode::Enter => {
let msg = std::mem::take(&mut state.input);
if msg.is_empty() {
None
} else {
state.messages.push((Role::User, msg.clone()));
Some(LoopControl::SendMessage(msg))
}
}
_ => None,
}
}
/// Execute a parsed command string and return the appropriate control signal.
///
/// | Command | Effect |
/// |---------------|-------------------------------------------------|
/// | `quit` / `q` | Return `LoopControl::Quit` |
/// | `clear` | Clear messages and scroll, return `ClearHistory` |
/// | anything else | Set `status_error` on state |
fn execute_command(buf: &str, state: &mut AppState) -> Option<LoopControl> {
match buf.trim() {
"quit" | "q" => Some(LoopControl::Quit),
"clear" => {
state.messages.clear();
state.scroll = 0;
Some(LoopControl::ClearHistory)
}
other => {
state.status_error = Some(format!("Unknown command: {other}"));
None
}
}
}
/// Handle a keypress in Command mode (ex-style `:` command entry).
///
/// | Key | Effect |
/// |-----------|-----------------------------------------------------|
/// | Esc | Abandon command, return to Normal |
/// | Enter | Accept command (execution deferred to 2.3), Normal |
/// | Backspace | Pop from buffer; if empty, return to Normal |
/// | Char(c) | Append to command buffer |
fn handle_command_key(key: KeyEvent, state: &mut AppState) -> Option<LoopControl> {
match key.code {
KeyCode::Esc => {
state.mode = Mode::Normal;
}
KeyCode::Enter => {
state.mode = Mode::Normal;
return execute_command(&state.command_buffer.clone(), state);
}
KeyCode::Backspace => {
state.command_buffer.pop();
}
KeyCode::Char(c) => {
state.command_buffer.push(c);
}
_ => {}
}
None
}
/// Drain all pending [`UIEvent`]s from `event_rx` and apply them to `state`.
///
/// This is non-blocking: it processes all currently-available events and returns
/// immediately when the channel is empty.
///
/// | Event | Effect |
/// |--------------------|------------------------------------------------------------|
/// | `StreamDelta(s)` | Append `s` to last message if it's `Assistant`; else push new |
/// | `TurnComplete` | No structural change; logged at debug level |
/// | `Error(msg)` | Push `(Assistant, "[error] {msg}")` |
fn drain_ui_events(event_rx: &mut mpsc::Receiver<UIEvent>, state: &mut AppState) {
while let Ok(event) = event_rx.try_recv() {
match event {
UIEvent::StreamDelta(chunk) => {
if let Some((Role::Assistant, content)) = state.messages.last_mut() {
content.push_str(&chunk);
} else {
state.messages.push((Role::Assistant, chunk));
}
}
UIEvent::TurnComplete => {
debug!("turn complete");
}
UIEvent::Error(msg) => {
state
.messages
.push((Role::Assistant, format!("[error] {msg}")));
}
}
}
}
/// 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
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.
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));
frame.render_widget(output, chunks[0]);
// --- Command overlay (floating box centered on output pane) ---
if state.mode == Mode::Command {
let overlay_area = command_overlay_rect(chunks[0]);
// 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(chunks[0]);
// 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]);
}
/// Run the TUI event loop.
///
/// This function owns the terminal for its entire lifetime. It initialises the
/// terminal, installs the panic hook, then spins in a ~60 fps loop:
///
/// ```text
/// loop:
/// 1. drain UIEvents (non-blocking try_recv)
/// 2. poll keyboard for up to 16 ms via EventStream (async, no blocking thread)
/// 3. handle key event -> Option<LoopControl>
/// 4. render frame (scroll updated inside draw closure)
/// 5. act on LoopControl: send message or break
/// ```
///
/// On `Ctrl+C` / `Ctrl+D`: sends [`UserAction::Quit`], restores the terminal, and
/// returns `Ok(())`.
pub async fn run(
action_tx: mpsc::Sender<UserAction>,
mut event_rx: mpsc::Receiver<UIEvent>,
) -> Result<(), TuiError> {
install_panic_hook();
let mut terminal = init_terminal()?;
let mut state = AppState::new();
let mut event_stream = EventStream::new();
loop {
// 1. Drain pending UI events.
drain_ui_events(&mut event_rx, &mut state);
// 2. Poll keyboard for up to 16 ms. EventStream integrates with the
// Tokio runtime via futures::Stream, so no blocking thread is needed.
// Timeout expiry, stream end, non-key events, and I/O errors all map
// to None -- the frame is rendered regardless.
let key_event: Option<KeyEvent> =
match tokio::time::timeout(Duration::from_millis(16), event_stream.next()).await {
Ok(Some(Ok(Event::Key(k)))) => Some(k),
_ => None,
};
// 3. Handle key.
let control = handle_key(key_event, &mut state);
// 4. Render (scroll updated inside draw closure to use current frame area).
terminal.draw(|frame| {
update_scroll(&mut state, frame.area());
render(frame, &state);
})?;
// 5. Act on control signal after render so the user sees the submitted message.
match control {
Some(LoopControl::SendMessage(msg)) => {
if action_tx.send(UserAction::SendMessage(msg)).await.is_err() {
break;
}
}
Some(LoopControl::Quit) => {
let _ = action_tx.send(UserAction::Quit).await;
break;
}
Some(LoopControl::ClearHistory) => {
let _ = action_tx.send(UserAction::ClearHistory).await;
}
None => {}
}
}
restore_terminal()?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::backend::TestBackend;
fn make_key(code: KeyCode) -> Option<KeyEvent> {
Some(KeyEvent::new(code, KeyModifiers::empty()))
}
fn ctrl_key(c: char) -> Option<KeyEvent> {
Some(KeyEvent::new(KeyCode::Char(c), KeyModifiers::CONTROL))
}
// --- handle_key tests ---
#[test]
fn handle_key_printable_appends() {
let mut state = AppState::new();
handle_key(make_key(KeyCode::Char('h')), &mut state);
assert_eq!(state.input, "h");
}
#[test]
fn handle_key_backspace_pops() {
let mut state = AppState::new();
state.input = "ab".to_string();
handle_key(make_key(KeyCode::Backspace), &mut state);
assert_eq!(state.input, "a");
}
#[test]
fn handle_key_backspace_empty_noop() {
let mut state = AppState::new();
handle_key(make_key(KeyCode::Backspace), &mut state);
assert_eq!(state.input, "");
}
#[test]
fn handle_key_enter_empty_noop() {
let mut state = AppState::new();
let result = handle_key(make_key(KeyCode::Enter), &mut state);
assert!(result.is_none());
assert!(state.messages.is_empty());
}
#[test]
fn handle_key_enter_sends_and_clears() {
let mut state = AppState::new();
state.input = "hello".to_string();
let result = handle_key(make_key(KeyCode::Enter), &mut state);
assert!(state.input.is_empty());
assert_eq!(state.messages.len(), 1);
assert!(matches!(result, Some(LoopControl::SendMessage(ref m)) if m == "hello"));
}
#[test]
fn handle_key_ctrl_c_quits() {
let mut state = AppState::new();
let result = handle_key(ctrl_key('c'), &mut state);
assert!(matches!(result, Some(LoopControl::Quit)));
}
#[test]
fn insert_ctrl_char_is_noop() {
let mut state = AppState::new();
handle_key(ctrl_key('a'), &mut state);
assert_eq!(state.input, "", "Ctrl+A should not insert 'a'");
}
// --- drain_ui_events tests ---
#[tokio::test]
async fn drain_appends_to_existing_assistant() {
let (tx, mut rx) = tokio::sync::mpsc::channel(8);
let mut state = AppState::new();
state.messages.push((Role::Assistant, "hello".to_string()));
tx.send(UIEvent::StreamDelta(" world".to_string()))
.await
.unwrap();
drop(tx);
drain_ui_events(&mut rx, &mut state);
assert_eq!(state.messages.last().unwrap().1, "hello world");
}
#[tokio::test]
async fn drain_creates_assistant_on_user_last() {
let (tx, mut rx) = tokio::sync::mpsc::channel(8);
let mut state = AppState::new();
state.messages.push((Role::User, "hi".to_string()));
tx.send(UIEvent::StreamDelta("hello".to_string()))
.await
.unwrap();
drop(tx);
drain_ui_events(&mut rx, &mut state);
assert_eq!(state.messages.len(), 2);
assert_eq!(state.messages[1].0, Role::Assistant);
assert_eq!(state.messages[1].1, "hello");
}
// --- render tests ---
#[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");
}
// --- mode tests ---
#[test]
fn mode_starts_insert() {
assert_eq!(AppState::new().mode, Mode::Insert);
}
#[test]
fn insert_esc_enters_normal() {
let mut state = AppState::new();
handle_key(make_key(KeyCode::Esc), &mut state);
assert_eq!(state.mode, Mode::Normal);
}
#[test]
fn normal_i_enters_insert() {
let mut state = AppState::new();
state.mode = Mode::Normal;
handle_key(make_key(KeyCode::Char('i')), &mut state);
assert_eq!(state.mode, Mode::Insert);
}
#[test]
fn normal_colon_enters_command() {
let mut state = AppState::new();
state.mode = Mode::Normal;
handle_key(make_key(KeyCode::Char(':')), &mut state);
assert_eq!(state.mode, Mode::Command);
}
#[test]
fn command_esc_enters_normal() {
let mut state = AppState::new();
state.mode = Mode::Command;
state.command_buffer = "q".to_string();
handle_key(make_key(KeyCode::Esc), &mut state);
assert_eq!(state.mode, Mode::Normal);
}
#[test]
fn command_enter_enters_normal() {
let mut state = AppState::new();
state.mode = Mode::Command;
handle_key(make_key(KeyCode::Enter), &mut state);
assert_eq!(state.mode, Mode::Normal);
}
#[test]
fn command_chars_append_to_buffer() {
let mut state = AppState::new();
state.mode = Mode::Command;
handle_key(make_key(KeyCode::Char('q')), &mut state);
assert_eq!(state.mode, Mode::Command);
assert_eq!(state.command_buffer, "q");
}
#[test]
fn command_backspace_empty_stays_in_command() {
let mut state = AppState::new();
state.mode = Mode::Command;
state.command_buffer = "ab".to_string();
handle_key(make_key(KeyCode::Backspace), &mut state);
assert_eq!(state.command_buffer, "a");
handle_key(make_key(KeyCode::Backspace), &mut state);
assert_eq!(state.command_buffer, "");
handle_key(make_key(KeyCode::Backspace), &mut state);
assert_eq!(state.command_buffer, "");
handle_key(make_key(KeyCode::Backspace), &mut state);
assert_eq!(state.command_buffer, "");
}
#[test]
fn normal_j_increments_scroll() {
let mut state = AppState::new();
state.mode = Mode::Normal;
handle_key(make_key(KeyCode::Char('j')), &mut state);
assert_eq!(state.scroll, 1);
}
#[test]
fn normal_k_decrements_scroll() {
let mut state = AppState::new();
state.mode = Mode::Normal;
state.scroll = 5;
handle_key(make_key(KeyCode::Char('k')), &mut state);
assert_eq!(state.scroll, 4);
}
#[test]
fn normal_k_clamps_at_zero() {
let mut state = AppState::new();
state.mode = Mode::Normal;
handle_key(make_key(KeyCode::Char('k')), &mut state);
assert_eq!(state.scroll, 0);
}
#[test]
fn normal_big_g_scrolls_bottom() {
let mut state = AppState::new();
state.mode = Mode::Normal;
handle_key(make_key(KeyCode::Char('G')), &mut state);
assert_eq!(state.scroll, u16::MAX);
}
#[test]
fn normal_gg_scrolls_top() {
let mut state = AppState::new();
state.mode = Mode::Normal;
state.scroll = 50;
handle_key(make_key(KeyCode::Char('g')), &mut state);
assert_eq!(state.pending_keys, vec!['g']); // prefix, waiting
handle_key(make_key(KeyCode::Char('g')), &mut state);
assert_eq!(state.scroll, 0);
assert!(state.pending_keys.is_empty());
}
#[test]
fn normal_g_then_other_clears_pending() {
let mut state = AppState::new();
state.mode = Mode::Normal;
state.scroll = 50;
handle_key(make_key(KeyCode::Char('g')), &mut state);
handle_key(make_key(KeyCode::Char('x')), &mut state);
assert!(state.pending_keys.is_empty());
assert_eq!(state.scroll, 50); // unchanged
}
#[test]
fn ctrl_c_quits_from_any_mode() {
for mode in [Mode::Normal, Mode::Insert, Mode::Command] {
let mut state = AppState::new();
state.mode = mode;
let result = handle_key(ctrl_key('c'), &mut state);
assert!(matches!(result, Some(LoopControl::Quit)));
}
}
#[test]
fn ctrl_d_quits_from_insert() {
let mut state = AppState::new();
let result = handle_key(ctrl_key('d'), &mut state);
assert!(matches!(result, Some(LoopControl::Quit)));
}
#[test]
fn ctrl_d_scrolls_in_normal() {
let mut state = AppState::new();
state.mode = Mode::Normal;
state.viewport_height = 20;
let result = handle_key(ctrl_key('d'), &mut state);
assert!(result.is_none());
assert_eq!(state.scroll, 10);
}
#[test]
fn ctrl_u_scrolls_up_in_normal() {
let mut state = AppState::new();
state.mode = Mode::Normal;
state.viewport_height = 20;
state.scroll = 15;
let result = handle_key(ctrl_key('u'), &mut state);
assert!(result.is_none());
assert_eq!(state.scroll, 5);
}
#[test]
fn ctrl_u_clamps_at_zero() {
let mut state = AppState::new();
state.mode = Mode::Normal;
state.viewport_height = 20;
state.scroll = 3;
handle_key(ctrl_key('u'), &mut state);
assert_eq!(state.scroll, 0);
}
// --- execute_command tests ---
#[test]
fn command_quit_returns_quit() {
let mut state = AppState::new();
let result = execute_command("quit", &mut state);
assert!(matches!(result, Some(LoopControl::Quit)));
}
#[test]
fn command_q_returns_quit() {
let mut state = AppState::new();
let result = execute_command("q", &mut state);
assert!(matches!(result, Some(LoopControl::Quit)));
}
#[test]
fn command_clear_empties_messages() {
let mut state = AppState::new();
state.messages.push((Role::User, "hi".to_string()));
state.messages.push((Role::Assistant, "hello".to_string()));
state.scroll = 10;
let result = execute_command("clear", &mut state);
assert!(matches!(result, Some(LoopControl::ClearHistory)));
assert!(state.messages.is_empty());
assert_eq!(state.scroll, 0);
}
#[test]
fn command_unknown_sets_status_error() {
let mut state = AppState::new();
let result = execute_command("foo", &mut state);
assert!(result.is_none());
assert_eq!(state.status_error.as_deref(), Some("Unknown command: foo"));
}
#[test]
fn status_error_cleared_on_next_keypress() {
let mut state = AppState::new();
state.status_error = Some("some error".to_string());
handle_key(make_key(KeyCode::Char('h')), &mut state);
assert!(state.status_error.is_none());
}
#[test]
fn command_enter_executes_quit() {
let mut state = AppState::new();
state.mode = Mode::Command;
state.command_buffer = "q".to_string();
let result = handle_key(make_key(KeyCode::Enter), &mut state);
assert!(matches!(result, Some(LoopControl::Quit)));
assert_eq!(state.mode, Mode::Normal);
}
// --- 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"
);
}
}