Skip to main content

horto_os_ui_tui/
tabs.rs

1//! TUI tab order and colored surface panels.
2
3use horto_os_ui_shared::{McpHostProbe, SurfaceProbeReport, LONG_VERSION};
4use ratatui::{
5    style::{Color, Modifier, Style},
6    text::{Line, Span},
7};
8
9/// Visible TUI screens (Reboot only when remote).
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum Screen {
12    Setup,
13    Overview,
14    Ssh,
15    Cli,
16    Api,
17    Mcp,
18    Reboot,
19    Logs,
20}
21
22impl Screen {
23    /// Tab titles for the current mode (digits match order).
24    #[must_use]
25    pub fn titles(remote: bool) -> Vec<&'static str> {
26        if remote {
27            vec![
28                "1 Setup",
29                "2 Overview",
30                "3 SSH",
31                "4 CLI",
32                "5 API",
33                "6 MCP",
34                "7 Reboot",
35                "8 Logs",
36            ]
37        } else {
38            vec![
39                "1 Setup",
40                "2 Overview",
41                "3 SSH",
42                "4 CLI",
43                "5 API",
44                "6 MCP",
45                "7 Logs",
46            ]
47        }
48    }
49
50    /// Ordered tab list for local or remote mode.
51    #[must_use]
52    pub fn all(remote: bool) -> Vec<Self> {
53        if remote {
54            vec![
55                Self::Setup,
56                Self::Overview,
57                Self::Ssh,
58                Self::Cli,
59                Self::Api,
60                Self::Mcp,
61                Self::Reboot,
62                Self::Logs,
63            ]
64        } else {
65            vec![
66                Self::Setup,
67                Self::Overview,
68                Self::Ssh,
69                Self::Cli,
70                Self::Api,
71                Self::Mcp,
72                Self::Logs,
73            ]
74        }
75    }
76
77    /// Zero-based index of this screen in [`Self::all`].
78    #[must_use]
79    pub fn index(self, remote: bool) -> usize {
80        Self::all(remote)
81            .iter()
82            .position(|s| *s == self)
83            .unwrap_or(0)
84    }
85
86    /// Map digit `1`..`n` to a screen for the current mode.
87    #[must_use]
88    pub fn from_digit(d: char, remote: bool) -> Option<Self> {
89        let n = d.to_digit(10)? as usize;
90        if n == 0 {
91            return None;
92        }
93        Self::all(remote).into_iter().nth(n - 1)
94    }
95
96    /// Next tab wrapping within the current mode's order.
97    #[must_use]
98    pub fn next(self, remote: bool) -> Self {
99        let all = Self::all(remote);
100        let i = self.index(remote);
101        all[(i + 1) % all.len()]
102    }
103
104    /// Previous tab wrapping within the current mode's order.
105    #[must_use]
106    pub fn prev(self, remote: bool) -> Self {
107        let all = Self::all(remote);
108        let i = self.index(remote);
109        all[(i + all.len() - 1) % all.len()]
110    }
111}
112
113fn label(s: &str) -> Span<'static> {
114    Span::styled(format!("{s:<14}"), Style::default().fg(Color::White))
115}
116
117fn value(s: impl Into<String>) -> Span<'static> {
118    Span::styled(s.into(), Style::default().fg(Color::Cyan))
119}
120
121fn muted(s: impl Into<String>) -> Span<'static> {
122    Span::styled(s.into(), Style::default().fg(Color::Cyan))
123}
124
125fn key_hint(s: &str) -> Span<'static> {
126    Span::styled(
127        s.to_owned(),
128        Style::default()
129            .fg(Color::Magenta)
130            .add_modifier(Modifier::BOLD),
131    )
132}
133
134fn section(title: &str) -> Line<'static> {
135    Line::from(Span::styled(
136        title.to_owned(),
137        Style::default()
138            .fg(Color::Green)
139            .add_modifier(Modifier::BOLD),
140    ))
141}
142
143fn blank() -> Line<'static> {
144    Line::from("")
145}
146
147fn kv(key: &str, val: Span<'static>) -> Line<'static> {
148    Line::from(vec![label(key), val])
149}
150
151fn action(keys: &str, desc: &str) -> Line<'static> {
152    Line::from(vec![key_hint(keys), Span::raw(format!("  {desc}"))])
153}
154
155/// Color a probe/status string (ok / fail / wait).
156fn status_badge(raw: &str) -> Span<'static> {
157    let lower = raw.to_ascii_lowercase();
158    let (dot, color) = if matches!(lower.as_str(), "ok" | "active" | "true" | "n/a" | "process")
159        || lower.contains("horto-os-ui")
160        || (raw.starts_with('/') && !lower.contains("missing"))
161    {
162        ('●', Color::Green)
163    } else if lower.contains("fail")
164        || lower.contains("unreachable")
165        || lower.contains("missing")
166        || lower == "inactive"
167        || lower == "false"
168        || lower.contains("error")
169        || lower.contains("auth")
170        || lower.contains("required")
171    {
172        ('●', Color::Red)
173    } else if lower.contains("probing") || lower.contains("pending") {
174        ('●', Color::Cyan)
175    } else {
176        ('●', Color::White)
177    };
178    Span::styled(
179        format!("{dot} {raw}"),
180        Style::default().fg(color).add_modifier(Modifier::BOLD),
181    )
182}
183
184fn bool_badge(ok: bool, yes: &str, no: &str) -> Span<'static> {
185    if ok {
186        Span::styled(
187            format!("● {yes}"),
188            Style::default()
189                .fg(Color::Green)
190                .add_modifier(Modifier::BOLD),
191        )
192    } else {
193        Span::styled(
194            format!("● {no}"),
195            Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
196        )
197    }
198}
199
200/// Format SSH panel from a probe (or empty state).
201#[must_use]
202pub fn panel_ssh(
203    remote: bool,
204    host: &str,
205    report: Option<&SurfaceProbeReport>,
206    pending_status: &str,
207    install_ssh_key: bool,
208) -> Vec<Line<'static>> {
209    if !remote {
210        return vec![
211            kv("Mode", value("embedded (on box)")),
212            kv("SSH", muted("n/a")),
213        ];
214    }
215    let mut lines = vec![section("Connection"), kv("Host", value(host.to_owned()))];
216    match report {
217        None => {
218            lines.push(kv("Status", status_badge(pending_status)));
219            lines.push(kv("Key auth", muted("…")));
220        }
221        Some(r) => {
222            lines.push(kv("Status", status_badge(&r.ssh.status)));
223            lines.push(kv(
224                "Key auth",
225                bool_badge(r.ssh.key_ok, "BatchMode ok", "password may be needed"),
226            ));
227        }
228    }
229    lines.push(blank());
230    lines.push(section("Actions"));
231    lines.push(action("Enter", "Edit OpenSSH Host"));
232    lines.push(action("f", "Fetch SSH status"));
233    if install_ssh_key {
234        lines.push(action("i", "Install this PC key on the box"));
235    } else {
236        lines.push(Line::from(muted(
237            "Key install off · start with --install-ssh-key to enable",
238        )));
239    }
240    lines
241}
242
243/// Format CLI panel.
244#[must_use]
245pub fn panel_cli(
246    remote: bool,
247    report: Option<&SurfaceProbeReport>,
248    pending_box: &str,
249) -> Vec<Line<'static>> {
250    let mut lines = vec![
251        section("Versions"),
252        kv("Local", value(LONG_VERSION.to_owned())),
253    ];
254    if !remote {
255        lines.push(kv("Box", value("local (embedded)")));
256        return lines;
257    }
258    match report {
259        None => {
260            lines.push(kv("Box", status_badge(pending_box)));
261            lines.push(blank());
262            lines.push(section("Actions"));
263            lines.push(action("f", "Fetch CLI status"));
264        }
265        Some(r) => {
266            lines.push(kv("Box", value(r.cli.status.as_label().to_owned())));
267            lines.push(kv(
268                "Match",
269                bool_badge(r.cli.current, "up to date", "out of date"),
270            ));
271            lines.push(blank());
272            lines.push(section("Actions"));
273            lines.push(action("Enter", "Sync CLI to box (s0)"));
274            lines.push(action("f", "Fetch CLI status"));
275        }
276    }
277    lines
278}
279
280/// Format API panel.
281#[must_use]
282pub fn panel_api(report: Option<&SurfaceProbeReport>) -> Vec<Line<'static>> {
283    report.map_or_else(
284        || {
285            vec![
286                section("Status API"),
287                Line::from(muted("No data yet")),
288                blank(),
289                section("Actions"),
290                action("f", "Fetch API status"),
291            ]
292        },
293        |r| {
294            let unit = if r.api.unit.is_empty() {
295                "-"
296            } else {
297                r.api.unit.as_str()
298            };
299            vec![
300                section("Status API"),
301                kv("URL", value(r.api.url.clone())),
302                kv("Health", status_badge(&r.api.health)),
303                kv("/v1/status", status_badge(&r.api.status)),
304                kv(
305                    "Token file",
306                    bool_badge(r.api.local_token, "present", "missing"),
307                ),
308                kv("Unit", status_badge(unit)),
309                blank(),
310                section("Actions"),
311                action("f", "Fetch API status"),
312            ]
313        },
314    )
315}
316
317/// Format MCP panel (PC + box; both stdio runtime and HTTP).
318#[must_use]
319pub fn panel_mcp(report: Option<&SurfaceProbeReport>) -> Vec<Line<'static>> {
320    report.map_or_else(
321        || {
322            vec![
323                Line::from(muted("No data yet")),
324                blank(),
325                section("Actions"),
326                action("f", "Fetch MCP status"),
327            ]
328        },
329        |r| {
330            let mut lines = Vec::new();
331            lines.extend(mcp_host_section("PC", &r.mcp_pc, true));
332            lines.push(blank());
333            lines.extend(mcp_host_section("Box", &r.mcp_box, false));
334            lines.push(blank());
335            lines.push(section("Actions"));
336            lines.push(action("f", "Fetch MCP status"));
337            lines
338        },
339    )
340}
341
342fn mcp_host_section(title: &str, p: &McpHostProbe, show_api: bool) -> Vec<Line<'static>> {
343    let docker = p.docker.as_deref().unwrap_or("missing");
344    let binary = p.binary.as_deref().unwrap_or("missing");
345    let unit = if p.unit.is_empty() {
346        "-"
347    } else {
348        p.unit.as_str()
349    };
350    let mut lines = vec![
351        section(title),
352        kv("Docker", status_badge(docker)),
353        kv("Binary", status_badge(binary)),
354        kv("HTTP", status_badge(&p.http_reach)),
355        Line::from(value(p.http_url.clone())),
356        kv("Unit", status_badge(unit)),
357    ];
358    if show_api {
359        lines.push(kv("API health", status_badge(&p.api_health)));
360    }
361    lines
362}
363
364/// Format Reboot panel.
365#[must_use]
366pub fn panel_reboot(host: &str) -> Vec<Line<'static>> {
367    vec![
368        kv("Target", value(host.to_owned())),
369        kv("Method", value("SSH + sudo on the box")),
370        blank(),
371        section("Actions"),
372        action("Enter", "Confirm reboot"),
373    ]
374}
375
376/// Overview facts from probe + optional doctor lines.
377#[must_use]
378pub fn panel_overview_remote(
379    host: &str,
380    report: Option<&SurfaceProbeReport>,
381    extra: &str,
382) -> Vec<Line<'static>> {
383    let mut lines = vec![section("Remote"), kv("Host", value(host.to_owned()))];
384    match report {
385        None => {
386            lines.push(kv("Surfaces", status_badge("probing…")));
387        }
388        Some(r) => {
389            lines.push(kv("Local CLI", value(r.local_version.clone())));
390            lines.push(kv("Box CLI", value(r.cli.status.as_label().to_owned())));
391            lines.push(blank());
392            lines.push(section("Surfaces"));
393            lines.push(kv("SSH", status_badge(&r.ssh.status)));
394            lines.push(kv("API", status_badge(&r.api.health)));
395            lines.push(kv("MCP PC HTTP", status_badge(&r.mcp_pc.http_reach)));
396            lines.push(kv("MCP box HTTP", status_badge(&r.mcp_box.http_reach)));
397        }
398    }
399    if !extra.is_empty() {
400        lines.push(blank());
401        lines.push(section("Doctor"));
402        for line in extra.lines() {
403            if line.is_empty() {
404                lines.push(blank());
405            } else {
406                lines.push(Line::from(muted(line.to_owned())));
407            }
408        }
409    }
410    lines.push(blank());
411    lines.push(section("Actions"));
412    lines.push(action("f", "Fetch overview surfaces"));
413    lines
414}
415
416/// Plain overview body (local / embedded) as styled lines.
417#[must_use]
418pub fn panel_lines_from_plain(text: &str) -> Vec<Line<'static>> {
419    text.lines()
420        .map(|l| Line::from(Span::raw(l.to_owned())))
421        .collect()
422}
423
424#[cfg(test)]
425mod tests {
426    use super::*;
427
428    fn lines_join(lines: &[Line<'static>]) -> String {
429        lines
430            .iter()
431            .map(std::string::ToString::to_string)
432            .collect::<Vec<_>>()
433            .join("\n")
434    }
435
436    #[test]
437    fn remote_tab_order_ends_with_logs() {
438        let t = Screen::titles(true);
439        assert_eq!(t.last().copied(), Some("8 Logs"));
440        assert!(t.iter().any(|x| x.contains("Reboot")));
441        assert_eq!(Screen::from_digit('8', true), Some(Screen::Logs));
442        assert_eq!(Screen::from_digit('7', false), Some(Screen::Logs));
443        assert_eq!(Screen::Setup.next(true), Screen::Overview);
444        assert_eq!(Screen::Logs.prev(true), Screen::Reboot);
445    }
446
447    #[test]
448    fn panels_pending_use_probing_not_question() {
449        let cli = lines_join(&panel_cli(true, None, "probing..."));
450        assert!(cli.contains("probing..."));
451        assert!(!cli.contains('?'));
452        assert!(!cli.contains("missing"));
453        let ssh = lines_join(&panel_ssh(true, "horto", None, "probing...", false));
454        assert!(ssh.contains("probing..."));
455        assert!(ssh.contains("Key install off"));
456        assert!(!ssh.contains("when started with"));
457    }
458
459    #[test]
460    fn ssh_key_install_hint_when_enabled() {
461        let ssh = lines_join(&panel_ssh(true, "horto", None, "ok", true));
462        assert!(ssh.contains("Install this PC key"));
463    }
464}