Skip to main content

horto_os_ui_tui/
app.rs

1//! TUI application state and setup/apply actions.
2
3use clap::Parser;
4use horto_os_ui_shared::{
5    backup_etc_timestamped, box_status, finish_save_api_token,
6    install_ecosystem_after_embedded_apply, pipeline, probe_api_surface, probe_cli_surface,
7    probe_disk_backup, probe_mcp_surface, probe_ssh_surface, probe_surfaces, remote_run_cli,
8    remote_upload_cli, require_root_for_apply, setup_run, setup_step, ApiSurfaceProbe, ApplyMode,
9    CliSurfaceProbe, DiskBackupOpts, EcosystemInstallChoice, HostContext, McpHostProbe,
10    RemoteBoxCliStatus, RemoteOptions, RemoteOptionsInput, RemoteRunFlags, RemoteRunRequest,
11    SetupKind, SshSurfaceProbe, StdioPrompts, SurfaceProbeReport, SystemProcessRunner,
12    DEFAULT_INSTALL_DIR, LONG_VERSION,
13};
14use ratatui::text::Line;
15use ratatui::widgets::ListState;
16use std::fmt::Write;
17use std::sync::atomic::{AtomicBool, Ordering};
18use std::sync::mpsc::{self, Receiver, Sender};
19use std::sync::Arc;
20use std::thread;
21use std::time::{Duration, Instant};
22
23use crate::probe_job::{run_remote_probe, RemoteProbeOk, RemoteProbeOutcome};
24use crate::prompt::{SecretInput, TextInput};
25use crate::tabs::{self, Screen};
26
27/// In-TUI overlay (confirm / free-text / sudo password).
28#[derive(Debug, Clone)]
29pub enum Modal {
30    Confirm(ConfirmKind),
31    TextHost(TextInput),
32    SudoPassword(SecretInput),
33    /// Wait while reboot SSH runs on a background thread.
34    Rebooting,
35}
36
37/// Progress from the background reboot thread.
38pub enum RebootEvent {
39    /// `sudo reboot` accepted over SSH.
40    Issued,
41    /// Box answers SSH again.
42    BoxBack,
43    Failed(String),
44}
45
46/// Result of `f` fetch for one surface tab (not a full `r` refresh).
47pub enum FetchEvent {
48    Overview(Box<SurfaceProbeReport>),
49    Ssh(SshSurfaceProbe),
50    Cli(CliSurfaceProbe),
51    Api(ApiSurfaceProbe),
52    Mcp(McpHostProbe, McpHostProbe),
53    Failed(String),
54}
55
56/// Confirmation dialog kinds shown as TUI overlays.
57#[derive(Debug, Clone)]
58pub enum ConfirmKind {
59    DestructiveStep(String),
60    Reboot,
61    SaveToken(String),
62    RebootAfterApply,
63    /// Ask whether to install status-api after full apply.
64    InstallStatusApi {
65        remote: bool,
66    },
67    /// Ask whether to install MCP (after status-api answer).
68    InstallMcp {
69        remote: bool,
70        status_api: bool,
71    },
72}
73
74impl ConfirmKind {
75    /// Short title for the confirm modal chrome.
76    #[must_use]
77    pub const fn title(&self) -> &'static str {
78        match self {
79            Self::DestructiveStep(_) => "Confirm destructive step",
80            Self::Reboot => "Confirm reboot",
81            Self::SaveToken(_) => "Save API token",
82            Self::RebootAfterApply => "Reboot after apply",
83            Self::InstallStatusApi { .. } => "Install status-api?",
84            Self::InstallMcp { .. } => "Install MCP?",
85        }
86    }
87
88    /// Operator-facing body text for the confirm modal.
89    #[must_use]
90    pub fn body(&self) -> String {
91        match self {
92            Self::DestructiveStep(id) => {
93                format!("Step {id} is destructive. Continue?")
94            }
95            Self::Reboot => "Reboot the box now?".into(),
96            Self::SaveToken(_) => {
97                "Save status-api bearer to ~/.config/horto-os-ui/api_token?".into()
98            }
99            Self::RebootAfterApply => "Remote apply succeeded. Reboot the box now?".into(),
100            Self::InstallStatusApi { remote } => {
101                if *remote {
102                    "Install status-api (systemd) on the remote box?".into()
103                } else {
104                    "Install status-api (systemd) on this host?".into()
105                }
106            }
107            Self::InstallMcp { remote, .. } => {
108                if *remote {
109                    "Install MCP (systemd) on the remote box?".into()
110                } else {
111                    "Install MCP (systemd) on this host?".into()
112                }
113            }
114        }
115    }
116}
117
118/// Box CLI footer/overview value (remote mode).
119#[derive(Debug, Clone, PartialEq, Eq)]
120pub enum BoxCliView {
121    /// Probe in flight or not yet applied (never shown as missing).
122    Probing,
123    /// Last probe result from shared remote layer.
124    Known(RemoteBoxCliStatus),
125}
126
127impl BoxCliView {
128    /// Compact footer/overview label (`probing...`, auth failed, version, …).
129    #[must_use]
130    pub const fn as_label(&self) -> &str {
131        match self {
132            Self::Probing => "probing...",
133            Self::Known(s) => s.as_label(),
134        }
135    }
136}
137
138#[allow(clippy::struct_excessive_bools)]
139#[derive(Parser, Debug)]
140#[command(
141    name = "horto-os-ui-tui",
142    about = "Horto OS UI terminal",
143    version,
144    long_version = LONG_VERSION
145)]
146/// Clap flags for the TUI binary (`--remote`, `--apply`, pipeline toggles).
147pub struct Cli {
148    #[arg(long)]
149    pub apply: bool,
150    #[arg(long)]
151    pub minimal: bool,
152    #[arg(long)]
153    pub skip_piper: bool,
154    /// OpenSSH Host alias or user@host; run setup via remote runner
155    #[arg(long)]
156    pub remote: Option<String>,
157    /// Opt-in: install this PC's public key on the box. Off by default.
158    #[arg(long, default_value_t = false)]
159    pub install_ssh_key: bool,
160    /// Local directory with box binaries (skips GitHub Release download)
161    #[arg(long, env = "HORTO_BIN_DIR")]
162    pub bin_dir: Option<std::path::PathBuf>,
163    /// GitHub Release tag for box tar.gz (`v0.1.0` or tip `dev-preview`)
164    #[arg(long, env = "HORTO_RELEASE_TAG")]
165    pub release_tag: Option<String>,
166}
167
168/// Mutable TUI session: screens, setup list, logs, modals, and background probe/fetch/reboot.
169#[allow(clippy::struct_excessive_bools)]
170pub struct App {
171    pub screen: Screen,
172    pub apply: bool,
173    pub kind: SetupKind,
174    pub skip_piper: bool,
175    pub remote: Option<String>,
176    pub install_ssh_key: bool,
177    pub bin_dir: Option<std::path::PathBuf>,
178    pub release_tag: Option<String>,
179    pub step_state: ListState,
180    pub logs: Vec<String>,
181    pub status_lines: Vec<String>,
182    pub overview_text: String,
183    pub panel_lines: Vec<Line<'static>>,
184    pub modal: Option<Modal>,
185    /// After save-token confirm, offer reboot when remote apply installed payload.
186    pub pending_reboot_offer: bool,
187    pub help_open: bool,
188    pub message: String,
189    /// Local TUI / tip CLI long version (`LONG_VERSION`).
190    pub cli_local: String,
191    /// Last known box CLI status (remote). Starts as [`BoxCliView::Probing`].
192    pub box_cli: BoxCliView,
193    /// Box CLI matches `cli_local` (remote mode). Local mode always true.
194    pub cli_current: bool,
195    /// Last full surface probe (SSH/CLI/API/MCP).
196    pub surfaces: Option<SurfaceProbeReport>,
197    /// Extra Overview lines from doctor/status snapshot.
198    pub overview_extra: String,
199    /// Sender half for background remote probes.
200    pub probe_tx: Sender<RemoteProbeOutcome>,
201    /// Receiver polled on the UI thread.
202    pub probe_rx: Receiver<RemoteProbeOutcome>,
203    /// True while a remote probe thread is still running.
204    pub probe_inflight: bool,
205    /// Background reboot progress.
206    pub reboot_rx: Option<Receiver<RebootEvent>>,
207    /// True while a reboot SSH thread is still running.
208    pub reboot_inflight: bool,
209    /// Set by Esc to stop the reboot wait thread.
210    pub reboot_cancel: Arc<AtomicBool>,
211    /// Background single-tab fetch (`f`).
212    pub fetch_rx: Option<Receiver<FetchEvent>>,
213    pub fetch_inflight: bool,
214}
215
216impl App {
217    /// Build app state from clap flags and kick off local refresh or remote probe.
218    pub fn new(cli: &Cli) -> Self {
219        let kind = if cli.minimal {
220            SetupKind::Minimal
221        } else {
222            SetupKind::Full
223        };
224        let mut step_state = ListState::default();
225        step_state.select(Some(0));
226        let (probe_tx, probe_rx) = mpsc::channel();
227        let mut app = Self {
228            screen: Screen::Setup,
229            apply: cli.apply,
230            kind,
231            skip_piper: cli.skip_piper,
232            remote: cli.remote.clone(),
233            install_ssh_key: cli.install_ssh_key,
234            bin_dir: cli.bin_dir.clone(),
235            release_tag: cli.release_tag.clone(),
236            step_state,
237            logs: Vec::new(),
238            status_lines: Vec::new(),
239            overview_text: String::new(),
240            panel_lines: Vec::new(),
241            modal: None,
242            pending_reboot_offer: false,
243            help_open: false,
244            message: String::new(),
245            cli_local: LONG_VERSION.to_owned(),
246            box_cli: if cli.remote.is_some() {
247                BoxCliView::Probing
248            } else {
249                BoxCliView::Known(RemoteBoxCliStatus::Found(LONG_VERSION.to_owned()))
250            },
251            cli_current: cli.remote.is_none(),
252            surfaces: None,
253            overview_extra: String::new(),
254            probe_tx,
255            probe_rx,
256            probe_inflight: false,
257            reboot_rx: None,
258            reboot_inflight: false,
259            reboot_cancel: Arc::new(AtomicBool::new(false)),
260            fetch_rx: None,
261            fetch_inflight: false,
262        };
263        if app.remote.is_some() {
264            app.rebuild_remote_steps(None);
265            app.message = format!("Refreshing {}", app.remote.as_deref().unwrap_or("box"));
266            app.refresh_panel_text();
267        } else {
268            app.refresh();
269        }
270        app
271    }
272    /// True when a remote OpenSSH host is configured.
273    pub const fn is_remote(&self) -> bool {
274        self.remote.is_some()
275    }
276    /// Rebuild colored panel lines for the active surface tab.
277    pub fn refresh_panel_text(&mut self) {
278        let host = self.remote.as_deref().unwrap_or("local");
279        self.panel_lines = match self.screen {
280            Screen::Ssh => tabs::panel_ssh(
281                self.is_remote(),
282                host,
283                self.surfaces.as_ref(),
284                self.box_cli.as_label(),
285                self.install_ssh_key,
286            ),
287            Screen::Cli => tabs::panel_cli(
288                self.is_remote(),
289                self.surfaces.as_ref(),
290                self.box_cli.as_label(),
291            ),
292            Screen::Api => tabs::panel_api(self.surfaces.as_ref()),
293            Screen::Mcp => tabs::panel_mcp(self.surfaces.as_ref()),
294            Screen::Reboot => tabs::panel_reboot(host),
295            Screen::Overview => {
296                if self.is_remote() {
297                    tabs::panel_overview_remote(host, self.surfaces.as_ref(), &self.overview_extra)
298                } else {
299                    tabs::panel_lines_from_plain(&self.overview_text)
300                }
301            }
302            Screen::Setup | Screen::Logs => Vec::new(),
303        };
304    }
305    /// Setup-list row for remote CLI sync (`s0`).
306    pub fn s0_line(&self) -> String {
307        let status = match &self.box_cli {
308            BoxCliView::Probing => "probing",
309            BoxCliView::Known(RemoteBoxCliStatus::AuthFailed | RemoteBoxCliStatus::Unreachable) => {
310                "blocked"
311            }
312            BoxCliView::Known(_) if self.cli_current => "done",
313            BoxCliView::Known(_) => "pending",
314        };
315        format!("s0 | {status} | Sync CLI to box")
316    }
317    /// Rebuild the setup list from an optional box status report (or placeholders).
318    pub fn rebuild_remote_steps(&mut self, setup: Option<&horto_os_ui_shared::SetupStatusReport>) {
319        let mut lines = vec![self.s0_line()];
320        if let Some(report) = setup {
321            lines.extend(report.steps.iter().map(|s| {
322                format!(
323                    "{} | {} | {}{}",
324                    s.id,
325                    s.status,
326                    s.title,
327                    if s.destructive { " *" } else { "" }
328                )
329            }));
330        } else {
331            lines.extend(pipeline(self.kind).iter().map(|s| {
332                format!(
333                    "{} | pending | {}{}",
334                    s.id(),
335                    s.title(),
336                    if s.destructive() { " *" } else { "" }
337                )
338            }));
339        }
340        self.status_lines = lines;
341        self.select_smart_step();
342    }
343    /// Select the first pending setup row (prefer `s0` when box CLI is stale).
344    pub fn select_smart_step(&mut self) {
345        if self.remote.is_some() && !self.cli_current {
346            self.step_state.select(Some(0));
347            return;
348        }
349        let start = usize::from(self.remote.is_some());
350        for (i, line) in self.status_lines.iter().enumerate().skip(start) {
351            let status = line.split(" | ").nth(1).unwrap_or("");
352            if status == "pending" {
353                self.step_state.select(Some(i));
354                return;
355            }
356        }
357        if self.status_lines.len() > start {
358            self.step_state.select(Some(start));
359        } else {
360            self.step_state.select(Some(0));
361        }
362    }
363    /// Switch full/minimal pipeline locally without SSH; refresh UI text.
364    pub fn set_pipeline_kind_local(&mut self, kind: SetupKind) {
365        self.kind = kind;
366        if self.remote.is_some() {
367            // Local only: no SSH/SCP. Keep s0 state; reset setup rows to placeholders.
368            self.rebuild_remote_steps(None);
369            self.message = format!("pipeline={} (press r for status)", kind.as_str());
370        } else {
371            self.refresh_local();
372            self.message = format!("pipeline={}", kind.as_str());
373        }
374    }
375    /// Build [`RemoteOptions`] from current host / key / bin-dir settings.
376    pub fn remote_opts(&self) -> Option<RemoteOptions> {
377        self.remote.as_ref().map(|host| {
378            RemoteOptions::from_input(RemoteOptionsInput {
379                host: host.clone(),
380                install_ssh_key: self.install_ssh_key,
381                bin_dir: self.bin_dir.clone(),
382                release_tag: self.release_tag.clone(),
383                force_askpass: true,
384            })
385        })
386    }
387    /// Refresh local status or start a background remote probe.
388    pub fn refresh(&mut self) {
389        if self.remote.is_some() {
390            self.start_remote_probe();
391        } else {
392            self.refresh_local();
393        }
394    }
395
396    /// Spawn a background remote probe if none is already running.
397    pub fn start_remote_probe(&mut self) {
398        let Some(opts) = self.remote_opts() else {
399            return;
400        };
401        if self.probe_inflight {
402            self.message = "Refresh already running".into();
403            return;
404        }
405        let host = opts.host.clone();
406        let full = self.kind != SetupKind::Minimal;
407        self.probe_inflight = true;
408        self.box_cli = BoxCliView::Probing;
409        self.surfaces = None;
410        self.rebuild_remote_steps(None);
411        self.message = format!("Refreshing {host}");
412        let tx = self.probe_tx.clone();
413        thread::spawn(move || {
414            let _ = tx.send(run_remote_probe(&opts, full));
415        });
416    }
417
418    /// Apply any completed background probe without blocking.
419    pub fn poll_probe(&mut self) {
420        match self.probe_rx.try_recv() {
421            Ok(outcome) => {
422                self.probe_inflight = false;
423                self.apply_remote_probe(outcome);
424            }
425            Err(mpsc::TryRecvError::Empty | mpsc::TryRecvError::Disconnected) => {}
426        }
427    }
428    /// Apply a finished remote probe outcome (success or unreachable error).
429    pub fn apply_remote_probe(&mut self, outcome: RemoteProbeOutcome) {
430        let host = outcome.host;
431        let ok = match outcome.result {
432            Ok(ok) => ok,
433            Err(e) => {
434                self.box_cli = BoxCliView::Known(RemoteBoxCliStatus::Unreachable);
435                self.cli_current = false;
436                self.surfaces = None;
437                self.push_log(format!("ERROR probe: {e}"));
438                self.message = format!("Refresh failed: {e}");
439                self.rebuild_remote_steps(None);
440                self.refresh_panel_text();
441                return;
442            }
443        };
444        self.apply_remote_probe_ok(&host, ok);
445    }
446    /// Merge a successful remote probe into box CLI, surfaces, and setup rows.
447    pub fn apply_remote_probe_ok(&mut self, _host: &str, ok: RemoteProbeOk) {
448        self.box_cli = BoxCliView::Known(ok.report.cli.status.clone());
449        self.cli_current = ok.report.cli.current;
450        self.surfaces = Some(ok.report);
451        self.push_log(format!(
452            "probe ssh={} cli={} api={}",
453            self.surfaces.as_ref().unwrap().ssh.status,
454            self.box_cli.as_label(),
455            self.surfaces.as_ref().unwrap().api.health
456        ));
457
458        self.overview_extra.clear();
459        match ok.snapshot {
460            Some(Ok(snap)) => {
461                self.rebuild_remote_steps(snap.setup.as_ref());
462                if let Some(doc) = &snap.doctor {
463                    let _ = writeln!(
464                        self.overview_extra,
465                        "doctor: root={} sudo={} docker={} full_env={} minimal_env={}",
466                        doc.is_root,
467                        doc.has_sudo,
468                        doc.docker_present,
469                        doc.full_env,
470                        doc.minimal_env
471                    );
472                }
473                self.push_log(format!(
474                    "setup steps={}",
475                    snap.setup.as_ref().map_or(0, |s| s.steps.len())
476                ));
477            }
478            Some(Err(e)) => {
479                self.rebuild_remote_steps(None);
480                self.push_log(format!("ERROR setup status: {e}"));
481            }
482            None => {
483                self.rebuild_remote_steps(None);
484            }
485        }
486
487        self.refresh_panel_text();
488        if matches!(
489            self.box_cli,
490            BoxCliView::Known(RemoteBoxCliStatus::AuthFailed)
491        ) {
492            self.message = "SSH auth failed. Check key or password; UI stays up.".into();
493        } else if self.message.starts_with("Refreshing") {
494            // End of refresh only: do not wipe unrelated user feedback (reboot, steps, …).
495            self.message.clear();
496        }
497    }
498    /// Open the OpenSSH Host text modal.
499    pub fn open_host_editor(&mut self) {
500        let initial = self.remote.clone().unwrap_or_default();
501        self.modal = Some(Modal::TextHost(TextInput::new("OpenSSH Host", initial)));
502        self.message = "Edit OpenSSH Host".into();
503    }
504    /// Apply a host edit and start a remote probe for the new host.
505    pub fn apply_host_edit(&mut self, host: &str) {
506        let host = host.trim().to_owned();
507        if host.is_empty() {
508            self.message = "Host unchanged (empty)".into();
509            return;
510        }
511        self.remote = Some(host.clone());
512        self.box_cli = BoxCliView::Probing;
513        self.cli_current = false;
514        self.surfaces = None;
515        self.rebuild_remote_steps(None);
516        self.refresh_panel_text();
517        self.push_log(format!("host set to {host}; refreshing..."));
518        self.start_remote_probe();
519    }
520    /// Run opt-in `ssh-copy-id` when `--install-ssh-key` was set.
521    pub fn install_ssh_key_action(&mut self) {
522        if !self.install_ssh_key {
523            self.message = "Start with --install-ssh-key to enable key install".into();
524            return;
525        }
526        let Some(opts) = self.remote_opts() else {
527            return;
528        };
529        self.push_log("SSH: installing key…");
530        match horto_os_ui_shared::remote_ensure_ssh_key(&SystemProcessRunner, &opts) {
531            Ok(()) => {
532                self.message = "SSH key installed (or already authorized)".into();
533                self.refresh();
534            }
535            Err(e) => {
536                self.push_log(format!("ERROR ssh key: {e}"));
537                self.message = format!("SSH key failed: {e}");
538            }
539        }
540    }
541    /// Enter-key action for the current surface tab (SSH host, CLI sync, reboot).
542    pub fn run_surface_enter(&mut self) {
543        match self.screen {
544            Screen::Ssh => self.open_host_editor(),
545            Screen::Cli => self.run_s0_sync(),
546            Screen::Reboot => self.arm_reboot_confirm(),
547            Screen::Overview | Screen::Api | Screen::Mcp | Screen::Setup | Screen::Logs => {}
548        }
549    }
550    /// Show the reboot confirmation modal.
551    pub fn arm_reboot_confirm(&mut self) {
552        self.modal = Some(Modal::Confirm(ConfirmKind::Reboot));
553    }
554    /// Set the footer status message.
555    pub fn note(&mut self, msg: impl Into<String>) {
556        self.message = msg.into();
557    }
558    /// Start background `sudo reboot` with the collected password.
559    pub fn do_reboot(&mut self, sudo_password: String) {
560        if self.reboot_inflight {
561            self.note("Reboot already running");
562            return;
563        }
564        let Some(opts) = self.remote_opts() else {
565            return;
566        };
567        let host = opts.host.clone();
568        self.push_log("reboot: sudo reboot on box…");
569        self.modal = Some(Modal::Rebooting);
570        self.reboot_inflight = true;
571        self.reboot_cancel.store(false, Ordering::SeqCst);
572        let cancel = Arc::clone(&self.reboot_cancel);
573        let (tx, rx) = mpsc::channel();
574        self.reboot_rx = Some(rx);
575        thread::spawn(move || {
576            let result = horto_os_ui_shared::remote_reboot_with_sudo_password(
577                &SystemProcessRunner,
578                &opts,
579                &sudo_password,
580            );
581            match result {
582                Ok(()) => {
583                    let _ = tx.send(RebootEvent::Issued);
584                    match wait_until_box_replies(&host, &cancel) {
585                        Ok(()) => {
586                            let _ = tx.send(RebootEvent::BoxBack);
587                        }
588                        Err(e) => {
589                            let _ = tx.send(RebootEvent::Failed(e));
590                        }
591                    }
592                }
593                Err(e) => {
594                    let _ = tx.send(RebootEvent::Failed(e.to_string()));
595                }
596            }
597        });
598    }
599    /// Drain reboot worker events into logs and modal state.
600    pub fn poll_reboot(&mut self) {
601        let Some(rx) = &self.reboot_rx else {
602            return;
603        };
604        match rx.try_recv() {
605            Ok(RebootEvent::Issued) => {
606                self.push_log("Reboot issued");
607                self.note("Reboot issued");
608            }
609            Ok(RebootEvent::BoxBack) => {
610                self.reboot_inflight = false;
611                self.reboot_rx = None;
612                if matches!(self.modal, Some(Modal::Rebooting)) {
613                    self.modal = None;
614                }
615                self.push_log("Reboot done");
616                self.note("Reboot done");
617            }
618            Ok(RebootEvent::Failed(e)) => {
619                self.reboot_inflight = false;
620                self.reboot_rx = None;
621                if matches!(self.modal, Some(Modal::Rebooting)) {
622                    self.modal = None;
623                }
624                if e == "cancelled" {
625                    return;
626                }
627                self.push_log(format!("ERROR reboot: {e}"));
628                self.note(short_reboot_err(&e));
629            }
630            Err(mpsc::TryRecvError::Empty) => {}
631            Err(mpsc::TryRecvError::Disconnected) => {
632                self.reboot_inflight = false;
633                self.reboot_rx = None;
634                if matches!(self.modal, Some(Modal::Rebooting)) {
635                    self.modal = None;
636                }
637                self.note("Reboot failed (worker dropped)");
638            }
639        }
640    }
641    /// Background-fetch the active surface tab (`f` key).
642    pub fn fetch_current_tab(&mut self) {
643        if !matches!(
644            self.screen,
645            Screen::Overview | Screen::Ssh | Screen::Cli | Screen::Api | Screen::Mcp
646        ) {
647            return;
648        }
649        if self.fetch_inflight || self.probe_inflight {
650            self.note("Fetch already running");
651            return;
652        }
653        let Some(opts) = self.remote_opts() else {
654            self.note("Fetch needs --remote");
655            return;
656        };
657        let screen = self.screen;
658        self.fetch_inflight = true;
659        self.note(match screen {
660            Screen::Overview => "Fetching overview…",
661            Screen::Ssh => "Fetching SSH…",
662            Screen::Cli => "Fetching CLI…",
663            Screen::Api => "Fetching API…",
664            Screen::Mcp => "Fetching MCP…",
665            _ => "Fetching…",
666        });
667        let (tx, rx) = mpsc::channel();
668        self.fetch_rx = Some(rx);
669        thread::spawn(move || {
670            let embedded = false;
671            let event = match screen {
672                Screen::Overview => match probe_surfaces(&SystemProcessRunner, &opts, embedded) {
673                    Ok(r) => FetchEvent::Overview(Box::new(r)),
674                    Err(e) => FetchEvent::Failed(e.to_string()),
675                },
676                Screen::Ssh => match probe_ssh_surface(&SystemProcessRunner, &opts, embedded) {
677                    Ok(r) => FetchEvent::Ssh(r),
678                    Err(e) => FetchEvent::Failed(e.to_string()),
679                },
680                Screen::Cli => match probe_cli_surface(&SystemProcessRunner, &opts, embedded) {
681                    Ok(r) => FetchEvent::Cli(r),
682                    Err(e) => FetchEvent::Failed(e.to_string()),
683                },
684                Screen::Api => match probe_api_surface(&SystemProcessRunner, &opts, embedded) {
685                    Ok(r) => FetchEvent::Api(r),
686                    Err(e) => FetchEvent::Failed(e.to_string()),
687                },
688                Screen::Mcp => match probe_mcp_surface(&SystemProcessRunner, &opts, embedded) {
689                    Ok((pc, bx)) => FetchEvent::Mcp(pc, bx),
690                    Err(e) => FetchEvent::Failed(e.to_string()),
691                },
692                _ => FetchEvent::Failed("unsupported tab".into()),
693            };
694            let _ = tx.send(event);
695        });
696    }
697    /// Ensure a placeholder [`SurfaceProbeReport`] exists before partial fetches.
698    pub fn ensure_surfaces_shell(&mut self) {
699        if self.surfaces.is_some() {
700            return;
701        }
702        let host = self.remote.clone().unwrap_or_else(|| "box".into());
703        self.surfaces = Some(SurfaceProbeReport {
704            local_version: LONG_VERSION.to_owned(),
705            ssh: SshSurfaceProbe {
706                host: host.clone(),
707                status: "…".into(),
708                key_ok: false,
709            },
710            cli: CliSurfaceProbe {
711                status: RemoteBoxCliStatus::Unreachable,
712                version: None,
713                current: false,
714            },
715            api: ApiSurfaceProbe {
716                url: format!("http://{host}:8787"),
717                health: "…".into(),
718                status: "…".into(),
719                local_token: false,
720                unit: String::new(),
721            },
722            mcp_pc: McpHostProbe {
723                docker: None,
724                binary: None,
725                http_url: format!("http://{host}:8790"),
726                http_reach: "…".into(),
727                unit: String::new(),
728                api_health: "…".into(),
729            },
730            mcp_box: McpHostProbe {
731                docker: None,
732                binary: None,
733                http_url: format!("http://{host}:8790"),
734                http_reach: "…".into(),
735                unit: String::new(),
736                api_health: "…".into(),
737            },
738        });
739    }
740    /// Drain single-tab fetch results into surfaces and panels.
741    pub fn poll_fetch(&mut self) {
742        let Some(rx) = &self.fetch_rx else {
743            return;
744        };
745        match rx.try_recv() {
746            Ok(event) => {
747                self.fetch_inflight = false;
748                self.fetch_rx = None;
749                match event {
750                    FetchEvent::Overview(report) => {
751                        self.box_cli = BoxCliView::Known(report.cli.status.clone());
752                        self.cli_current = report.cli.current;
753                        self.push_log(format!(
754                            "fetch overview ssh={} api={} mcp={}",
755                            report.ssh.status, report.api.health, report.mcp_box.http_reach
756                        ));
757                        self.surfaces = Some(*report);
758                        self.note("Overview fetched");
759                    }
760                    FetchEvent::Ssh(ssh) => {
761                        self.ensure_surfaces_shell();
762                        if let Some(s) = self.surfaces.as_mut() {
763                            s.ssh = ssh;
764                        }
765                        self.push_log(format!(
766                            "fetch ssh={}",
767                            self.surfaces
768                                .as_ref()
769                                .map_or("?", |s| s.ssh.status.as_str())
770                        ));
771                        self.note("SSH fetched");
772                    }
773                    FetchEvent::Cli(cli) => {
774                        self.box_cli = BoxCliView::Known(cli.status.clone());
775                        self.cli_current = cli.current;
776                        self.ensure_surfaces_shell();
777                        if let Some(s) = self.surfaces.as_mut() {
778                            s.cli = cli;
779                        }
780                        self.push_log(format!("fetch cli={}", self.box_cli.as_label()));
781                        self.note("CLI fetched");
782                    }
783                    FetchEvent::Api(api) => {
784                        self.ensure_surfaces_shell();
785                        self.push_log(format!("fetch api health={}", api.health));
786                        if let Some(s) = self.surfaces.as_mut() {
787                            s.api = api;
788                        }
789                        self.note("API fetched");
790                    }
791                    FetchEvent::Mcp(pc, bx) => {
792                        self.ensure_surfaces_shell();
793                        self.push_log(format!(
794                            "fetch mcp pc={} box={}",
795                            pc.http_reach, bx.http_reach
796                        ));
797                        if let Some(s) = self.surfaces.as_mut() {
798                            s.mcp_pc = pc;
799                            s.mcp_box = bx;
800                        }
801                        self.note("MCP fetched");
802                    }
803                    FetchEvent::Failed(e) => {
804                        self.push_log(format!("ERROR fetch: {e}"));
805                        self.note(format!("Fetch failed: {e}"));
806                    }
807                }
808                self.refresh_panel_text();
809            }
810            Err(mpsc::TryRecvError::Empty) => {}
811            Err(mpsc::TryRecvError::Disconnected) => {
812                self.fetch_inflight = false;
813                self.fetch_rx = None;
814            }
815        }
816    }
817    /// Accept the current confirm modal (run step, install, reboot, save token).
818    pub fn resolve_confirm_yes(&mut self) {
819        let Some(Modal::Confirm(kind)) = self.modal.take() else {
820            return;
821        };
822        match kind {
823            ConfirmKind::DestructiveStep(id) => self.execute_step(&id),
824            ConfirmKind::Reboot | ConfirmKind::RebootAfterApply => {
825                if !self.apply {
826                    self.note("PLAN: reboot not sent (Tab → APPLY)");
827                    self.push_log("PLAN: reboot not sent (press Tab for APPLY)");
828                    return;
829                }
830                self.modal = Some(Modal::SudoPassword(SecretInput::new("Sudo password (box)")));
831                self.message = "Enter sudo password".into();
832            }
833            ConfirmKind::SaveToken(token) => {
834                match finish_save_api_token(&token, "y") {
835                    Ok(true) => {
836                        self.push_log(
837                            "Saved status-api bearer to ~/.config/horto-os-ui/api_token".to_owned(),
838                        );
839                        self.message = "API token saved".into();
840                    }
841                    Ok(false) => {
842                        self.push_log("Skipped saving status-api bearer locally");
843                        self.message = "Token save skipped".into();
844                    }
845                    Err(e) => {
846                        self.push_log(format!("Token save failed: {e}"));
847                        self.message = format!("Token save failed: {e}");
848                    }
849                }
850                self.maybe_offer_reboot_after_token();
851            }
852            ConfirmKind::InstallStatusApi { remote } => {
853                self.modal = Some(Modal::Confirm(ConfirmKind::InstallMcp {
854                    remote,
855                    status_api: true,
856                }));
857                self.message = "Install MCP? Enter/y confirm, Esc/n cancel.".into();
858            }
859            ConfirmKind::InstallMcp { remote, status_api } => {
860                self.run_full_pipeline_with_ecosystem(
861                    remote,
862                    EcosystemInstallChoice {
863                        status_api,
864                        mcp: true,
865                    },
866                );
867            }
868        }
869    }
870    /// Decline the current confirm modal (skip install bits or cancel).
871    pub fn resolve_confirm_no(&mut self) {
872        let Some(Modal::Confirm(kind)) = self.modal.take() else {
873            self.modal = None;
874            self.message = "Cancelled".into();
875            return;
876        };
877        match kind {
878            ConfirmKind::InstallStatusApi { remote } => {
879                self.modal = Some(Modal::Confirm(ConfirmKind::InstallMcp {
880                    remote,
881                    status_api: false,
882                }));
883                self.message = "Install MCP? Enter/y confirm, Esc/n cancel.".into();
884            }
885            ConfirmKind::InstallMcp { remote, status_api } => {
886                self.run_full_pipeline_with_ecosystem(
887                    remote,
888                    EcosystemInstallChoice {
889                        status_api,
890                        mcp: false,
891                    },
892                );
893            }
894            ConfirmKind::SaveToken(_) => {
895                self.message = "Cancelled".into();
896                self.push_log("Skipped saving status-api bearer locally");
897                self.maybe_offer_reboot_after_token();
898            }
899            _ => {
900                self.message = "Cancelled".into();
901            }
902        }
903    }
904    /// After token save, offer reboot when a remote apply installed payload.
905    pub fn maybe_offer_reboot_after_token(&mut self) {
906        if !self.pending_reboot_offer {
907            return;
908        }
909        self.pending_reboot_offer = false;
910        self.modal = Some(Modal::Confirm(ConfirmKind::RebootAfterApply));
911        self.message = "Reboot after apply? Enter/y confirm, Esc/n cancel.".into();
912    }
913    /// Upload / sync the CLI agent to the box (`s0`).
914    pub fn run_s0_sync(&mut self) {
915        let Some(opts) = self.remote_opts() else {
916            return;
917        };
918        self.push_log("s0: syncing CLI to box…");
919        match remote_upload_cli(&SystemProcessRunner, &opts) {
920            Ok(probe) => {
921                self.box_cli = BoxCliView::Known(probe.status.clone());
922                self.cli_current = probe.current;
923                self.push_log(format!("s0: box={}", probe.status.as_label()));
924                if probe.current {
925                    self.refresh();
926                    self.message = "s0 done; refreshing...".into();
927                } else {
928                    self.rebuild_remote_steps(None);
929                    self.message = format!(
930                        "s0 finished but box still {}; check SSH/auth",
931                        probe.status.as_label()
932                    );
933                }
934            }
935            Err(e) => {
936                self.push_log(format!("ERROR s0: {e}"));
937                self.message = format!("s0 failed: {e}");
938            }
939        }
940    }
941    /// Refresh setup list, overview text, and embedded surface probes.
942    pub fn refresh_local(&mut self) {
943        let ctx = self.make_ctx();
944        let report = horto_os_ui_shared::setup_status(&ctx, self.kind);
945        self.status_lines = report
946            .steps
947            .iter()
948            .map(|s| {
949                format!(
950                    "{} | {} | {}{}",
951                    s.id,
952                    s.status,
953                    s.title,
954                    if s.destructive { " *" } else { "" }
955                )
956            })
957            .collect();
958        let box_st = box_status(&ctx, self.kind);
959        let mut overview = String::new();
960        overview.push_str("Mode: embedded\n");
961        let _ = writeln!(overview, "Hostname: {}", box_st.hostname);
962        let _ = writeln!(
963            overview,
964            "Root: {}  Docker: {}  Full env: {}  Minimal env: {}",
965            box_st.doctor.is_root,
966            box_st.doctor.docker_present,
967            box_st.doctor.full_env,
968            box_st.doctor.minimal_env
969        );
970        for n in &box_st.doctor.notes {
971            let _ = writeln!(overview, "- {n}");
972        }
973        overview.push_str("\nContainers:\n");
974        if box_st.containers.is_empty() {
975            overview.push_str("  (none)\n");
976        } else {
977            for c in &box_st.containers {
978                let _ = writeln!(overview, "  {} {}", c.names, c.status);
979            }
980        }
981        overview.push_str("\nURLs:\n");
982        for u in &box_st.urls {
983            let mark = if u.up { "up" } else { "down" };
984            let _ = writeln!(overview, "  {} [{}]: {}", u.name, mark, u.url);
985        }
986        let _ = writeln!(overview, "\nLeases: {}", box_st.leases.len());
987        for l in box_st.leases.iter().take(12) {
988            let _ = writeln!(overview, "  {} {}", l.hostname, l.ip);
989        }
990        overview.push_str("\nBackup:\n");
991        let _ = writeln!(
992            overview,
993            "  initial_setup: {}",
994            box_st.backup.initial_setup_present
995        );
996        if box_st.backup.timestamped.is_empty() {
997            overview.push_str("  timestamped: (none)\n");
998        } else {
999            let recent: Vec<_> = box_st.backup.timestamped.iter().rev().take(5).collect();
1000            let _ = writeln!(
1001                overview,
1002                "  timestamped ({}): {}",
1003                box_st.backup.timestamped.len(),
1004                recent
1005                    .iter()
1006                    .map(|s| s.as_str())
1007                    .collect::<Vec<_>>()
1008                    .join(", ")
1009            );
1010        }
1011        let _ = writeln!(
1012            overview,
1013            "  disk root={} safe={} blockers={}",
1014            box_st.backup.disk.root_source,
1015            box_st.backup.disk.safe_to_apply,
1016            box_st.backup.disk.blockers.len()
1017        );
1018        self.overview_text = overview;
1019        let opts = RemoteOptions::default();
1020        match probe_surfaces(&SystemProcessRunner, &opts, true) {
1021            Ok(report) => {
1022                self.surfaces = Some(report);
1023            }
1024            Err(e) => {
1025                self.push_log(format!("ERROR local probe: {e}"));
1026            }
1027        }
1028        self.refresh_panel_text();
1029    }
1030    /// Run argv on the box via the remote runner; handle token/reboot prompts.
1031    pub fn run_remote_cli(
1032        &mut self,
1033        rest: &[&str],
1034        use_sudo: bool,
1035        ecosystem: EcosystemInstallChoice,
1036    ) {
1037        let Some(options) = self.remote_opts() else {
1038            return;
1039        };
1040        let mut cli_args = Vec::new();
1041        if self.apply {
1042            cli_args.push("--apply".into());
1043        }
1044        if self.skip_piper {
1045            cli_args.push("--skip-piper".into());
1046        }
1047        for a in rest {
1048            cli_args.push((*a).to_owned());
1049        }
1050        match remote_run_cli(
1051            &SystemProcessRunner,
1052            &RemoteRunRequest {
1053                options,
1054                cli_args,
1055                flags: RemoteRunFlags {
1056                    use_sudo,
1057                    install_payload_on_success: ecosystem.any(),
1058                    offer_reboot_on_success: false,
1059                    capture_output: false,
1060                },
1061                ecosystem,
1062            },
1063        ) {
1064            Ok(outcome) => {
1065                for line in outcome.log.lines() {
1066                    self.push_log(line.to_owned());
1067                }
1068                let offer_reboot = ecosystem.any() && self.apply;
1069                if let Some(token) = outcome.api_token {
1070                    self.pending_reboot_offer = offer_reboot;
1071                    self.modal = Some(Modal::Confirm(ConfirmKind::SaveToken(token)));
1072                    self.message = "Save API token? Enter/y confirm, Esc/n cancel.".into();
1073                } else if offer_reboot {
1074                    self.modal = Some(Modal::Confirm(ConfirmKind::RebootAfterApply));
1075                    self.message = "Reboot after apply? Enter/y confirm, Esc/n cancel.".into();
1076                } else {
1077                    self.message = "Remote command finished".into();
1078                }
1079            }
1080            Err(e) => {
1081                self.push_log(format!("ERROR: {e}"));
1082                self.message = format!("Remote failed: {e}");
1083            }
1084        }
1085    }
1086    /// Build a [`HostContext`] from apply mode, pipeline kind, and skip-piper.
1087    pub fn make_ctx(&self) -> HostContext {
1088        let mode = if self.apply {
1089            ApplyMode::Apply
1090        } else {
1091            ApplyMode::DryRun
1092        };
1093        let mut ctx = HostContext::new(mode, self.kind).with_prompts(Box::new(StdioPrompts));
1094        ctx.skip_piper = self.skip_piper;
1095        ctx
1096    }
1097    /// Step id of the highlighted setup-list row, when any.
1098    pub fn selected_step_id(&self) -> Option<String> {
1099        let idx = self.step_state.selected()?;
1100        self.status_lines
1101            .get(idx)
1102            .and_then(|l| l.split(" | ").next())
1103            .map(str::to_string)
1104    }
1105    /// Append a timestamped log line (keeps the last 500).
1106    pub fn push_log(&mut self, line: impl Into<String>) {
1107        let ts = chrono::Local::now().format("%H:%M:%S");
1108        self.logs.push(format!("{ts} {}", line.into()));
1109        if self.logs.len() > 500 {
1110            self.logs.drain(0..self.logs.len() - 500);
1111        }
1112    }
1113    /// Clear the in-memory log buffer.
1114    pub fn clear_logs(&mut self) {
1115        self.logs.clear();
1116        self.message = "Logs cleared".into();
1117    }
1118    /// Move to the next tab for the current local/remote mode.
1119    pub fn next_screen(&mut self) {
1120        self.screen = self.screen.next(self.is_remote());
1121        self.refresh_panel_text();
1122    }
1123    /// Move to the previous tab for the current local/remote mode.
1124    pub fn prev_screen(&mut self) {
1125        self.screen = self.screen.prev(self.is_remote());
1126        self.refresh_panel_text();
1127    }
1128    /// Jump to a tab and close help.
1129    pub fn select_screen(&mut self, screen: Screen) {
1130        self.screen = screen;
1131        self.help_open = false;
1132        self.refresh_panel_text();
1133    }
1134    /// Run the highlighted setup step (confirm when destructive + apply).
1135    pub fn run_selected(&mut self) {
1136        let Some(id) = self.selected_step_id() else {
1137            return;
1138        };
1139        if id == "s0" {
1140            self.run_s0_sync();
1141            return;
1142        }
1143        if self.remote.is_some() && !self.cli_current {
1144            self.message = match &self.box_cli {
1145                BoxCliView::Probing => "Still probing box CLI...".into(),
1146                BoxCliView::Known(RemoteBoxCliStatus::AuthFailed) => {
1147                    "SSH auth failed: install key (--install-ssh-key) or run s0 after login".into()
1148                }
1149                BoxCliView::Known(RemoteBoxCliStatus::Unreachable) => {
1150                    "Box unreachable: check host/network, then press r".into()
1151                }
1152                BoxCliView::Known(_) => "Run s0 (Sync CLI) before other steps".into(),
1153            };
1154            return;
1155        }
1156        let ctx_probe = self.make_ctx();
1157        let report = horto_os_ui_shared::setup_status(&ctx_probe, self.kind);
1158        if let Some(row) = report.steps.iter().find(|s| s.id == id) {
1159            if row.destructive && self.apply {
1160                self.modal = Some(Modal::Confirm(ConfirmKind::DestructiveStep(id)));
1161                self.message = "Step is destructive. Enter/y confirm, Esc/n cancel.".into();
1162                return;
1163            }
1164        }
1165        self.execute_step(&id);
1166    }
1167    /// Execute one setup step locally or remotely, then refresh.
1168    pub fn execute_step(&mut self, id: &str) {
1169        self.push_log(format!("Running step {id} (apply={})", self.apply));
1170        if self.remote.is_some() {
1171            let kind = if self.kind == SetupKind::Minimal {
1172                "--minimal"
1173            } else {
1174                "--full"
1175            };
1176            self.run_remote_cli(
1177                &["setup", "step", id, kind],
1178                self.apply,
1179                EcosystemInstallChoice::none(),
1180            );
1181            self.refresh();
1182            return;
1183        }
1184        let mut ctx = self.make_ctx();
1185        match setup_step(&mut ctx, self.kind, id) {
1186            Ok(()) => {
1187                for l in &ctx.logs {
1188                    self.push_log(l.clone());
1189                }
1190                self.message = format!("Step {id} finished");
1191            }
1192            Err(e) => {
1193                self.push_log(format!("ERROR: {e}"));
1194                self.message = format!("Step {id} failed: {e}");
1195            }
1196        }
1197        self.refresh();
1198    }
1199    /// Run the full pipeline; prompt for ecosystem installs when applying full.
1200    pub fn run_all(&mut self) {
1201        if self.remote.is_some() && !self.cli_current {
1202            self.message = match &self.box_cli {
1203                BoxCliView::Probing => "Still probing box CLI...".into(),
1204                BoxCliView::Known(_) => "Run s0 (Sync CLI) before running all steps".into(),
1205            };
1206            return;
1207        }
1208        if self.apply && self.kind == SetupKind::Full {
1209            let remote = self.remote.is_some();
1210            self.modal = Some(Modal::Confirm(ConfirmKind::InstallStatusApi { remote }));
1211            self.message = "Install status-api? Enter/y confirm, Esc/n skip.".into();
1212            return;
1213        }
1214        self.run_full_pipeline_with_ecosystem(
1215            self.remote.is_some(),
1216            EcosystemInstallChoice::none(),
1217        );
1218    }
1219    /// Run all pipeline steps with the chosen ecosystem install set.
1220    pub fn run_full_pipeline_with_ecosystem(
1221        &mut self,
1222        remote: bool,
1223        ecosystem: EcosystemInstallChoice,
1224    ) {
1225        self.push_log(format!(
1226            "Running all pipeline steps (apply={}, status_api={}, mcp={})",
1227            self.apply, ecosystem.status_api, ecosystem.mcp
1228        ));
1229        if remote {
1230            let kind = if self.kind == SetupKind::Minimal {
1231                "--minimal"
1232            } else {
1233                "--full"
1234            };
1235            self.run_remote_cli(&["setup", "run", kind], self.apply, ecosystem);
1236            self.refresh();
1237            return;
1238        }
1239        let mut ctx = self.make_ctx();
1240        match setup_run(&mut ctx, self.kind) {
1241            Ok(()) => {
1242                for l in &ctx.logs {
1243                    self.push_log(l.clone());
1244                }
1245                self.message = "Pipeline finished".into();
1246                if self.apply && self.kind == SetupKind::Full && ecosystem.any() {
1247                    match install_ecosystem_after_embedded_apply(
1248                        &SystemProcessRunner,
1249                        std::path::Path::new(DEFAULT_INSTALL_DIR),
1250                        ecosystem,
1251                    ) {
1252                        Ok(token) => {
1253                            self.push_log(format!(
1254                                "Installed selected ecosystem services under {DEFAULT_INSTALL_DIR}"
1255                            ));
1256                            if let Some(hex) = token {
1257                                self.modal = Some(Modal::Confirm(ConfirmKind::SaveToken(hex)));
1258                                self.message =
1259                                    "Save API token? Enter/y confirm, Esc/n cancel.".into();
1260                            }
1261                        }
1262                        Err(e) => {
1263                            self.push_log(format!("ERROR ecosystem install: {e}"));
1264                            self.message = format!("Ecosystem install failed: {e}");
1265                        }
1266                    }
1267                } else if self.apply && self.kind == SetupKind::Full && !ecosystem.any() {
1268                    self.push_log("Skipped status-api / MCP install");
1269                }
1270            }
1271            Err(e) => {
1272                self.push_log(format!("ERROR: {e}"));
1273                self.message = format!("Pipeline failed: {e}");
1274            }
1275        }
1276        self.refresh();
1277    }
1278    /// Timestamped `/etc` backup on the local host (apply gate applies).
1279    pub fn run_backup_etc(&mut self) {
1280        self.push_log(format!("Timestamped /etc backup (apply={})", self.apply));
1281        let mut ctx = self.make_ctx();
1282        if let Err(e) = require_root_for_apply(ctx.mode) {
1283            self.push_log(format!("ERROR: {e}"));
1284            self.message = format!("Backup etc failed: {e}");
1285            return;
1286        }
1287        match backup_etc_timestamped(&mut ctx) {
1288            Ok(report) => {
1289                for l in &ctx.logs {
1290                    self.push_log(l.clone());
1291                }
1292                self.message = format!("Backup etc -> {}", report.dest);
1293            }
1294            Err(e) => {
1295                self.push_log(format!("ERROR: {e}"));
1296                self.message = format!("Backup etc failed: {e}");
1297            }
1298        }
1299        self.refresh();
1300    }
1301    /// Probe disk-backup readiness and switch to the Logs tab.
1302    pub fn show_disk_backup_status(&mut self) {
1303        let probe = probe_disk_backup(&DiskBackupOpts::default());
1304        self.push_log(format!(
1305            "disk backup: root={} safe={} partclone={}",
1306            probe.root_source, probe.safe_to_apply, probe.partclone_present
1307        ));
1308        for b in &probe.blockers {
1309            self.push_log(format!("blocker: {b}"));
1310        }
1311        self.message = if probe.safe_to_apply {
1312            "Disk backup looks safe (CLI: horto backup disk)".into()
1313        } else {
1314            format!(
1315                "Disk backup blocked ({}). See Logs. Boot from SD for eMMC image.",
1316                probe.blockers.len()
1317            )
1318        };
1319        self.screen = Screen::Logs;
1320        self.refresh_panel_text();
1321    }
1322}
1323
1324fn short_reboot_err(err: &str) -> String {
1325    let lower = err.to_ascii_lowercase();
1326    if lower.contains("sorry, try again") || lower.contains("incorrect password") {
1327        return "Reboot failed: wrong sudo password".into();
1328    }
1329    if lower.contains("no password was provided") || lower.contains("a terminal is required") {
1330        return "Reboot failed: sudo password not accepted".into();
1331    }
1332    let one_line: String = err
1333        .chars()
1334        .map(|c| if c == '\n' || c == '\r' { ' ' } else { c })
1335        .collect();
1336    let trimmed = one_line.trim();
1337    if trimmed.len() > 80 {
1338        format!("Reboot failed: {}…", &trimmed[..77])
1339    } else {
1340        format!("Reboot failed: {trimmed}")
1341    }
1342}
1343
1344/// After reboot is accepted: wait until the box stops answering once, then until it replies again.
1345/// Esc sets `cancel`. Returns `Err("cancelled")` or timeout.
1346fn wait_until_box_replies(host: &str, cancel: &AtomicBool) -> std::result::Result<(), String> {
1347    let deadline = Instant::now() + Duration::from_secs(300);
1348    let mut saw_down = false;
1349    let mut interval = Duration::from_millis(500);
1350    let max_interval = Duration::from_secs(3);
1351
1352    while Instant::now() < deadline {
1353        if cancel.load(Ordering::SeqCst) {
1354            return Err("cancelled".into());
1355        }
1356        let up = ssh_host_up(host);
1357        if !saw_down {
1358            if !up {
1359                saw_down = true;
1360            }
1361        } else if up {
1362            return Ok(());
1363        }
1364        sleep_cancellable(interval, cancel)?;
1365        interval = (interval + Duration::from_millis(250)).min(max_interval);
1366    }
1367    Err("timed out waiting for box to reply".into())
1368}
1369
1370fn ssh_host_up(host: &str) -> bool {
1371    std::process::Command::new("ssh")
1372        .args([
1373            "-o",
1374            "BatchMode=yes",
1375            "-o",
1376            "ConnectTimeout=3",
1377            "-o",
1378            "StrictHostKeyChecking=accept-new",
1379            host,
1380            "true",
1381        ])
1382        .stdout(std::process::Stdio::null())
1383        .stderr(std::process::Stdio::null())
1384        .status()
1385        .is_ok_and(|s| s.success())
1386}
1387
1388fn sleep_cancellable(total: Duration, cancel: &AtomicBool) -> std::result::Result<(), String> {
1389    let mut slept = Duration::ZERO;
1390    let slice = Duration::from_millis(100);
1391    while slept < total {
1392        if cancel.load(Ordering::SeqCst) {
1393            return Err("cancelled".into());
1394        }
1395        thread::sleep(slice.min(total.checked_sub(slept).unwrap()));
1396        slept += slice;
1397    }
1398    Ok(())
1399}