From 33714e5a6820ca7954060f9d58bdf3bbdae76e36 Mon Sep 17 00:00:00 2001 From: Drew Galbraith Date: Tue, 24 Feb 2026 16:47:08 -0800 Subject: [PATCH] Split tui.rs --- src/tui/events.rs | 72 ++++ src/tui/input.rs | 509 ++++++++++++++++++++++++ src/tui/mod.rs | 967 +--------------------------------------------- src/tui/render.rs | 389 +++++++++++++++++++ 4 files changed, 983 insertions(+), 954 deletions(-) create mode 100644 src/tui/events.rs create mode 100644 src/tui/input.rs create mode 100644 src/tui/render.rs diff --git a/src/tui/events.rs b/src/tui/events.rs new file mode 100644 index 0000000..74c7842 --- /dev/null +++ b/src/tui/events.rs @@ -0,0 +1,72 @@ +//! Server event draining: applies incoming [`UIEvent`]s to the TUI state. + +use tokio::sync::mpsc; +use tracing::debug; + +use super::AppState; +use crate::core::types::{Role, UIEvent}; + +/// 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}")` | +pub(super) 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}"))); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[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"); + } +} diff --git a/src/tui/input.rs b/src/tui/input.rs new file mode 100644 index 0000000..59770ef --- /dev/null +++ b/src/tui/input.rs @@ -0,0 +1,509 @@ +//! Keyboard input handling: vim modes, chords, and command execution. + +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + +use super::{AppState, Mode}; +use crate::core::types::Role; + +/// Internal control flow signal returned by [`handle_key`]. +pub(super) 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`]. +pub(super) 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 +} + +#[cfg(test)] +mod tests { + use super::*; + + 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'"); + } + + // --- 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); + } +} diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 143d84b..78c5f21 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -9,23 +9,22 @@ //! 2. Enter alternate screen //! 3. On exit (or panic), disable raw mode and leave the alternate screen +mod events; +mod input; +mod render; + use std::io::{self, Stdout}; use std::time::Duration; -use crossterm::event::{Event, EventStream, KeyCode, KeyEvent, KeyModifiers}; +use crossterm::event::{Event, EventStream, KeyEvent}; use crossterm::execute; use crossterm::terminal::{ EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode, }; use futures::StreamExt; +use ratatui::Terminal; 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}; @@ -124,431 +123,6 @@ pub fn install_panic_hook() { })); } -/// 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 @@ -576,7 +150,7 @@ pub async fn run( loop { // 1. Drain pending UI events. - drain_ui_events(&mut event_rx, &mut state); + 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. @@ -589,26 +163,26 @@ pub async fn run( }; // 3. Handle key. - let control = handle_key(key_event, &mut state); + let control = input::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); + render::update_scroll(&mut state, frame.area()); + render::render(frame, &state); })?; // 5. Act on control signal after render so the user sees the submitted message. match control { - Some(LoopControl::SendMessage(msg)) => { + Some(input::LoopControl::SendMessage(msg)) => { if action_tx.send(UserAction::SendMessage(msg)).await.is_err() { break; } } - Some(LoopControl::Quit) => { + Some(input::LoopControl::Quit) => { let _ = action_tx.send(UserAction::Quit).await; break; } - Some(LoopControl::ClearHistory) => { + Some(input::LoopControl::ClearHistory) => { let _ = action_tx.send(UserAction::ClearHistory).await; } None => {} @@ -618,518 +192,3 @@ pub async fn run( 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" - ); - } -} diff --git a/src/tui/render.rs b/src/tui/render.rs new file mode 100644 index 0000000..82561e1 --- /dev/null +++ b/src/tui/render.rs @@ -0,0 +1,389 @@ +//! 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 = 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]); +} + +#[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" + ); + } +} -- 2.49.1