//! 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 futures::StreamExt; use crossterm::execute; use crossterm::terminal::{ EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode, }; use ratatui::backend::CrosstermBackend; use ratatui::layout::{Constraint, Layout, Rect}; use ratatui::style::{Color, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, 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), } /// 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, } impl AppState { fn new() -> Self { Self { messages: Vec::new(), input: String::new(), scroll: 0, } } } /// 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, } /// Map a key event to a [`LoopControl`] signal, mutating `state` as a side-effect. /// /// Returns `None` when the key is consumed with no further loop-level action needed. /// /// | Key | Effect | /// |------------------|-------------------------------------------------| /// | Printable (no CTRL) | `state.input.push(c)` | /// | Backspace | `state.input.pop()` | /// | Enter (non-empty)| Take input, push User message, return `SendMessage` | /// | Enter (empty) | No-op | /// | Ctrl+C / Ctrl+D | Return `Quit` | fn handle_key(key: Option, state: &mut AppState) -> Option { let key = key?; match key.code { KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { Some(LoopControl::Quit) } KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => { Some(LoopControl::Quit) } 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, } } /// 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) { // 3 = height of the input pane (border top + content + border bottom) let viewport_height = area.height.saturating_sub(3); 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 } state.scroll = total_lines.saturating_sub(viewport_height); } /// Render the full TUI into `frame`. /// /// Layout (top to bottom): /// ```text /// +--------------------------------+ /// | conversation history | Fill(1) /// | | /// +--------------------------------+ /// | Input | Length(3) /// | > _ | /// +--------------------------------+ /// ``` /// /// Role headers are coloured: `"You:"` in cyan, `"Assistant:"` in green. fn render(frame: &mut Frame, state: &AppState) { let chunks = Layout::vertical([Constraint::Fill(1), Constraint::Length(3)]).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]); // --- Input pane --- let input_text = format!("> {}", state.input); let input_widget = Paragraph::new(input_text).block(Block::bordered().title("Input")); frame.render_widget(input_widget, chunks[1]); } /// 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; } 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))); } // --- 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"); } }