You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

319 lines
11 KiB
Rust

//! 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<CrosstermBackend<Stdout>>,
}
impl TerminalGuard {
/// Set up raw mode + alternate screen and build the `Terminal`.
fn new() -> Result<Self> {
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");
}
}