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