//! TUI entry point: terminal setup, event loop, and graceful teardown. //! //! The public surface is [`run_tui`], a synchronous function that: //! //! 1. Enables raw mode and switches to the alternate screen. //! 2. Installs a panic hook that restores the terminal before printing the //! panic message, so the user's shell is never left in raw mode. //! 3. Runs the main draw / event loop until the user quits. //! 4. Tears down the terminal (disable raw mode, leave alternate screen) via //! an RAII guard, so cleanup happens even if an error is returned. use std::io::{self, Stdout}; use std::time::Duration; use ratatui::crossterm::event::{self, Event, KeyCode, KeyEventKind}; use ratatui::crossterm::execute; use ratatui::crossterm::terminal::{ EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode, }; use ratatui::Terminal; use ratatui::backend::CrosstermBackend; use crate::error::Result; use crate::parser::discovery::{discover_agents_for_session, discover_sessions}; use crate::tui::modals::help_modal::{handle_help_modal_event, render_help_modal}; use crate::tui::modals::quit_dialog::{handle_quit_dialog_event, render_quit_dialog}; use crate::tui::screens::session_list::{handle_session_list_event, render_session_list}; use crate::tui::screens::transcript::{ handle_transcript_event, load_transcript_for_agent, load_transcript_for_session, render_transcript, }; use crate::tui::state::{AppState, Screen, SessionListItem}; // --------------------------------------------------------------------------- // RAII terminal guard // --------------------------------------------------------------------------- /// Restores the terminal to its original state when dropped. /// /// Wraps the `Terminal` so that `disable_raw_mode` and /// `LeaveAlternateScreen` are always called, whether the event loop exits /// normally or returns an error. struct TerminalGuard { terminal: Terminal>, } impl TerminalGuard { /// Set up raw mode + alternate screen and build the `Terminal`. fn new() -> Result { enable_raw_mode()?; let mut stdout = io::stdout(); execute!(stdout, EnterAlternateScreen)?; let backend = CrosstermBackend::new(io::stdout()); let terminal = Terminal::new(backend)?; Ok(Self { terminal }) } } impl Drop for TerminalGuard { fn drop(&mut self) { // Best-effort: ignore errors during cleanup so we never panic in Drop. let _ = disable_raw_mode(); let _ = execute!(self.terminal.backend_mut(), LeaveAlternateScreen); } } // --------------------------------------------------------------------------- // Panic hook // --------------------------------------------------------------------------- /// Install a panic hook that restores the terminal before printing the panic. /// /// Without this, a panic in raw mode leaves the user's shell in an unusable /// state. The hook disables raw mode and leaves the alternate screen, then /// delegates to the default panic handler. fn install_panic_hook() { let default_hook = std::panic::take_hook(); std::panic::set_hook(Box::new(move |info| { // Ignore errors — we are already panicking. let _ = disable_raw_mode(); let _ = execute!(io::stdout(), LeaveAlternateScreen); default_hook(info); })); } // --------------------------------------------------------------------------- // Rendering // --------------------------------------------------------------------------- /// Draw a single frame, dispatching to the appropriate screen renderer. /// /// Modals are rendered on top of the base screen when active. fn render(frame: &mut ratatui::Frame, state: &AppState) { let area = frame.area(); match &state.screen { Screen::SessionList => render_session_list(frame, area, state), Screen::Transcript { .. } | Screen::SubagentTranscript { .. } => { render_transcript(frame, area, state); } } // Overlay modals on top of the active screen. if state.show_quit_dialog { render_quit_dialog(frame, area); } else if state.show_help { render_help_modal(frame, area); } } // --------------------------------------------------------------------------- // Event handling // --------------------------------------------------------------------------- /// Handle a single crossterm [`Event`], mutating `state` as needed. /// /// Modal dialogs are checked first and intercept all input while open. /// If no modal is active, dispatches to the appropriate screen handler, /// then falls back to global keybindings for any event not consumed by /// the screen. fn handle_event(event: Event, state: &mut AppState) { // Modal dialogs intercept all input while visible. if state.show_quit_dialog { handle_quit_dialog_event(event, state); return; } if state.show_help { handle_help_modal_event(event, state); return; } let consumed = match &state.screen { Screen::SessionList => handle_session_list_event(event.clone(), state), Screen::Transcript { .. } | Screen::SubagentTranscript { .. } => { handle_transcript_event(event.clone(), state) } }; if consumed { return; } // Global fallback for screens that don't handle quit/back themselves. if let Event::Key(key) = event { if key.kind != KeyEventKind::Press { return; } match key.code { KeyCode::Char('q') => state.should_quit = true, KeyCode::Char('c') => state.color_enabled = !state.color_enabled, KeyCode::Esc => state.go_back(), _ => {} } } } // --------------------------------------------------------------------------- // Transcript data loading // --------------------------------------------------------------------------- /// Load transcript entries into `state` if we are on a transcript screen and /// the entries have not been loaded yet. /// /// Called once per event loop iteration, after event handling. Because /// discovery and file I/O can be slow, this is a no-op when entries are /// already populated. fn maybe_load_transcript(state: &mut AppState) { if !state.transcript_entries.is_empty() { return; } match state.screen.clone() { Screen::Transcript { session_id } => { load_transcript_for_session(&session_id, state); } Screen::SubagentTranscript { parent_session_id, agent_id, } => { load_transcript_for_agent(&parent_session_id, &agent_id, state); } Screen::SessionList => {} } } // --------------------------------------------------------------------------- // Main event loop // --------------------------------------------------------------------------- /// Run the TUI synchronously. /// /// Initialises the terminal, enters the event loop, and tears down cleanly /// whether the loop exits via `should_quit` or via an error. pub fn run_tui() -> Result<()> { install_panic_hook(); let mut guard = TerminalGuard::new()?; let mut state = AppState::new(); // Populate the session list from disk before entering the event loop. // Silently use an empty vec if discovery fails so the TUI still starts. let mut session_refs = discover_sessions().unwrap_or_default(); // Sort most-recent-first before converting to display items. session_refs.sort_by(|a, b| b.modified_at.cmp(&a.modified_at)); state.sessions = session_refs .into_iter() .map(|sr| { let short_id = sr.session_id.chars().take(8).collect(); let full_id = sr.session_id.clone(); let date = sr.modified_at.format("%Y-%m-%d %H:%M:%S").to_string(); let project = sr.project_path.clone().unwrap_or_default(); let agent_count = discover_agents_for_session(&sr.file_path) .map(|v| v.len()) .unwrap_or(0); SessionListItem { short_id, full_id, date, project, model: String::new(), msg_count: 0, agent_count, } }) .collect(); loop { // Load transcript data lazily when entering a transcript screen. maybe_load_transcript(&mut state); guard.terminal.draw(|f| render(f, &state))?; if event::poll(Duration::from_millis(50))? { handle_event(event::read()?, &mut state); } if state.should_quit { break; } } Ok(()) // `guard` is dropped here → terminal restored automatically. } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[cfg(test)] mod tests { use ratatui::crossterm::event::{KeyEvent, KeyEventState, KeyModifiers}; use super::*; use crate::tui::state::Screen; fn key_press(code: KeyCode) -> Event { Event::Key(KeyEvent { code, modifiers: KeyModifiers::NONE, kind: KeyEventKind::Press, state: KeyEventState::NONE, }) } /// Pressing `q` on the session-list screen shows the quit dialog. #[test] fn q_on_session_list_shows_quit_dialog() { let mut state = AppState::new(); handle_event(key_press(KeyCode::Char('q')), &mut state); assert!(state.show_quit_dialog); assert!(!state.should_quit); } /// Pressing `q` on the transcript screen shows the quit dialog (same as session list). #[test] fn q_on_transcript_shows_quit_dialog() { let mut state = AppState::new(); state.enter_transcript("some-session"); handle_event(key_press(KeyCode::Char('q')), &mut state); assert!(state.show_quit_dialog); assert!(!state.should_quit); } /// Pressing `Esc` on the transcript screen calls `go_back` (returns to SessionList). #[test] fn esc_goes_back() { let mut state = AppState::new(); state.enter_transcript("some-session"); handle_event(key_press(KeyCode::Esc), &mut state); assert_eq!(state.screen, Screen::SessionList); } /// Key-release events are ignored. #[test] fn key_release_ignored() { let mut state = AppState::new(); let release_event = Event::Key(KeyEvent { code: KeyCode::Char('q'), modifiers: KeyModifiers::NONE, kind: KeyEventKind::Release, state: KeyEventState::NONE, }); handle_event(release_event, &mut state); assert!(!state.should_quit); } /// Pressing `c` toggles `color_enabled` off then back on. #[test] fn c_toggles_color_enabled() { // SAFETY: single-threaded test; no other threads reading the env. unsafe { std::env::remove_var("NO_COLOR") }; let mut state = AppState::new(); assert!(state.color_enabled, "color starts enabled"); handle_event(key_press(KeyCode::Char('c')), &mut state); assert!(!state.color_enabled, "color toggled off"); handle_event(key_press(KeyCode::Char('c')), &mut state); assert!(state.color_enabled, "color toggled back on"); } }