Skip to main content

deck_tui/
lib.rs

1//! deck-tui — ratatui front-end.
2//!
3//! The TUI owns the terminal and an [`App`] that drives input + render.
4//! All LLM/MCP/Store traffic flows through a [`deck_orchestrator::Handle`].
5
6mod app;
7mod event;
8mod ui;
9
10use std::io::{self, IsTerminal, Stdout};
11
12use anyhow::{bail, Context, Result};
13use crossterm::event::{DisableMouseCapture, EnableMouseCapture};
14use crossterm::execute;
15use crossterm::terminal::{
16    disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
17};
18use deck_orchestrator::Handle;
19use ratatui::backend::CrosstermBackend;
20use ratatui::Terminal;
21
22pub use app::App;
23
24/// Standalone entry (no orchestrator wired). Useful for `--no-llm` smoke
25/// tests and the first-launch onboarding screen.
26pub async fn run() -> Result<()> {
27    let mut app = App::new(None);
28    run_with(&mut app).await
29}
30
31/// Wired entry. The binary crate calls this with an Orchestrator handle.
32pub async fn run_with_handle(handle: Handle, session: deck_core::SessionId) -> Result<()> {
33    let mut app = App::new(Some(AppHandle { handle, session }));
34    run_with(&mut app).await
35}
36
37/// RAII guard for the terminal alt-screen + raw-mode pair. Whatever the
38/// inner future does (Ok, Err, panic), the terminal is restored before we
39/// leave this scope. Without this guard, an early error left the user's
40/// shell in raw-mode + alt-screen.
41struct TerminalGuard {
42    terminal: Terminal<CrosstermBackend<Stdout>>,
43}
44
45impl TerminalGuard {
46    fn enter() -> Result<Self> {
47        if !io::stdin().is_terminal() {
48            bail!(
49                "ono-sendai needs an interactive TTY on stdin. \
50                 Run it from a real terminal — not a pipe, CI runner, or \
51                 non-PTY ssh session."
52            );
53        }
54        enable_raw_mode().context("enable raw mode")?;
55        let mut stdout = io::stdout();
56        if let Err(e) = execute!(stdout, EnterAlternateScreen, EnableMouseCapture) {
57            // We entered raw mode but failed to enter alt-screen; undo
58            // the raw-mode so the shell is left usable.
59            let _ = disable_raw_mode();
60            return Err(e).context("enter alternate screen");
61        }
62        let backend = CrosstermBackend::new(stdout);
63        let terminal = Terminal::new(backend).context("create terminal")?;
64        Ok(Self { terminal })
65    }
66}
67
68impl Drop for TerminalGuard {
69    fn drop(&mut self) {
70        // Best-effort restore. Don't panic from Drop and don't shadow the
71        // caller's error with a teardown failure.
72        let _ = execute!(
73            self.terminal.backend_mut(),
74            LeaveAlternateScreen,
75            DisableMouseCapture
76        );
77        let _ = self.terminal.show_cursor();
78        let _ = disable_raw_mode();
79    }
80}
81
82async fn run_with(app: &mut App) -> Result<()> {
83    let mut guard = TerminalGuard::enter()?;
84    app.run(&mut guard.terminal).await
85}
86
87/// Carrier struct so `App` can hold an optional orchestrator binding
88/// without `App::new` taking three arguments.
89#[derive(Clone)]
90pub struct AppHandle {
91    pub handle: Handle,
92    pub session: deck_core::SessionId,
93}
94
95impl std::fmt::Debug for AppHandle {
96    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
97        f.debug_struct("AppHandle")
98            .field("session", &self.session)
99            .finish_non_exhaustive()
100    }
101}