From c564f197b52181d85763810d6d9cee198f088a97 Mon Sep 17 00:00:00 2001 From: Drew Galbraith Date: Mon, 23 Feb 2026 23:14:48 -0800 Subject: [PATCH] Core convo --- src/provider/claude.rs | 5 +- src/tui/mod.rs | 467 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 471 insertions(+), 1 deletion(-) diff --git a/src/provider/claude.rs b/src/provider/claude.rs index 0857625..27c8a17 100644 --- a/src/provider/claude.rs +++ b/src/provider/claude.rs @@ -383,7 +383,10 @@ mod tests { #[test] fn test_parse_message_stop_yields_done() { let event_str = "event: message_stop\ndata: {\"type\":\"message_stop\"}\n"; - assert!(matches!(parse_sse_event(event_str), Some(StreamEvent::Done))); + assert!(matches!( + parse_sse_event(event_str), + Some(StreamEvent::Done) + )); } #[test] diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 8b13789..9ac7ca9 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -1 +1,468 @@ +// Types and functions are scaffolding — wired into main.rs in Stage 1.6. +#![allow(dead_code)] +//! 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::{self, Event, KeyCode, KeyEvent, KeyModifiers}; +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 action channel was closed before the event loop exited cleanly. + #[error("action channel closed")] + ChannelClosed, +} + +/// 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 ← spawn_blocking keeps async runtime free +/// 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(); + + loop { + // 1. Drain pending UI events. + drain_ui_events(&mut event_rx, &mut state); + + // 2. Poll keyboard without blocking the async runtime. + let key_event: Option = tokio::task::spawn_blocking(|| { + if event::poll(Duration::from_millis(16)).unwrap_or(false) { + match event::read() { + Ok(Event::Key(k)) => Some(k), + _ => None, + } + } else { + None + } + }) + .await + .unwrap_or(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"); + } +}