//! 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 mod events; mod input; mod render; use std::io::{self, Stdout}; use std::time::Duration; 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 tokio::sync::mpsc; 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, /// A tool approval request waiting for user input (y/n). pub pending_approval: Option, /// Whether the sandbox is in yolo (unsandboxed) mode. pub sandbox_yolo: bool, /// Whether network access is currently allowed. pub network_allowed: bool, } 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, pending_approval: None, sandbox_yolo: false, network_allowed: false, } } } /// 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); })); } /// 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, sandbox_yolo: bool, ) -> Result<(), TuiError> { install_panic_hook(); let mut terminal = init_terminal()?; let mut state = AppState::new(); state.sandbox_yolo = sandbox_yolo; let mut event_stream = EventStream::new(); loop { // 1. Drain pending UI events. 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 = input::handle_key(key_event, &mut state); // 4. Render (scroll updated inside draw closure to use current frame area). terminal.draw(|frame| { 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(input::LoopControl::SendMessage(msg)) => { if action_tx.send(UserAction::SendMessage(msg)).await.is_err() { break; } } Some(input::LoopControl::Quit) => { let _ = action_tx.send(UserAction::Quit).await; break; } Some(input::LoopControl::ClearHistory) => { let _ = action_tx.send(UserAction::ClearHistory).await; } Some(input::LoopControl::ToolApproval { tool_use_id, approved, }) => { let _ = action_tx .send(UserAction::ToolApprovalResponse { tool_use_id, approved, }) .await; } Some(input::LoopControl::SetNetworkPolicy(allowed)) => { let _ = action_tx.send(UserAction::SetNetworkPolicy(allowed)).await; } None => {} } } restore_terminal()?; Ok(()) }