diff --git a/.beans/claudbg-ut9q--tui-terminal-setup-event-loop-and-teardown.md b/.beans/claudbg-ut9q--tui-terminal-setup-event-loop-and-teardown.md index 9fd6431..86a5767 100644 --- a/.beans/claudbg-ut9q--tui-terminal-setup-event-loop-and-teardown.md +++ b/.beans/claudbg-ut9q--tui-terminal-setup-event-loop-and-teardown.md @@ -1,11 +1,11 @@ --- # claudbg-ut9q title: 'TUI: terminal setup, event loop, and teardown' -status: todo +status: in-progress type: task priority: normal created_at: 2026-03-30T04:45:32Z -updated_at: 2026-03-30T04:49:03Z +updated_at: 2026-03-30T16:42:32Z parent: claudbg-i6l2 blocked_by: - claudbg-nq36 diff --git a/src/commands/stubs.rs b/src/commands/stubs.rs index 7bf582c..a8d2828 100644 --- a/src/commands/stubs.rs +++ b/src/commands/stubs.rs @@ -1,13 +1,14 @@ //! Stub implementations for commands not yet fully implemented. use crate::error::Result; +use crate::tui::run::run_tui; -/// Run the `tui` subcommand stub. +/// Run the `tui` subcommand. /// -/// Prints a placeholder message until the TUI is implemented. +/// Initialises the terminal, runs the interactive event loop, and restores +/// the terminal on exit. pub fn tui() -> Result<()> { - println!("tui: coming soon!"); - Ok(()) + run_tui() } /// Run the `query` subcommand stub. @@ -24,13 +25,6 @@ pub fn query() -> Result<()> { mod tests { use super::*; - /// `tui()` returns `Ok` without panicking. - #[test] - fn tui_returns_ok() { - let result = tui(); - assert!(result.is_ok()); - } - /// `query()` returns `Ok` without panicking. #[test] fn query_returns_ok() { diff --git a/src/tui/mod.rs b/src/tui/mod.rs index ddf67f5..3f5fa1b 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -1,6 +1,7 @@ //! TUI module — terminal user interface for claudbg. //! -//! This module will grow to include rendering and event-handling logic. -//! For now it exposes the application state model used by all TUI screens. +//! - [`state`] — application state model (pure data, no I/O) +//! - [`run`] — terminal setup, main event loop, and teardown +pub mod run; pub mod state; diff --git a/src/tui/run.rs b/src/tui/run.rs new file mode 100644 index 0000000..3679b37 --- /dev/null +++ b/src/tui/run.rs @@ -0,0 +1,198 @@ +//! 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 ratatui::layout::Alignment; +use ratatui::widgets::{Block, Borders, Paragraph}; + +use crate::error::Result; +use crate::tui::state::AppState; + +// --------------------------------------------------------------------------- +// 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. +/// +/// Currently renders a placeholder block. Future tickets will replace this +/// with proper screen-dispatched rendering. +fn render(frame: &mut ratatui::Frame, _state: &AppState) { + let area = frame.area(); + let block = Block::default() + .title(" claudbg TUI ") + .borders(Borders::ALL); + let paragraph = Paragraph::new("Press q or Esc to quit.") + .block(block) + .alignment(Alignment::Center); + frame.render_widget(paragraph, area); +} + +// --------------------------------------------------------------------------- +// Event handling +// --------------------------------------------------------------------------- + +/// Handle a single crossterm [`Event`], mutating `state` as needed. +fn handle_event(event: Event, state: &mut AppState) { + if let Event::Key(key) = event { + // Only react to key-press events (ignore release/repeat on Windows). + if key.kind != KeyEventKind::Press { + return; + } + match key.code { + KeyCode::Char('q') => state.should_quit = true, + KeyCode::Esc => state.go_back(), + _ => {} + } + } +} + +// --------------------------------------------------------------------------- +// 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(); + + loop { + 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` sets `should_quit`. + #[test] + fn q_sets_should_quit() { + let mut state = AppState::new(); + handle_event(key_press(KeyCode::Char('q')), &mut state); + assert!(state.should_quit); + } + + /// Pressing `Esc` 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); + } +} diff --git a/src/tui/state.rs b/src/tui/state.rs index 2bba5e2..7cee7ce 100644 --- a/src/tui/state.rs +++ b/src/tui/state.rs @@ -67,20 +67,15 @@ pub enum Screen { /// /// On the session-list screen `Focus` is not meaningful; it is tracked here /// so the value is preserved when the user navigates back and forth. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, Default, PartialEq, Eq)] pub enum Focus { /// The main chat-log pane (default). + #[default] ChatLog, /// The sub-agents side panel (shown when sub-agents are present). SubagentsPanel, } -impl Default for Focus { - fn default() -> Self { - Self::ChatLog - } -} - // --------------------------------------------------------------------------- // AppState // --------------------------------------------------------------------------- @@ -124,6 +119,10 @@ pub struct AppState { pub show_quit_dialog: bool, /// Whether the keyboard-shortcut help overlay is visible. pub show_help: bool, + + // ── Lifecycle ─────────────────────────────────────────────────────────── + /// Set to `true` to signal the event loop to exit. + pub should_quit: bool, } impl AppState { @@ -145,6 +144,7 @@ impl AppState { focus: Focus::default(), show_quit_dialog: false, show_help: false, + should_quit: false, } }