1use crate::component_status::{is_component_installed, python_interpreters};
2use crate::config::InstallerConfig;
3use crate::hardware::{detect_hardware, run_preflight_checks};
4use crate::installer::{run_installation, InstallerEvent};
5use crate::installers::components::llama_cpp::{LlamaCppConfig, LlamaCppInstaller};
6use crate::state::{
7 default_components, Category, Component, HardwareState, InstallStatus, PreflightResult, Stage,
8};
9use crate::telemetry::opt_in::{
10 OptInGate, TELEMETRY_DESCRIPTION, TELEMETRY_DISABLE_LABEL, TELEMETRY_ENABLE_LABEL,
11 TELEMETRY_PRIVACY_NOTE, TELEMETRY_STATUS_DISABLED, TELEMETRY_STATUS_ENABLED,
12};
13use crate::widgets::benchmarks_page::{
14 export_benchmark_report_html, load_benchmark_results, render_benchmark_page,
15};
16use chrono::Local;
17use crossterm::event::KeyModifiers;
18use ratatui::layout::{Constraint, Direction, Layout, Rect};
19use ratatui::style::{Color, Modifier, Style};
20use ratatui::text::{Line, Span, Text};
21use ratatui::widgets::{
22 Block, Borders, Cell, Clear, Gauge, List, ListItem, Paragraph, Row, Table, Wrap,
23};
24use ratatui::Frame;
25use std::collections::HashMap;
26use std::path::PathBuf;
27use std::sync::mpsc::{self, Receiver, Sender};
28use std::thread;
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum InputMode {
32 Line,
33 Raw,
34}
35
36impl InputMode {
37 fn label(self) -> &'static str {
38 match self {
39 InputMode::Line => "line",
40 InputMode::Raw => "raw",
41 }
42 }
43}
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46enum TaskStatus {
47 Pending,
48 Running,
49 Done,
50 Failed,
51 Skipped,
52}
53
54#[derive(Debug, Clone)]
55struct UiNotice {
56 message: String,
57 color: Color,
58 expires_at_tick: u64,
59}
60
61impl TaskStatus {
62 fn icon(self) -> &'static str {
63 match self {
64 TaskStatus::Pending => "○",
65 TaskStatus::Running => "…",
66 TaskStatus::Done => "✓",
67 TaskStatus::Failed => "✗",
68 TaskStatus::Skipped => "⊘",
69 }
70 }
71
72 fn label(self) -> &'static str {
73 match self {
74 TaskStatus::Pending => "pending",
75 TaskStatus::Running => "running",
76 TaskStatus::Done => "done",
77 TaskStatus::Failed => "failed",
78 TaskStatus::Skipped => "skipped",
79 }
80 }
81
82 fn color(self) -> Color {
83 match self {
84 TaskStatus::Pending => Color::Yellow,
85 TaskStatus::Running => Color::Cyan,
86 TaskStatus::Done => Color::Green,
87 TaskStatus::Failed => Color::Red,
88 TaskStatus::Skipped => Color::DarkGray,
89 }
90 }
91}
92
93#[derive(Debug)]
94pub struct App {
95 pub stage: Stage,
96 pub components: Vec<Component>,
97 pub selected_category: usize,
98 pub selected_component: usize,
99 pub logs: Vec<String>,
100 pub errors: Vec<String>,
101 pub hardware: HardwareState,
102 pub preflight: PreflightResult,
103 pub preflight_selection: usize,
104 pub tick_count: u64,
105 pub install_status: InstallStatus,
106 pub config: InstallerConfig,
107 pub config_selection: usize,
108 pub confirm_selection: usize,
109 pub config_dirty: bool,
110 pub recovery_selection: usize,
111 pub sudo_password: Option<String>,
112 pub entering_password: bool,
113 pub should_exit: bool,
114 pub password_input: String,
115 pub verification_reports: HashMap<String, Vec<String>>,
116 pub install_input_buffer: String,
117 pub install_input_mode: InputMode,
118 pub last_line_transient: bool,
119 pub summary_scroll: u16,
120 pub benchmark_tab_index: usize,
121 benchmark_notice: Option<UiNotice>,
122 telemetry_gate: Option<OptInGate>,
123 telemetry_prompt_pending: bool,
124 telemetry_prompt_active: bool,
125 install_log_popup: bool,
126 install_log_scroll: usize,
127 install_log_file_path: Option<String>,
128 install_input_sender: Option<Sender<String>>,
129 install_receiver: Option<Receiver<InstallerEvent>>,
130 hardware_receiver: Option<Receiver<anyhow::Result<HardwareState>>>,
131}
132
133impl App {
134 pub fn new(scripts_dir: String) -> Self {
135 #[cfg(unix)]
136 let entering_password = unsafe { libc::geteuid() != 0 };
137 #[cfg(not(unix))]
138 let entering_password = false;
139 let mut config = InstallerConfig::load_or_default(&scripts_dir).unwrap_or_else(|_| {
140 InstallerConfig::default_with_paths(
142 &scripts_dir,
143 "/tmp/mlstack/logs".into(),
144 PathBuf::from("/tmp/mlstack/config.json"),
145 )
146 });
147 config.scripts_dir = scripts_dir;
148 Self {
149 stage: Stage::Welcome,
150 components: default_components(),
151 selected_category: 0,
152 selected_component: 0,
153 logs: Vec::new(),
154 errors: Vec::new(),
155 hardware: HardwareState::default(),
156 preflight: PreflightResult::default(),
157 preflight_selection: 0,
158 tick_count: 0,
159 install_status: InstallStatus::default(),
160 config,
161 config_selection: 0,
162 confirm_selection: 0,
163 config_dirty: false,
164 recovery_selection: 0,
165 sudo_password: None,
166 entering_password,
167 should_exit: false,
168 password_input: String::new(),
169 verification_reports: HashMap::new(),
170 install_input_buffer: String::new(),
171 install_input_mode: InputMode::Line,
172 last_line_transient: false,
173 summary_scroll: 0,
174 benchmark_tab_index: 0,
175 benchmark_notice: None,
176 telemetry_gate: OptInGate::new().ok(),
177 telemetry_prompt_pending: false,
178 telemetry_prompt_active: false,
179 install_log_popup: false,
180 install_log_scroll: 0,
181 install_log_file_path: None,
182 install_input_sender: None,
183 install_receiver: None,
184 hardware_receiver: None,
185 }
186 }
187
188 pub fn on_tick(&mut self) {
189 self.tick_count = self.tick_count.wrapping_add(1);
190 if self
191 .benchmark_notice
192 .as_ref()
193 .is_some_and(|notice| self.tick_count > notice.expires_at_tick)
194 {
195 self.benchmark_notice = None;
196 }
197 self.poll_hardware();
198 self.poll_installer();
199 }
200
201 pub fn handle_key(&mut self, key: crossterm::event::KeyEvent) {
202 use crossterm::event::KeyCode;
203 match self.stage {
204 Stage::Welcome => match key.code {
205 KeyCode::Enter => {
206 if self.entering_password {
207 if !self.password_input.is_empty() {
208 self.sudo_password = Some(self.password_input.clone());
209 self.entering_password = false;
210 } else {
211 self.errors.push("Password required for sudo".into());
212 }
213 return;
214 }
215 self.stage = Stage::HardwareDetect;
216 self.start_hardware_detection();
217 }
218 KeyCode::Char('q') => {
219 self.stage = Stage::Recovery;
220 self.errors.push("User quit".into());
221 }
222 KeyCode::Char(c) if self.entering_password => {
223 if c == '\n' || c == '\r' {
224 return;
225 }
226 self.password_input.push(c);
227 }
228 KeyCode::Char(_) => {}
229 KeyCode::Backspace if self.entering_password => {
230 self.password_input.pop();
231 }
232 KeyCode::Backspace => {}
233 KeyCode::Tab if self.entering_password => {
234 self.sudo_password = Some(self.password_input.clone());
235 self.entering_password = false;
236 }
237 _ => {}
238 },
239 Stage::HardwareDetect => match key.code {
240 KeyCode::Enter => {
241 self.preflight = run_preflight_checks(
242 &self.hardware.system,
243 &self.hardware.gpu,
244 self.sudo_password.as_deref(),
245 );
246 self.preflight_selection = 0;
247 self.stage = Stage::Preflight;
248 }
249 KeyCode::Esc => self.stage = Stage::Welcome,
250 KeyCode::Char('q') => self.stage = Stage::Recovery,
251 _ => {}
252 },
253 Stage::Preflight => match key.code {
254 KeyCode::Up => self.move_preflight_selection(-1),
255 KeyCode::Down => self.move_preflight_selection(1),
256 KeyCode::Enter => {
257 if self.preflight.can_continue {
258 self.refresh_component_statuses();
259 self.stage = Stage::ComponentSelect;
260 } else {
261 self.errors
262 .push("Preflight checks failed; resolve critical issues".into());
263 self.stage = Stage::Recovery;
264 }
265 }
266 KeyCode::Esc => self.stage = Stage::HardwareDetect,
267 KeyCode::Char('q') => self.stage = Stage::Recovery,
268 _ => {}
269 },
270 Stage::ComponentSelect => match key.code {
271 KeyCode::Up => self.move_selection(-1),
272 KeyCode::Down => self.move_selection(1),
273 KeyCode::Left => self.change_category(-1),
274 KeyCode::Right => self.change_category(1),
275 KeyCode::Char(' ') => self.toggle_component(),
276 KeyCode::Char('a') => self.toggle_all(),
277 KeyCode::Enter => self.stage = Stage::Configuration,
278 KeyCode::Esc => self.stage = Stage::Preflight,
279 KeyCode::Char('q') => self.stage = Stage::Recovery,
280 _ => {}
281 },
282 Stage::Configuration => match key.code {
283 KeyCode::Up => self.move_config_selection(-1),
284 KeyCode::Down => self.move_config_selection(1),
285 KeyCode::Enter => self.activate_config_selection(),
286 KeyCode::Char('s') => self.save_config(),
287 KeyCode::Char('n') => {
288 self.confirm_selection = 0;
289 self.stage = Stage::Confirm;
290 }
291 KeyCode::Esc => self.stage = Stage::ComponentSelect,
292 KeyCode::Char('q') => self.stage = Stage::Recovery,
293 _ => {}
294 },
295 Stage::Confirm => match key.code {
296 KeyCode::Up => self.move_confirm_selection(-1),
297 KeyCode::Down => self.move_confirm_selection(1),
298 KeyCode::Left => self.adjust_confirm_selection(-1),
299 KeyCode::Right => self.adjust_confirm_selection(1),
300 KeyCode::Enter => self.activate_confirm_selection(),
301 KeyCode::Esc => self.stage = Stage::Configuration,
302 KeyCode::Char('q') => self.stage = Stage::Recovery,
303 _ => {}
304 },
305 Stage::Installing => match key.code {
306 KeyCode::Char('l') | KeyCode::Char('L') => {
307 self.install_log_popup = !self.install_log_popup;
308 }
309 KeyCode::Esc if self.install_log_popup => {
310 self.install_log_popup = false;
311 }
312 KeyCode::Up if self.install_log_popup => {
313 self.install_log_scroll = self.install_log_scroll.saturating_sub(1);
314 }
315 KeyCode::Down if self.install_log_popup => {
316 self.install_log_scroll = self.install_log_scroll.saturating_add(1);
317 }
318 KeyCode::PageUp if self.install_log_popup => {
319 self.install_log_scroll = self.install_log_scroll.saturating_sub(20);
320 }
321 KeyCode::PageDown if self.install_log_popup => {
322 self.install_log_scroll = self.install_log_scroll.saturating_add(20);
323 }
324 KeyCode::Home if self.install_log_popup => {
325 self.install_log_scroll = 0;
326 }
327 KeyCode::Char('q') => {
328 self.errors.push("Installation cancelled by user".into());
329 self.stage = Stage::Recovery;
330 }
331 KeyCode::Char('r') if key.modifiers.contains(KeyModifiers::CONTROL) => {
332 self.install_input_mode = match self.install_input_mode {
333 InputMode::Line => InputMode::Raw,
334 InputMode::Raw => InputMode::Line,
335 };
336 }
337 KeyCode::Backspace if !self.install_log_popup => {
338 self.install_input_buffer.pop();
339 }
340 KeyCode::Backspace => {}
341 KeyCode::Enter if !self.install_log_popup => {
342 if self.telemetry_prompt_active {
343 if let Some(gate) = self.telemetry_gate.as_ref() {
344 if gate.is_enabled() {
345 let gpu = self.hardware.gpu.clone();
346 let report = crate::installers::common::BuildReport::from_hardware(
347 &gpu,
348 std::env::consts::OS.to_string(),
349 std::env::var("ID").unwrap_or_else(|_| "unknown".to_string()),
350 std::time::Duration::from_secs(
351 self.install_status.progress as u64,
352 ),
353 "unknown",
354 self.config.install_path.clone(),
355 vec![],
356 self.config.install_path.clone(),
357 "unknown",
358 false,
359 );
360 crate::installers::common::submit_build_report(report);
361 }
362 }
363 self.telemetry_prompt_pending = false;
364 self.telemetry_prompt_active = false;
365 self.stage = Stage::Benchmarks;
366 return;
367 }
368 self.flush_install_input();
369 }
370 KeyCode::Enter => {}
371 KeyCode::Char(c)
372 if !self.install_log_popup
373 && !key.modifiers.contains(KeyModifiers::CONTROL) =>
374 {
375 if self.telemetry_prompt_active {
376 match c {
377 'y' | 'Y' => {
378 if let Some(gate) = self.telemetry_gate.as_ref() {
379 if gate.is_enabled() {
380 let gpu = self.hardware.gpu.clone();
381 let report =
382 crate::installers::common::BuildReport::from_hardware(
383 &gpu,
384 std::env::consts::OS.to_string(),
385 std::env::var("ID")
386 .unwrap_or_else(|_| "unknown".to_string()),
387 std::time::Duration::from_secs(
388 self.install_status.progress as u64,
389 ),
390 "unknown",
391 self.config.install_path.clone(),
392 vec![],
393 self.config.install_path.clone(),
394 "unknown",
395 false,
396 );
397 crate::installers::common::submit_build_report(report);
398 }
399 }
400 self.telemetry_prompt_pending = false;
401 self.telemetry_prompt_active = false;
402 self.stage = Stage::Benchmarks;
403 }
404 'n' | 'N' => {
405 self.telemetry_prompt_pending = false;
406 self.telemetry_prompt_active = false;
407 self.stage = Stage::Benchmarks;
408 }
409 _ => {
410 self.install_input_buffer.push(c);
411 if self.install_input_mode == InputMode::Raw {
412 self.send_install_input(c.to_string());
413 }
414 }
415 }
416 return;
417 }
418 self.install_input_buffer.push(c);
419 if self.install_input_mode == InputMode::Raw {
420 self.send_install_input(c.to_string());
421 }
422 }
423 _ => {}
424 },
425 Stage::Complete => match key.code {
426 KeyCode::Esc | KeyCode::Char('q') => self.stage = Stage::Recovery,
427 KeyCode::Char('b') | KeyCode::Char('B') => {
428 self.stage = Stage::Benchmarks;
429 self.benchmark_tab_index = 0;
430 }
431 KeyCode::Up => {
432 self.summary_scroll = self.summary_scroll.saturating_sub(1);
433 }
434 KeyCode::Down => {
435 self.summary_scroll = self.summary_scroll.saturating_add(1);
436 }
437 KeyCode::PageUp => {
438 self.summary_scroll = self.summary_scroll.saturating_sub(10);
439 }
440 KeyCode::PageDown => {
441 self.summary_scroll = self.summary_scroll.saturating_add(10);
442 }
443 _ => {}
444 },
445 Stage::Benchmarks => match key.code {
446 KeyCode::Left if self.benchmark_tab_index > 0 => {
447 self.benchmark_tab_index -= 1;
448 }
449 KeyCode::Left => {}
450 KeyCode::Right if self.benchmark_tab_index < 9 => {
451 self.benchmark_tab_index += 1;
452 }
453 KeyCode::Right => {}
454 KeyCode::Char('e') | KeyCode::Char('E') => self.export_benchmark_report(),
455 KeyCode::Esc | KeyCode::Char('q') => self.stage = Stage::Complete,
456 _ => {}
457 },
458 Stage::Recovery => match key.code {
459 KeyCode::Up => self.move_recovery_selection(-1),
460 KeyCode::Down => self.move_recovery_selection(1),
461 KeyCode::Enter => self.activate_recovery_selection(),
462 KeyCode::Char('q') => self.should_exit = true,
463 _ => {}
464 },
465 }
466 }
467
468 pub fn draw(&self, frame: &mut Frame) {
469 let chunks = Layout::default()
470 .direction(Direction::Vertical)
471 .constraints(
472 [
473 Constraint::Length(5),
474 Constraint::Min(5),
475 Constraint::Length(3),
476 ]
477 .as_ref(),
478 )
479 .split(frame.area());
480
481 let spinner = self.spinner();
482 let title_lines = vec![
483 Line::from(vec![
484 Span::styled("✦", Style::default().fg(Color::Yellow)),
485 Span::raw(" "),
486 Span::styled(
487 "Rusty-Stack",
488 Style::default()
489 .fg(Color::Cyan)
490 .add_modifier(Modifier::BOLD),
491 ),
492 Span::raw(" "),
493 Span::styled(spinner, Style::default().fg(Color::Magenta)),
494 ]),
495 Line::from(vec![
496 Span::styled(
497 "AMD ML Stack Installer",
498 Style::default()
499 .fg(Color::Blue)
500 .add_modifier(Modifier::BOLD),
501 ),
502 Span::raw(" • "),
503 Span::styled("Ratatui Edition", Style::default().fg(Color::Gray)),
504 ]),
505 ];
506 let title = Paragraph::new(Text::from(title_lines))
507 .block(Block::default().borders(Borders::ALL).title("Installer"));
508 frame.render_widget(title, chunks[0]);
509
510 match self.stage {
511 Stage::Welcome => self.draw_welcome(frame, chunks[1]),
512 Stage::HardwareDetect => self.draw_hardware(frame, chunks[1]),
513 Stage::Preflight => self.draw_preflight(frame, chunks[1]),
514 Stage::ComponentSelect => self.draw_component_select(frame, chunks[1]),
515 Stage::Configuration => self.draw_configuration(frame, chunks[1]),
516 Stage::Confirm => self.draw_confirm(frame, chunks[1]),
517 Stage::Installing => self.draw_installing(frame, chunks[1]),
518 Stage::Complete => self.draw_complete(frame, chunks[1]),
519 Stage::Benchmarks => self.draw_benchmarks(frame, chunks[1]),
520 Stage::Recovery => self.draw_recovery(frame, chunks[1]),
521 }
522 self.draw_notice(frame, chunks[1]);
523
524 let footer = Paragraph::new(format!(
525 "Stage: {:?} | Keys: {} | Logs: {} | {}",
526 self.stage,
527 self.stage_keymap(),
528 self.logs.len(),
529 Local::now().format("%H:%M:%S")
530 ))
531 .block(Block::default().borders(Borders::ALL));
532 frame.render_widget(footer, chunks[2]);
533 }
534
535 fn stage_keymap(&self) -> &'static str {
536 match self.stage {
537 Stage::Welcome => "Enter start • Q recovery",
538 Stage::HardwareDetect => "Enter preflight • Esc back • Q recovery",
539 Stage::Preflight => "↑/↓ select • Enter continue • Esc back • Q recovery",
540 Stage::ComponentSelect => {
541 "↑/↓ select • ←/→ category • Space toggle • A toggle all • Enter config • Esc back"
542 }
543 Stage::Configuration => "↑/↓ select • Enter toggle • S save • N next • Esc back",
544 Stage::Confirm => "↑/↓ select • ←/→ adjust • Enter choose • Esc back",
545 Stage::Installing => "L logs • Ctrl+R input mode • Enter send • Q recovery",
546 Stage::Complete => "Esc recovery • B benchmarks",
547 Stage::Benchmarks => "←/→ tabs • E export HTML • Esc/B back • Q quit",
548 Stage::Recovery => "↑/↓ select • Enter apply • Q quit",
549 }
550 }
551
552 fn spinner(&self) -> &'static str {
553 const FRAMES: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
554 let idx = (self.tick_count % FRAMES.len() as u64) as usize;
555 FRAMES[idx]
556 }
557
558 fn draw_welcome(&self, frame: &mut Frame, area: ratatui::layout::Rect) {
559 let mut lines = vec![
560 Line::from(vec![Span::styled(
561 "Welcome to Rusty-Stack",
562 Style::default()
563 .fg(Color::Cyan)
564 .add_modifier(Modifier::BOLD),
565 )]),
566 Line::from("High-performance AMD ML Stack installer"),
567 Line::from(""),
568 Line::from("• Press Enter to begin hardware detection"),
569 Line::from("• Press Q to quit"),
570 ];
571
572 if self.entering_password {
573 lines.push(Line::from(""));
574 lines.push(Line::from(Span::styled(
575 "Sudo password required to run installers",
576 Style::default().fg(Color::Yellow),
577 )));
578 lines.push(Line::from(
579 "Type password and press Enter (input is hidden).",
580 ));
581 let masked = "*".repeat(self.password_input.len());
582 lines.push(Line::from(format!("Password: {}", masked)));
583 }
584
585 let paragraph = Paragraph::new(Text::from(lines))
586 .block(Block::default().borders(Borders::ALL).title("Welcome"))
587 .wrap(Wrap { trim: true });
588 frame.render_widget(paragraph, area);
589 }
590
591 fn draw_hardware(&self, frame: &mut Frame, area: ratatui::layout::Rect) {
592 let status_color = if self.hardware.progress < 1.0 {
593 Color::Yellow
594 } else {
595 Color::Green
596 };
597
598 let chunks = Layout::default()
600 .direction(Direction::Vertical)
601 .constraints([
602 Constraint::Length(3),
603 Constraint::Min(0),
604 Constraint::Length(3),
605 ])
606 .split(area);
607
608 let header = Paragraph::new(Text::from(vec![Line::from(vec![
610 Span::styled(
611 "🌐 Hardware Detection",
612 Style::default()
613 .fg(Color::Cyan)
614 .add_modifier(Modifier::BOLD),
615 ),
616 Span::raw(" "),
617 Span::styled(self.spinner(), Style::default().fg(status_color)),
618 Span::raw(" "),
619 Span::styled(
620 self.hardware.status.clone(),
621 Style::default().fg(status_color),
622 ),
623 ])]))
624 .block(Block::default().borders(Borders::ALL))
625 .wrap(Wrap { trim: true });
626 frame.render_widget(header, chunks[0]);
627
628 let info_chunks = Layout::default()
630 .direction(Direction::Horizontal)
631 .constraints([Constraint::Percentage(40), Constraint::Percentage(60)])
632 .split(chunks[1]);
633
634 let system_lines = vec![
636 Line::from(""),
637 Line::from(vec![
638 Span::styled("🖥️ System", Style::default().fg(Color::Blue)),
639 Span::raw(" "),
640 Span::styled(
641 self.hardware.system.distribution.clone(),
642 Style::default().fg(Color::Cyan),
643 ),
644 ]),
645 Line::from(vec![
646 Span::styled("🧠 Kernel", Style::default().fg(Color::Blue)),
647 Span::raw(" "),
648 Span::styled(
649 self.hardware.system.kernel.clone(),
650 Style::default().fg(Color::Cyan),
651 ),
652 ]),
653 Line::from(vec![
654 Span::styled("💻 CPU", Style::default().fg(Color::Blue)),
655 Span::raw(" "),
656 Span::styled(
657 self.hardware.system.cpu_model.clone(),
658 Style::default().fg(Color::Cyan),
659 ),
660 ]),
661 Line::from(vec![
662 Span::styled("💾 Memory", Style::default().fg(Color::Blue)),
663 Span::raw(" "),
664 Span::styled(
665 format!("{:.1} GB", self.hardware.system.memory_gb),
666 Style::default().fg(Color::Cyan),
667 ),
668 ]),
669 Line::from(vec![
670 Span::styled("💾 Storage", Style::default().fg(Color::Blue)),
671 Span::raw(" "),
672 Span::styled(
673 format!(
674 "{:.1} GB (free {:.1} GB)",
675 self.hardware.system.storage_gb, self.hardware.system.storage_available_gb
676 ),
677 Style::default().fg(Color::Cyan),
678 ),
679 ]),
680 ];
681
682 let gpu_lines = vec![
684 Line::from(""),
685 Line::from(vec![
686 Span::styled("🖥️ GPU", Style::default().fg(Color::Green)),
687 Span::raw(" "),
688 Span::styled(
689 self.hardware.gpu.model.clone(),
690 Style::default().fg(Color::Cyan),
691 ),
692 ]),
693 Line::from(vec![
694 Span::styled("🏗️ Architecture", Style::default().fg(Color::Green)),
695 Span::raw(" "),
696 Span::styled(
697 self.hardware.gpu.architecture.clone(),
698 Style::default().fg(Color::Cyan),
699 ),
700 ]),
701 Line::from(vec![
702 Span::styled("🔧 ROCm", Style::default().fg(Color::Green)),
703 Span::raw(" "),
704 Span::styled(
705 if self.hardware.gpu.rocm_version.is_empty() {
706 "unknown".to_string()
707 } else {
708 self.hardware.gpu.rocm_version.clone()
709 },
710 Style::default().fg(Color::Cyan),
711 ),
712 ]),
713 Line::from(vec![
714 Span::styled("💰 GPU Count", Style::default().fg(Color::Green)),
715 Span::raw(" "),
716 Span::styled(
717 self.hardware.gpu.gpu_count.to_string(),
718 Style::default().fg(Color::Cyan),
719 ),
720 ]),
721 Line::from(vec![
722 Span::styled("💾 GPU Memory", Style::default().fg(Color::Green)),
723 Span::raw(" "),
724 Span::styled(
725 format!("{:.1} GB", self.hardware.gpu.memory_gb),
726 Style::default().fg(Color::Cyan),
727 ),
728 ]),
729 Line::from(vec![
730 Span::styled("🌡️ GPU Temp", Style::default().fg(Color::Green)),
731 Span::raw(" "),
732 Span::styled(
733 self.hardware
734 .gpu
735 .temperature_c
736 .map(|v| format!("{:.1} C", v))
737 .unwrap_or_else(|| "n/a".into()),
738 Style::default().fg(Color::Cyan),
739 ),
740 ]),
741 Line::from(vec![
742 Span::styled("⚡ GPU Power", Style::default().fg(Color::Green)),
743 Span::raw(" "),
744 Span::styled(
745 self.hardware
746 .gpu
747 .power_watts
748 .map(|v| format!("{:.1} W", v))
749 .unwrap_or_else(|| "n/a".into()),
750 Style::default().fg(Color::Cyan),
751 ),
752 ]),
753 ];
754
755 let system_panel = Paragraph::new(Text::from(system_lines))
756 .block(Block::default().borders(Borders::ALL).title("System"))
757 .wrap(Wrap { trim: true });
758 frame.render_widget(system_panel, info_chunks[0]);
759
760 let gpu_panel = Paragraph::new(Text::from(gpu_lines))
761 .block(Block::default().borders(Borders::ALL).title("GPU"))
762 .wrap(Wrap { trim: true });
763 frame.render_widget(gpu_panel, info_chunks[1]);
764
765 let footer_lines = vec![
767 Line::from(""),
768 Line::from(Span::styled(
769 "← Press Enter to run preflight checks",
770 Style::default().fg(Color::Yellow),
771 )),
772 Line::from(Span::styled(
773 "← Press Q to quit",
774 Style::default().fg(Color::Red),
775 )),
776 ];
777 let footer = Paragraph::new(Text::from(footer_lines))
778 .block(Block::default().borders(Borders::ALL))
779 .wrap(Wrap { trim: true });
780 frame.render_widget(footer, chunks[2]);
781 }
782
783 fn draw_preflight(&self, frame: &mut Frame, area: ratatui::layout::Rect) {
784 let chunks = Layout::default()
785 .direction(Direction::Horizontal)
786 .constraints([Constraint::Percentage(68), Constraint::Percentage(32)].as_ref())
787 .split(area);
788
789 let header = Row::new(vec![
790 Cell::from(Span::styled(
791 "Check",
792 Style::default().add_modifier(Modifier::BOLD),
793 )),
794 Cell::from(Span::styled(
795 "Status",
796 Style::default().add_modifier(Modifier::BOLD),
797 )),
798 Cell::from(Span::styled(
799 "Message",
800 Style::default().add_modifier(Modifier::BOLD),
801 )),
802 ])
803 .style(Style::default().fg(Color::Magenta));
804
805 let rows: Vec<Row> = self
806 .preflight
807 .checks
808 .iter()
809 .enumerate()
810 .map(|(idx, check)| {
811 let status_color = match check.status {
812 crate::state::PreflightStatus::Passed => Color::Green,
813 crate::state::PreflightStatus::Warning => Color::Yellow,
814 crate::state::PreflightStatus::Failed => Color::Red,
815 };
816 let mut row_style = Style::default();
817 if idx == self.preflight_selection {
818 row_style = row_style.bg(Color::DarkGray).add_modifier(Modifier::BOLD);
819 }
820 Row::new(vec![
821 Cell::from(check.name.clone()),
822 Cell::from(Span::styled(
823 check.status.label(),
824 Style::default()
825 .fg(status_color)
826 .add_modifier(Modifier::BOLD),
827 )),
828 Cell::from(check.message.clone()),
829 ])
830 .style(row_style)
831 })
832 .collect();
833
834 let table = Table::new(
835 rows,
836 [
837 Constraint::Percentage(38),
838 Constraint::Length(10),
839 Constraint::Percentage(52),
840 ],
841 )
842 .header(header)
843 .block(
844 Block::default()
845 .borders(Borders::ALL)
846 .title("Preflight Checks"),
847 )
848 .column_spacing(1);
849 frame.render_widget(table, chunks[0]);
850
851 let selected = self.preflight.checks.get(self.preflight_selection);
852 let summary_color = if self.preflight.can_continue {
853 Color::Green
854 } else {
855 Color::Red
856 };
857 let mut summary_lines = vec![
858 Line::from(Span::styled(
859 self.preflight.summary.clone(),
860 Style::default()
861 .fg(summary_color)
862 .add_modifier(Modifier::BOLD),
863 )),
864 Line::from(format!(
865 "Checks: {} | Score: {}",
866 self.preflight.checks.len(),
867 self.preflight.total_score
868 )),
869 Line::from(format!(
870 "Passed: {} Warnings: {} Failed: {}",
871 self.preflight.passed_count,
872 self.preflight.warning_count,
873 self.preflight.failed_count
874 )),
875 Line::from(format!(
876 "Can continue: {}",
877 if self.preflight.can_continue {
878 "yes"
879 } else {
880 "no"
881 }
882 )),
883 Line::from(""),
884 ];
885
886 if let Some(check) = selected {
887 summary_lines.push(Line::from(Span::styled(
888 format!("Selected: {}", check.name),
889 Style::default()
890 .fg(Color::Cyan)
891 .add_modifier(Modifier::BOLD),
892 )));
893 summary_lines.push(Line::from(format!("Message: {}", check.message)));
894 summary_lines.push(Line::from(format!("Details: {}", check.details)));
895 }
896
897 summary_lines.push(Line::from(""));
898 summary_lines.push(Line::from("↑/↓ select • Enter continue • Q recovery"));
899
900 let summary = Paragraph::new(Text::from(summary_lines))
901 .block(Block::default().borders(Borders::ALL).title("Summary"))
902 .wrap(Wrap { trim: true });
903 frame.render_widget(summary, chunks[1]);
904 }
905
906 fn draw_component_select(&self, frame: &mut Frame, area: ratatui::layout::Rect) {
907 let chunks = Layout::default()
908 .direction(Direction::Horizontal)
909 .constraints([
910 Constraint::Percentage(25),
911 Constraint::Percentage(45),
912 Constraint::Percentage(30),
913 ])
914 .split(area);
915
916 let categories = [
917 (Category::Environment, "🌍", "Environment"),
918 (Category::Foundation, "🔧", "Foundation"),
919 (Category::Core, "⚙️", "Core"),
920 (Category::Extension, "📦", "Extensions"),
921 (Category::UiUx, "🎨", "UI/UX"),
922 (Category::Verification, "✅", "Verification"),
923 (Category::Performance, "📊", "Performance"),
924 ];
925 let category_items: Vec<ListItem> = categories
926 .iter()
927 .enumerate()
928 .map(|(idx, (_, icon, label))| {
929 let style = if idx == self.selected_category {
930 Style::default()
931 .fg(Color::Yellow)
932 .bg(Color::DarkGray)
933 .add_modifier(Modifier::BOLD)
934 } else {
935 Style::default()
936 };
937 ListItem::new(Line::from(Span::styled(
938 format!("{} {}", icon, label),
939 style,
940 )))
941 })
942 .collect();
943
944 let category_list = List::new(category_items)
945 .block(Block::default().borders(Borders::ALL).title("Categories"));
946 frame.render_widget(category_list, chunks[0]);
947
948 let current_category = categories[self.selected_category].0;
949 let filtered: Vec<(usize, &Component)> = self
950 .components
951 .iter()
952 .enumerate()
953 .filter(|(_, c)| c.category == current_category)
954 .collect();
955
956 let component_items: Vec<ListItem> = filtered
957 .iter()
958 .enumerate()
959 .map(|(idx, (_, comp))| {
960 let selected = idx == self.selected_component;
961
962 let icon = match comp.category {
964 Category::Environment => "🌍",
965 Category::Foundation => "🔧",
966 Category::Core => "⚙️",
967 Category::UiUx => "🎨",
968 Category::Extension => "📦",
969 Category::Verification => "✅",
970 Category::Performance => "📊",
971 };
972
973 let indicator = if comp.selected { "☑" } else { "☐" };
974 let status_indicator = if comp.installed { "✓" } else { "○" };
975
976 let line = format!(
977 "{} {} {} {} [{}]",
978 indicator, icon, comp.name, status_indicator, comp.estimate
979 );
980
981 let style = if selected {
982 Style::default()
983 .fg(Color::Cyan)
984 .bg(Color::DarkGray)
985 .add_modifier(Modifier::BOLD)
986 } else if comp.installed {
987 Style::default().fg(Color::Green)
988 } else {
989 Style::default()
990 };
991 ListItem::new(Line::from(Span::styled(line, style)))
992 })
993 .collect();
994
995 let component_list = List::new(component_items)
996 .block(Block::default().borders(Borders::ALL).title("Components"));
997 frame.render_widget(component_list, chunks[1]);
998
999 let mut detail_lines = vec![Line::from(Span::styled(
1000 "Component Details",
1001 Style::default()
1002 .fg(Color::Cyan)
1003 .add_modifier(Modifier::BOLD),
1004 ))];
1005 if let Some((_, comp)) = filtered.get(self.selected_component) {
1006 detail_lines.push(Line::from(""));
1007 detail_lines.push(Line::from(vec![
1008 Span::styled("Name: ", Style::default().fg(Color::Gray)),
1009 Span::styled(comp.name.clone(), Style::default().fg(Color::White)),
1010 ]));
1011 detail_lines.push(Line::from(vec![
1012 Span::styled("Required: ", Style::default().fg(Color::Gray)),
1013 Span::styled(on_off(comp.required), Style::default().fg(Color::Yellow)),
1014 ]));
1015 detail_lines.push(Line::from(vec![
1016 Span::styled("Selected: ", Style::default().fg(Color::Gray)),
1017 Span::styled(on_off(comp.selected), Style::default().fg(Color::Cyan)),
1018 ]));
1019
1020 let (status_label, status_color, status_icon) = match comp.category {
1022 Category::Environment => {
1023 if comp.installed {
1024 ("Configured", Color::Green, "✓")
1025 } else {
1026 ("Not configured", Color::Yellow, "○")
1027 }
1028 }
1029 Category::Core => {
1030 if comp.installed {
1032 ("Installed", Color::Green, "✓")
1033 } else {
1034 ("Not installed", Color::Yellow, "○")
1035 }
1036 }
1037 Category::UiUx => {
1038 if comp.installed {
1039 ("Installed", Color::Green, "✓")
1040 } else {
1041 ("Not installed", Color::Yellow, "○")
1042 }
1043 }
1044 Category::Verification => {
1045 if comp.installed {
1046 ("Verified", Color::Green, "✓")
1047 } else {
1048 ("Unverified", Color::Yellow, "○")
1049 }
1050 }
1051 Category::Performance => {
1052 if comp.installed {
1053 ("Benchmarked", Color::Green, "✓")
1054 } else if comp.progress > 0.0 {
1055 ("Failed", Color::Red, "✗")
1057 } else {
1058 ("Pending benchmark", Color::Yellow, "○")
1059 }
1060 }
1061 _ => {
1062 if comp.installed {
1063 ("Installed", Color::Green, "✓")
1064 } else {
1065 ("Not installed", Color::Yellow, "○")
1066 }
1067 }
1068 };
1069
1070 let status_line = Line::from(vec![
1071 Span::styled("Status: ", Style::default().fg(Color::Gray)),
1072 Span::styled(
1073 format!("{} {}", status_icon, status_label),
1074 Style::default()
1075 .fg(status_color)
1076 .add_modifier(Modifier::BOLD),
1077 ),
1078 ]);
1079 detail_lines.push(status_line);
1080 detail_lines.push(Line::from(vec![
1081 Span::styled("Estimate: ", Style::default().fg(Color::Gray)),
1082 Span::styled(comp.estimate.clone(), Style::default().fg(Color::Magenta)),
1083 ]));
1084 detail_lines.push(Line::from(""));
1085 detail_lines.push(Line::from(comp.description.clone()));
1086
1087 if comp.category == Category::Environment {
1089 detail_lines.push(Line::from(""));
1090 detail_lines.push(Line::from(Span::styled(
1091 "Environment Configuration",
1092 Style::default()
1093 .fg(Color::Cyan)
1094 .add_modifier(Modifier::BOLD),
1095 )));
1096 detail_lines.push(Line::from("Sets up persistent ROCm environment variables"));
1097 detail_lines.push(Line::from("for Python 3.12 across sessions."));
1098 } else if comp.category == Category::Verification {
1099 detail_lines.push(Line::from(""));
1100 detail_lines.push(Line::from(Span::styled(
1101 "Verification Summary",
1102 Style::default()
1103 .fg(Color::Cyan)
1104 .add_modifier(Modifier::BOLD),
1105 )));
1106 if let Some(report) = self.verification_reports.get(&comp.id) {
1107 for line in report {
1108 detail_lines.push(Line::from(line.clone()));
1109 }
1110 } else {
1111 detail_lines.push(Line::from("No verification report yet."));
1112 }
1113 } else if comp.category == Category::Performance {
1114 detail_lines.push(Line::from(""));
1115 detail_lines.push(Line::from(Span::styled(
1116 "Performance Benchmarks",
1117 Style::default()
1118 .fg(Color::Cyan)
1119 .add_modifier(Modifier::BOLD),
1120 )));
1121 detail_lines.push(Line::from("Run performance tests and benchmarks"));
1122 detail_lines.push(Line::from("to measure GPU throughput and efficiency."));
1123 }
1124 }
1125 detail_lines.push(Line::from(""));
1126 let selected_count = self.components.iter().filter(|c| c.selected).count();
1127 detail_lines.push(Line::from(format!(
1128 "Selected: {} of {}",
1129 selected_count,
1130 self.components.len()
1131 )));
1132 detail_lines.push(Line::from(""));
1133 detail_lines.push(Line::from(
1134 "Controls: ↑/↓ select • ←/→ category • Space toggle • Enter config • Q recovery",
1135 ));
1136
1137 let detail_panel = Paragraph::new(Text::from(detail_lines))
1138 .block(Block::default().borders(Borders::ALL).title("Details"))
1139 .wrap(Wrap { trim: true });
1140 frame.render_widget(detail_panel, chunks[2]);
1141 }
1142
1143 fn draw_configuration(&self, frame: &mut Frame, area: ratatui::layout::Rect) {
1144 let items = self.config_items();
1145 let list_items: Vec<ListItem> = items
1146 .iter()
1147 .enumerate()
1148 .map(|(idx, item)| {
1149 let style = if idx == self.config_selection {
1150 Style::default()
1151 .fg(Color::Yellow)
1152 .add_modifier(Modifier::BOLD)
1153 } else {
1154 Style::default()
1155 };
1156 ListItem::new(Line::from(Span::styled(item.clone(), style)))
1157 })
1158 .collect();
1159
1160 let hint = "Enter: toggle/cycle • s: save • n: next • esc: back";
1161 let mut lines = vec![Line::from("Configuration"), Line::from("")];
1162 lines.push(Line::from(format!(
1163 "Scripts Dir: {}",
1164 self.config.scripts_dir
1165 )));
1166 lines.push(Line::from(format!("Log Dir: {}", self.config.log_dir)));
1167 lines.push(Line::from(format!(
1168 "ROCm Install Path: {}",
1169 self.config.install_path
1170 )));
1171 lines.push(Line::from(""));
1172 let dirty_style = if self.config_dirty {
1173 Style::default().fg(Color::Yellow)
1174 } else {
1175 Style::default().fg(Color::Green)
1176 };
1177 lines.push(Line::from(Span::styled(
1178 format!("Dirty: {}", if self.config_dirty { "yes" } else { "no" }),
1179 dirty_style,
1180 )));
1181 lines.push(Line::from(""));
1182 lines.push(Line::from(Span::styled(
1183 "Selected setting:",
1184 Style::default()
1185 .fg(Color::Cyan)
1186 .add_modifier(Modifier::BOLD),
1187 )));
1188 for line in self.config_help_lines() {
1189 lines.push(line);
1190 }
1191 lines.push(Line::from(""));
1192 lines.push(Line::from(Span::styled(
1193 hint,
1194 Style::default().fg(Color::Gray),
1195 )));
1196
1197 let chunks = Layout::default()
1198 .direction(Direction::Horizontal)
1199 .constraints([Constraint::Percentage(60), Constraint::Percentage(40)].as_ref())
1200 .split(area);
1201
1202 let list =
1203 List::new(list_items).block(Block::default().borders(Borders::ALL).title("Settings"));
1204 frame.render_widget(list, chunks[0]);
1205
1206 let detail = Paragraph::new(Text::from(lines))
1207 .block(Block::default().borders(Borders::ALL).title("Details"))
1208 .wrap(Wrap { trim: true });
1209 frame.render_widget(detail, chunks[1]);
1210 }
1211
1212 fn draw_confirm(&self, frame: &mut Frame, area: ratatui::layout::Rect) {
1213 let selected = self.selected_components();
1214 let execution_mode = if self.config.batch_mode {
1215 "non-interactive"
1216 } else {
1217 "interactive"
1218 };
1219 let options = [
1220 format!("Execution Mode: {}", execution_mode),
1221 format!("Install Method: {}", self.config.install_method),
1222 "Back to Configuration".into(),
1223 "Start Installation".into(),
1224 ];
1225 let mut lines = vec![
1226 Line::from(Span::styled(
1227 "Review your selection",
1228 Style::default()
1229 .fg(Color::Cyan)
1230 .add_modifier(Modifier::BOLD),
1231 )),
1232 Line::from(""),
1233 Line::from("Selected Components:"),
1234 Line::from(""),
1235 ];
1236 for comp in selected {
1237 lines.push(Line::from(format!("• {} ({})", comp.name, comp.estimate)));
1238 }
1239 lines.push(Line::from(""));
1240 lines.push(Line::from(format!(
1241 "ROCm Install Path: {}",
1242 self.config.install_path
1243 )));
1244 lines.push(Line::from(format!("Execution Mode: {}", execution_mode)));
1245 lines.push(Line::from(format!(
1246 "Install Method: {}",
1247 self.config.install_method
1248 )));
1249 lines.push(Line::from(""));
1250 lines.push(Line::from(Span::styled(
1251 "Pre-install options:",
1252 Style::default()
1253 .fg(Color::Cyan)
1254 .add_modifier(Modifier::BOLD),
1255 )));
1256 lines.push(Line::from(""));
1257 for (idx, option) in options.iter().enumerate() {
1258 let style = if idx == self.confirm_selection {
1259 Style::default()
1260 .fg(Color::Yellow)
1261 .add_modifier(Modifier::BOLD)
1262 } else {
1263 Style::default()
1264 };
1265 let prefix = if idx == self.confirm_selection {
1266 "▶"
1267 } else {
1268 " "
1269 };
1270 lines.push(Line::from(Span::styled(
1271 format!("{} {}", prefix, option),
1272 style,
1273 )));
1274 }
1275 lines.push(Line::from(""));
1276 lines.push(Line::from("Use Back to update config before install."));
1277 let paragraph = Paragraph::new(Text::from(lines))
1278 .block(Block::default().borders(Borders::ALL).title("Confirm"))
1279 .wrap(Wrap { trim: true });
1280 frame.render_widget(paragraph, area);
1281 }
1282
1283 fn draw_installing(&self, frame: &mut Frame, area: ratatui::layout::Rect) {
1284 if area.height < 10 || area.width < 30 {
1285 frame.render_widget(
1286 Paragraph::new("Terminal too small to display installation UI"),
1287 area,
1288 );
1289 return;
1290 }
1291
1292 let chunks = Layout::default()
1293 .direction(Direction::Vertical)
1294 .constraints(
1295 [
1296 Constraint::Length(3), Constraint::Length(2), Constraint::Length(6), Constraint::Min(5), Constraint::Min(10), ]
1302 .as_ref(),
1303 )
1304 .split(area);
1305
1306 let clean_msg = self.install_status.message.trim_start_matches(|c: char| {
1307 c == '['
1308 || c == ']'
1309 || c == '⠋'
1310 || c == '⠙'
1311 || c == '⠹'
1312 || c == '⠸'
1313 || c == '⠼'
1314 || c == '⠴'
1315 || c == '⠦'
1316 || c == '⠧'
1317 || c == '⠇'
1318 || c == '⠏'
1319 || c.is_whitespace()
1320 });
1321
1322 let percent = (self.install_status.progress * 100.0).round() as i32;
1323 let label = Span::styled(
1324 format!("{} {}% {}", self.spinner(), percent, clean_msg),
1325 Style::default()
1326 .fg(Color::Black)
1327 .add_modifier(Modifier::BOLD),
1328 );
1329
1330 let gauge = Gauge::default()
1331 .block(
1332 Block::default()
1333 .borders(Borders::ALL)
1334 .title("Overall Progress"),
1335 )
1336 .gauge_style(Style::default().fg(Color::Cyan))
1337 .ratio(self.install_status.progress as f64)
1338 .label(label);
1339 frame.render_widget(gauge, chunks[0]);
1340
1341 let install_path_text = Paragraph::new(Line::from(format!(
1342 "Install Path: {}",
1343 self.config.install_path
1344 )))
1345 .block(Block::default().borders(Borders::ALL).title("Target"));
1346 frame.render_widget(install_path_text, chunks[1]);
1347
1348 let install_details = vec![
1349 Line::from(format!(
1350 "Target binary: {}/bin/llama-cli",
1351 self.config.install_path
1352 )),
1353 Line::from(format!("Install prefix: {}", self.config.install_path)),
1354 ];
1355 let install_details_panel = Paragraph::new(Text::from(install_details)).block(
1356 Block::default()
1357 .borders(Borders::ALL)
1358 .title("Install Location"),
1359 );
1360 frame.render_widget(install_details_panel, chunks[2]);
1361
1362 let stage_title = self.install_stage_title();
1363 let stage_title_area = if self.telemetry_prompt_pending {
1364 let prompt = Layout::default()
1365 .direction(Direction::Vertical)
1366 .constraints([Constraint::Length(3), Constraint::Min(2)].as_ref())
1367 .split(chunks[3]);
1368 let stage_panel = Paragraph::new(stage_title)
1369 .block(Block::default().borders(Borders::ALL).title("llama.cpp"));
1370 frame.render_widget(stage_panel, prompt[1]);
1371 Some(prompt[0])
1372 } else {
1373 let stage_panel = Paragraph::new(stage_title)
1374 .block(Block::default().borders(Borders::ALL).title("llama.cpp"));
1375 frame.render_widget(stage_panel, chunks[3]);
1376 None
1377 };
1378
1379 if let Some(prompt_area) = stage_title_area {
1380 let prompt_text = Paragraph::new(Text::from(vec![
1381 Line::from(Span::styled(
1382 "Share anonymized build data to help validate future releases? [Y/n]",
1383 Style::default()
1384 .fg(Color::Yellow)
1385 .add_modifier(Modifier::BOLD),
1386 )),
1387 Line::from("Y/y/Enter submits build data • n/N skips silently"),
1388 Line::from("This prompt is part of the install flow."),
1389 ]))
1390 .block(
1391 Block::default()
1392 .borders(Borders::ALL)
1393 .title("Telemetry Opt-In"),
1394 )
1395 .wrap(Wrap { trim: true });
1396 frame.render_widget(prompt_text, prompt_area);
1397 }
1398
1399 let body = Layout::default()
1400 .direction(Direction::Horizontal)
1401 .constraints([Constraint::Percentage(70), Constraint::Percentage(30)].as_ref())
1402 .split(chunks[4]);
1403
1404 let log_height = body[0].height.saturating_sub(2) as usize;
1405 let log_items: Vec<ListItem> = if self.logs.is_empty() {
1406 vec![ListItem::new(Line::from("Waiting for installer output..."))]
1407 } else {
1408 let start = self.logs.len().saturating_sub(log_height);
1409 self.logs[start..]
1410 .iter()
1411 .map(|line| ListItem::new(Line::from(line.clone())))
1412 .collect()
1413 };
1414 let log_list = List::new(log_items).block(
1415 Block::default()
1416 .borders(Borders::ALL)
1417 .title("Installation Log"),
1418 );
1419 frame.render_widget(log_list, body[0]);
1420
1421 let selected: Vec<&Component> = self.components.iter().filter(|c| c.selected).collect();
1422 let installed_count = selected.iter().filter(|c| c.installed).count();
1423 let failed_count = selected
1424 .iter()
1425 .filter(|c| !c.installed && c.progress > 0.0)
1426 .count();
1427 let pending_count = selected.iter().filter(|c| c.progress == 0.0).count();
1428
1429 let base_lines = vec![
1430 Line::from(Span::styled(
1431 "Installation Status",
1432 Style::default()
1433 .fg(Color::Cyan)
1434 .add_modifier(Modifier::BOLD),
1435 )),
1436 Line::from(""),
1437 Line::from(format!("Installed: {}", installed_count)),
1438 Line::from(format!("Failed: {}", failed_count)),
1439 Line::from(format!("Pending: {}", pending_count)),
1440 Line::from(""),
1441 Line::from(format!("Current: {}", self.install_status.message)),
1442 ];
1443
1444 let tail_lines = vec![
1445 Line::from(""),
1446 Line::from(format!(
1447 "Input mode: {} (Ctrl+R to toggle)",
1448 self.install_input_mode.label()
1449 )),
1450 Line::from(format!("Input buffer: {}", self.install_input_buffer)),
1451 Line::from("Enter sends line; raw mode sends keystrokes"),
1452 Line::from(Span::styled(
1453 "Press L to open full logs",
1454 Style::default().fg(Color::Cyan),
1455 )),
1456 Line::from(""),
1457 Line::from(Span::styled(
1458 "Press Q for recovery",
1459 Style::default().fg(Color::Yellow),
1460 )),
1461 ];
1462
1463 let available_lines = body[1].height.saturating_sub(2) as usize;
1464 let checklist_budget =
1465 available_lines.saturating_sub(base_lines.len() + tail_lines.len() + 1);
1466 let checklist_lines = self.checklist_lines(checklist_budget);
1467
1468 let mut status_lines = base_lines;
1469 if !checklist_lines.is_empty() {
1470 status_lines.push(Line::from(""));
1471 status_lines.extend(checklist_lines);
1472 }
1473 status_lines.extend(tail_lines);
1474
1475 let status_panel = Paragraph::new(Text::from(status_lines))
1476 .block(Block::default().borders(Borders::ALL).title("Status"))
1477 .wrap(Wrap { trim: true });
1478 frame.render_widget(status_panel, body[1]);
1479
1480 if self.install_log_popup {
1481 self.draw_install_log_popup(frame, area);
1482 }
1483 }
1484
1485 fn draw_install_log_popup(&self, frame: &mut Frame, area: ratatui::layout::Rect) {
1486 let width = area.width.saturating_sub(4).max(40);
1487 let height = area.height.saturating_sub(2).max(10);
1488 let popup = Rect {
1489 x: area.x + area.width.saturating_sub(width) / 2,
1490 y: area.y + area.height.saturating_sub(height) / 2,
1491 width,
1492 height,
1493 };
1494
1495 frame.render_widget(Clear, popup);
1496 let file_hint = self
1497 .latest_install_log_path()
1498 .unwrap_or_else(|| "(not detected yet)".to_string());
1499
1500 let mut lines = Vec::new();
1501 lines.push(Line::from(Span::styled(
1502 "Full Installation Logs",
1503 Style::default()
1504 .fg(Color::Cyan)
1505 .add_modifier(Modifier::BOLD),
1506 )));
1507 lines.push(Line::from(format!("Log file: {}", file_hint)));
1508 lines.push(Line::from(""));
1509
1510 let visible_lines = popup.height.saturating_sub(7) as usize;
1511 if self.logs.is_empty() {
1512 lines.push(Line::from("No logs captured yet."));
1513 } else {
1514 let max_scroll = self.logs.len().saturating_sub(visible_lines);
1515 let start = self.install_log_scroll.min(max_scroll);
1516 let end = (start + visible_lines).min(self.logs.len());
1517 for entry in &self.logs[start..end] {
1518 lines.push(Line::from(entry.clone()));
1519 }
1520 lines.push(Line::from(""));
1521 lines.push(Line::from(format!(
1522 "Showing lines {}-{} of {}",
1523 start.saturating_add(1),
1524 end,
1525 self.logs.len()
1526 )));
1527 }
1528 lines.push(Line::from("Up/Down/PageUp/PageDown scroll • Esc/L close"));
1529
1530 let widget = Paragraph::new(Text::from(lines))
1531 .block(
1532 Block::default()
1533 .borders(Borders::ALL)
1534 .title("Logs")
1535 .border_style(Style::default().fg(Color::Cyan)),
1536 )
1537 .wrap(Wrap { trim: false });
1538 frame.render_widget(widget, popup);
1539 }
1540
1541 fn draw_complete(&self, frame: &mut Frame, area: ratatui::layout::Rect) {
1542 frame.render_widget(Clear, area);
1543 let (installed, failed, skipped) = self.partition_components();
1544 let (benchmarks, tests) = self.count_log_keywords();
1545 let mut lines = vec![
1546 Line::from(Span::styled(
1547 "Installation Summary",
1548 Style::default()
1549 .fg(Color::Cyan)
1550 .add_modifier(Modifier::BOLD),
1551 )),
1552 Line::from(""),
1553 ];
1554
1555 lines.push(Line::from(Span::styled(
1556 "Installed:",
1557 Style::default()
1558 .fg(Color::Green)
1559 .add_modifier(Modifier::BOLD),
1560 )));
1561 if installed.is_empty() {
1562 lines.push(Line::from(" (none)"));
1563 } else {
1564 for comp in installed.iter() {
1565 let (status, color) = if comp.installed {
1566 ("installed", Color::Green)
1567 } else if comp.progress > 0.0 {
1568 ("failed", Color::Red)
1569 } else {
1570 ("skipped", Color::Yellow)
1571 };
1572 lines.push(Line::from(vec![
1573 Span::raw("✓ "),
1574 Span::raw(comp.name.clone()),
1575 Span::raw(" ("),
1576 Span::styled(status, Style::default().fg(color)),
1577 Span::raw(")"),
1578 ]));
1579 }
1580 }
1581
1582 lines.push(Line::from(""));
1583 lines.push(Line::from(Span::styled(
1584 "Failed:",
1585 Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
1586 )));
1587 if failed.is_empty() {
1588 lines.push(Line::from(" (none)"));
1589 } else {
1590 for comp in failed.iter() {
1591 lines.push(Line::from(vec![
1592 Span::raw("✗ "),
1593 Span::styled(comp.name.clone(), Style::default().fg(Color::Red)),
1594 ]));
1595 }
1596 }
1597
1598 lines.push(Line::from(""));
1599 lines.push(Line::from(Span::styled(
1600 "Skipped:",
1601 Style::default()
1602 .fg(Color::Yellow)
1603 .add_modifier(Modifier::BOLD),
1604 )));
1605 if skipped.is_empty() {
1606 lines.push(Line::from(" (none)"));
1607 } else {
1608 for comp in skipped.iter() {
1609 lines.push(Line::from(vec![
1610 Span::raw("• "),
1611 Span::styled(comp.name.clone(), Style::default().fg(Color::DarkGray)),
1612 ]));
1613 }
1614 }
1615
1616 let uiux_apps: Vec<&Component> = self
1618 .components
1619 .iter()
1620 .filter(|c| c.category == Category::UiUx && c.installed)
1621 .collect();
1622 if !uiux_apps.is_empty() {
1623 lines.push(Line::from(""));
1624 lines.push(Line::from(Span::styled(
1625 "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━",
1626 Style::default().fg(Color::Magenta),
1627 )));
1628 lines.push(Line::from(Span::styled(
1629 " UI/UX Applications",
1630 Style::default()
1631 .fg(Color::Magenta)
1632 .add_modifier(Modifier::BOLD),
1633 )));
1634 lines.push(Line::from(Span::styled(
1635 "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━",
1636 Style::default().fg(Color::Magenta),
1637 )));
1638
1639 for app in uiux_apps {
1640 lines.push(Line::from(""));
1641 if app.id == "vllm-studio" {
1642 lines.push(Line::from(vec![Span::styled(
1643 "🎨 vLLM Studio:",
1644 Style::default()
1645 .fg(Color::Cyan)
1646 .add_modifier(Modifier::BOLD),
1647 )]));
1648 lines.push(Line::from(vec![
1649 Span::raw(" Run: "),
1650 Span::styled("vllm-studio", Style::default().fg(Color::Green)),
1651 ]));
1652 lines.push(Line::from(vec![
1653 Span::raw(" Tips: "),
1654 Span::styled(
1655 "Start from $HOME/vllm-studio/controller",
1656 Style::default().fg(Color::Gray),
1657 ),
1658 ]));
1659 } else if app.id == "comfyui" {
1660 lines.push(Line::from(vec![Span::styled(
1661 "🎨 ComfyUI:",
1662 Style::default()
1663 .fg(Color::Cyan)
1664 .add_modifier(Modifier::BOLD),
1665 )]));
1666 lines.push(Line::from(vec![
1667 Span::raw(" Run: "),
1668 Span::styled("comfy", Style::default().fg(Color::Green)),
1669 ]));
1670 lines.push(Line::from(vec![
1671 Span::raw(" URL: "),
1672 Span::styled("http://localhost:8188", Style::default().fg(Color::Green)),
1673 ]));
1674 lines.push(Line::from(vec![
1675 Span::raw(" Tips: "),
1676 Span::styled(
1677 "Uses ROCm GPU acceleration",
1678 Style::default().fg(Color::Gray),
1679 ),
1680 ]));
1681 }
1682 }
1683
1684 lines.push(Line::from(Span::styled(
1685 "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━",
1686 Style::default().fg(Color::Magenta),
1687 )));
1688 }
1689
1690 let (env, verification) = self.partition_categories();
1691 lines.push(Line::from(""));
1692 lines.push(Line::from(Span::styled(
1693 "llama.cpp Turbo Quant",
1694 Style::default()
1695 .fg(Color::Magenta)
1696 .add_modifier(Modifier::BOLD),
1697 )));
1698 lines.push(Line::from("Experimental tier • o"));
1699 if let Some(summary) = self.llama_completion_summary() {
1700 for line in summary {
1701 lines.push(line);
1702 }
1703 }
1704 lines.push(Line::from(""));
1705 lines.push(Line::from(Span::styled(
1706 "Environment:",
1707 Style::default()
1708 .fg(Color::Blue)
1709 .add_modifier(Modifier::BOLD),
1710 )));
1711 if env.is_empty() {
1712 lines.push(Line::from(" (none)"));
1713 } else {
1714 for comp in env.iter() {
1715 let (status_text, color) = if comp.installed {
1716 ("configured", Color::Green)
1717 } else {
1718 ("pending", Color::Yellow)
1719 };
1720 lines.push(Line::from(vec![
1721 Span::raw("• "),
1722 Span::raw(comp.name.clone()),
1723 Span::raw(" ("),
1724 Span::styled(status_text, Style::default().fg(color)),
1725 Span::raw(")"),
1726 ]));
1727 }
1728 }
1729
1730 let env_details = self.env_summary();
1731 if !env_details.is_empty() {
1732 lines.push(Line::from(""));
1733 lines.push(Line::from(Span::styled(
1734 "Environment Details:",
1735 Style::default()
1736 .fg(Color::Blue)
1737 .add_modifier(Modifier::BOLD),
1738 )));
1739 for (key, value) in env_details {
1740 lines.push(Line::from(vec![
1741 Span::styled(format!(" {}: ", key), Style::default().fg(Color::Gray)),
1742 Span::styled(value, Style::default().fg(Color::Cyan)),
1743 ]));
1744 }
1745 }
1746
1747 lines.push(Line::from(""));
1748 lines.push(Line::from(Span::styled(
1749 "Verification:",
1750 Style::default()
1751 .fg(Color::Blue)
1752 .add_modifier(Modifier::BOLD),
1753 )));
1754 if verification.is_empty() {
1755 lines.push(Line::from(" (none)"));
1756 } else {
1757 for comp in verification.iter() {
1758 let (status, color) = match self.verification_reports.get(&comp.id) {
1759 Some(_) => {
1760 let s = self.verification_status(&comp.id);
1761 let c = match s {
1762 "verified" => Color::Green,
1763 "failed" => Color::Red,
1764 "missing" => Color::Yellow,
1765 _ => Color::Gray,
1766 };
1767 (s, c)
1768 }
1769 None if comp.installed => ("completed", Color::Green),
1770 None => ("pending", Color::Yellow),
1771 };
1772 lines.push(Line::from(vec![
1773 Span::raw("• "),
1774 Span::raw(comp.name.clone()),
1775 Span::raw(" ("),
1776 Span::styled(status, Style::default().fg(color)),
1777 Span::raw(")"),
1778 ]));
1779 }
1780 }
1781
1782 lines.push(Line::from(""));
1783 lines.push(Line::from(Span::styled(
1784 "Component Verification:",
1785 Style::default()
1786 .fg(Color::Blue)
1787 .add_modifier(Modifier::BOLD),
1788 )));
1789 let selected = self.selected_components();
1790 if selected.is_empty() {
1791 lines.push(Line::from(" (none)"));
1792 } else {
1793 for comp in selected {
1794 let status = self.verification_status(&comp.id);
1795 let color = match status {
1796 "verified" => Color::Green,
1797 "failed" => Color::Red,
1798 "missing" => Color::Yellow,
1799 _ => Color::Gray,
1800 };
1801 lines.push(Line::from(vec![
1802 Span::raw("• "),
1803 Span::raw(comp.name.clone()),
1804 Span::raw(" ("),
1805 Span::styled(status, Style::default().fg(color)),
1806 Span::raw(")"),
1807 ]));
1808 }
1809 }
1810
1811 lines.push(Line::from(""));
1812 lines.push(Line::from(Span::styled(
1813 "Verification Report:",
1814 Style::default()
1815 .fg(Color::Blue)
1816 .add_modifier(Modifier::BOLD),
1817 )));
1818 let report_lines = self.verification_report_lines();
1819 if report_lines.is_empty() {
1820 lines.push(Line::from(" (none)"));
1821 } else {
1822 lines.extend(report_lines);
1823 }
1824
1825 lines.push(Line::from(""));
1826 let benchmarks_line = Line::from(vec![
1827 Span::styled("Benchmarks logged: ", Style::default().fg(Color::Gray)),
1828 Span::styled(benchmarks.to_string(), Style::default().fg(Color::Cyan)),
1829 Span::styled(" | ", Style::default().fg(Color::DarkGray)),
1830 Span::styled("Tests logged: ", Style::default().fg(Color::Gray)),
1831 Span::styled(tests.to_string(), Style::default().fg(Color::Cyan)),
1832 ]);
1833 lines.push(benchmarks_line);
1834
1835 let total_lines = lines.len() as u16;
1836 let visible_height = area.height.saturating_sub(2);
1837 let max_scroll = total_lines.saturating_sub(visible_height);
1838
1839 let paragraph = Paragraph::new(Text::from(lines))
1840 .block(Block::default().borders(Borders::ALL).title(format!(
1841 "Complete (↑↓ to scroll, Esc to recovery) [{}/{}]",
1842 self.summary_scroll, max_scroll
1843 )))
1844 .wrap(Wrap { trim: true })
1845 .scroll((self.summary_scroll, 0));
1846 frame.render_widget(paragraph, area);
1847 }
1848
1849 fn llama_completion_summary(&self) -> Option<Vec<Line<'_>>> {
1850 let comp = self.components.iter().find(|c| c.id == "llama-cpp")?;
1851 if !comp.installed {
1852 return None;
1853 }
1854 Some(vec![
1855 Line::from(format!("Version: {}", self.llama_version_label())),
1856 Line::from(format!("Path: {}", self.config.install_path)),
1857 Line::from(format!("GPU Arch: {}", self.hardware.gpu.architecture)),
1858 Line::from(format!("Install Type: {}", self.llama_install_type_label())),
1859 ])
1860 }
1861
1862 fn llama_version_label(&self) -> String {
1863 self.verification_reports
1864 .get("llama-cpp")
1865 .and_then(|lines| lines.iter().find(|line| line.contains("version")))
1866 .cloned()
1867 .unwrap_or_else(|| "installed".into())
1868 }
1869
1870 fn llama_install_type_label(&self) -> &'static str {
1871 if self
1872 .logs
1873 .iter()
1874 .any(|line| line.contains("prebuilt") || line.contains("download"))
1875 {
1876 "pre-built"
1877 } else {
1878 "source"
1879 }
1880 }
1881
1882 fn draw_benchmarks(&self, frame: &mut Frame, area: ratatui::layout::Rect) {
1883 let results = load_benchmark_results();
1884 render_benchmark_page(frame, area, &results, self.benchmark_tab_index);
1885 }
1886
1887 fn export_benchmark_report(&mut self) {
1888 let results = load_benchmark_results();
1889 match export_benchmark_report_html(&results, None) {
1890 Ok(path) => {
1891 let msg = format!("Benchmark HTML report exported: {}", path.display());
1892 self.logs.push(msg.clone());
1893 self.benchmark_notice = Some(UiNotice {
1894 message: msg,
1895 color: Color::Green,
1896 expires_at_tick: self.tick_count.saturating_add(80),
1897 });
1898 }
1899 Err(err) => {
1900 let msg = format!("Failed to export benchmark HTML report: {}", err);
1901 self.errors.push(msg.clone());
1902 self.benchmark_notice = Some(UiNotice {
1903 message: msg,
1904 color: Color::Red,
1905 expires_at_tick: self.tick_count.saturating_add(120),
1906 });
1907 }
1908 }
1909 }
1910
1911 fn draw_notice(&self, frame: &mut Frame, area: Rect) {
1912 let Some(notice) = &self.benchmark_notice else {
1913 return;
1914 };
1915
1916 let width = area.width.saturating_sub(4).clamp(20, 110);
1917 let height = 5u16;
1918 let x = area.x + area.width.saturating_sub(width) / 2;
1919 let y = area.y + 1;
1920 let popup = Rect {
1921 x,
1922 y,
1923 width,
1924 height,
1925 };
1926
1927 frame.render_widget(Clear, popup);
1928 let text = vec![
1929 Line::from(Span::styled(
1930 "Benchmark Export",
1931 Style::default()
1932 .fg(notice.color)
1933 .add_modifier(Modifier::BOLD),
1934 )),
1935 Line::from(""),
1936 Line::from(notice.message.clone()),
1937 ];
1938 let notice_widget = Paragraph::new(Text::from(text))
1939 .wrap(Wrap { trim: true })
1940 .block(
1941 Block::default()
1942 .borders(Borders::ALL)
1943 .border_style(Style::default().fg(notice.color)),
1944 );
1945 frame.render_widget(notice_widget, popup);
1946 }
1947
1948 fn draw_recovery(&self, frame: &mut Frame, area: ratatui::layout::Rect) {
1949 let chunks = Layout::default()
1950 .direction(Direction::Horizontal)
1951 .constraints([Constraint::Percentage(60), Constraint::Percentage(40)].as_ref())
1952 .split(area);
1953
1954 let items = self.recovery_items();
1955 let list_items: Vec<ListItem> = items
1956 .iter()
1957 .enumerate()
1958 .map(|(idx, item)| {
1959 let style = if idx == self.recovery_selection {
1960 Style::default()
1961 .fg(Color::Yellow)
1962 .add_modifier(Modifier::BOLD)
1963 } else {
1964 Style::default()
1965 };
1966 ListItem::new(Line::from(Span::styled(item.clone(), style)))
1967 })
1968 .collect();
1969
1970 let list = List::new(list_items).block(
1971 Block::default()
1972 .borders(Borders::ALL)
1973 .title("Recovery Options"),
1974 );
1975 frame.render_widget(list, chunks[0]);
1976
1977 let mut lines = vec![Line::from("Recovery / Diagnostics"), Line::from("")];
1978 if !self.errors.is_empty() {
1979 lines.push(Line::from(Span::styled(
1980 "Errors:",
1981 Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
1982 )));
1983 for err in &self.errors {
1984 lines.push(Line::from(Span::styled(
1985 format!("• {}", err),
1986 Style::default().fg(Color::Red),
1987 )));
1988 }
1989 } else {
1990 lines.push(Line::from(Span::styled(
1991 "No errors captured.",
1992 Style::default().fg(Color::Green),
1993 )));
1994 }
1995 lines.push(Line::from(""));
1996 lines.push(Line::from("Use ↑/↓ then Enter to select."));
1997 lines.push(Line::from("Press Q to quit immediately."));
1998
1999 let paragraph = Paragraph::new(Text::from(lines))
2000 .block(Block::default().borders(Borders::ALL).title("Status"))
2001 .wrap(Wrap { trim: true });
2002 frame.render_widget(paragraph, chunks[1]);
2003 }
2004
2005 fn move_selection(&mut self, delta: i32) {
2006 let current_category = self.current_category();
2007 let filtered: Vec<&Component> = self
2008 .components
2009 .iter()
2010 .filter(|c| c.category == current_category)
2011 .collect();
2012 if filtered.is_empty() {
2013 self.selected_component = 0;
2014 return;
2015 }
2016 let len = filtered.len() as i32;
2017 let mut idx = self.selected_component as i32 + delta;
2018 if idx < 0 {
2019 idx = len - 1;
2020 }
2021 if idx >= len {
2022 idx = 0;
2023 }
2024 self.selected_component = idx as usize;
2025 }
2026
2027 fn change_category(&mut self, delta: i32) {
2028 let categories_len = 7i32; let mut idx = self.selected_category as i32 + delta;
2030 if idx < 0 {
2031 idx = categories_len - 1;
2032 }
2033 if idx >= categories_len {
2034 idx = 0;
2035 }
2036 self.selected_category = idx as usize;
2037 self.selected_component = 0;
2038 }
2039
2040 fn toggle_component(&mut self) {
2041 let current_category = self.current_category();
2042 let indices: Vec<usize> = self
2043 .components
2044 .iter()
2045 .enumerate()
2046 .filter(|(_, c)| c.category == current_category)
2047 .map(|(idx, _)| idx)
2048 .collect();
2049 if indices.is_empty() {
2050 return;
2051 }
2052 if self.selected_component >= indices.len() {
2053 self.selected_component = 0;
2054 }
2055 let index = indices[self.selected_component];
2056 if let Some(component) = self.components.get_mut(index) {
2057 component.selected = !component.selected;
2058 }
2059 }
2060
2061 fn toggle_all(&mut self) {
2062 let any_selected = self.components.iter().any(|c| c.selected);
2063 for comp in self.components.iter_mut() {
2064 comp.selected = !any_selected;
2065 }
2066 }
2067
2068 fn config_items(&self) -> Vec<String> {
2069 vec![
2070 format!("ROCm Install Path: {}", self.config.install_path),
2071 format!("Batch Mode: {}", on_off(self.config.batch_mode)),
2072 format!("Auto Confirm: {}", on_off(self.config.auto_confirm)),
2073 format!("Star ML Stack Repo: {}", on_off(self.config.star_repos)),
2074 format!(
2075 "Force Reinstall All: {}",
2076 on_off(self.config.force_reinstall)
2077 ),
2078 format!("Theme: {}", self.config.theme),
2079 format!("Performance Profile: {}", self.config.performance_profile),
2080 "Save Configuration".into(),
2081 ]
2082 }
2083
2084 fn config_help_lines(&self) -> Vec<Line<'_>> {
2085 match self.config_selection {
2086 0 => vec![
2087 Line::from("ROCm install path used by installers."),
2088 Line::from("Default: /opt/rocm (system-wide ROCm)."),
2089 ],
2090 1 => vec![
2091 Line::from("Batch mode runs scripts non-interactively."),
2092 Line::from("Defaults are chosen when prompts appear."),
2093 ],
2094 2 => vec![
2095 Line::from("Auto confirm answers yes to prompts"),
2096 Line::from("when supported by the script."),
2097 ],
2098 3 => vec![
2099 Line::from("Star the ML Stack repository on GitHub."),
2100 Line::from("https://github.com/scooter-lacroix/Stan-s-ML-Stack"),
2101 ],
2102 4 => vec![
2103 Line::from("FORCE REINSTALL ALL COMPONENTS."),
2104 Line::from("Forces purging and re-downloading of everything."),
2105 ],
2106 5 => vec![
2107 Line::from("Theme affects TUI color styling."),
2108 Line::from("Switches between dark/light palettes."),
2109 ],
2110 6 => vec![
2111 Line::from("Performance profile adjusts installer tuning."),
2112 Line::from("Balanced/performance/efficiency presets."),
2113 ],
2114 7 => vec![
2115 Line::from("Persist current settings to config.json."),
2116 Line::from("Dirty=yes means there are unsaved changes."),
2117 ],
2118 _ => vec![
2119 Line::from("Select a setting for details."),
2120 Line::from(TELEMETRY_PRIVACY_NOTE),
2121 ],
2122 }
2123 }
2124
2125 fn move_config_selection(&mut self, delta: i32) {
2126 let len = self.config_items().len() as i32;
2127 let mut idx = self.config_selection as i32 + delta;
2128 if idx < 0 {
2129 idx = len - 1;
2130 }
2131 if idx >= len {
2132 idx = 0;
2133 }
2134 self.config_selection = idx as usize;
2135 }
2136
2137 fn move_confirm_selection(&mut self, delta: i32) {
2138 let len = 4i32;
2139 let mut idx = self.confirm_selection as i32 + delta;
2140 if idx < 0 {
2141 idx = len - 1;
2142 }
2143 if idx >= len {
2144 idx = 0;
2145 }
2146 self.confirm_selection = idx as usize;
2147 }
2148
2149 fn adjust_confirm_selection(&mut self, delta: i32) {
2150 match self.confirm_selection {
2151 0 => {
2152 self.config.batch_mode = if delta < 0 {
2153 true
2154 } else if delta > 0 {
2155 false
2156 } else {
2157 !self.config.batch_mode
2158 };
2159 self.config_dirty = true;
2160 }
2161 1 => {
2162 self.cycle_install_method(delta);
2163 self.config_dirty = true;
2164 }
2165 _ => {}
2166 }
2167 }
2168
2169 fn activate_confirm_selection(&mut self) {
2170 match self.confirm_selection {
2171 0 => self.adjust_confirm_selection(0),
2172 1 => self.adjust_confirm_selection(1),
2173 2 => self.stage = Stage::Configuration,
2174 3 => self.start_installation(),
2175 _ => {}
2176 }
2177 }
2178
2179 fn cycle_install_method(&mut self, delta: i32) {
2180 let methods = ["auto", "global", "venv"];
2181 let current = methods
2182 .iter()
2183 .position(|m| *m == self.config.install_method)
2184 .unwrap_or(0);
2185 let next = (current as i32 + delta).rem_euclid(methods.len() as i32) as usize;
2186 self.config.install_method = methods[next].into();
2187 }
2188
2189 fn move_preflight_selection(&mut self, delta: i32) {
2190 let len = self.preflight.checks.len() as i32;
2191 if len == 0 {
2192 self.preflight_selection = 0;
2193 return;
2194 }
2195 let mut idx = self.preflight_selection as i32 + delta;
2196 if idx < 0 {
2197 idx = len - 1;
2198 }
2199 if idx >= len {
2200 idx = 0;
2201 }
2202 self.preflight_selection = idx as usize;
2203 }
2204
2205 fn activate_config_selection(&mut self) {
2206 match self.config_selection {
2207 1 => {
2208 self.config.batch_mode = !self.config.batch_mode;
2209 self.config_dirty = true;
2210 }
2211 2 => {
2212 self.config.auto_confirm = !self.config.auto_confirm;
2213 self.config_dirty = true;
2214 }
2215 3 => {
2216 self.config.star_repos = !self.config.star_repos;
2217 self.config_dirty = true;
2218 }
2219 4 => {
2220 self.config.force_reinstall = !self.config.force_reinstall;
2221 self.config_dirty = true;
2222 }
2223 5 => {
2224 self.config.theme = if self.config.theme == "dark" {
2225 "light".into()
2226 } else {
2227 "dark".into()
2228 };
2229 self.config_dirty = true;
2230 }
2231 6 => {
2232 let profiles = ["balanced", "performance", "efficiency"];
2233 let current = profiles
2234 .iter()
2235 .position(|p| *p == self.config.performance_profile)
2236 .unwrap_or(0);
2237 let next = (current + 1) % profiles.len();
2238 self.config.performance_profile = profiles[next].into();
2239 self.config_dirty = true;
2240 }
2241 7 => self.save_config(),
2242 _ => {}
2243 }
2244 }
2245
2246 fn save_config(&mut self) {
2247 let existing = std::fs::read_to_string(&self.config.config_path)
2248 .ok()
2249 .and_then(|raw| serde_json::from_str(&raw).ok());
2250 match self.config.save(existing) {
2251 Ok(_) => {
2252 self.push_log("Configuration saved".into());
2253 self.config_dirty = false;
2254 }
2255 Err(err) => {
2256 self.errors.push(format!("Config save failed: {err}"));
2257 }
2258 }
2259 }
2260
2261 fn recovery_items(&self) -> Vec<String> {
2262 vec![
2263 "Re-run hardware detection".into(),
2264 "Re-run preflight checks".into(),
2265 "Return to component selection".into(),
2266 "Exit installer".into(),
2267 ]
2268 }
2269
2270 fn move_recovery_selection(&mut self, delta: i32) {
2271 let len = self.recovery_items().len() as i32;
2272 let mut idx = self.recovery_selection as i32 + delta;
2273 if idx < 0 {
2274 idx = len - 1;
2275 }
2276 if idx >= len {
2277 idx = 0;
2278 }
2279 self.recovery_selection = idx as usize;
2280 }
2281
2282 fn activate_recovery_selection(&mut self) {
2283 match self.recovery_selection {
2284 0 => {
2285 self.stage = Stage::HardwareDetect;
2286 self.start_hardware_detection();
2287 }
2288 1 => {
2289 self.preflight = run_preflight_checks(
2290 &self.hardware.system,
2291 &self.hardware.gpu,
2292 self.sudo_password.as_deref(),
2293 );
2294 self.preflight_selection = 0;
2295 self.stage = Stage::Preflight;
2296 }
2297 2 => {
2298 self.refresh_component_statuses();
2299 self.stage = Stage::ComponentSelect;
2300 }
2301 3 => self.should_exit = true,
2302 _ => {}
2303 }
2304 }
2305
2306 fn start_installation(&mut self) {
2307 let selected = self.selected_components();
2308 if selected.is_empty() {
2309 self.errors
2310 .push("No components selected. Go back and select at least one component.".into());
2311 self.stage = Stage::Recovery;
2312 return;
2313 }
2314 let any_needs_sudo = selected.iter().any(|c| c.needs_sudo);
2316 #[cfg(unix)]
2317 let is_non_root = unsafe { libc::geteuid() != 0 };
2318 #[cfg(not(unix))]
2319 let is_non_root = false;
2320 if is_non_root && any_needs_sudo && self.sudo_password.is_none() {
2321 self.errors
2322 .push("Sudo password required for selected components. Press Esc to go back to Welcome screen and enter your sudo password.".into());
2323 self.stage = Stage::Recovery;
2324 return;
2325 }
2326
2327 if self.config.force_reinstall {
2329 std::env::set_var("FORCE", "true");
2330 std::env::set_var("PYTORCH_REINSTALL", "true");
2331 std::env::set_var("MLSTACK_FORCE_REINSTALL", "true");
2332 } else {
2333 std::env::remove_var("FORCE");
2334 std::env::remove_var("PYTORCH_REINSTALL");
2335 std::env::remove_var("MLSTACK_FORCE_REINSTALL");
2336 }
2337
2338 self.stage = Stage::Installing;
2339 self.install_status.progress = 0.0;
2340 self.install_status.message = "Starting installation".into();
2341 let (tx, rx) = mpsc::channel();
2342 let (input_tx, input_rx) = mpsc::channel();
2343 let sudo_password = self.sudo_password.clone();
2344 let config = self.config.clone();
2345 self.install_input_sender = Some(input_tx);
2346 self.install_input_buffer.clear();
2347 self.install_log_popup = false;
2348 self.install_log_scroll = 0;
2349 self.install_log_file_path = None;
2350 thread::spawn(move || {
2351 run_installation(selected, config, sudo_password, tx, input_rx);
2352 });
2353 self.install_receiver = Some(rx);
2354 }
2355
2356 fn poll_installer(&mut self) {
2357 let events: Vec<_> = if let Some(receiver) = &self.install_receiver {
2358 std::iter::from_fn(|| receiver.try_recv().ok()).collect()
2359 } else {
2360 vec![]
2361 };
2362
2363 for event in events {
2364 match event {
2365 InstallerEvent::Log(line, is_transient) => self.push_log_ext(line, is_transient),
2366 InstallerEvent::Progress {
2367 component_id,
2368 progress,
2369 message,
2370 } => {
2371 if component_id != "__overall__" {
2372 self.update_component_progress(&component_id, progress);
2373 }
2374 self.install_status.message = message.clone();
2375 self.recalculate_overall_progress();
2376 self.push_log_ext(message, true);
2378 }
2379 InstallerEvent::ComponentStart { component_id, name } => {
2380 if let Some(comp) = self.components.iter_mut().find(|c| c.id == component_id) {
2381 self.install_status.message = if comp.category == Category::Performance {
2382 format!("Running {}", name)
2383 } else {
2384 format!("Installing {}", name)
2385 };
2386 comp.progress = 0.05;
2387 comp.installed = false;
2388 } else {
2389 self.install_status.message = format!("Installing {}", name);
2390 }
2391 self.recalculate_overall_progress();
2392 self.push_log(self.install_status.message.clone());
2393 }
2394 InstallerEvent::ComponentComplete {
2395 component_id,
2396 success,
2397 message,
2398 } => {
2399 if let Some(comp) = self.components.iter_mut().find(|c| c.id == component_id) {
2400 comp.installed = success;
2401 comp.progress = 1.0;
2402 }
2403 self.recalculate_overall_progress();
2404 if !success {
2405 self.errors.push(message.clone());
2406 }
2407 self.push_log(message);
2408 }
2409 InstallerEvent::VerificationReport {
2410 component_id,
2411 lines,
2412 } => {
2413 let cleaned = lines
2414 .into_iter()
2415 .map(|line| Self::sanitize_line(&line))
2416 .collect();
2417 self.verification_reports.insert(component_id, cleaned);
2418 }
2419 InstallerEvent::Finished { success } => {
2420 self.install_status.completed = success;
2421 self.install_input_sender = None;
2422 self.install_input_buffer.clear();
2423 self.recalculate_overall_progress();
2424 self.install_status.progress = 1.0;
2425 if success {
2426 self.telemetry_prompt_pending = true;
2430 self.telemetry_prompt_active = true;
2431 } else {
2432 self.stage = Stage::Complete;
2433 }
2434 }
2435 }
2436 }
2437 }
2438
2439 fn update_component_progress(&mut self, component_id: &str, progress: f32) {
2440 if let Some(component) = self.components.iter_mut().find(|c| c.id == component_id) {
2441 let next = progress.clamp(0.0, 1.0);
2442 if next > component.progress {
2443 component.progress = next;
2444 }
2445 }
2446 }
2447
2448 fn recalculate_overall_progress(&mut self) {
2449 let selected: Vec<&Component> = self.components.iter().filter(|c| c.selected).collect();
2450 if selected.is_empty() {
2451 self.install_status.progress = 0.0;
2452 return;
2453 }
2454 let total: f32 = selected
2455 .iter()
2456 .map(|comp| comp.progress.clamp(0.0, 1.0))
2457 .sum();
2458 self.install_status.progress = (total / selected.len() as f32).clamp(0.0, 1.0);
2459 }
2460
2461 fn install_task_status(&self, component: &Component) -> TaskStatus {
2462 if component.progress == 0.0 {
2463 TaskStatus::Pending
2464 } else if component.progress < 1.0 {
2465 TaskStatus::Running
2466 } else if component.installed {
2467 TaskStatus::Done
2468 } else {
2469 TaskStatus::Failed
2470 }
2471 }
2472
2473 fn verification_task_status(&self, component_id: &str) -> TaskStatus {
2474 match self.verification_status(component_id) {
2475 "verified" => TaskStatus::Done,
2476 "failed" => TaskStatus::Failed,
2477 "missing" => TaskStatus::Failed,
2478 "unknown" => TaskStatus::Pending,
2479 "none" => TaskStatus::Pending,
2480 _ => TaskStatus::Pending,
2481 }
2482 }
2483
2484 fn verify_task_status(&self, component: &Component) -> TaskStatus {
2485 if component.category == Category::Verification {
2486 return self.verification_task_status(&component.id);
2487 }
2488 if self.verification_reports.contains_key(&component.id) {
2489 return self.verification_task_status(&component.id);
2490 }
2491 if component.progress >= 1.0 && !component.installed {
2492 return TaskStatus::Skipped;
2493 }
2494 if component.progress >= 0.8 && component.progress < 1.0 {
2495 return TaskStatus::Running;
2496 }
2497 TaskStatus::Pending
2498 }
2499
2500 fn checklist_lines(&self, max_lines: usize) -> Vec<Line<'_>> {
2501 if max_lines == 0 {
2502 return Vec::new();
2503 }
2504 let selected: Vec<&Component> = self.components.iter().filter(|c| c.selected).collect();
2505 let mut lines = Vec::new();
2506 let mut remaining = max_lines;
2507
2508 lines.push(Line::from(Span::styled(
2509 "Checklist",
2510 Style::default()
2511 .fg(Color::Cyan)
2512 .add_modifier(Modifier::BOLD),
2513 )));
2514 remaining = remaining.saturating_sub(1);
2515
2516 let mut truncated = false;
2517 for comp in selected {
2518 if remaining == 0 {
2519 truncated = true;
2520 break;
2521 }
2522 lines.push(Line::from(Span::styled(
2523 format!("• {}", comp.name),
2524 Style::default()
2525 .fg(Color::White)
2526 .add_modifier(Modifier::BOLD),
2527 )));
2528 remaining = remaining.saturating_sub(1);
2529
2530 if remaining == 0 {
2531 truncated = true;
2532 break;
2533 }
2534
2535 if comp.category != Category::Verification {
2536 let task_label = if comp.category == Category::Performance {
2537 "Benchmark"
2538 } else {
2539 "Install"
2540 };
2541 lines.push(self.task_line(task_label, self.install_task_status(comp)));
2542 remaining = remaining.saturating_sub(1);
2543 }
2544
2545 if remaining == 0 {
2546 truncated = true;
2547 break;
2548 }
2549
2550 lines.push(self.task_line("Verify", self.verify_task_status(comp)));
2551 remaining = remaining.saturating_sub(1);
2552 }
2553
2554 if truncated {
2555 lines.push(Line::from(" …"));
2556 }
2557
2558 lines
2559 }
2560
2561 fn install_stage_title(&self) -> Text<'_> {
2562 let lines = match self.install_status.message.as_str() {
2563 m if m.contains("download") => vec![
2564 Line::from("Pre-built download in progress"),
2565 Line::from("Progress is tied to download completion."),
2566 ],
2567 m if m.contains("configure") => vec![
2568 Line::from("Source build stage: configure"),
2569 Line::from(self.llama_build_flags_line()),
2570 ],
2571 m if m.contains("compile") => vec![
2572 Line::from("Source build stage: compile"),
2573 Line::from(self.llama_build_flags_line()),
2574 ],
2575 m if m.contains("link") => vec![
2576 Line::from("Source build stage: link"),
2577 Line::from(self.llama_build_flags_line()),
2578 ],
2579 _ => vec![
2580 Line::from("Installing llama.cpp Turbo Quant"),
2581 Line::from(self.llama_build_flags_line()),
2582 ],
2583 };
2584 let mut lines = lines;
2585 lines.push(Line::from(""));
2586 lines.push(Line::from(self.telemetry_inline_prompt()));
2587 Text::from(lines)
2588 }
2589
2590 fn llama_build_flags_line(&self) -> String {
2591 let config = LlamaCppConfig {
2592 gpu_arch: self.hardware.gpu.architecture.clone(),
2593 ..Default::default()
2594 };
2595 let flags = LlamaCppInstaller::new(config).cmake_flags().join(" ");
2596 format!("Build flags: {}", flags)
2597 }
2598
2599 fn telemetry_inline_prompt(&self) -> String {
2600 let status = self
2601 .telemetry_gate
2602 .as_ref()
2603 .map(|gate| {
2604 if gate.is_enabled() {
2605 TELEMETRY_STATUS_ENABLED
2606 } else {
2607 TELEMETRY_STATUS_DISABLED
2608 }
2609 })
2610 .unwrap_or(TELEMETRY_STATUS_DISABLED);
2611 format!(
2612 "{} | {} / {} | {}",
2613 TELEMETRY_DESCRIPTION, TELEMETRY_ENABLE_LABEL, TELEMETRY_DISABLE_LABEL, status
2614 )
2615 }
2616
2617 fn task_line(&self, label: &str, status: TaskStatus) -> Line<'_> {
2618 Line::from(vec![
2619 Span::styled(
2620 format!(" {}", status.icon()),
2621 Style::default().fg(status.color()),
2622 ),
2623 Span::raw(format!(" {} ({})", label, status.label())),
2624 ])
2625 }
2626
2627 fn send_install_input(&self, payload: String) {
2628 if let Some(sender) = &self.install_input_sender {
2629 let _ = sender.send(payload);
2630 }
2631 }
2632
2633 fn flush_install_input(&mut self) {
2634 if self.install_input_mode == InputMode::Raw {
2635 self.send_install_input("\n".to_string());
2636 return;
2637 }
2638
2639 if self.install_input_buffer.is_empty() {
2640 self.send_install_input("\n".to_string());
2641 return;
2642 }
2643
2644 let mut payload = self.install_input_buffer.clone();
2645 payload.push('\n');
2646 self.send_install_input(payload);
2647 self.install_input_buffer.clear();
2648 }
2649
2650 fn start_hardware_detection(&mut self) {
2651 let (tx, rx) = mpsc::channel();
2652 thread::spawn(move || {
2653 let result = detect_hardware();
2654 let _ = tx.send(result);
2655 });
2656 self.hardware_receiver = Some(rx);
2657 self.hardware.status = "Detecting hardware...".into();
2658 self.hardware.progress = 0.1;
2659 }
2660
2661 fn poll_hardware(&mut self) {
2662 let results: Vec<_> = if let Some(receiver) = &self.hardware_receiver {
2663 std::iter::from_fn(|| receiver.try_recv().ok()).collect()
2664 } else {
2665 vec![]
2666 };
2667
2668 for result in results {
2669 match result {
2670 Ok(state) => {
2671 self.hardware = state;
2672 }
2673 Err(err) => {
2674 self.errors.push(err.to_string());
2675 }
2676 }
2677 }
2678 }
2679
2680 fn refresh_component_statuses(&mut self) {
2681 let python_candidates = python_interpreters();
2682 let force_reinstall = self.config.force_reinstall;
2683 for component in &mut self.components {
2684 if component.category == Category::Verification {
2685 continue;
2686 }
2687 component.installed = is_component_installed(component, &python_candidates);
2688 if component.installed && !force_reinstall {
2689 component.selected = false;
2693 }
2694 }
2695 }
2696
2697 fn selected_components(&self) -> Vec<Component> {
2698 self.components
2699 .iter()
2700 .filter(|c| c.selected)
2701 .cloned()
2702 .collect()
2703 }
2704
2705 fn current_category(&self) -> Category {
2706 match self.selected_category {
2707 0 => Category::Environment,
2708 1 => Category::Foundation,
2709 2 => Category::Core,
2710 3 => Category::Extension,
2711 4 => Category::UiUx,
2712 5 => Category::Verification,
2713 _ => Category::Performance,
2714 }
2715 }
2716
2717 fn sanitize_line(value: &str) -> String {
2718 let mut clean = String::with_capacity(value.len());
2719 let mut in_escape = false;
2720 for c in value.chars() {
2721 if c == '\x1b' {
2722 in_escape = true;
2723 continue;
2724 }
2725 if in_escape {
2726 if ('@'..='~').contains(&c) || c == 'm' {
2727 in_escape = false;
2728 }
2729 continue;
2730 }
2731 if c == '\t' {
2732 clean.push(' ');
2733 } else if !c.is_control() {
2734 clean.push(c);
2735 }
2736 }
2737 clean
2738 }
2739
2740 fn push_log(&mut self, line: String) {
2741 self.push_log_ext(line, false);
2742 }
2743
2744 fn push_log_ext(&mut self, line: String, is_transient: bool) {
2745 let timestamp = Local::now().format("%H:%M:%S");
2746 let clean_line = Self::sanitize_line(&line);
2747 if clean_line.trim().is_empty() {
2748 return;
2749 }
2750 if let Some(path) = clean_line.split("Log file:").nth(1) {
2751 let candidate = path.trim();
2752 if !candidate.is_empty() {
2753 self.install_log_file_path = Some(candidate.to_string());
2754 }
2755 }
2756 let entry = format!("[{}] {}", timestamp, clean_line);
2757
2758 if is_transient && self.last_line_transient && !self.logs.is_empty() {
2759 let last_idx = self.logs.len() - 1;
2761 self.logs[last_idx] = entry.clone();
2762 } else {
2763 self.logs.push(entry.clone());
2764 }
2765
2766 self.last_line_transient = is_transient;
2767
2768 if self.logs.len() > 2000 {
2769 self.logs.drain(0..500);
2770 }
2771
2772 if !is_transient {
2774 let log_dir = std::path::Path::new(&self.config.log_dir);
2775 let log_path = log_dir.join("rusty-stack.log");
2776 if std::fs::create_dir_all(log_dir).is_ok() {
2777 let _ = std::fs::OpenOptions::new()
2778 .create(true)
2779 .append(true)
2780 .open(log_path)
2781 .and_then(|mut file| {
2782 use std::io::Write;
2783 writeln!(file, "{}", entry)
2784 });
2785 }
2786 }
2787 }
2788
2789 fn latest_install_log_path(&self) -> Option<String> {
2790 if let Some(path) = &self.install_log_file_path {
2791 return Some(path.clone());
2792 }
2793 if self.config.log_dir.trim().is_empty() {
2794 return None;
2795 }
2796 Some(
2797 std::path::Path::new(&self.config.log_dir)
2798 .join("rusty-stack.log")
2799 .display()
2800 .to_string(),
2801 )
2802 }
2803
2804 fn partition_categories(&self) -> (Vec<Component>, Vec<Component>) {
2805 let env = self
2806 .components
2807 .iter()
2808 .filter(|c| c.category == Category::Environment && c.selected)
2809 .cloned()
2810 .collect::<Vec<_>>();
2811 let verification = self
2812 .components
2813 .iter()
2814 .filter(|c| c.category == Category::Verification && c.selected)
2815 .cloned()
2816 .collect::<Vec<_>>();
2817 (env, verification)
2818 }
2819
2820 fn env_summary(&self) -> Vec<(String, String)> {
2821 let mut entries = Vec::new();
2822 let env_path = std::path::Path::new(&self.config.install_path).join(".mlstack_env");
2823 let fallback =
2824 std::path::Path::new(&std::env::var("HOME").unwrap_or_default()).join(".mlstack_env");
2825 let path = if env_path.exists() {
2826 env_path
2827 } else {
2828 fallback
2829 };
2830
2831 if let Ok(contents) = std::fs::read_to_string(&path) {
2832 for line in contents.lines() {
2833 let trimmed = line.trim();
2834 if trimmed.is_empty() || trimmed.starts_with('#') {
2835 continue;
2836 }
2837 if let Some(export_idx) = trimmed.rfind("export ") {
2839 let rest = &trimmed[export_idx + 7..]; if let Some((key, value)) = rest.split_once('=') {
2841 let key = key.trim();
2842 if key.is_empty() || key.contains(' ') || key.contains('$') {
2843 continue;
2844 }
2845 let value = value.trim();
2846 let value = Self::strip_shell_value(value);
2848 let value = value
2849 .strip_prefix('"')
2850 .and_then(|v| v.strip_suffix('"'))
2851 .unwrap_or(&value);
2852 entries.push((key.to_string(), value.to_string()));
2853 }
2854 }
2855 }
2856 }
2857
2858 let keys = [
2859 "ROCM_VERSION",
2860 "ROCM_CHANNEL",
2861 "GPU_ARCH",
2862 "ROCM_HOME",
2863 "ROCM_PATH",
2864 "HIP_PATH",
2865 "HIP_VISIBLE_DEVICES",
2866 "CUDA_VISIBLE_DEVICES",
2867 "PYTORCH_ROCM_DEVICE",
2868 ];
2869
2870 let mut filtered = Vec::new();
2871 for key in keys {
2872 if let Some((_, value)) = entries.iter().find(|(k, _)| k == key) {
2873 filtered.push((key.to_string(), Self::sanitize_line(value)));
2874 } else if let Ok(value) = std::env::var(key) {
2875 filtered.push((key.to_string(), Self::sanitize_line(&value)));
2876 }
2877 }
2878 filtered
2879 }
2880
2881 fn strip_shell_value(value: &str) -> String {
2889 let mut v = value.to_string();
2890 if let Some(idx) = v.find("; fi") {
2892 v.truncate(idx);
2893 }
2894 if let Some(idx) = v.find("&& ") {
2896 v.truncate(idx);
2897 }
2898 v.trim().to_string()
2899 }
2900
2901 fn verification_report_lines(&self) -> Vec<Line<'_>> {
2902 let mut lines = Vec::new();
2903 for comp in self.components.iter().filter(|c| c.selected) {
2904 if let Some(report) = self.verification_reports.get(&comp.id) {
2905 lines.push(Line::from(Span::styled(
2906 format!("{} Report:", comp.name),
2907 Style::default()
2908 .fg(Color::Cyan)
2909 .add_modifier(Modifier::BOLD),
2910 )));
2911 for entry in report {
2912 if entry.trim().is_empty() {
2913 lines.push(Line::from(""));
2914 } else {
2915 let style = if entry.contains("Verified") {
2916 Style::default().fg(Color::Green)
2917 } else if entry.contains("Failed") {
2918 Style::default().fg(Color::Red)
2919 } else if entry.contains("Missing") || entry.contains("Skipped") {
2920 Style::default().fg(Color::Yellow)
2921 } else {
2922 Style::default()
2923 };
2924 lines.push(Line::from(Span::styled(format!(" {}", entry), style)));
2925 }
2926 }
2927 lines.push(Line::from(""));
2928 }
2929 }
2930 lines
2931 }
2932
2933 fn verification_status(&self, component_id: &str) -> &'static str {
2934 let Some(report) = self.verification_reports.get(component_id) else {
2935 return "none";
2936 };
2937 let mut has_verified = false;
2938 for line in report {
2939 if line.contains("Failed") {
2940 return "failed";
2941 }
2942 if line.contains("Missing") {
2943 return "missing";
2944 }
2945 if line.contains("Verified") {
2946 has_verified = true;
2947 }
2948 }
2949 if has_verified {
2950 "verified"
2951 } else {
2952 "unknown"
2953 }
2954 }
2955
2956 fn count_log_keywords(&self) -> (usize, usize) {
2957 let mut benchmarks = 0usize;
2958 let mut tests = 0usize;
2959 for entry in &self.logs {
2960 let lower = entry.to_lowercase();
2961 if lower.contains("benchmark") {
2962 benchmarks += 1;
2963 }
2964 if lower.contains("test") || lower.contains("verify") {
2965 tests += 1;
2966 }
2967 }
2968 for report in self.verification_reports.values() {
2969 for entry in report {
2970 let lower = entry.to_lowercase();
2971 if lower.contains("benchmark") {
2972 benchmarks += 1;
2973 }
2974 if lower.contains("test") || lower.contains("verify") {
2975 tests += 1;
2976 }
2977 }
2978 }
2979 (benchmarks, tests)
2980 }
2981
2982 fn partition_components(&self) -> (Vec<Component>, Vec<Component>, Vec<Component>) {
2983 let mut installed = Vec::new();
2984 let mut failed = Vec::new();
2985 let mut skipped = Vec::new();
2986 for comp in self.components.iter().filter(|c| c.selected) {
2987 if comp.installed {
2988 installed.push(comp.clone());
2989 } else if comp.progress > 0.0 {
2990 failed.push(comp.clone());
2991 } else {
2992 skipped.push(comp.clone());
2993 }
2994 }
2995 (installed, failed, skipped)
2996 }
2997}
2998
2999fn on_off(value: bool) -> &'static str {
3000 if value {
3001 "on"
3002 } else {
3003 "off"
3004 }
3005}