feat(tui): add quit confirmation dialog and help modal

Quit dialog: centered 26×5 Clear+Block overlay triggered by q/Q,
confirms with q, dismisses with Esc. Help modal: centered 32×14 overlay
triggered by ?, lists all keyboard shortcuts, dismisses with Esc.
Both modals intercept all input while open. Wired into run.rs dispatch.

Closes claudbg-1e1c
Closes claudbg-1tlk

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
main
Elijah Voigt 2 months ago
parent 06b0c7cc57
commit 523bf4b89c

@ -1,11 +1,11 @@
--- ---
# claudbg-1e1c # claudbg-1e1c
title: 'TUI: quit confirmation dialog' title: 'TUI: quit confirmation dialog'
status: todo status: in-progress
type: task type: task
priority: normal priority: normal
created_at: 2026-03-30T04:47:46Z created_at: 2026-03-30T04:47:46Z
updated_at: 2026-03-30T04:49:03Z updated_at: 2026-03-30T16:50:26Z
parent: claudbg-i6l2 parent: claudbg-i6l2
blocked_by: blocked_by:
- claudbg-ut9q - claudbg-ut9q

@ -1,11 +1,11 @@
--- ---
# claudbg-1tlk # claudbg-1tlk
title: 'TUI: help modal listing all keyboard shortcuts' title: 'TUI: help modal listing all keyboard shortcuts'
status: todo status: in-progress
type: task type: task
priority: normal priority: normal
created_at: 2026-03-30T04:48:38Z created_at: 2026-03-30T04:48:38Z
updated_at: 2026-03-30T04:49:03Z updated_at: 2026-03-30T16:50:26Z
parent: claudbg-i6l2 parent: claudbg-i6l2
blocked_by: blocked_by:
- claudbg-ut9q - claudbg-ut9q

@ -3,7 +3,9 @@
//! - [`state`] — application state model (pure data, no I/O) //! - [`state`] — application state model (pure data, no I/O)
//! - [`run`] — terminal setup, main event loop, and teardown //! - [`run`] — terminal setup, main event loop, and teardown
//! - [`screens`] — per-screen rendering and event-handling logic //! - [`screens`] — per-screen rendering and event-handling logic
//! - [`modals`] — overlay dialogs (quit confirmation, help)
pub mod modals;
pub mod run; pub mod run;
pub mod screens; pub mod screens;
pub mod state; pub mod state;

@ -0,0 +1,179 @@
//! Keyboard-shortcut help modal.
//!
//! Renders a static reference card as a centered overlay.
//! Any key (including `Esc`) closes the modal.
use ratatui::Frame;
use ratatui::crossterm::event::{Event, KeyCode, KeyEventKind};
use ratatui::layout::{Alignment, Rect};
use ratatui::widgets::{Block, Borders, Clear, Paragraph};
use crate::tui::state::AppState;
// ---------------------------------------------------------------------------
// Geometry helpers
// ---------------------------------------------------------------------------
/// Dialog dimensions.
const DIALOG_WIDTH: u16 = 32;
const DIALOG_HEIGHT: u16 = 14;
/// Compute a centered [`Rect`] of the given size within `area`.
fn centered_rect(width: u16, height: u16, area: Rect) -> Rect {
let x = area.x + area.width.saturating_sub(width) / 2;
let y = area.y + area.height.saturating_sub(height) / 2;
Rect {
x,
y,
width: width.min(area.width),
height: height.min(area.height),
}
}
// ---------------------------------------------------------------------------
// Static help content
// ---------------------------------------------------------------------------
const HELP_TEXT: &str = "\
Navigation\n\
\u{2191}/\u{2193} k/j scroll up/dn\n\
\u{2190}/\u{2192} h/l scroll lr\n\
Tab cycle panes\n\
Enter open/select\n\
Esc go back\n\
\n\
Global\n\
q/Q quit\n\
? this help";
// ---------------------------------------------------------------------------
// Rendering
// ---------------------------------------------------------------------------
/// Render the help overlay as a centered dialog.
pub fn render_help_modal(f: &mut Frame, area: Rect) {
let dialog_area = centered_rect(DIALOG_WIDTH, DIALOG_HEIGHT, area);
// Clear the background behind the dialog.
f.render_widget(Clear, dialog_area);
let block = Block::default()
.title(" Keyboard Shortcuts ")
.borders(Borders::ALL);
let paragraph = Paragraph::new(HELP_TEXT)
.block(block)
.alignment(Alignment::Left);
f.render_widget(paragraph, dialog_area);
}
// ---------------------------------------------------------------------------
// Event handling
// ---------------------------------------------------------------------------
/// Handle an event while the help modal is visible.
///
/// `Esc` closes the modal; all other keys are also consumed so the modal
/// intercepts all input while open. Always returns `true`.
pub fn handle_help_modal_event(event: Event, state: &mut AppState) -> bool {
let Event::Key(key) = event else {
return true;
};
if key.kind != KeyEventKind::Press {
return true;
}
if key.code == KeyCode::Esc {
state.show_help = false;
}
true
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use ratatui::crossterm::event::{KeyEvent, KeyEventState, KeyModifiers};
use super::*;
fn press(code: KeyCode) -> Event {
Event::Key(KeyEvent {
code,
modifiers: KeyModifiers::NONE,
kind: KeyEventKind::Press,
state: KeyEventState::NONE,
})
}
fn release(code: KeyCode) -> Event {
Event::Key(KeyEvent {
code,
modifiers: KeyModifiers::NONE,
kind: KeyEventKind::Release,
state: KeyEventState::NONE,
})
}
/// `Esc` closes the help modal.
#[test]
fn esc_closes_help() {
let mut state = AppState::new();
state.show_help = true;
let consumed = handle_help_modal_event(press(KeyCode::Esc), &mut state);
assert!(consumed);
assert!(!state.show_help);
}
/// Any other key is consumed but leaves the modal open.
#[test]
fn other_key_consumed_modal_stays_open() {
let mut state = AppState::new();
state.show_help = true;
let consumed = handle_help_modal_event(press(KeyCode::Char('x')), &mut state);
assert!(consumed);
assert!(state.show_help);
}
/// Key-release events are consumed (modal intercepts all input).
#[test]
fn key_release_consumed() {
let mut state = AppState::new();
state.show_help = true;
let consumed = handle_help_modal_event(release(KeyCode::Esc), &mut state);
assert!(consumed);
// Release does NOT close the modal.
assert!(state.show_help);
}
/// Non-key events are consumed.
#[test]
fn non_key_event_consumed() {
let mut state = AppState::new();
state.show_help = true;
let consumed = handle_help_modal_event(Event::FocusGained, &mut state);
assert!(consumed);
}
/// `centered_rect` places the dialog in the middle of the area.
#[test]
fn centered_rect_centers_correctly() {
let area = Rect {
x: 0,
y: 0,
width: 80,
height: 24,
};
let r = centered_rect(32, 14, area);
// x = (80 - 32) / 2 = 24
assert_eq!(r.x, 24);
// y = (24 - 14) / 2 = 5
assert_eq!(r.y, 5);
assert_eq!(r.width, 32);
assert_eq!(r.height, 14);
}
}

@ -0,0 +1,7 @@
//! TUI modal overlays.
//!
//! - [`quit_dialog`] — "Quit?" confirmation dialog
//! - [`help_modal`] — keyboard-shortcut reference card
pub mod help_modal;
pub mod quit_dialog;

@ -0,0 +1,190 @@
//! Quit confirmation dialog modal.
//!
//! Renders a small centered overlay asking the user to confirm quitting.
//! Pressing `q`/`Q` confirms; pressing `Esc` dismisses.
use ratatui::Frame;
use ratatui::crossterm::event::{Event, KeyCode, KeyEventKind};
use ratatui::layout::{Alignment, Rect};
use ratatui::widgets::{Block, Borders, Clear, Paragraph};
use crate::tui::state::AppState;
// ---------------------------------------------------------------------------
// Geometry helpers
// ---------------------------------------------------------------------------
/// Dialog dimensions.
const DIALOG_WIDTH: u16 = 26;
const DIALOG_HEIGHT: u16 = 5;
/// Compute a centered [`Rect`] of the given size within `area`.
fn centered_rect(width: u16, height: u16, area: Rect) -> Rect {
let x = area.x + area.width.saturating_sub(width) / 2;
let y = area.y + area.height.saturating_sub(height) / 2;
Rect {
x,
y,
width: width.min(area.width),
height: height.min(area.height),
}
}
// ---------------------------------------------------------------------------
// Rendering
// ---------------------------------------------------------------------------
/// Render the quit confirmation dialog as a centered overlay.
pub fn render_quit_dialog(f: &mut Frame, area: Rect) {
let dialog_area = centered_rect(DIALOG_WIDTH, DIALOG_HEIGHT, area);
// Clear the background behind the dialog.
f.render_widget(Clear, dialog_area);
let block = Block::default()
.title(" Quit? ")
.borders(Borders::ALL);
let paragraph = Paragraph::new(" q = yes Esc = no ")
.block(block)
.alignment(Alignment::Center);
f.render_widget(paragraph, dialog_area);
}
// ---------------------------------------------------------------------------
// Event handling
// ---------------------------------------------------------------------------
/// Handle an event while the quit dialog is visible.
///
/// All keys are consumed (the dialog intercepts input). Returns `true`
/// unconditionally so the caller knows the event was handled.
pub fn handle_quit_dialog_event(event: Event, state: &mut AppState) -> bool {
let Event::Key(key) = event else {
return true;
};
if key.kind != KeyEventKind::Press {
return true;
}
match key.code {
KeyCode::Char('q') | KeyCode::Char('Q') => {
state.should_quit = true;
}
KeyCode::Esc => {
state.show_quit_dialog = false;
}
_ => {}
}
true
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use ratatui::crossterm::event::{KeyEvent, KeyEventState, KeyModifiers};
use super::*;
fn press(code: KeyCode) -> Event {
Event::Key(KeyEvent {
code,
modifiers: KeyModifiers::NONE,
kind: KeyEventKind::Press,
state: KeyEventState::NONE,
})
}
fn release(code: KeyCode) -> Event {
Event::Key(KeyEvent {
code,
modifiers: KeyModifiers::NONE,
kind: KeyEventKind::Release,
state: KeyEventState::NONE,
})
}
/// `q` confirms quit.
#[test]
fn q_confirms_quit() {
let mut state = AppState::new();
state.show_quit_dialog = true;
let consumed = handle_quit_dialog_event(press(KeyCode::Char('q')), &mut state);
assert!(consumed);
assert!(state.should_quit);
}
/// `Q` also confirms quit.
#[test]
fn big_q_confirms_quit() {
let mut state = AppState::new();
state.show_quit_dialog = true;
handle_quit_dialog_event(press(KeyCode::Char('Q')), &mut state);
assert!(state.should_quit);
}
/// `Esc` dismisses the dialog without quitting.
#[test]
fn esc_dismisses_dialog() {
let mut state = AppState::new();
state.show_quit_dialog = true;
let consumed = handle_quit_dialog_event(press(KeyCode::Esc), &mut state);
assert!(consumed);
assert!(!state.show_quit_dialog);
assert!(!state.should_quit);
}
/// Any other key is consumed but does nothing else.
#[test]
fn other_key_consumed() {
let mut state = AppState::new();
state.show_quit_dialog = true;
let consumed = handle_quit_dialog_event(press(KeyCode::Char('x')), &mut state);
assert!(consumed);
assert!(state.show_quit_dialog);
assert!(!state.should_quit);
}
/// Key-release events are consumed (dialog intercepts all input).
#[test]
fn key_release_consumed() {
let mut state = AppState::new();
state.show_quit_dialog = true;
let consumed = handle_quit_dialog_event(release(KeyCode::Char('q')), &mut state);
assert!(consumed);
// Release should NOT trigger quit.
assert!(!state.should_quit);
}
/// Non-key events are consumed.
#[test]
fn non_key_event_consumed() {
let mut state = AppState::new();
state.show_quit_dialog = true;
let consumed = handle_quit_dialog_event(Event::FocusGained, &mut state);
assert!(consumed);
}
/// `centered_rect` places the dialog in the middle of the area.
#[test]
fn centered_rect_centers_correctly() {
let area = Rect {
x: 0,
y: 0,
width: 80,
height: 24,
};
let r = centered_rect(26, 5, area);
// x = (80 - 26) / 2 = 27
assert_eq!(r.x, 27);
// y = (24 - 5) / 2 = 9
assert_eq!(r.y, 9);
assert_eq!(r.width, 26);
assert_eq!(r.height, 5);
}
}

@ -21,6 +21,8 @@ use ratatui::Terminal;
use ratatui::backend::CrosstermBackend; use ratatui::backend::CrosstermBackend;
use crate::error::Result; use crate::error::Result;
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::session_list::{handle_session_list_event, render_session_list};
use crate::tui::state::{AppState, Screen}; use crate::tui::state::{AppState, Screen};
@ -81,6 +83,8 @@ fn install_panic_hook() {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/// Draw a single frame, dispatching to the appropriate screen renderer. /// 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) { fn render(frame: &mut ratatui::Frame, state: &AppState) {
let area = frame.area(); let area = frame.area();
match &state.screen { match &state.screen {
@ -94,6 +98,13 @@ fn render(frame: &mut ratatui::Frame, state: &AppState) {
frame.render_widget(block, area); frame.render_widget(block, area);
} }
} }
// 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);
}
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -102,9 +113,21 @@ fn render(frame: &mut ratatui::Frame, state: &AppState) {
/// Handle a single crossterm [`Event`], mutating `state` as needed. /// Handle a single crossterm [`Event`], mutating `state` as needed.
/// ///
/// Dispatches to the appropriate screen handler first; falls back to global /// Modal dialogs are checked first and intercept all input while open.
/// keybindings for any event not consumed by the screen. /// 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) { 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 { let consumed = match &state.screen {
Screen::SessionList => handle_session_list_event(event.clone(), state), Screen::SessionList => handle_session_list_event(event.clone(), state),
// Future screens will add their own handlers here. // Future screens will add their own handlers here.
@ -178,15 +201,25 @@ mod tests {
}) })
} }
/// Pressing `q` sets `should_quit`. /// Pressing `q` on the session-list screen shows the quit dialog.
#[test] #[test]
fn q_sets_should_quit() { fn q_on_session_list_shows_quit_dialog() {
let mut state = AppState::new(); let mut state = AppState::new();
handle_event(key_press(KeyCode::Char('q')), &mut state); handle_event(key_press(KeyCode::Char('q')), &mut state);
assert!(state.show_quit_dialog);
assert!(!state.should_quit);
}
/// Pressing `q` on the transcript screen (global fallback) sets `should_quit`.
#[test]
fn q_on_transcript_sets_should_quit() {
let mut state = AppState::new();
state.enter_transcript("some-session");
handle_event(key_press(KeyCode::Char('q')), &mut state);
assert!(state.should_quit); assert!(state.should_quit);
} }
/// Pressing `Esc` calls `go_back` (returns to SessionList). /// Pressing `Esc` on the transcript screen calls `go_back` (returns to SessionList).
#[test] #[test]
fn esc_goes_back() { fn esc_goes_back() {
let mut state = AppState::new(); let mut state = AppState::new();

@ -152,9 +152,9 @@ pub fn handle_session_list_event(event: Event, state: &mut AppState) -> bool {
} }
true true
} }
// Quit. // Quit — show confirmation dialog rather than exiting immediately.
KeyCode::Char('q') | KeyCode::Char('Q') | KeyCode::Esc => { KeyCode::Char('q') | KeyCode::Char('Q') | KeyCode::Esc => {
state.should_quit = true; state.show_quit_dialog = true;
true true
} }
// Help overlay. // Help overlay.
@ -315,25 +315,28 @@ mod tests {
} }
#[test] #[test]
fn q_sets_should_quit() { fn q_shows_quit_dialog() {
let mut state = AppState::new(); let mut state = AppState::new();
let consumed = handle_session_list_event(press(KeyCode::Char('q')), &mut state); let consumed = handle_session_list_event(press(KeyCode::Char('q')), &mut state);
assert!(consumed); assert!(consumed);
assert!(state.should_quit); assert!(state.show_quit_dialog);
assert!(!state.should_quit);
} }
#[test] #[test]
fn big_q_sets_should_quit() { fn big_q_shows_quit_dialog() {
let mut state = AppState::new(); let mut state = AppState::new();
handle_session_list_event(press(KeyCode::Char('Q')), &mut state); handle_session_list_event(press(KeyCode::Char('Q')), &mut state);
assert!(state.should_quit); assert!(state.show_quit_dialog);
assert!(!state.should_quit);
} }
#[test] #[test]
fn esc_sets_should_quit() { fn esc_shows_quit_dialog() {
let mut state = AppState::new(); let mut state = AppState::new();
handle_session_list_event(press(KeyCode::Esc), &mut state); handle_session_list_event(press(KeyCode::Esc), &mut state);
assert!(state.should_quit); assert!(state.show_quit_dialog);
assert!(!state.should_quit);
} }
#[test] #[test]

Loading…
Cancel
Save