feat(tui): add terminal setup, event loop, and teardown

Implements run_tui() with RAII terminal guard (TerminalGuard), panic hook
for safe cleanup, and 50ms poll event loop. Placeholder renderer draws a
bordered box. Wires into stubs.rs replacing the coming-soon stub.

Closes claudbg-ut9q

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
main
Elijah Voigt 2 months ago
parent c3721bc9ef
commit 1ddf7fb4fe

@ -1,11 +1,11 @@
--- ---
# claudbg-ut9q # claudbg-ut9q
title: 'TUI: terminal setup, event loop, and teardown' title: 'TUI: terminal setup, event loop, and teardown'
status: todo status: in-progress
type: task type: task
priority: normal priority: normal
created_at: 2026-03-30T04:45:32Z created_at: 2026-03-30T04:45:32Z
updated_at: 2026-03-30T04:49:03Z updated_at: 2026-03-30T16:42:32Z
parent: claudbg-i6l2 parent: claudbg-i6l2
blocked_by: blocked_by:
- claudbg-nq36 - claudbg-nq36

@ -1,13 +1,14 @@
//! Stub implementations for commands not yet fully implemented. //! Stub implementations for commands not yet fully implemented.
use crate::error::Result; 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<()> { pub fn tui() -> Result<()> {
println!("tui: coming soon!"); run_tui()
Ok(())
} }
/// Run the `query` subcommand stub. /// Run the `query` subcommand stub.
@ -24,13 +25,6 @@ pub fn query() -> Result<()> {
mod tests { mod tests {
use super::*; use super::*;
/// `tui()` returns `Ok` without panicking.
#[test]
fn tui_returns_ok() {
let result = tui();
assert!(result.is_ok());
}
/// `query()` returns `Ok` without panicking. /// `query()` returns `Ok` without panicking.
#[test] #[test]
fn query_returns_ok() { fn query_returns_ok() {

@ -1,6 +1,7 @@
//! TUI module — terminal user interface for claudbg. //! TUI module — terminal user interface for claudbg.
//! //!
//! This module will grow to include rendering and event-handling logic. //! - [`state`] — application state model (pure data, no I/O)
//! For now it exposes the application state model used by all TUI screens. //! - [`run`] — terminal setup, main event loop, and teardown
pub mod run;
pub mod state; pub mod state;

@ -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<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.
///
/// 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);
}
}

@ -67,20 +67,15 @@ pub enum Screen {
/// ///
/// On the session-list screen `Focus` is not meaningful; it is tracked here /// 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. /// 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 { pub enum Focus {
/// The main chat-log pane (default). /// The main chat-log pane (default).
#[default]
ChatLog, ChatLog,
/// The sub-agents side panel (shown when sub-agents are present). /// The sub-agents side panel (shown when sub-agents are present).
SubagentsPanel, SubagentsPanel,
} }
impl Default for Focus {
fn default() -> Self {
Self::ChatLog
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// AppState // AppState
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -124,6 +119,10 @@ pub struct AppState {
pub show_quit_dialog: bool, pub show_quit_dialog: bool,
/// Whether the keyboard-shortcut help overlay is visible. /// Whether the keyboard-shortcut help overlay is visible.
pub show_help: bool, pub show_help: bool,
// ── Lifecycle ───────────────────────────────────────────────────────────
/// Set to `true` to signal the event loop to exit.
pub should_quit: bool,
} }
impl AppState { impl AppState {
@ -145,6 +144,7 @@ impl AppState {
focus: Focus::default(), focus: Focus::default(),
show_quit_dialog: false, show_quit_dialog: false,
show_help: false, show_help: false,
should_quit: false,
} }
} }

Loading…
Cancel
Save