//! 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, /// 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, } 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>, 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, state: &mut AppState) -> Option { 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 { 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 { 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 { 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 { 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, 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 = 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 /// 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, mut event_rx: mpsc::Receiver, ) -> 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 = 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 { Some(KeyEvent::new(code, KeyModifiers::empty())) } fn ctrl_key(c: char) -> Option { 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" ); } }