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
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");
|
|
}
|
|
}
|