Skip to main content

horto_os_ui_tui/
main.rs

1//! Horto OS UI TUI (`horto-os-ui-tui`): Ratatui wizard over the shared engine.
2//!
3//! Tabs: Setup, Overview, SSH, CLI, API, MCP, Reboot, Logs. With `--remote`,
4//! probes and installs run over SSH; secrets use askpass, not leave-alt-screen
5//! suspend. Confirm and text prompts are Ratatui modals.
6
7mod app;
8mod draw;
9mod event;
10mod probe_job;
11mod prompt;
12mod tabs;
13
14use anyhow::Result;
15use clap::Parser;
16use crossterm::{
17    cursor::{Hide, Show},
18    event::{self as cterm_event, Event, KeyCode, KeyEventKind},
19    execute,
20    terminal::{
21        disable_raw_mode, enable_raw_mode, Clear as CtClear, ClearType, EnterAlternateScreen,
22        LeaveAlternateScreen,
23    },
24    ExecutableCommand,
25};
26use ratatui::{backend::CrosstermBackend, Terminal};
27use std::io::{self, stdout, Write as IoWrite};
28use std::panic;
29use std::sync::atomic::{AtomicBool, Ordering};
30
31use app::{App, Cli};
32use draw::ui;
33use event::{ctrl_c_quit, handle_modal_key, handle_screen_key, is_quit};
34
35/// Set by SIGINT/SIGTERM so the loop can restore the tty before exit.
36static STOP: AtomicBool = AtomicBool::new(false);
37
38fn install_signal_handlers() {
39    let _ = ctrlc::set_handler(|| {
40        STOP.store(true, Ordering::SeqCst);
41    });
42}
43
44fn install_panic_hook() {
45    let previous = panic::take_hook();
46    panic::set_hook(Box::new(move |info| {
47        restore_terminal();
48        previous(info);
49    }));
50}
51
52/// Restores the terminal on drop (normal exit, `?`, or unwind).
53struct TerminalGuard;
54
55impl TerminalGuard {
56    fn enter() -> io::Result<(Self, Terminal<CrosstermBackend<io::Stdout>>)> {
57        enable_raw_mode()?;
58        execute!(
59            stdout(),
60            EnterAlternateScreen,
61            Hide,
62            CtClear(ClearType::All),
63            CtClear(ClearType::Purge)
64        )?;
65        let mut terminal = Terminal::new(CrosstermBackend::new(stdout()))?;
66        terminal.clear()?;
67        Ok((Self, terminal))
68    }
69}
70
71impl Drop for TerminalGuard {
72    fn drop(&mut self) {
73        restore_terminal();
74    }
75}
76
77/// Reset SGR, show cursor, leave alt buffer, clear leftover TUI cells.
78fn hard_reset_tty() {
79    let mut out = stdout();
80    let _ = write!(
81        out,
82        "\x1b[0m\x1b[?25h\x1b[?1049l\x1b[?47l\x1b[2J\x1b[3J\x1b[H"
83    );
84    let _ = out.execute(CtClear(ClearType::All));
85    let _ = out.execute(CtClear(ClearType::Purge));
86    let _ = out.execute(Show);
87    let _ = out.flush();
88}
89
90fn restore_terminal() {
91    let _ = disable_raw_mode();
92    let mut out = stdout();
93    let _ = out.execute(LeaveAlternateScreen);
94    let _ = out.flush();
95    hard_reset_tty();
96}
97
98fn main() -> Result<()> {
99    let cli = Cli::parse();
100    install_signal_handlers();
101    install_panic_hook();
102    let (_guard, mut terminal) = TerminalGuard::enter()?;
103    let mut app = App::new(&cli);
104    run_app(&mut terminal, &mut app)
105}
106
107fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>, app: &mut App) -> Result<()> {
108    let mut boot_remote_probe = app.is_remote();
109    loop {
110        if STOP.load(Ordering::SeqCst) {
111            return Ok(());
112        }
113        app.poll_probe();
114        app.poll_reboot();
115        app.poll_fetch();
116        terminal.draw(|f| ui(f, app))?;
117        if boot_remote_probe {
118            app.start_remote_probe();
119            boot_remote_probe = false;
120        }
121        if !cterm_event::poll(std::time::Duration::from_millis(200))? {
122            continue;
123        }
124        let Event::Key(key) = cterm_event::read()? else {
125            continue;
126        };
127        if key.kind != KeyEventKind::Press {
128            continue;
129        }
130        if ctrl_c_quit(key) || is_quit(key) {
131            return Ok(());
132        }
133        if handle_modal_key(app, key) {
134            continue;
135        }
136        if app.help_open {
137            if matches!(key.code, KeyCode::Esc | KeyCode::Char('?')) {
138                app.help_open = false;
139            }
140            continue;
141        }
142        if key.code == KeyCode::Esc {
143            return Ok(());
144        }
145        handle_screen_key(app, key);
146    }
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152    use crate::app::BoxCliView;
153    use crate::draw::{footer_cli_label, setup_step_line};
154    use clap::CommandFactory;
155    use clap::Parser;
156    use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
157    use horto_os_ui_shared::RemoteBoxCliStatus;
158
159    #[test]
160    fn cli_debug_assert() {
161        Cli::command().debug_assert();
162    }
163
164    #[test]
165    fn parses_flags() {
166        let cli = Cli::try_parse_from(["horto-os-ui-tui", "--apply", "--minimal", "--skip-piper"])
167            .unwrap();
168        assert!(cli.apply);
169        assert!(cli.minimal);
170        assert!(cli.skip_piper);
171    }
172
173    #[test]
174    fn quit_keys() {
175        let q = KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE);
176        let ctrl_c = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
177        let plain_c = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE);
178        assert!(is_quit(q));
179        assert!(is_quit(ctrl_c));
180        assert!(!is_quit(plain_c));
181    }
182
183    #[test]
184    fn footer_cli_label_probing_not_missing() {
185        let label = footer_cli_label("0.1.0 (abc)", true, &BoxCliView::Probing);
186        assert_eq!(label, "local=0.1.0 (abc) box=probing...");
187        assert!(!label.contains('?'));
188        assert!(!label.contains("missing"));
189    }
190
191    #[test]
192    fn footer_cli_label_known_statuses() {
193        assert_eq!(
194            footer_cli_label(
195                "0.1.0",
196                true,
197                &BoxCliView::Known(RemoteBoxCliStatus::Missing)
198            ),
199            "local=0.1.0 box=missing"
200        );
201        assert_eq!(
202            footer_cli_label(
203                "0.1.0",
204                true,
205                &BoxCliView::Known(RemoteBoxCliStatus::AuthFailed)
206            ),
207            "local=0.1.0 box=auth failed"
208        );
209        assert_eq!(
210            footer_cli_label(
211                "0.1.0",
212                true,
213                &BoxCliView::Known(RemoteBoxCliStatus::Unreachable)
214            ),
215            "local=0.1.0 box=unreachable"
216        );
217        assert_eq!(
218            footer_cli_label(
219                "0.1.0",
220                true,
221                &BoxCliView::Known(RemoteBoxCliStatus::Found("0.1.0 (deadbeef)".into()))
222            ),
223            "local=0.1.0 box=0.1.0 (deadbeef)"
224        );
225        assert_eq!(
226            footer_cli_label("0.1.0", false, &BoxCliView::Probing),
227            "local=0.1.0"
228        );
229    }
230
231    #[test]
232    fn remote_app_starts_with_box_probing() {
233        let cli = Cli::try_parse_from(["horto-os-ui-tui", "--remote", "horto"]).unwrap();
234        let app = App::new(&cli);
235        assert_eq!(app.box_cli, BoxCliView::Probing);
236        assert!(app.s0_line().contains("| probing |"));
237        assert!(!app.cli_current);
238    }
239
240    #[test]
241    fn setup_step_line_splits_id_status_title() {
242        let line = setup_step_line("s0 | pending | Sync CLI to box");
243        assert_eq!(line.spans.len(), 5);
244        assert_eq!(line.spans[0].content.as_ref(), "s0");
245        assert_eq!(line.spans[2].content.as_ref(), "pending");
246        assert_eq!(line.spans[4].content.as_ref(), "Sync CLI to box");
247    }
248}