1use horto_os_ui_shared::{GIT_COMMIT, VERSION};
4use ratatui::{
5 layout::{Constraint, Direction, Layout, Rect},
6 style::{Color, Modifier, Style},
7 text::{Line, Span},
8 widgets::{Block, Borders, Clear, List, ListItem, Paragraph, Tabs, Wrap},
9 Frame,
10};
11
12#[cfg(test)]
13use crate::app::BoxCliView;
14use crate::app::{App, Modal};
15use crate::prompt::{draw_confirm, draw_rebooting, draw_secret_input, draw_text_input};
16use crate::tabs::Screen;
17
18#[cfg(test)]
20pub fn footer_cli_label(cli_local: &str, remote: bool, box_cli: &BoxCliView) -> String {
21 if remote {
22 format!("local={cli_local} box={}", box_cli.as_label())
23 } else {
24 format!("local={cli_local}")
25 }
26}
27
28fn footer_status_line(app: &App) -> Line<'static> {
30 let mode = if app.apply { "APPLY" } else { "PLAN" };
31 let mode_style = if app.apply {
32 Style::default()
33 .fg(Color::LightRed)
34 .add_modifier(Modifier::BOLD)
35 } else {
36 Style::default()
37 .fg(Color::Yellow)
38 .add_modifier(Modifier::BOLD)
39 };
40 let value_style = Style::default().fg(Color::Cyan);
41 let mut spans = vec![
42 Span::styled(format!("[{mode}]"), mode_style),
43 Span::raw(" pipeline="),
44 Span::styled(app.kind.as_str().to_owned(), value_style),
45 Span::raw(" · local="),
46 Span::styled(app.cli_local.clone(), value_style),
47 ];
48 if app.is_remote() {
49 spans.push(Span::raw(" box="));
50 spans.push(Span::styled(app.box_cli.as_label().to_owned(), value_style));
51 }
52 if !app.message.is_empty() {
53 spans.push(Span::raw(" · "));
54 spans.push(Span::styled(
55 app.message.clone(),
56 Style::default()
57 .fg(Color::White)
58 .add_modifier(Modifier::BOLD),
59 ));
60 }
61 Line::from(spans)
62}
63
64fn footer_key(label: &str) -> Span<'static> {
65 Span::styled(
66 label.to_owned(),
67 Style::default()
68 .fg(Color::Magenta)
69 .add_modifier(Modifier::BOLD),
70 )
71}
72
73fn footer_muted(text: &str) -> Span<'static> {
74 Span::styled(text.to_owned(), Style::default().fg(Color::DarkGray))
75}
76
77fn panel_title(name: &str) -> Span<'static> {
79 Span::styled(
80 name.to_owned(),
81 Style::default()
82 .fg(Color::Yellow)
83 .add_modifier(Modifier::BOLD),
84 )
85}
86
87fn footer_hints_line(app: &App) -> Line<'static> {
88 if app.help_open {
89 return Line::from(vec![
90 footer_muted("Esc or "),
91 footer_key("?"),
92 footer_muted(" close help"),
93 ]);
94 }
95 if let Some(line) = footer_for_modal(app.modal.as_ref()) {
96 return line;
97 }
98 footer_for_screen(app.screen)
99}
100
101fn footer_for_modal(modal: Option<&Modal>) -> Option<Line<'static>> {
102 match modal {
103 Some(Modal::Confirm(_)) => Some(Line::from(vec![
104 footer_key("Enter"),
105 footer_muted("/"),
106 footer_key("y"),
107 footer_muted(" confirm · "),
108 footer_key("Esc"),
109 footer_muted("/"),
110 footer_key("n"),
111 footer_muted(" cancel · "),
112 footer_key("Ctrl+C"),
113 footer_muted(" quit"),
114 ])),
115 Some(Modal::TextHost(_)) => Some(Line::from(vec![
116 footer_muted("Type host · "),
117 footer_key("Enter"),
118 footer_muted(" submit · "),
119 footer_key("Esc"),
120 footer_muted(" cancel · "),
121 footer_key("Ctrl+C"),
122 footer_muted(" quit"),
123 ])),
124 Some(Modal::SudoPassword(_)) => Some(Line::from(vec![
125 footer_muted("Sudo password · "),
126 footer_key("Enter"),
127 footer_muted(" submit · "),
128 footer_key("Esc"),
129 footer_muted(" cancel · "),
130 footer_key("Ctrl+C"),
131 footer_muted(" quit"),
132 ])),
133 Some(Modal::Rebooting) => Some(Line::from(vec![
134 footer_muted("Rebooting… · "),
135 footer_key("Esc"),
136 footer_muted(" close · "),
137 footer_key("Ctrl+C"),
138 footer_muted(" quit"),
139 ])),
140 None => None,
141 }
142}
143
144fn footer_for_screen(screen: Screen) -> Line<'static> {
145 match screen {
146 Screen::Setup => footer_key_line(&[
147 ("j/k", "/"),
148 ("↑/↓", " select · "),
149 ("←/→", " tabs · "),
150 ("Enter", " run · "),
151 ("a", " all · "),
152 ("b", " backup · "),
153 ("p", " pipeline · "),
154 ("Tab", " plan/apply · "),
155 ("r", " refresh · "),
156 ("?", " help · "),
157 ("q", " quit"),
158 ]),
159 Screen::Logs => footer_key_line(&[
160 ("c", " clear · "),
161 ("←/→", " tabs · "),
162 ("Tab", " plan/apply · "),
163 ("r", " refresh · "),
164 ("B", " disk · "),
165 ("?", " help · "),
166 ("q", " quit"),
167 ]),
168 Screen::Ssh => footer_key_line(&[
169 ("Enter", " edit host · "),
170 ("f", " fetch · "),
171 ("i", " key · "),
172 ("←/→", " tabs · "),
173 ("Tab", " plan/apply · "),
174 ("r", " refresh all · "),
175 ("?", " help · "),
176 ("q", " quit"),
177 ]),
178 Screen::Cli => footer_key_line(&[
179 ("Enter", " sync CLI · "),
180 ("f", " fetch · "),
181 ("←/→", " tabs · "),
182 ("Tab", " plan/apply · "),
183 ("r", " refresh all · "),
184 ("?", " help · "),
185 ("q", " quit"),
186 ]),
187 Screen::Overview | Screen::Api | Screen::Mcp => footer_key_line(&[
188 ("f", " fetch · "),
189 ("←/→", " tabs · "),
190 ("Tab", " plan/apply · "),
191 ("r", " refresh all · "),
192 ("?", " help · "),
193 ("q", " quit"),
194 ]),
195 Screen::Reboot => footer_key_line(&[
196 ("Enter", " reboot · "),
197 ("←/→", " tabs · "),
198 ("Tab", " plan/apply · "),
199 ("r", " refresh · "),
200 ("?", " help · "),
201 ("q", " quit"),
202 ]),
203 }
204}
205
206fn footer_key_line(parts: &[(&str, &str)]) -> Line<'static> {
207 let mut spans = Vec::with_capacity(parts.len() * 2);
208 for &(key, tail) in parts {
209 spans.push(footer_key(key));
210 spans.push(footer_muted(tail));
211 }
212 Line::from(spans)
213}
214
215fn tab_title_line(title: &str) -> Line<'static> {
216 let mut chars = title.chars();
217 let Some(digit) = chars.next() else {
218 return Line::from(title.to_owned());
219 };
220 let rest: String = chars.collect();
221 Line::from(vec![
222 Span::styled(
223 digit.to_string(),
224 Style::default()
225 .fg(Color::Magenta)
226 .add_modifier(Modifier::BOLD),
227 ),
228 Span::raw(rest),
229 ])
230}
231
232pub fn ui(f: &mut Frame, app: &mut App) {
234 let chunks = Layout::default()
235 .direction(Direction::Vertical)
236 .constraints([
237 Constraint::Length(3),
238 Constraint::Min(5),
239 Constraint::Length(4),
240 ])
241 .split(f.area());
242
243 let remote = app.is_remote();
244 let titles = Screen::titles(remote)
245 .into_iter()
246 .map(tab_title_line)
247 .collect::<Vec<_>>();
248 let idx = app.screen.index(remote);
249 let tabs = Tabs::new(titles)
250 .select(idx)
251 .block(
252 Block::default()
253 .borders(Borders::ALL)
254 .title(format!("horto-tui/{VERSION}/{GIT_COMMIT}")),
255 )
256 .highlight_style(
257 Style::default()
258 .fg(Color::Yellow)
259 .add_modifier(Modifier::BOLD),
260 );
261 f.render_widget(tabs, chunks[0]);
262
263 match app.screen {
264 Screen::Setup => draw_setup(f, app, chunks[1]),
265 Screen::Logs => draw_logs(f, app, chunks[1]),
266 Screen::Overview
267 | Screen::Ssh
268 | Screen::Cli
269 | Screen::Api
270 | Screen::Mcp
271 | Screen::Reboot => draw_panel(f, app, chunks[1]),
272 }
273
274 draw_footer(f, app, chunks[2]);
275
276 if app.help_open {
277 draw_help(f);
278 }
279 match &app.modal {
280 Some(Modal::Confirm(kind)) => {
281 draw_confirm(f, kind.title(), &kind.body());
282 }
283 Some(Modal::TextHost(input)) => {
284 draw_text_input(f, input);
285 }
286 Some(Modal::SudoPassword(input)) => {
287 draw_secret_input(f, input);
288 }
289 Some(Modal::Rebooting) => {
290 draw_rebooting(f);
291 }
292 None => {}
293 }
294}
295
296fn draw_footer(f: &mut Frame, app: &App, area: Rect) {
298 f.render_widget(Clear, area);
299 let block = Block::default().borders(Borders::ALL).title("Status");
300 let inner = block.inner(area);
301 f.render_widget(block, area);
302 if inner.width == 0 || inner.height == 0 {
303 return;
304 }
305 let status = pad_footer_line(footer_status_line(app), inner.width);
306 f.render_widget(
307 Paragraph::new(status),
308 Rect {
309 x: inner.x,
310 y: inner.y,
311 width: inner.width,
312 height: 1,
313 },
314 );
315 if inner.height >= 2 {
316 let hints = pad_footer_line(footer_hints_line(app), inner.width);
317 f.render_widget(
318 Paragraph::new(hints),
319 Rect {
320 x: inner.x,
321 y: inner.y + 1,
322 width: inner.width,
323 height: 1,
324 },
325 );
326 }
327}
328
329fn pad_footer_line(line: Line<'static>, width: u16) -> Line<'static> {
330 let width = width as usize;
331 if width == 0 {
332 return Line::default();
333 }
334 let used = line.width();
335 if used > width {
336 let s = line.to_string();
337 let mut out = String::new();
338 for ch in s.chars() {
339 if out.chars().count() + 1 >= width {
340 break;
341 }
342 out.push(ch);
343 }
344 if s.chars().count() > width {
345 if out.chars().count() == width {
346 out.pop();
347 }
348 out.push('…');
349 }
350 return Line::from(out);
351 }
352 if used < width {
353 let mut line = line;
354 line.spans.push(Span::raw(" ".repeat(width - used)));
355 return line;
356 }
357 line
358}
359
360fn draw_help(f: &mut Frame) {
361 let area = centered_rect(70, 80, f.area());
362 f.render_widget(Clear, area);
363 let body = [
364 "Horto TUI help",
365 "",
366 "q / Esc / Ctrl+C Quit",
367 "? Toggle this help",
368 "Left / Right Previous / next tab",
369 "1-8 Jump to tab (remote: 7 Reboot, 8 Logs)",
370 "j k / Up / Down Move step selection (Setup)",
371 "p Toggle full / minimal pipeline",
372 "Tab Toggle plan / apply",
373 "Enter Setup: run step · SSH: edit Host · other surfaces: action",
374 "e / i SSH: edit Host / install key (--install-ssh-key)",
375 "a Run all pipeline steps",
376 "b / B Timestamped /etc backup / disk probe",
377 "r Refresh surfaces (SSH/CLI/API/MCP; background)",
378 "c Clear Logs (on Logs tab)",
379 "y / n Confirm / cancel (modals)",
380 "",
381 "Remote open paints first, then refreshes SSH/CLI/API/MCP in the background.",
382 "Press r to refresh without blocking. Confirm and Host edit use on-screen dialogs.",
383 "Mouse capture is off so you can select and copy text.",
384 "Press Esc or ? to close.",
385 ]
386 .join("\n");
387 let p = Paragraph::new(body).wrap(Wrap { trim: false }).block(
388 Block::default()
389 .borders(Borders::ALL)
390 .title("Help")
391 .border_style(Style::default().fg(Color::Cyan)),
392 );
393 f.render_widget(p, area);
394}
395
396fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect {
397 let popup = Layout::default()
398 .direction(Direction::Vertical)
399 .constraints([
400 Constraint::Percentage((100 - percent_y) / 2),
401 Constraint::Percentage(percent_y),
402 Constraint::Percentage((100 - percent_y) / 2),
403 ])
404 .split(area);
405 Layout::default()
406 .direction(Direction::Horizontal)
407 .constraints([
408 Constraint::Percentage((100 - percent_x) / 2),
409 Constraint::Percentage(percent_x),
410 Constraint::Percentage((100 - percent_x) / 2),
411 ])
412 .split(popup[1])[1]
413}
414
415fn draw_setup(f: &mut Frame, app: &mut App, area: Rect) {
416 let items: Vec<ListItem> = app
417 .status_lines
418 .iter()
419 .map(|l| ListItem::new(setup_step_line(l)))
420 .collect();
421 let list = List::new(items)
422 .block(
423 Block::default()
424 .borders(Borders::ALL)
425 .padding(ratatui::widgets::Padding::new(0, 0, 1, 0))
426 .title(Line::from(vec![
427 panel_title("Steps"),
428 Span::raw(" ("),
429 footer_key("Enter"),
430 Span::raw(" run · "),
431 footer_key("a"),
432 Span::raw(" = all · "),
433 footer_key("*"),
434 Span::raw(" = destructive)"),
435 ])),
436 )
437 .highlight_style(
438 Style::default()
439 .bg(Color::DarkGray)
440 .add_modifier(Modifier::BOLD),
441 )
442 .highlight_symbol(">> ");
443 f.render_stateful_widget(list, area, &mut app.step_state);
444}
445
446pub fn setup_step_line(raw: &str) -> Line<'static> {
448 let parts: Vec<&str> = raw.splitn(3, " | ").collect();
449 if parts.len() < 3 {
450 return Line::from(Span::raw(raw.to_owned()));
451 }
452 let id = parts[0];
453 let status = parts[1];
454 let title = parts[2];
455 let status_style = match status {
456 "done" => Style::default()
457 .fg(Color::Green)
458 .add_modifier(Modifier::BOLD),
459 "pending" => Style::default()
460 .fg(Color::White)
461 .add_modifier(Modifier::BOLD),
462 "probing" => Style::default()
463 .fg(Color::Blue)
464 .add_modifier(Modifier::BOLD),
465 "stale" => Style::default()
466 .fg(Color::Rgb(255, 165, 0))
467 .add_modifier(Modifier::BOLD),
468 "failed" | "blocked" => Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
469 _ => Style::default()
470 .fg(Color::Gray)
471 .add_modifier(Modifier::BOLD),
472 };
473 Line::from(vec![
474 Span::styled(
475 id.to_owned(),
476 Style::default()
477 .fg(Color::White)
478 .add_modifier(Modifier::BOLD),
479 ),
480 Span::raw(" | "),
481 Span::styled(status.to_owned(), status_style),
482 Span::raw(" | "),
483 Span::styled(title.to_owned(), Style::default().fg(Color::Gray)),
484 ])
485}
486
487fn draw_logs(f: &mut Frame, app: &App, area: Rect) {
488 let n = app.logs.len();
489 let lines: Vec<Line<'static>> = if app.logs.is_empty() {
490 vec![Line::from(Span::styled(
491 "(empty · r refresh · c clear)".to_owned(),
492 Style::default().fg(Color::Cyan),
493 ))]
494 } else {
495 app.logs
496 .iter()
497 .rev()
498 .take(40)
499 .rev()
500 .map(|line| style_log_line(line))
501 .collect()
502 };
503 let p = Paragraph::new(lines).wrap(Wrap { trim: false }).block(
504 Block::default()
505 .borders(Borders::ALL)
506 .border_style(Style::default().fg(Color::DarkGray))
507 .padding(ratatui::widgets::Padding::new(0, 0, 1, 0))
508 .title(Line::from(vec![
509 panel_title("Logs"),
510 Span::styled(format!(" ({n})"), Style::default().fg(Color::Cyan)),
511 ])),
512 );
513 f.render_widget(p, area);
514}
515
516fn style_log_line(line: &str) -> Line<'static> {
517 let (ts, rest) = match line.split_once(' ') {
518 Some((t, r)) if t.len() == 8 && t.chars().filter(|c| *c == ':').count() == 2 => (t, r),
519 _ => {
520 return Line::from(Span::styled(
521 line.to_owned(),
522 Style::default().fg(Color::White),
523 ));
524 }
525 };
526 let lower = rest.to_ascii_lowercase();
527 let msg_style =
528 if lower.contains("error") || lower.contains("failed") || lower.contains("fail:") {
529 Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)
530 } else {
531 Style::default().fg(Color::White)
532 };
533 Line::from(vec![
534 Span::styled(ts.to_owned(), Style::default().fg(Color::Gray)),
535 Span::raw(" "),
536 Span::styled(rest.to_owned(), msg_style),
537 ])
538}
539
540fn draw_panel(f: &mut Frame, app: &App, area: Rect) {
541 let title = match app.screen {
542 Screen::Overview => "Overview",
543 Screen::Ssh => "SSH",
544 Screen::Cli => "CLI",
545 Screen::Api => "API",
546 Screen::Mcp => "MCP",
547 Screen::Reboot => "Reboot",
548 Screen::Setup | Screen::Logs => "",
549 };
550 let p = Paragraph::new(app.panel_lines.clone())
551 .wrap(Wrap { trim: false })
552 .block(
553 Block::default()
554 .borders(Borders::ALL)
555 .border_style(Style::default().fg(Color::DarkGray))
556 .padding(ratatui::widgets::Padding::new(0, 0, 1, 0))
557 .title(Line::from(panel_title(title))),
558 );
559 f.render_widget(p, area);
560}