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,
8 RunMode, Stage,
9};
10use crate::telemetry::opt_in::{
11 OptInGate, TELEMETRY_DESCRIPTION, TELEMETRY_DISABLE_LABEL, TELEMETRY_ENABLE_LABEL,
12 TELEMETRY_PRIVACY_NOTE, TELEMETRY_STATUS_DISABLED, TELEMETRY_STATUS_ENABLED,
13};
14use crate::widgets::benchmarks_page::{
15 export_benchmark_report_html, load_benchmark_results, render_benchmark_page,
16};
17use chrono::Local;
18use crossterm::event::KeyModifiers;
19use ratatui::layout::{Constraint, Direction, Layout, Rect};
20use ratatui::style::{Color, Modifier, Style};
21use ratatui::text::{Line, Span, Text};
22use ratatui::widgets::{
23 Block, Borders, Cell, Clear, Gauge, List, ListItem, Paragraph, Row, Table, Wrap,
24};
25use ratatui::Frame;
26use std::collections::HashMap;
27use std::path::PathBuf;
28use std::sync::mpsc::{self, Receiver, Sender};
29use std::thread;
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum InputMode {
33 Line,
34 Raw,
35}
36
37impl InputMode {
38 fn label(self) -> &'static str {
39 match self {
40 InputMode::Line => "line",
41 InputMode::Raw => "raw",
42 }
43 }
44}
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47enum TaskStatus {
48 Pending,
49 Running,
50 Done,
51 Failed,
52 Skipped,
53}
54
55#[derive(Debug, Clone)]
56struct UiNotice {
57 message: String,
58 color: Color,
59 expires_at_tick: u64,
60}
61
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63enum ComponentDetectionStatus {
64 Pending,
65 Running,
66 Installed,
67 NotInstalled,
68 Skipped,
69}
70
71#[derive(Debug)]
72enum ComponentStatusEvent {
73 Started(usize),
74 Finished { index: usize, installed: bool },
75 Skipped(usize),
76 Complete(Vec<Component>),
77}
78
79impl ComponentDetectionStatus {
80 fn icon(self) -> &'static str {
81 match self {
82 ComponentDetectionStatus::Pending => "○",
83 ComponentDetectionStatus::Running => "…",
84 ComponentDetectionStatus::Installed => "✓",
85 ComponentDetectionStatus::NotInstalled => "◇",
86 ComponentDetectionStatus::Skipped => "⊘",
87 }
88 }
89
90 fn label(self) -> &'static str {
91 match self {
92 ComponentDetectionStatus::Pending => "pending",
93 ComponentDetectionStatus::Running => "checking",
94 ComponentDetectionStatus::Installed => "installed",
95 ComponentDetectionStatus::NotInstalled => "not installed",
96 ComponentDetectionStatus::Skipped => "skipped",
97 }
98 }
99
100 fn color(self) -> Color {
101 match self {
102 ComponentDetectionStatus::Pending => Color::Yellow,
103 ComponentDetectionStatus::Running => Color::Cyan,
104 ComponentDetectionStatus::Installed => Color::Green,
105 ComponentDetectionStatus::NotInstalled => Color::Gray,
106 ComponentDetectionStatus::Skipped => Color::DarkGray,
107 }
108 }
109}
110
111impl TaskStatus {
112 fn icon(self) -> &'static str {
113 match self {
114 TaskStatus::Pending => "○",
115 TaskStatus::Running => "…",
116 TaskStatus::Done => "✓",
117 TaskStatus::Failed => "✗",
118 TaskStatus::Skipped => "⊘",
119 }
120 }
121
122 fn label(self) -> &'static str {
123 match self {
124 TaskStatus::Pending => "pending",
125 TaskStatus::Running => "running",
126 TaskStatus::Done => "done",
127 TaskStatus::Failed => "failed",
128 TaskStatus::Skipped => "skipped",
129 }
130 }
131
132 fn color(self) -> Color {
133 match self {
134 TaskStatus::Pending => Color::Yellow,
135 TaskStatus::Running => Color::Cyan,
136 TaskStatus::Done => Color::Green,
137 TaskStatus::Failed => Color::Red,
138 TaskStatus::Skipped => Color::DarkGray,
139 }
140 }
141}
142
143#[derive(Debug)]
144pub struct App {
145 pub stage: Stage,
146 pub components: Vec<Component>,
147 pub selected_category: usize,
148 pub selected_component: usize,
149 pub logs: Vec<String>,
150 pub errors: Vec<String>,
151 pub hardware: HardwareState,
152 pub preflight: PreflightResult,
153 pub preflight_selection: usize,
154 pub tick_count: u64,
155 pub install_status: InstallStatus,
156 pub config: InstallerConfig,
157 pub config_selection: usize,
158 pub confirm_selection: usize,
159 pub config_dirty: bool,
160 pub recovery_selection: usize,
161 pub sudo_password: Option<String>,
162 pub entering_password: bool,
163 pub should_exit: bool,
164 pub password_input: String,
165 pub verification_reports: HashMap<String, Vec<String>>,
166 pub install_input_buffer: String,
167 pub install_input_mode: InputMode,
168 pub last_line_transient: bool,
169 pub summary_scroll: u16,
170 pub benchmark_tab_index: usize,
171 benchmark_notice: Option<UiNotice>,
172 telemetry_gate: Option<OptInGate>,
173 telemetry_prompt_pending: bool,
174 telemetry_prompt_active: bool,
175 install_log_popup: bool,
176 install_log_scroll: usize,
177 install_log_file_path: Option<String>,
178 install_input_sender: Option<Sender<String>>,
179 install_receiver: Option<Receiver<InstallerEvent>>,
180 hardware_receiver: Option<Receiver<anyhow::Result<HardwareState>>>,
181 component_status_receiver: Option<Receiver<ComponentStatusEvent>>,
182 component_detection_statuses: Vec<ComponentDetectionStatus>,
183 component_detection_done: bool,
184 install_activity_tick: u64,
185}
186
187impl App {
188 pub fn new(scripts_dir: String) -> Self {
189 #[cfg(unix)]
190 let entering_password = unsafe { libc::geteuid() != 0 };
191 #[cfg(not(unix))]
192 let entering_password = false;
193 let mut config = InstallerConfig::load_or_default(&scripts_dir).unwrap_or_else(|_| {
194 InstallerConfig::default_with_paths(
196 &scripts_dir,
197 "/tmp/mlstack/logs".into(),
198 PathBuf::from("/tmp/mlstack/config.json"),
199 )
200 });
201 config.scripts_dir = scripts_dir;
202 Self {
203 stage: Stage::Welcome,
204 components: default_components(),
205 selected_category: 0,
206 selected_component: 0,
207 logs: Vec::new(),
208 errors: Vec::new(),
209 hardware: HardwareState::default(),
210 preflight: PreflightResult::default(),
211 preflight_selection: 0,
212 tick_count: 0,
213 install_status: InstallStatus::default(),
214 config,
215 config_selection: 0,
216 confirm_selection: 0,
217 config_dirty: false,
218 recovery_selection: 0,
219 sudo_password: None,
220 entering_password,
221 should_exit: false,
222 password_input: String::new(),
223 verification_reports: HashMap::new(),
224 install_input_buffer: String::new(),
225 install_input_mode: InputMode::Line,
226 last_line_transient: false,
227 summary_scroll: 0,
228 benchmark_tab_index: 0,
229 benchmark_notice: None,
230 telemetry_gate: OptInGate::new().ok(),
231 telemetry_prompt_pending: false,
232 telemetry_prompt_active: false,
233 install_log_popup: false,
234 install_log_scroll: 0,
235 install_log_file_path: None,
236 install_input_sender: None,
237 install_receiver: None,
238 hardware_receiver: None,
239 component_status_receiver: None,
240 component_detection_statuses: Vec::new(),
241 component_detection_done: false,
242 install_activity_tick: 0,
243 }
244 }
245
246 pub fn on_tick(&mut self) {
247 self.tick_count = self.tick_count.wrapping_add(1);
248 if self
249 .benchmark_notice
250 .as_ref()
251 .is_some_and(|notice| self.tick_count > notice.expires_at_tick)
252 {
253 self.benchmark_notice = None;
254 }
255 self.poll_hardware();
256 self.poll_component_status_detection();
257 self.poll_installer();
258 }
259
260 pub fn handle_key(&mut self, key: crossterm::event::KeyEvent) {
261 use crossterm::event::KeyCode;
262 match self.stage {
263 Stage::Welcome => match key.code {
264 KeyCode::Enter => {
265 if self.entering_password {
266 if !self.password_input.is_empty() {
267 self.sudo_password = Some(self.password_input.clone());
268 self.entering_password = false;
269 } else {
270 self.errors.push("Password required for sudo".into());
271 }
272 return;
273 }
274 self.stage = Stage::HardwareDetect;
275 self.start_hardware_detection();
276 }
277 KeyCode::Char('q') => {
278 self.stage = Stage::Recovery;
279 self.errors.push("User quit".into());
280 }
281 KeyCode::Char(c) if self.entering_password => {
282 if c == '\n' || c == '\r' {
283 return;
284 }
285 self.password_input.push(c);
286 }
287 KeyCode::Char(_) => {}
288 KeyCode::Backspace if self.entering_password => {
289 self.password_input.pop();
290 }
291 KeyCode::Backspace => {}
292 KeyCode::Tab if self.entering_password => {
293 self.sudo_password = Some(self.password_input.clone());
294 self.entering_password = false;
295 }
296 _ => {}
297 },
298 Stage::HardwareDetect => match key.code {
299 KeyCode::Enter => {
300 self.preflight = run_preflight_checks(
301 &self.hardware.system,
302 &self.hardware.gpu,
303 self.sudo_password.as_deref(),
304 );
305 self.preflight_selection = 0;
306 self.stage = Stage::Preflight;
307 }
308 KeyCode::Esc => self.stage = Stage::Welcome,
309 KeyCode::Char('q') => self.stage = Stage::Recovery,
310 _ => {}
311 },
312 Stage::Preflight => match key.code {
313 KeyCode::Up => self.move_preflight_selection(-1),
314 KeyCode::Down => self.move_preflight_selection(1),
315 KeyCode::Enter => {
316 if self.preflight.can_continue {
317 self.start_component_status_detection();
318 } else {
319 self.errors
320 .push("Preflight checks failed; resolve critical issues".into());
321 self.stage = Stage::Recovery;
322 }
323 }
324 KeyCode::Esc => self.stage = Stage::HardwareDetect,
325 KeyCode::Char('q') => self.stage = Stage::Recovery,
326 _ => {}
327 },
328 Stage::ComponentDetect => {
329 if let KeyCode::Enter = key.code {
330 if self.component_detection_done {
331 self.stage = Stage::ComponentSelect;
332 }
333 } else if let KeyCode::Char('q') = key.code {
334 self.stage = Stage::Recovery;
335 }
336 }
337 Stage::ComponentSelect => match key.code {
338 KeyCode::Up => self.move_selection(-1),
339 KeyCode::Down => self.move_selection(1),
340 KeyCode::Left => self.change_category(-1),
341 KeyCode::Right => self.change_category(1),
342 KeyCode::Char(' ') => self.toggle_component(),
343 KeyCode::Char('a') => self.toggle_all(),
344 KeyCode::Enter => self.stage = Stage::Configuration,
345 KeyCode::Esc => self.stage = Stage::Preflight,
346 KeyCode::Char('q') => self.stage = Stage::Recovery,
347 _ => {}
348 },
349 Stage::Configuration => match key.code {
350 KeyCode::Up => self.move_config_selection(-1),
351 KeyCode::Down => self.move_config_selection(1),
352 KeyCode::Enter => self.activate_config_selection(),
353 KeyCode::Char('s') => self.save_config(),
354 KeyCode::Char('n') => {
355 self.confirm_selection = 0;
356 self.stage = Stage::Confirm;
357 }
358 KeyCode::Esc => self.stage = Stage::ComponentSelect,
359 KeyCode::Char('q') => self.stage = Stage::Recovery,
360 _ => {}
361 },
362 Stage::Confirm => match key.code {
363 KeyCode::Up => self.move_confirm_selection(-1),
364 KeyCode::Down => self.move_confirm_selection(1),
365 KeyCode::Left => self.adjust_confirm_selection(-1),
366 KeyCode::Right => self.adjust_confirm_selection(1),
367 KeyCode::Enter => self.activate_confirm_selection(),
368 KeyCode::Esc => self.stage = Stage::Configuration,
369 KeyCode::Char('q') => self.stage = Stage::Recovery,
370 _ => {}
371 },
372 Stage::Installing => match key.code {
373 KeyCode::Char('l') | KeyCode::Char('L') => {
374 self.install_log_popup = !self.install_log_popup;
375 }
376 KeyCode::Esc if self.install_log_popup => {
377 self.install_log_popup = false;
378 }
379 KeyCode::Up if self.install_log_popup => {
380 self.install_log_scroll = self.install_log_scroll.saturating_sub(1);
381 }
382 KeyCode::Down if self.install_log_popup => {
383 self.install_log_scroll = self.install_log_scroll.saturating_add(1);
384 }
385 KeyCode::PageUp if self.install_log_popup => {
386 self.install_log_scroll = self.install_log_scroll.saturating_sub(20);
387 }
388 KeyCode::PageDown if self.install_log_popup => {
389 self.install_log_scroll = self.install_log_scroll.saturating_add(20);
390 }
391 KeyCode::Home if self.install_log_popup => {
392 self.install_log_scroll = 0;
393 }
394 KeyCode::Char('q') => {
395 self.errors.push("Installation cancelled by user".into());
396 self.stage = Stage::Recovery;
397 }
398 KeyCode::Char('r') if key.modifiers.contains(KeyModifiers::CONTROL) => {
399 self.install_input_mode = match self.install_input_mode {
400 InputMode::Line => InputMode::Raw,
401 InputMode::Raw => InputMode::Line,
402 };
403 }
404 KeyCode::Backspace if !self.install_log_popup => {
405 self.install_input_buffer.pop();
406 }
407 KeyCode::Backspace => {}
408 KeyCode::Enter if !self.install_log_popup => {
409 if self.telemetry_prompt_active {
410 if let Some(gate) = self.telemetry_gate.as_ref() {
411 if gate.is_enabled() {
412 let gpu = self.hardware.gpu.clone();
413 let report = crate::installers::common::BuildReport::from_hardware(
414 &gpu,
415 std::env::consts::OS.to_string(),
416 std::env::var("ID").unwrap_or_else(|_| "unknown".to_string()),
417 std::time::Duration::from_secs(
418 self.install_status.progress as u64,
419 ),
420 crate::installers::common::BuildReportArtifacts {
421 git_commit: "unknown".into(),
422 install_path: self.config.install_path.clone(),
423 cmake_flags: vec![],
424 verification_path: self.config.install_path.clone(),
425 binary_version: "unknown".into(),
426 was_prebuilt: false,
427 },
428 );
429 crate::installers::common::submit_build_report(report);
430 }
431 }
432 self.telemetry_prompt_pending = false;
433 self.telemetry_prompt_active = false;
434 self.stage = Stage::Benchmarks;
435 return;
436 }
437 self.flush_install_input();
438 }
439 KeyCode::Enter => {}
440 KeyCode::Char(c)
441 if !self.install_log_popup
442 && !key.modifiers.contains(KeyModifiers::CONTROL) =>
443 {
444 if self.telemetry_prompt_active {
445 match c {
446 'y' | 'Y' => {
447 if let Some(gate) = self.telemetry_gate.as_ref() {
448 if gate.is_enabled() {
449 let gpu = self.hardware.gpu.clone();
450 let report =
451 crate::installers::common::BuildReport::from_hardware(
452 &gpu,
453 std::env::consts::OS.to_string(),
454 std::env::var("ID")
455 .unwrap_or_else(|_| "unknown".to_string()),
456 std::time::Duration::from_secs(
457 self.install_status.progress as u64,
458 ),
459 crate::installers::common::BuildReportArtifacts {
460 git_commit: "unknown".into(),
461 install_path: self.config.install_path.clone(),
462 cmake_flags: vec![],
463 verification_path: self
464 .config
465 .install_path
466 .clone(),
467 binary_version: "unknown".into(),
468 was_prebuilt: false,
469 },
470 );
471 crate::installers::common::submit_build_report(report);
472 }
473 }
474 self.telemetry_prompt_pending = false;
475 self.telemetry_prompt_active = false;
476 self.stage = Stage::Benchmarks;
477 }
478 'n' | 'N' => {
479 self.telemetry_prompt_pending = false;
480 self.telemetry_prompt_active = false;
481 self.stage = Stage::Benchmarks;
482 }
483 _ => {
484 self.install_input_buffer.push(c);
485 if self.install_input_mode == InputMode::Raw {
486 self.send_install_input(c.to_string());
487 }
488 }
489 }
490 return;
491 }
492 self.install_input_buffer.push(c);
493 if self.install_input_mode == InputMode::Raw {
494 self.send_install_input(c.to_string());
495 }
496 }
497 _ => {}
498 },
499 Stage::Complete => match key.code {
500 KeyCode::Esc | KeyCode::Char('q') => self.stage = Stage::Recovery,
501 KeyCode::Char('b') | KeyCode::Char('B') => {
502 self.stage = Stage::Benchmarks;
503 self.benchmark_tab_index = 0;
504 }
505 KeyCode::Up => {
506 self.summary_scroll = self.summary_scroll.saturating_sub(1);
507 }
508 KeyCode::Down => {
509 self.summary_scroll = self.summary_scroll.saturating_add(1);
510 }
511 KeyCode::PageUp => {
512 self.summary_scroll = self.summary_scroll.saturating_sub(10);
513 }
514 KeyCode::PageDown => {
515 self.summary_scroll = self.summary_scroll.saturating_add(10);
516 }
517 _ => {}
518 },
519 Stage::Benchmarks => match key.code {
520 KeyCode::Left if self.benchmark_tab_index > 0 => {
521 self.benchmark_tab_index -= 1;
522 }
523 KeyCode::Left => {}
524 KeyCode::Right if self.benchmark_tab_index < 9 => {
525 self.benchmark_tab_index += 1;
526 }
527 KeyCode::Right => {}
528 KeyCode::Char('e') | KeyCode::Char('E') => self.export_benchmark_report(),
529 KeyCode::Esc | KeyCode::Char('q') => self.stage = Stage::Complete,
530 _ => {}
531 },
532 Stage::Recovery => match key.code {
533 KeyCode::Up => self.move_recovery_selection(-1),
534 KeyCode::Down => self.move_recovery_selection(1),
535 KeyCode::Enter => self.activate_recovery_selection(),
536 KeyCode::Char('q') => self.should_exit = true,
537 _ => {}
538 },
539 }
540 }
541
542 pub fn draw(&self, frame: &mut Frame) {
543 let chunks = Layout::default()
544 .direction(Direction::Vertical)
545 .constraints(
546 [
547 Constraint::Length(5),
548 Constraint::Min(5),
549 Constraint::Length(3),
550 ]
551 .as_ref(),
552 )
553 .split(frame.area());
554
555 let spinner = self.spinner();
556 let title_lines = vec![
557 Line::from(vec![
558 Span::styled("✦", Style::default().fg(Color::Yellow)),
559 Span::raw(" "),
560 Span::styled(
561 "Rusty-Stack",
562 Style::default()
563 .fg(Color::Cyan)
564 .add_modifier(Modifier::BOLD),
565 ),
566 Span::raw(" "),
567 Span::styled(spinner, Style::default().fg(Color::Magenta)),
568 ]),
569 Line::from(vec![
570 Span::styled(
571 "AMD ML Stack Installer",
572 Style::default()
573 .fg(Color::Blue)
574 .add_modifier(Modifier::BOLD),
575 ),
576 Span::raw(" • "),
577 Span::styled("Ratatui Edition", Style::default().fg(Color::Gray)),
578 ]),
579 ];
580 let title = Paragraph::new(Text::from(title_lines))
581 .block(Block::default().borders(Borders::ALL).title("Installer"));
582 frame.render_widget(title, chunks[0]);
583
584 match self.stage {
585 Stage::Welcome => self.draw_welcome(frame, chunks[1]),
586 Stage::HardwareDetect => self.draw_hardware(frame, chunks[1]),
587 Stage::Preflight => self.draw_preflight(frame, chunks[1]),
588 Stage::ComponentDetect => self.draw_component_detection(frame, chunks[1]),
589 Stage::ComponentSelect => self.draw_component_select(frame, chunks[1]),
590 Stage::Configuration => self.draw_configuration(frame, chunks[1]),
591 Stage::Confirm => self.draw_confirm(frame, chunks[1]),
592 Stage::Installing => self.draw_installing(frame, chunks[1]),
593 Stage::Complete => self.draw_complete(frame, chunks[1]),
594 Stage::Benchmarks => self.draw_benchmarks(frame, chunks[1]),
595 Stage::Recovery => self.draw_recovery(frame, chunks[1]),
596 }
597 self.draw_notice(frame, chunks[1]);
598
599 let footer = Paragraph::new(format!(
600 "Stage: {:?} | Keys: {} | Logs: {} | {}",
601 self.stage,
602 self.stage_keymap(),
603 self.logs.len(),
604 Local::now().format("%H:%M:%S")
605 ))
606 .block(Block::default().borders(Borders::ALL));
607 frame.render_widget(footer, chunks[2]);
608 }
609
610 fn stage_keymap(&self) -> &'static str {
611 match self.stage {
612 Stage::Welcome => "Enter start • Q recovery",
613 Stage::HardwareDetect => "Enter preflight • Esc back • Q recovery",
614 Stage::Preflight => "↑/↓ select • Enter continue • Esc back • Q recovery",
615 Stage::ComponentDetect => "checking components • Q recovery",
616 Stage::ComponentSelect => {
617 "↑/↓ select • ←/→ category • Space toggle • A toggle all • Enter config • Esc back"
618 }
619 Stage::Configuration => "↑/↓ select • Enter toggle • S save • N next • Esc back",
620 Stage::Confirm => "↑/↓ select • ←/→ adjust • Enter choose • Esc back",
621 Stage::Installing => "L logs • Ctrl+R input mode • Enter send • Q recovery",
622 Stage::Complete => "Esc recovery • B benchmarks",
623 Stage::Benchmarks => "←/→ tabs • E export HTML • Esc/B back • Q quit",
624 Stage::Recovery => "↑/↓ select • Enter apply • Q quit",
625 }
626 }
627
628 fn spinner(&self) -> &'static str {
629 const FRAMES: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
630 let idx = (self.tick_count % FRAMES.len() as u64) as usize;
631 FRAMES[idx]
632 }
633
634 fn draw_welcome(&self, frame: &mut Frame, area: ratatui::layout::Rect) {
635 let mut lines = vec![
636 Line::from(vec![Span::styled(
637 "Welcome to Rusty-Stack",
638 Style::default()
639 .fg(Color::Cyan)
640 .add_modifier(Modifier::BOLD),
641 )]),
642 Line::from("High-performance AMD ML Stack installer"),
643 Line::from(""),
644 Line::from("• Press Enter to begin hardware detection"),
645 Line::from("• Press Q to quit"),
646 ];
647
648 if self.entering_password {
649 lines.push(Line::from(""));
650 lines.push(Line::from(Span::styled(
651 "Sudo password required to run installers",
652 Style::default().fg(Color::Yellow),
653 )));
654 lines.push(Line::from(
655 "Type password and press Enter (input is hidden).",
656 ));
657 let masked = "*".repeat(self.password_input.len());
658 lines.push(Line::from(format!("Password: {}", masked)));
659 }
660
661 let paragraph = Paragraph::new(Text::from(lines))
662 .block(Block::default().borders(Borders::ALL).title("Welcome"))
663 .wrap(Wrap { trim: true });
664 frame.render_widget(paragraph, area);
665 }
666
667 fn draw_hardware(&self, frame: &mut Frame, area: ratatui::layout::Rect) {
668 let status_color = if self.hardware.progress < 1.0 {
669 Color::Yellow
670 } else {
671 Color::Green
672 };
673
674 let chunks = Layout::default()
676 .direction(Direction::Vertical)
677 .constraints([
678 Constraint::Length(3),
679 Constraint::Min(0),
680 Constraint::Length(3),
681 ])
682 .split(area);
683
684 let header = Paragraph::new(Text::from(vec![Line::from(vec![
686 Span::styled(
687 "🌐 Hardware Detection",
688 Style::default()
689 .fg(Color::Cyan)
690 .add_modifier(Modifier::BOLD),
691 ),
692 Span::raw(" "),
693 Span::styled(self.spinner(), Style::default().fg(status_color)),
694 Span::raw(" "),
695 Span::styled(
696 self.hardware.status.clone(),
697 Style::default().fg(status_color),
698 ),
699 ])]))
700 .block(Block::default().borders(Borders::ALL))
701 .wrap(Wrap { trim: true });
702 frame.render_widget(header, chunks[0]);
703
704 let info_chunks = Layout::default()
706 .direction(Direction::Horizontal)
707 .constraints([Constraint::Percentage(40), Constraint::Percentage(60)])
708 .split(chunks[1]);
709
710 let system_lines = vec![
712 Line::from(""),
713 Line::from(vec![
714 Span::styled("🖥️ System", Style::default().fg(Color::Blue)),
715 Span::raw(" "),
716 Span::styled(
717 self.hardware.system.distribution.clone(),
718 Style::default().fg(Color::Cyan),
719 ),
720 ]),
721 Line::from(vec![
722 Span::styled("🧠 Kernel", Style::default().fg(Color::Blue)),
723 Span::raw(" "),
724 Span::styled(
725 self.hardware.system.kernel.clone(),
726 Style::default().fg(Color::Cyan),
727 ),
728 ]),
729 Line::from(vec![
730 Span::styled("💻 CPU", Style::default().fg(Color::Blue)),
731 Span::raw(" "),
732 Span::styled(
733 self.hardware.system.cpu_model.clone(),
734 Style::default().fg(Color::Cyan),
735 ),
736 ]),
737 Line::from(vec![
738 Span::styled("💾 Memory", Style::default().fg(Color::Blue)),
739 Span::raw(" "),
740 Span::styled(
741 format!("{:.1} GB", self.hardware.system.memory_gb),
742 Style::default().fg(Color::Cyan),
743 ),
744 ]),
745 Line::from(vec![
746 Span::styled("💾 Storage", Style::default().fg(Color::Blue)),
747 Span::raw(" "),
748 Span::styled(
749 format!(
750 "{:.1} GB (free {:.1} GB)",
751 self.hardware.system.storage_gb, self.hardware.system.storage_available_gb
752 ),
753 Style::default().fg(Color::Cyan),
754 ),
755 ]),
756 ];
757
758 let gpu_lines = vec![
760 Line::from(""),
761 Line::from(vec![
762 Span::styled("🖥️ GPU", Style::default().fg(Color::Green)),
763 Span::raw(" "),
764 Span::styled(
765 self.hardware.gpu.model.clone(),
766 Style::default().fg(Color::Cyan),
767 ),
768 ]),
769 Line::from(vec![
770 Span::styled("🏗️ Architecture", Style::default().fg(Color::Green)),
771 Span::raw(" "),
772 Span::styled(
773 self.hardware.gpu.architecture.clone(),
774 Style::default().fg(Color::Cyan),
775 ),
776 ]),
777 Line::from(vec![
778 Span::styled("🔧 ROCm", Style::default().fg(Color::Green)),
779 Span::raw(" "),
780 Span::styled(
781 if self.hardware.gpu.rocm_version.is_empty() {
782 "unknown".to_string()
783 } else {
784 self.hardware.gpu.rocm_version.clone()
785 },
786 Style::default().fg(Color::Cyan),
787 ),
788 ]),
789 Line::from(vec![
790 Span::styled("💰 GPU Count", Style::default().fg(Color::Green)),
791 Span::raw(" "),
792 Span::styled(
793 self.hardware.gpu.gpu_count.to_string(),
794 Style::default().fg(Color::Cyan),
795 ),
796 ]),
797 Line::from(vec![
798 Span::styled("💾 GPU Memory", Style::default().fg(Color::Green)),
799 Span::raw(" "),
800 Span::styled(
801 format!("{:.1} GB", self.hardware.gpu.memory_gb),
802 Style::default().fg(Color::Cyan),
803 ),
804 ]),
805 Line::from(vec![
806 Span::styled("🌡️ GPU Temp", Style::default().fg(Color::Green)),
807 Span::raw(" "),
808 Span::styled(
809 self.hardware
810 .gpu
811 .temperature_c
812 .map(|v| format!("{:.1} C", v))
813 .unwrap_or_else(|| "n/a".into()),
814 Style::default().fg(Color::Cyan),
815 ),
816 ]),
817 Line::from(vec![
818 Span::styled("⚡ GPU Power", Style::default().fg(Color::Green)),
819 Span::raw(" "),
820 Span::styled(
821 self.hardware
822 .gpu
823 .power_watts
824 .map(|v| format!("{:.1} W", v))
825 .unwrap_or_else(|| "n/a".into()),
826 Style::default().fg(Color::Cyan),
827 ),
828 ]),
829 ];
830
831 let system_panel = Paragraph::new(Text::from(system_lines))
832 .block(Block::default().borders(Borders::ALL).title("System"))
833 .wrap(Wrap { trim: true });
834 frame.render_widget(system_panel, info_chunks[0]);
835
836 let gpu_panel = Paragraph::new(Text::from(gpu_lines))
837 .block(Block::default().borders(Borders::ALL).title("GPU"))
838 .wrap(Wrap { trim: true });
839 frame.render_widget(gpu_panel, info_chunks[1]);
840
841 let footer_lines = vec![
843 Line::from(""),
844 Line::from(Span::styled(
845 "← Press Enter to run preflight checks",
846 Style::default().fg(Color::Yellow),
847 )),
848 Line::from(Span::styled(
849 "← Press Q to quit",
850 Style::default().fg(Color::Red),
851 )),
852 ];
853 let footer = Paragraph::new(Text::from(footer_lines))
854 .block(Block::default().borders(Borders::ALL))
855 .wrap(Wrap { trim: true });
856 frame.render_widget(footer, chunks[2]);
857 }
858
859 fn draw_preflight(&self, frame: &mut Frame, area: ratatui::layout::Rect) {
860 let chunks = Layout::default()
861 .direction(Direction::Horizontal)
862 .constraints([Constraint::Percentage(68), Constraint::Percentage(32)].as_ref())
863 .split(area);
864
865 let header = Row::new(vec![
866 Cell::from(Span::styled(
867 "Check",
868 Style::default().add_modifier(Modifier::BOLD),
869 )),
870 Cell::from(Span::styled(
871 "Status",
872 Style::default().add_modifier(Modifier::BOLD),
873 )),
874 Cell::from(Span::styled(
875 "Message",
876 Style::default().add_modifier(Modifier::BOLD),
877 )),
878 ])
879 .style(Style::default().fg(Color::Magenta));
880
881 let rows: Vec<Row> = self
882 .preflight
883 .checks
884 .iter()
885 .enumerate()
886 .map(|(idx, check)| {
887 let status_color = match check.status {
888 crate::state::PreflightStatus::Passed => Color::Green,
889 crate::state::PreflightStatus::Warning => Color::Yellow,
890 crate::state::PreflightStatus::Failed => Color::Red,
891 };
892 let mut row_style = Style::default();
893 if idx == self.preflight_selection {
894 row_style = row_style.bg(Color::DarkGray).add_modifier(Modifier::BOLD);
895 }
896 Row::new(vec![
897 Cell::from(check.name.clone()),
898 Cell::from(Span::styled(
899 check.status.label(),
900 Style::default()
901 .fg(status_color)
902 .add_modifier(Modifier::BOLD),
903 )),
904 Cell::from(check.message.clone()),
905 ])
906 .style(row_style)
907 })
908 .collect();
909
910 let table = Table::new(
911 rows,
912 [
913 Constraint::Percentage(38),
914 Constraint::Length(10),
915 Constraint::Percentage(52),
916 ],
917 )
918 .header(header)
919 .block(
920 Block::default()
921 .borders(Borders::ALL)
922 .title("Preflight Checks"),
923 )
924 .column_spacing(1);
925 frame.render_widget(table, chunks[0]);
926
927 let selected = self.preflight.checks.get(self.preflight_selection);
928 let summary_color = if self.preflight.can_continue {
929 Color::Green
930 } else {
931 Color::Red
932 };
933 let mut summary_lines = vec![
934 Line::from(Span::styled(
935 self.preflight.summary.clone(),
936 Style::default()
937 .fg(summary_color)
938 .add_modifier(Modifier::BOLD),
939 )),
940 Line::from(format!(
941 "Checks: {} | Score: {}",
942 self.preflight.checks.len(),
943 self.preflight.total_score
944 )),
945 Line::from(format!(
946 "Passed: {} Warnings: {} Failed: {}",
947 self.preflight.passed_count,
948 self.preflight.warning_count,
949 self.preflight.failed_count
950 )),
951 Line::from(format!(
952 "Can continue: {}",
953 if self.preflight.can_continue {
954 "yes"
955 } else {
956 "no"
957 }
958 )),
959 Line::from(""),
960 ];
961
962 if let Some(check) = selected {
963 summary_lines.push(Line::from(Span::styled(
964 format!("Selected: {}", check.name),
965 Style::default()
966 .fg(Color::Cyan)
967 .add_modifier(Modifier::BOLD),
968 )));
969 summary_lines.push(Line::from(format!("Message: {}", check.message)));
970 summary_lines.push(Line::from(format!("Details: {}", check.details)));
971 }
972
973 summary_lines.push(Line::from(""));
974 summary_lines.push(Line::from("↑/↓ select • Enter continue • Q recovery"));
975
976 let summary = Paragraph::new(Text::from(summary_lines))
977 .block(Block::default().borders(Borders::ALL).title("Summary"))
978 .wrap(Wrap { trim: true });
979 frame.render_widget(summary, chunks[1]);
980 }
981
982 fn draw_component_detection(&self, frame: &mut Frame, area: ratatui::layout::Rect) {
983 let chunks = Layout::default()
984 .direction(Direction::Vertical)
985 .constraints([
986 Constraint::Length(4),
987 Constraint::Length(3),
988 Constraint::Min(0),
989 Constraint::Length(4),
990 ])
991 .split(area);
992
993 let visible: Vec<(usize, &Component)> = self
994 .components
995 .iter()
996 .enumerate()
997 .filter(|(_, component)| show_on_component_detection_screen(component))
998 .collect();
999 let total = visible.len();
1000 let checked = visible
1001 .iter()
1002 .filter(|(idx, _)| {
1003 let status = self
1004 .component_detection_statuses
1005 .get(*idx)
1006 .copied()
1007 .unwrap_or(ComponentDetectionStatus::Pending);
1008 matches!(
1009 status,
1010 ComponentDetectionStatus::Installed | ComponentDetectionStatus::NotInstalled
1011 )
1012 })
1013 .count();
1014 let installed = visible
1015 .iter()
1016 .filter(|(idx, _)| {
1017 self.component_detection_statuses.get(*idx).copied()
1018 == Some(ComponentDetectionStatus::Installed)
1019 })
1020 .count();
1021 let not_installed = visible
1022 .iter()
1023 .filter(|(idx, _)| {
1024 self.component_detection_statuses.get(*idx).copied()
1025 == Some(ComponentDetectionStatus::NotInstalled)
1026 })
1027 .count();
1028 let ratio = if total == 0 {
1029 1.0
1030 } else {
1031 checked as f64 / total as f64
1032 };
1033
1034 let header = Paragraph::new(Text::from(vec![
1035 Line::from(vec![
1036 Span::styled(
1037 "🔎 Component Detection",
1038 Style::default()
1039 .fg(Color::Cyan)
1040 .add_modifier(Modifier::BOLD),
1041 ),
1042 Span::raw(" "),
1043 Span::styled(self.spinner(), Style::default().fg(Color::Cyan)),
1044 Span::raw(" Running comprehensive installed-component checks"),
1045 ]),
1046 Line::from(
1047 "Full probes stay enabled; slow ROCm/Python checks report progress instead of freezing.",
1048 ),
1049 ]))
1050 .block(Block::default().borders(Borders::ALL));
1051 frame.render_widget(header, chunks[0]);
1052
1053 let gauge = Gauge::default()
1054 .block(Block::default().borders(Borders::ALL).title("Progress"))
1055 .gauge_style(Style::default().fg(Color::Green))
1056 .label(format!("{checked}/{total} checks complete"))
1057 .ratio(ratio);
1058 frame.render_widget(gauge, chunks[1]);
1059
1060 let body = Layout::default()
1061 .direction(Direction::Horizontal)
1062 .constraints([Constraint::Percentage(68), Constraint::Percentage(32)])
1063 .split(chunks[2]);
1064
1065 let items: Vec<ListItem> = visible
1066 .iter()
1067 .map(|(idx, component)| {
1068 let status = self
1069 .component_detection_statuses
1070 .get(*idx)
1071 .copied()
1072 .unwrap_or(ComponentDetectionStatus::Pending);
1073 ListItem::new(Line::from(vec![
1074 Span::styled(status.icon(), Style::default().fg(status.color())),
1075 Span::raw(" "),
1076 Span::styled(
1077 format!("{:<13}", status.label()),
1078 Style::default().fg(status.color()),
1079 ),
1080 Span::raw(" "),
1081 Span::raw(component.name.clone()),
1082 ]))
1083 })
1084 .collect();
1085 let list = List::new(items).block(
1086 Block::default()
1087 .borders(Borders::ALL)
1088 .title("Installable Component Checks"),
1089 );
1090 frame.render_widget(list, body[0]);
1091
1092 let summary_prompt = if self.component_detection_done {
1093 "Complete. Press Enter to continue."
1094 } else {
1095 "Running. Please wait."
1096 };
1097 let summary = Paragraph::new(Text::from(vec![
1098 Line::from(Span::styled(
1099 "Detection Summary",
1100 Style::default()
1101 .fg(Color::Cyan)
1102 .add_modifier(Modifier::BOLD),
1103 )),
1104 Line::from(""),
1105 Line::from(format!("Checked: {checked}/{total}")),
1106 Line::from(format!("Installed: {installed}")),
1107 Line::from(format!("Not installed: {not_installed}")),
1108 Line::from(""),
1109 Line::from("Hidden here: verify/repair and benchmark actions."),
1110 Line::from("They remain available in their categories."),
1111 Line::from(""),
1112 Line::from(Span::styled(
1113 summary_prompt,
1114 Style::default().fg(if self.component_detection_done {
1115 Color::Green
1116 } else {
1117 Color::Yellow
1118 }),
1119 )),
1120 ]))
1121 .block(Block::default().borders(Borders::ALL).title("Summary"))
1122 .wrap(Wrap { trim: true });
1123 frame.render_widget(summary, body[1]);
1124
1125 let footer = Paragraph::new(Text::from(vec![
1126 Line::from("This stage may import torch/vLLM/FastVideo and run ROCm probes."),
1127 Line::from("When detection finishes, press Enter to open component selection."),
1128 ]))
1129 .block(Block::default().borders(Borders::ALL).title("Status"));
1130 frame.render_widget(footer, chunks[3]);
1131 }
1132
1133 fn draw_component_select(&self, frame: &mut Frame, area: ratatui::layout::Rect) {
1134 let chunks = Layout::default()
1135 .direction(Direction::Horizontal)
1136 .constraints([
1137 Constraint::Percentage(25),
1138 Constraint::Percentage(45),
1139 Constraint::Percentage(30),
1140 ])
1141 .split(area);
1142
1143 let categories = [
1144 (Category::Environment, "🌍", "Environment"),
1145 (Category::Foundation, "🔧", "Foundation"),
1146 (Category::Core, "💠", "Core"),
1147 (Category::Extension, "📦", "Extensions"),
1148 (Category::UiUx, "🎨", "UI/UX"),
1149 (Category::Maintenance, "🛠️", "Maintenance"),
1150 (Category::Performance, "📊", "Performance"),
1151 ];
1152 let category_items: Vec<ListItem> = categories
1153 .iter()
1154 .enumerate()
1155 .map(|(idx, (_, icon, label))| {
1156 let style = if idx == self.selected_category {
1157 Style::default()
1158 .fg(Color::Yellow)
1159 .bg(Color::DarkGray)
1160 .add_modifier(Modifier::BOLD)
1161 } else {
1162 Style::default()
1163 };
1164 ListItem::new(Line::from(Span::styled(
1165 format!("{} {}", icon, label),
1166 style,
1167 )))
1168 })
1169 .collect();
1170
1171 let category_list = List::new(category_items)
1172 .block(Block::default().borders(Borders::ALL).title("Categories"));
1173 frame.render_widget(category_list, chunks[0]);
1174
1175 let current_category = categories[self.selected_category].0;
1176 let filtered: Vec<(usize, &Component)> = self
1177 .components
1178 .iter()
1179 .enumerate()
1180 .filter(|(_, c)| c.category == current_category)
1181 .collect();
1182
1183 let component_items: Vec<ListItem> = filtered
1184 .iter()
1185 .enumerate()
1186 .map(|(idx, (_, comp))| {
1187 let selected = idx == self.selected_component;
1188
1189 let icon = match comp.category {
1191 Category::Environment => "🌍",
1192 Category::Foundation => "🔧",
1193 Category::Core => "💠",
1194 Category::UiUx => "🎨",
1195 Category::Extension => "📦",
1196 Category::Maintenance => "🛠️",
1197 Category::Performance => "📊",
1198 };
1199
1200 let indicator = if comp.selected { "☑" } else { "☐" };
1201 let status_indicator = if comp.installed { "✓" } else { "○" };
1202
1203 let display_name = if comp.experimental {
1204 format!("🧪 {}", comp.name)
1205 } else {
1206 comp.name.clone()
1207 };
1208 let line = format!(
1209 "{} {} {} {} [{}]",
1210 indicator, icon, display_name, status_indicator, comp.estimate
1211 );
1212
1213 let style = if selected {
1214 Style::default()
1215 .fg(Color::Cyan)
1216 .bg(Color::DarkGray)
1217 .add_modifier(Modifier::BOLD)
1218 } else if comp.installed {
1219 Style::default().fg(Color::Green)
1220 } else {
1221 Style::default()
1222 };
1223 ListItem::new(Line::from(Span::styled(line, style)))
1224 })
1225 .collect();
1226
1227 let component_list = List::new(component_items)
1228 .block(Block::default().borders(Borders::ALL).title("Components"));
1229 frame.render_widget(component_list, chunks[1]);
1230
1231 let mut detail_lines = vec![Line::from(Span::styled(
1232 "Component Details",
1233 Style::default()
1234 .fg(Color::Cyan)
1235 .add_modifier(Modifier::BOLD),
1236 ))];
1237 if let Some((_, comp)) = filtered.get(self.selected_component) {
1238 detail_lines.push(Line::from(""));
1239 detail_lines.push(Line::from(vec![
1240 Span::styled("Name: ", Style::default().fg(Color::Gray)),
1241 Span::styled(comp.name.clone(), Style::default().fg(Color::White)),
1242 ]));
1243 if comp.experimental {
1244 detail_lines.push(Line::from(Span::styled(
1245 "⚠ EXPERIMENTAL BUILD",
1246 Style::default()
1247 .fg(Color::Yellow)
1248 .add_modifier(Modifier::BOLD),
1249 )));
1250 if let Some(note) = &comp.note {
1251 let mut wrap = String::new();
1252 for word in note.split_whitespace() {
1253 if !wrap.is_empty() && wrap.len() + word.len() + 1 > 42 {
1254 detail_lines.push(Line::from(Span::styled(
1255 std::mem::take(&mut wrap),
1256 Style::default().fg(Color::Yellow),
1257 )));
1258 }
1259 if !wrap.is_empty() {
1260 wrap.push(' ');
1261 }
1262 wrap.push_str(word);
1263 }
1264 if !wrap.is_empty() {
1265 detail_lines.push(Line::from(Span::styled(
1266 wrap,
1267 Style::default().fg(Color::Yellow),
1268 )));
1269 }
1270 }
1271 }
1272 detail_lines.push(Line::from(vec![
1273 Span::styled("Required: ", Style::default().fg(Color::Gray)),
1274 Span::styled(on_off(comp.required), Style::default().fg(Color::Yellow)),
1275 ]));
1276 detail_lines.push(Line::from(vec![
1277 Span::styled("Selected: ", Style::default().fg(Color::Gray)),
1278 Span::styled(on_off(comp.selected), Style::default().fg(Color::Cyan)),
1279 ]));
1280
1281 let (status_label, status_color, status_icon) = match comp.category {
1283 Category::Environment => {
1284 if comp.installed {
1285 ("Configured", Color::Green, "✓")
1286 } else {
1287 ("Not configured", Color::Yellow, "○")
1288 }
1289 }
1290 Category::Core => {
1291 if comp.installed {
1293 ("Installed", Color::Green, "✓")
1294 } else {
1295 ("Not installed", Color::Yellow, "○")
1296 }
1297 }
1298 Category::UiUx => {
1299 if comp.installed {
1300 ("Installed", Color::Green, "✓")
1301 } else {
1302 ("Not installed", Color::Yellow, "○")
1303 }
1304 }
1305 Category::Maintenance => {
1306 if comp.installed {
1308 ("Completed", Color::Green, "✓")
1309 } else {
1310 ("Not run", Color::Yellow, "○")
1311 }
1312 }
1313 Category::Performance => {
1314 if comp.installed {
1315 ("Benchmarked", Color::Green, "✓")
1316 } else if comp.progress > 0.0 {
1317 ("Failed", Color::Red, "✗")
1319 } else {
1320 ("Pending benchmark", Color::Yellow, "○")
1321 }
1322 }
1323 _ => {
1324 if comp.installed {
1325 ("Installed", Color::Green, "✓")
1326 } else {
1327 ("Not installed", Color::Yellow, "○")
1328 }
1329 }
1330 };
1331
1332 let status_line = Line::from(vec![
1333 Span::styled("Status: ", Style::default().fg(Color::Gray)),
1334 Span::styled(
1335 format!("{} {}", status_icon, status_label),
1336 Style::default()
1337 .fg(status_color)
1338 .add_modifier(Modifier::BOLD),
1339 ),
1340 ]);
1341 detail_lines.push(status_line);
1342 detail_lines.push(Line::from(vec![
1343 Span::styled("Estimate: ", Style::default().fg(Color::Gray)),
1344 Span::styled(comp.estimate.clone(), Style::default().fg(Color::Magenta)),
1345 ]));
1346 detail_lines.push(Line::from(""));
1347 detail_lines.push(Line::from(comp.description.clone()));
1348
1349 if comp.category == Category::Environment {
1351 detail_lines.push(Line::from(""));
1352 detail_lines.push(Line::from(Span::styled(
1353 "Environment Configuration",
1354 Style::default()
1355 .fg(Color::Cyan)
1356 .add_modifier(Modifier::BOLD),
1357 )));
1358 detail_lines.push(Line::from("Sets up persistent ROCm environment variables"));
1359 detail_lines.push(Line::from("for Python 3.12 across sessions."));
1360 } else if comp.category == Category::Maintenance {
1361 detail_lines.push(Line::from(""));
1362 detail_lines.push(Line::from(Span::styled(
1363 "Maintenance Summary",
1364 Style::default()
1365 .fg(Color::Cyan)
1366 .add_modifier(Modifier::BOLD),
1367 )));
1368 if let Some(report) = self.verification_reports.get(&comp.id) {
1369 for line in report {
1370 detail_lines.push(Line::from(line.clone()));
1371 }
1372 } else {
1373 detail_lines.push(Line::from("No verification report yet."));
1374 }
1375 } else if comp.category == Category::Performance {
1376 detail_lines.push(Line::from(""));
1377 detail_lines.push(Line::from(Span::styled(
1378 "Performance Benchmarks",
1379 Style::default()
1380 .fg(Color::Cyan)
1381 .add_modifier(Modifier::BOLD),
1382 )));
1383 detail_lines.push(Line::from("Run performance tests and benchmarks"));
1384 detail_lines.push(Line::from("to measure GPU throughput and efficiency."));
1385 }
1386 }
1387 detail_lines.push(Line::from(""));
1388 let selected_count = self.components.iter().filter(|c| c.selected).count();
1389 detail_lines.push(Line::from(format!(
1390 "Selected: {} of {}",
1391 selected_count,
1392 self.components.len()
1393 )));
1394 detail_lines.push(Line::from(""));
1395 detail_lines.push(Line::from(
1396 "Controls: ↑/↓ select • ←/→ category • Space toggle • Enter config • Q recovery",
1397 ));
1398
1399 let detail_panel = Paragraph::new(Text::from(detail_lines))
1400 .block(Block::default().borders(Borders::ALL).title("Details"))
1401 .wrap(Wrap { trim: true });
1402 frame.render_widget(detail_panel, chunks[2]);
1403 }
1404
1405 fn draw_configuration(&self, frame: &mut Frame, area: ratatui::layout::Rect) {
1406 let items = self.config_items();
1407 let list_items: Vec<ListItem> = items
1408 .iter()
1409 .enumerate()
1410 .map(|(idx, item)| {
1411 let style = if idx == self.config_selection {
1412 Style::default()
1413 .fg(Color::Yellow)
1414 .add_modifier(Modifier::BOLD)
1415 } else {
1416 Style::default()
1417 };
1418 ListItem::new(Line::from(Span::styled(item.clone(), style)))
1419 })
1420 .collect();
1421
1422 let hint = "Enter: toggle/cycle • s: save • n: next • esc: back";
1423 let mut lines = vec![Line::from("Configuration"), Line::from("")];
1424 lines.push(Line::from(format!(
1425 "Scripts Dir: {}",
1426 self.config.scripts_dir
1427 )));
1428 lines.push(Line::from(format!("Log Dir: {}", self.config.log_dir)));
1429 lines.push(Line::from(format!(
1430 "ROCm Install Path: {}",
1431 self.config.install_path
1432 )));
1433 lines.push(Line::from(""));
1434 let dirty_style = if self.config_dirty {
1435 Style::default().fg(Color::Yellow)
1436 } else {
1437 Style::default().fg(Color::Green)
1438 };
1439 lines.push(Line::from(Span::styled(
1440 format!("Dirty: {}", if self.config_dirty { "yes" } else { "no" }),
1441 dirty_style,
1442 )));
1443 lines.push(Line::from(""));
1444 lines.push(Line::from(Span::styled(
1445 "Selected setting:",
1446 Style::default()
1447 .fg(Color::Cyan)
1448 .add_modifier(Modifier::BOLD),
1449 )));
1450 for line in self.config_help_lines() {
1451 lines.push(line);
1452 }
1453 lines.push(Line::from(""));
1454 lines.push(Line::from(Span::styled(
1455 hint,
1456 Style::default().fg(Color::Gray),
1457 )));
1458
1459 let chunks = Layout::default()
1460 .direction(Direction::Horizontal)
1461 .constraints([Constraint::Percentage(60), Constraint::Percentage(40)].as_ref())
1462 .split(area);
1463
1464 let list =
1465 List::new(list_items).block(Block::default().borders(Borders::ALL).title("Settings"));
1466 frame.render_widget(list, chunks[0]);
1467
1468 let detail = Paragraph::new(Text::from(lines))
1469 .block(Block::default().borders(Borders::ALL).title("Details"))
1470 .wrap(Wrap { trim: true });
1471 frame.render_widget(detail, chunks[1]);
1472 }
1473
1474 fn draw_confirm(&self, frame: &mut Frame, area: ratatui::layout::Rect) {
1475 let selected = self.selected_components();
1476 let execution_mode = if self.config.batch_mode {
1477 "non-interactive"
1478 } else {
1479 "interactive"
1480 };
1481 let options = [
1482 format!("Execution Mode: {}", execution_mode),
1483 format!("Install Method: {}", self.config.install_method),
1484 "Back to Configuration".into(),
1485 self.action_label().into(),
1486 ];
1487 let mut lines = vec![
1488 Line::from(Span::styled(
1489 "Review your selection",
1490 Style::default()
1491 .fg(Color::Cyan)
1492 .add_modifier(Modifier::BOLD),
1493 )),
1494 Line::from(""),
1495 Line::from(format!("{}:", self.selection_label())),
1496 Line::from(""),
1497 ];
1498 for comp in selected {
1499 lines.push(Line::from(format!("• {} ({})", comp.name, comp.estimate)));
1500 }
1501 lines.push(Line::from(""));
1502 if self.run_mode() == RunMode::Install {
1504 lines.push(Line::from(format!(
1505 "ROCm Install Path: {}",
1506 self.config.install_path
1507 )));
1508 }
1509 lines.push(Line::from(format!("Execution Mode: {}", execution_mode)));
1510 if self.run_mode() == RunMode::Install {
1511 lines.push(Line::from(format!(
1512 "Install Method: {}",
1513 self.config.install_method
1514 )));
1515 }
1516 lines.push(Line::from(""));
1517 lines.push(Line::from(Span::styled(
1518 if self.run_mode() == RunMode::Install {
1519 "Pre-install options:"
1520 } else {
1521 "Run options:"
1522 },
1523 Style::default()
1524 .fg(Color::Cyan)
1525 .add_modifier(Modifier::BOLD),
1526 )));
1527 lines.push(Line::from(""));
1528 for (idx, option) in options.iter().enumerate() {
1529 let style = if idx == self.confirm_selection {
1530 Style::default()
1531 .fg(Color::Yellow)
1532 .add_modifier(Modifier::BOLD)
1533 } else {
1534 Style::default()
1535 };
1536 let prefix = if idx == self.confirm_selection {
1537 "▶"
1538 } else {
1539 " "
1540 };
1541 lines.push(Line::from(Span::styled(
1542 format!("{} {}", prefix, option),
1543 style,
1544 )));
1545 }
1546 lines.push(Line::from(""));
1547 lines.push(Line::from("Use Back to update config before install."));
1548 let paragraph = Paragraph::new(Text::from(lines))
1549 .block(Block::default().borders(Borders::ALL).title("Confirm"))
1550 .wrap(Wrap { trim: true });
1551 frame.render_widget(paragraph, area);
1552 }
1553
1554 fn draw_installing(&self, frame: &mut Frame, area: ratatui::layout::Rect) {
1555 if area.height < 10 || area.width < 30 {
1556 frame.render_widget(
1557 Paragraph::new("Terminal too small to display installation UI"),
1558 area,
1559 );
1560 return;
1561 }
1562
1563 let chunks = Layout::default()
1564 .direction(Direction::Vertical)
1565 .constraints(
1566 [
1567 Constraint::Length(3), Constraint::Length(2), Constraint::Length(6), Constraint::Min(5), Constraint::Min(10), ]
1573 .as_ref(),
1574 )
1575 .split(area);
1576
1577 let clean_msg = self.install_status.message.trim_start_matches(|c: char| {
1578 c == '['
1579 || c == ']'
1580 || c == '⠋'
1581 || c == '⠙'
1582 || c == '⠹'
1583 || c == '⠸'
1584 || c == '⠼'
1585 || c == '⠴'
1586 || c == '⠦'
1587 || c == '⠧'
1588 || c == '⠇'
1589 || c == '⠏'
1590 || c.is_whitespace()
1591 });
1592
1593 let percent = (self.install_status.progress * 100.0).round() as i32;
1594 let label = Span::styled(
1595 format!("{} {}% {}", self.spinner(), percent, clean_msg),
1596 Style::default()
1597 .fg(Color::Black)
1598 .add_modifier(Modifier::BOLD),
1599 );
1600
1601 let gauge = Gauge::default()
1602 .block(
1603 Block::default()
1604 .borders(Borders::ALL)
1605 .title("Overall Progress"),
1606 )
1607 .gauge_style(Style::default().fg(Color::Cyan))
1608 .ratio(self.install_status.progress as f64)
1609 .label(label);
1610 frame.render_widget(gauge, chunks[0]);
1611
1612 let install_path_text = Paragraph::new(Line::from(format!(
1613 "Install Path: {}",
1614 self.config.install_path
1615 )))
1616 .block(Block::default().borders(Borders::ALL).title("Target"));
1617 frame.render_widget(install_path_text, chunks[1]);
1618
1619 let install_details = vec![
1620 Line::from(format!(
1621 "Target binary: {}/bin/llama-cli",
1622 self.config.install_path
1623 )),
1624 Line::from(format!("Install prefix: {}", self.config.install_path)),
1625 ];
1626 let install_details_panel = Paragraph::new(Text::from(install_details)).block(
1627 Block::default()
1628 .borders(Borders::ALL)
1629 .title("Install Location"),
1630 );
1631 frame.render_widget(install_details_panel, chunks[2]);
1632
1633 let stage_title = self.install_stage_title();
1634 let stage_title_area = if self.telemetry_prompt_pending {
1635 let prompt = Layout::default()
1636 .direction(Direction::Vertical)
1637 .constraints([Constraint::Length(3), Constraint::Min(2)].as_ref())
1638 .split(chunks[3]);
1639 let stage_panel = Paragraph::new(stage_title)
1640 .block(Block::default().borders(Borders::ALL).title("llama.cpp"));
1641 frame.render_widget(stage_panel, prompt[1]);
1642 Some(prompt[0])
1643 } else {
1644 let stage_panel = Paragraph::new(stage_title)
1645 .block(Block::default().borders(Borders::ALL).title("llama.cpp"));
1646 frame.render_widget(stage_panel, chunks[3]);
1647 None
1648 };
1649
1650 if let Some(prompt_area) = stage_title_area {
1651 let prompt_text = Paragraph::new(Text::from(vec![
1652 Line::from(Span::styled(
1653 "Share anonymized build data to help validate future releases? [Y/n]",
1654 Style::default()
1655 .fg(Color::Yellow)
1656 .add_modifier(Modifier::BOLD),
1657 )),
1658 Line::from("Y/y/Enter submits build data • n/N skips silently"),
1659 Line::from("This prompt is part of the install flow."),
1660 ]))
1661 .block(
1662 Block::default()
1663 .borders(Borders::ALL)
1664 .title("Telemetry Opt-In"),
1665 )
1666 .wrap(Wrap { trim: true });
1667 frame.render_widget(prompt_text, prompt_area);
1668 }
1669
1670 let body = Layout::default()
1671 .direction(Direction::Horizontal)
1672 .constraints([Constraint::Percentage(70), Constraint::Percentage(30)].as_ref())
1673 .split(chunks[4]);
1674
1675 let log_height = body[0].height.saturating_sub(2) as usize;
1676 let log_items: Vec<ListItem> = if self.logs.is_empty() {
1677 vec![ListItem::new(Line::from("Waiting for installer output..."))]
1678 } else {
1679 let start = self.logs.len().saturating_sub(log_height);
1680 self.logs[start..]
1681 .iter()
1682 .map(|line| ListItem::new(Line::from(line.clone())))
1683 .collect()
1684 };
1685 let log_list = List::new(log_items).block(
1686 Block::default()
1687 .borders(Borders::ALL)
1688 .title(format!("{} Log", self.flow_noun())),
1689 );
1690 frame.render_widget(log_list, body[0]);
1691
1692 let selected: Vec<&Component> = self.components.iter().filter(|c| c.selected).collect();
1693 let installed_count = selected.iter().filter(|c| c.installed).count();
1694 let failed_count = selected
1695 .iter()
1696 .filter(|c| !c.installed && c.progress > 0.0)
1697 .count();
1698 let pending_count = selected.iter().filter(|c| c.progress == 0.0).count();
1699 let idle_secs = self.tick_count.saturating_sub(self.install_activity_tick) / 10;
1700
1701 let base_lines = vec![
1702 Line::from(Span::styled(
1703 format!("{} Status", self.flow_noun()),
1704 Style::default()
1705 .fg(Color::Cyan)
1706 .add_modifier(Modifier::BOLD),
1707 )),
1708 Line::from(""),
1709 Line::from(format!("Installed: {}", installed_count)),
1710 Line::from(format!("Failed: {}", failed_count)),
1711 Line::from(format!("Pending: {}", pending_count)),
1712 Line::from(""),
1713 Line::from(format!("Current: {}", self.install_status.message)),
1714 Line::from(format!(
1715 "Activity: {} live; last output ~{}s ago",
1716 self.spinner(),
1717 idle_secs
1718 )),
1719 ];
1720
1721 let tail_lines = vec![
1722 Line::from(""),
1723 Line::from(format!(
1724 "Input mode: {} (Ctrl+R to toggle)",
1725 self.install_input_mode.label()
1726 )),
1727 Line::from(format!("Input buffer: {}", self.install_input_buffer)),
1728 Line::from("Enter sends line; raw mode sends keystrokes"),
1729 Line::from(Span::styled(
1730 "Press L to open full logs",
1731 Style::default().fg(Color::Cyan),
1732 )),
1733 Line::from(""),
1734 Line::from(Span::styled(
1735 "Press Q for recovery",
1736 Style::default().fg(Color::Yellow),
1737 )),
1738 ];
1739
1740 let available_lines = body[1].height.saturating_sub(2) as usize;
1741 let checklist_budget =
1742 available_lines.saturating_sub(base_lines.len() + tail_lines.len() + 1);
1743 let checklist_lines = self.checklist_lines(checklist_budget);
1744
1745 let mut status_lines = base_lines;
1746 if !checklist_lines.is_empty() {
1747 status_lines.push(Line::from(""));
1748 status_lines.extend(checklist_lines);
1749 }
1750 status_lines.extend(tail_lines);
1751
1752 let status_panel = Paragraph::new(Text::from(status_lines))
1753 .block(Block::default().borders(Borders::ALL).title("Status"))
1754 .wrap(Wrap { trim: true });
1755 frame.render_widget(status_panel, body[1]);
1756
1757 if self.install_log_popup {
1758 self.draw_install_log_popup(frame, area);
1759 }
1760 }
1761
1762 fn draw_install_log_popup(&self, frame: &mut Frame, area: ratatui::layout::Rect) {
1763 let width = area.width.saturating_sub(4).max(40);
1764 let height = area.height.saturating_sub(2).max(10);
1765 let popup = Rect {
1766 x: area.x + area.width.saturating_sub(width) / 2,
1767 y: area.y + area.height.saturating_sub(height) / 2,
1768 width,
1769 height,
1770 };
1771
1772 frame.render_widget(Clear, popup);
1773 let file_hint = self
1774 .latest_install_log_path()
1775 .unwrap_or_else(|| "(not detected yet)".to_string());
1776
1777 let mut lines = Vec::new();
1778 lines.push(Line::from(Span::styled(
1779 format!("Full {} Logs", self.flow_noun()),
1780 Style::default()
1781 .fg(Color::Cyan)
1782 .add_modifier(Modifier::BOLD),
1783 )));
1784 lines.push(Line::from(format!("Log file: {}", file_hint)));
1785 lines.push(Line::from(""));
1786
1787 let visible_lines = popup.height.saturating_sub(7) as usize;
1788 if self.logs.is_empty() {
1789 lines.push(Line::from("No logs captured yet."));
1790 } else {
1791 let max_scroll = self.logs.len().saturating_sub(visible_lines);
1792 let start = self.install_log_scroll.min(max_scroll);
1793 let end = (start + visible_lines).min(self.logs.len());
1794 for entry in &self.logs[start..end] {
1795 lines.push(Line::from(entry.clone()));
1796 }
1797 lines.push(Line::from(""));
1798 lines.push(Line::from(format!(
1799 "Showing lines {}-{} of {}",
1800 start.saturating_add(1),
1801 end,
1802 self.logs.len()
1803 )));
1804 }
1805 lines.push(Line::from("Up/Down/PageUp/PageDown scroll • Esc/L close"));
1806
1807 let widget = Paragraph::new(Text::from(lines))
1808 .block(
1809 Block::default()
1810 .borders(Borders::ALL)
1811 .title("Logs")
1812 .border_style(Style::default().fg(Color::Cyan)),
1813 )
1814 .wrap(Wrap { trim: false });
1815 frame.render_widget(widget, popup);
1816 }
1817
1818 fn draw_complete(&self, frame: &mut Frame, area: ratatui::layout::Rect) {
1819 frame.render_widget(Clear, area);
1820 let (installed, failed, skipped) = self.partition_components();
1821 let (benchmarks, tests) = self.count_log_keywords();
1822 let mut lines = vec![
1823 Line::from(Span::styled(
1824 self.summary_title(),
1825 Style::default()
1826 .fg(Color::Cyan)
1827 .add_modifier(Modifier::BOLD),
1828 )),
1829 Line::from(""),
1830 ];
1831
1832 lines.push(Line::from(Span::styled(
1833 format!("{}:", self.success_label()),
1834 Style::default()
1835 .fg(Color::Green)
1836 .add_modifier(Modifier::BOLD),
1837 )));
1838 if installed.is_empty() {
1839 lines.push(Line::from(" (none)"));
1840 } else {
1841 for comp in installed.iter() {
1842 let (status, color) = if comp.installed {
1843 (self.success_status(), Color::Green)
1844 } else if comp.progress > 0.0 {
1845 ("failed", Color::Red)
1846 } else {
1847 ("skipped", Color::Yellow)
1848 };
1849 lines.push(Line::from(vec![
1850 Span::raw("✓ "),
1851 Span::raw(comp.name.clone()),
1852 Span::raw(" ("),
1853 Span::styled(status, Style::default().fg(color)),
1854 Span::raw(")"),
1855 ]));
1856 }
1857 }
1858
1859 lines.push(Line::from(""));
1860 lines.push(Line::from(Span::styled(
1861 "Failed:",
1862 Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
1863 )));
1864 if failed.is_empty() {
1865 lines.push(Line::from(" (none)"));
1866 } else {
1867 for comp in failed.iter() {
1868 lines.push(Line::from(vec![
1869 Span::raw("✗ "),
1870 Span::styled(comp.name.clone(), Style::default().fg(Color::Red)),
1871 ]));
1872 }
1873 }
1874
1875 lines.push(Line::from(""));
1876 lines.push(Line::from(Span::styled(
1877 "Skipped:",
1878 Style::default()
1879 .fg(Color::Yellow)
1880 .add_modifier(Modifier::BOLD),
1881 )));
1882 if skipped.is_empty() {
1883 lines.push(Line::from(" (none)"));
1884 } else {
1885 for comp in skipped.iter() {
1886 lines.push(Line::from(vec![
1887 Span::raw("• "),
1888 Span::styled(comp.name.clone(), Style::default().fg(Color::DarkGray)),
1889 ]));
1890 }
1891 }
1892
1893 let uiux_apps: Vec<&Component> = self
1895 .components
1896 .iter()
1897 .filter(|c| c.category == Category::UiUx && c.installed)
1898 .collect();
1899 if !uiux_apps.is_empty() {
1900 lines.push(Line::from(""));
1901 lines.push(Line::from(Span::styled(
1902 "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━",
1903 Style::default().fg(Color::Magenta),
1904 )));
1905 lines.push(Line::from(Span::styled(
1906 " UI/UX Applications",
1907 Style::default()
1908 .fg(Color::Magenta)
1909 .add_modifier(Modifier::BOLD),
1910 )));
1911 lines.push(Line::from(Span::styled(
1912 "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━",
1913 Style::default().fg(Color::Magenta),
1914 )));
1915
1916 for app in uiux_apps {
1917 lines.push(Line::from(""));
1918 if app.id == "vllm-studio" {
1919 lines.push(Line::from(vec![Span::styled(
1920 "🎨 vLLM Studio:",
1921 Style::default()
1922 .fg(Color::Cyan)
1923 .add_modifier(Modifier::BOLD),
1924 )]));
1925 lines.push(Line::from(vec![
1926 Span::raw(" Run: "),
1927 Span::styled("vllm-studio", Style::default().fg(Color::Green)),
1928 ]));
1929 lines.push(Line::from(vec![
1930 Span::raw(" Tips: "),
1931 Span::styled(
1932 "Start from $HOME/vllm-studio/controller",
1933 Style::default().fg(Color::Gray),
1934 ),
1935 ]));
1936 } else if app.id == "comfyui" {
1937 lines.push(Line::from(vec![Span::styled(
1938 "🎨 ComfyUI:",
1939 Style::default()
1940 .fg(Color::Cyan)
1941 .add_modifier(Modifier::BOLD),
1942 )]));
1943 lines.push(Line::from(vec![
1944 Span::raw(" Run: "),
1945 Span::styled("comfy", Style::default().fg(Color::Green)),
1946 ]));
1947 lines.push(Line::from(vec![
1948 Span::raw(" URL: "),
1949 Span::styled("http://localhost:8188", Style::default().fg(Color::Green)),
1950 ]));
1951 lines.push(Line::from(vec![
1952 Span::raw(" Tips: "),
1953 Span::styled(
1954 "Uses ROCm GPU acceleration",
1955 Style::default().fg(Color::Gray),
1956 ),
1957 ]));
1958 }
1959 }
1960
1961 lines.push(Line::from(Span::styled(
1962 "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━",
1963 Style::default().fg(Color::Magenta),
1964 )));
1965 }
1966
1967 let (env, verification) = self.partition_categories();
1968 lines.push(Line::from(""));
1969 lines.push(Line::from(Span::styled(
1970 "llama.cpp Turbo Quant",
1971 Style::default()
1972 .fg(Color::Magenta)
1973 .add_modifier(Modifier::BOLD),
1974 )));
1975 lines.push(Line::from("Experimental tier • o"));
1976 if let Some(summary) = self.llama_completion_summary() {
1977 for line in summary {
1978 lines.push(line);
1979 }
1980 }
1981 lines.push(Line::from(""));
1982 lines.push(Line::from(Span::styled(
1983 "Environment:",
1984 Style::default()
1985 .fg(Color::Blue)
1986 .add_modifier(Modifier::BOLD),
1987 )));
1988 if env.is_empty() {
1989 lines.push(Line::from(" (none)"));
1990 } else {
1991 for comp in env.iter() {
1992 let (status_text, color) = if comp.installed {
1993 ("configured", Color::Green)
1994 } else {
1995 ("pending", Color::Yellow)
1996 };
1997 lines.push(Line::from(vec![
1998 Span::raw("• "),
1999 Span::raw(comp.name.clone()),
2000 Span::raw(" ("),
2001 Span::styled(status_text, Style::default().fg(color)),
2002 Span::raw(")"),
2003 ]));
2004 }
2005 }
2006
2007 let env_details = self.env_summary();
2008 if !env_details.is_empty() {
2009 lines.push(Line::from(""));
2010 lines.push(Line::from(Span::styled(
2011 "Environment Details:",
2012 Style::default()
2013 .fg(Color::Blue)
2014 .add_modifier(Modifier::BOLD),
2015 )));
2016 for (key, value) in env_details {
2017 lines.push(Line::from(vec![
2018 Span::styled(format!(" {}: ", key), Style::default().fg(Color::Gray)),
2019 Span::styled(value, Style::default().fg(Color::Cyan)),
2020 ]));
2021 }
2022 }
2023
2024 lines.push(Line::from(""));
2025 lines.push(Line::from(Span::styled(
2026 "Verification:",
2027 Style::default()
2028 .fg(Color::Blue)
2029 .add_modifier(Modifier::BOLD),
2030 )));
2031 if verification.is_empty() {
2032 lines.push(Line::from(" (none)"));
2033 } else {
2034 for comp in verification.iter() {
2035 let (status, color) = match self.verification_reports.get(&comp.id) {
2036 Some(_) => {
2037 let s = self.verification_status(&comp.id);
2038 let c = match s {
2039 "verified" => Color::Green,
2040 "failed" => Color::Red,
2041 "missing" => Color::Yellow,
2042 _ => Color::Gray,
2043 };
2044 (s, c)
2045 }
2046 None if comp.installed => ("completed", Color::Green),
2047 None => ("pending", Color::Yellow),
2048 };
2049 lines.push(Line::from(vec![
2050 Span::raw("• "),
2051 Span::raw(comp.name.clone()),
2052 Span::raw(" ("),
2053 Span::styled(status, Style::default().fg(color)),
2054 Span::raw(")"),
2055 ]));
2056 }
2057 }
2058
2059 lines.push(Line::from(""));
2060 lines.push(Line::from(Span::styled(
2061 "Component Verification:",
2062 Style::default()
2063 .fg(Color::Blue)
2064 .add_modifier(Modifier::BOLD),
2065 )));
2066 let selected = self.selected_components();
2067 if selected.is_empty() {
2068 lines.push(Line::from(" (none)"));
2069 } else {
2070 for comp in selected {
2071 let status = self.verification_status(&comp.id);
2072 let color = match status {
2073 "verified" => Color::Green,
2074 "failed" => Color::Red,
2075 "missing" => Color::Yellow,
2076 _ => Color::Gray,
2077 };
2078 lines.push(Line::from(vec![
2079 Span::raw("• "),
2080 Span::raw(comp.name.clone()),
2081 Span::raw(" ("),
2082 Span::styled(status, Style::default().fg(color)),
2083 Span::raw(")"),
2084 ]));
2085 }
2086 }
2087
2088 lines.push(Line::from(""));
2089 lines.push(Line::from(Span::styled(
2090 "Verification Report:",
2091 Style::default()
2092 .fg(Color::Blue)
2093 .add_modifier(Modifier::BOLD),
2094 )));
2095 let report_lines = self.verification_report_lines();
2096 if report_lines.is_empty() {
2097 lines.push(Line::from(" (none)"));
2098 } else {
2099 lines.extend(report_lines);
2100 }
2101
2102 lines.push(Line::from(""));
2103 let benchmarks_line = Line::from(vec![
2104 Span::styled("Benchmarks logged: ", Style::default().fg(Color::Gray)),
2105 Span::styled(benchmarks.to_string(), Style::default().fg(Color::Cyan)),
2106 Span::styled(" | ", Style::default().fg(Color::DarkGray)),
2107 Span::styled("Tests logged: ", Style::default().fg(Color::Gray)),
2108 Span::styled(tests.to_string(), Style::default().fg(Color::Cyan)),
2109 ]);
2110 lines.push(benchmarks_line);
2111
2112 let total_lines = lines.len() as u16;
2113 let visible_height = area.height.saturating_sub(2);
2114 let max_scroll = total_lines.saturating_sub(visible_height);
2115
2116 let paragraph = Paragraph::new(Text::from(lines))
2117 .block(Block::default().borders(Borders::ALL).title(format!(
2118 "Complete (↑↓ to scroll, Esc to recovery) [{}/{}]",
2119 self.summary_scroll, max_scroll
2120 )))
2121 .wrap(Wrap { trim: true })
2122 .scroll((self.summary_scroll, 0));
2123 frame.render_widget(paragraph, area);
2124 }
2125
2126 fn llama_completion_summary(&self) -> Option<Vec<Line<'_>>> {
2127 let comp = self.components.iter().find(|c| c.id == "llama-cpp")?;
2128 if !comp.installed {
2129 return None;
2130 }
2131 Some(vec![
2132 Line::from(format!("Version: {}", self.llama_version_label())),
2133 Line::from(format!("Path: {}", self.config.install_path)),
2134 Line::from(format!("GPU Arch: {}", self.hardware.gpu.architecture)),
2135 Line::from(format!("Install Type: {}", self.llama_install_type_label())),
2136 ])
2137 }
2138
2139 fn llama_version_label(&self) -> String {
2140 self.verification_reports
2141 .get("llama-cpp")
2142 .and_then(|lines| lines.iter().find(|line| line.contains("version")))
2143 .cloned()
2144 .unwrap_or_else(|| "installed".into())
2145 }
2146
2147 fn llama_install_type_label(&self) -> &'static str {
2148 if self
2149 .logs
2150 .iter()
2151 .any(|line| line.contains("prebuilt") || line.contains("download"))
2152 {
2153 "pre-built"
2154 } else {
2155 "source"
2156 }
2157 }
2158
2159 fn draw_benchmarks(&self, frame: &mut Frame, area: ratatui::layout::Rect) {
2160 let results = load_benchmark_results();
2161 render_benchmark_page(frame, area, &results, self.benchmark_tab_index);
2162 }
2163
2164 fn export_benchmark_report(&mut self) {
2165 let results = load_benchmark_results();
2166 match export_benchmark_report_html(&results, None) {
2167 Ok(path) => {
2168 let msg = format!("Benchmark HTML report exported: {}", path.display());
2169 self.logs.push(msg.clone());
2170 self.benchmark_notice = Some(UiNotice {
2171 message: msg,
2172 color: Color::Green,
2173 expires_at_tick: self.tick_count.saturating_add(80),
2174 });
2175 }
2176 Err(err) => {
2177 let msg = format!("Failed to export benchmark HTML report: {}", err);
2178 self.errors.push(msg.clone());
2179 self.benchmark_notice = Some(UiNotice {
2180 message: msg,
2181 color: Color::Red,
2182 expires_at_tick: self.tick_count.saturating_add(120),
2183 });
2184 }
2185 }
2186 }
2187
2188 fn draw_notice(&self, frame: &mut Frame, area: Rect) {
2189 let Some(notice) = &self.benchmark_notice else {
2190 return;
2191 };
2192
2193 let width = area.width.saturating_sub(4).clamp(20, 110);
2194 let height = 5u16;
2195 let x = area.x + area.width.saturating_sub(width) / 2;
2196 let y = area.y + 1;
2197 let popup = Rect {
2198 x,
2199 y,
2200 width,
2201 height,
2202 };
2203
2204 frame.render_widget(Clear, popup);
2205 let text = vec![
2206 Line::from(Span::styled(
2207 "Benchmark Export",
2208 Style::default()
2209 .fg(notice.color)
2210 .add_modifier(Modifier::BOLD),
2211 )),
2212 Line::from(""),
2213 Line::from(notice.message.clone()),
2214 ];
2215 let notice_widget = Paragraph::new(Text::from(text))
2216 .wrap(Wrap { trim: true })
2217 .block(
2218 Block::default()
2219 .borders(Borders::ALL)
2220 .border_style(Style::default().fg(notice.color)),
2221 );
2222 frame.render_widget(notice_widget, popup);
2223 }
2224
2225 fn draw_recovery(&self, frame: &mut Frame, area: ratatui::layout::Rect) {
2226 let chunks = Layout::default()
2227 .direction(Direction::Horizontal)
2228 .constraints([Constraint::Percentage(60), Constraint::Percentage(40)].as_ref())
2229 .split(area);
2230
2231 let items = self.recovery_items();
2232 let list_items: Vec<ListItem> = items
2233 .iter()
2234 .enumerate()
2235 .map(|(idx, item)| {
2236 let style = if idx == self.recovery_selection {
2237 Style::default()
2238 .fg(Color::Yellow)
2239 .add_modifier(Modifier::BOLD)
2240 } else {
2241 Style::default()
2242 };
2243 ListItem::new(Line::from(Span::styled(item.clone(), style)))
2244 })
2245 .collect();
2246
2247 let list = List::new(list_items).block(
2248 Block::default()
2249 .borders(Borders::ALL)
2250 .title("Recovery Options"),
2251 );
2252 frame.render_widget(list, chunks[0]);
2253
2254 let mut lines = vec![Line::from("Recovery / Diagnostics"), Line::from("")];
2255 if !self.errors.is_empty() {
2256 lines.push(Line::from(Span::styled(
2257 "Errors:",
2258 Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
2259 )));
2260 for err in &self.errors {
2261 lines.push(Line::from(Span::styled(
2262 format!("• {}", err),
2263 Style::default().fg(Color::Red),
2264 )));
2265 }
2266 } else {
2267 lines.push(Line::from(Span::styled(
2268 "No errors captured.",
2269 Style::default().fg(Color::Green),
2270 )));
2271 }
2272 lines.push(Line::from(""));
2273 lines.push(Line::from("Use ↑/↓ then Enter to select."));
2274 lines.push(Line::from("Press Q to quit immediately."));
2275
2276 let paragraph = Paragraph::new(Text::from(lines))
2277 .block(Block::default().borders(Borders::ALL).title("Status"))
2278 .wrap(Wrap { trim: true });
2279 frame.render_widget(paragraph, chunks[1]);
2280 }
2281
2282 fn move_selection(&mut self, delta: i32) {
2283 let current_category = self.current_category();
2284 let filtered: Vec<&Component> = self
2285 .components
2286 .iter()
2287 .filter(|c| c.category == current_category)
2288 .collect();
2289 if filtered.is_empty() {
2290 self.selected_component = 0;
2291 return;
2292 }
2293 let len = filtered.len() as i32;
2294 let mut idx = self.selected_component as i32 + delta;
2295 if idx < 0 {
2296 idx = len - 1;
2297 }
2298 if idx >= len {
2299 idx = 0;
2300 }
2301 self.selected_component = idx as usize;
2302 }
2303
2304 fn change_category(&mut self, delta: i32) {
2305 let categories_len = 7i32; let mut idx = self.selected_category as i32 + delta;
2307 if idx < 0 {
2308 idx = categories_len - 1;
2309 }
2310 if idx >= categories_len {
2311 idx = 0;
2312 }
2313 self.selected_category = idx as usize;
2314 self.selected_component = 0;
2315 }
2316
2317 fn toggle_component(&mut self) {
2318 let current_category = self.current_category();
2319 let indices: Vec<usize> = self
2320 .components
2321 .iter()
2322 .enumerate()
2323 .filter(|(_, c)| c.category == current_category)
2324 .map(|(idx, _)| idx)
2325 .collect();
2326 if indices.is_empty() {
2327 return;
2328 }
2329 if self.selected_component >= indices.len() {
2330 self.selected_component = 0;
2331 }
2332 let index = indices[self.selected_component];
2333 if let Some(component) = self.components.get_mut(index) {
2334 component.selected = !component.selected;
2335 }
2336 self.enforce_flash_attn_exclusivity(Some(index));
2341 }
2342
2343 fn toggle_all(&mut self) {
2344 let any_selected = self.components.iter().any(|c| c.selected);
2345 for comp in self.components.iter_mut() {
2346 comp.selected = !any_selected;
2347 }
2348 self.enforce_flash_attn_exclusivity(None);
2350 }
2351
2352 fn enforce_flash_attn_exclusivity(&mut self, toggled_index: Option<usize>) {
2358 const FA_BACKENDS: &[&str] = &["flash-attn-triton", "flash-attn-ck", "flash-attn"];
2359 match toggled_index {
2360 Some(idx) => {
2361 let turned_on = self
2362 .components
2363 .get(idx)
2364 .map(|c| c.selected && FA_BACKENDS.contains(&c.id.as_str()))
2365 == Some(true);
2366 if !turned_on {
2367 return;
2368 }
2369 let toggled_id = self.components[idx].id.clone();
2370 for c in self.components.iter_mut() {
2371 if c.id != toggled_id && FA_BACKENDS.contains(&c.id.as_str()) {
2372 c.selected = false;
2373 }
2374 }
2375 }
2376 None => {
2377 let first_selected = self
2378 .components
2379 .iter()
2380 .position(|c| c.selected && FA_BACKENDS.contains(&c.id.as_str()));
2381 let Some(keep) = first_selected else {
2382 return;
2383 };
2384 let keep_id = self.components[keep].id.clone();
2385 for c in self.components.iter_mut() {
2386 if c.id != keep_id && FA_BACKENDS.contains(&c.id.as_str()) {
2387 c.selected = false;
2388 }
2389 }
2390 }
2391 }
2392 }
2393
2394 fn config_rows(&self) -> Vec<(ConfigKey, String)> {
2395 vec![
2396 (
2397 ConfigKey::RocmPath,
2398 format!("ROCm Install Path: {}", self.config.install_path),
2399 ),
2400 (
2401 ConfigKey::BatchMode,
2402 format!("Batch Mode: {}", on_off(self.config.batch_mode)),
2403 ),
2404 (
2405 ConfigKey::AutoConfirm,
2406 format!("Auto Confirm: {}", on_off(self.config.auto_confirm)),
2407 ),
2408 (
2409 ConfigKey::StarRepo,
2410 format!("Star ML Stack Repo: {}", on_off(self.config.star_repos)),
2411 ),
2412 (
2413 ConfigKey::ForceReinstall,
2414 format!(
2415 "Force Reinstall All: {}",
2416 on_off(self.config.force_reinstall)
2417 ),
2418 ),
2419 (ConfigKey::Theme, format!("Theme: {}", self.config.theme)),
2420 (
2421 ConfigKey::PerfProfile,
2422 format!("Performance Profile: {}", self.config.performance_profile),
2423 ),
2424 (
2425 ConfigKey::VllmVersionLag,
2426 format!(
2427 "vLLM version lag: {} ({}; 0=latest)",
2428 self.config.vllm_version_lag,
2429 if self.config.vllm_version_lag == 0 {
2430 "latest"
2431 } else {
2432 "skip newest"
2433 }
2434 ),
2435 ),
2436 (
2437 ConfigKey::VllmVersionAge,
2438 format!(
2439 "vLLM min release age: {} days ({}; 0=off)",
2440 self.config.vllm_version_min_age_days,
2441 if self.config.vllm_version_min_age_days == 0 {
2442 "off"
2443 } else {
2444 "age gate"
2445 }
2446 ),
2447 ),
2448 (
2449 ConfigKey::OnnxInstallMethod,
2450 format!(
2451 "ONNX install method: {} ({})",
2452 self.config.onnx_install_method,
2453 match self.config.onnx_install_method.as_str() {
2454 "source" => "build from source",
2455 "prebuilt" => "legacy onnxruntime-rocm",
2456 _ => "PyPI onnxruntime-migraphx",
2457 }
2458 ),
2459 ),
2460 (
2461 ConfigKey::OnnxVersion,
2462 format!(
2463 "ONNX version: {}",
2464 self.config
2465 .onnx_version
2466 .clone()
2467 .unwrap_or_else(|| "default (1.27.1)".into())
2468 ),
2469 ),
2470 (ConfigKey::Save, "Save Configuration".into()),
2471 ]
2472 }
2473
2474 fn selected_config_key(&self) -> ConfigKey {
2475 let mode = self.run_mode();
2476 self.config_rows()
2477 .into_iter()
2478 .filter(|(k, _)| config_applies(*k, mode))
2479 .nth(self.config_selection)
2480 .map(|(k, _)| k)
2481 .unwrap_or(ConfigKey::Save)
2482 }
2483
2484 fn config_items(&self) -> Vec<String> {
2485 let mode = self.run_mode();
2486 self.config_rows()
2487 .into_iter()
2488 .filter(|(k, _)| config_applies(*k, mode))
2489 .map(|(_, label)| label)
2490 .collect()
2491 }
2492
2493 fn config_help_lines(&self) -> Vec<Line<'_>> {
2494 let mut lines = match self.selected_config_key() {
2495 ConfigKey::RocmPath => vec![
2496 Line::from("ROCm install path used by installers."),
2497 Line::from("Default: /opt/rocm (system-wide ROCm)."),
2498 ],
2499 ConfigKey::BatchMode => vec![
2500 Line::from("Batch mode runs scripts non-interactively."),
2501 Line::from("Defaults are chosen when prompts appear."),
2502 ],
2503 ConfigKey::AutoConfirm => vec![
2504 Line::from("Auto confirm answers yes to prompts"),
2505 Line::from("when supported by the script."),
2506 ],
2507 ConfigKey::StarRepo => vec![
2508 Line::from("Star the ML Stack repository on GitHub."),
2509 Line::from("https://github.com/scooter-lacroix/Stan-s-ML-Stack"),
2510 ],
2511 ConfigKey::ForceReinstall => vec![
2512 Line::from("FORCE REINSTALL ALL COMPONENTS."),
2513 Line::from("Forces purging and re-downloading of everything."),
2514 ],
2515 ConfigKey::Theme => vec![
2516 Line::from("Theme affects TUI color styling."),
2517 Line::from("Switches between dark/light palettes."),
2518 ],
2519 ConfigKey::PerfProfile => vec![
2520 Line::from("Performance profile adjusts installer tuning."),
2521 Line::from("Balanced/performance/efficiency presets."),
2522 ],
2523 ConfigKey::VllmVersionLag => vec![
2524 Line::from("Supply-chain gate: skip this many newest vLLM releases."),
2525 Line::from("0=latest, 1=skip newest (default), 2+=more conservative."),
2526 ],
2527 ConfigKey::VllmVersionAge => vec![
2528 Line::from("Supply-chain gate: only adopt vLLM releases at least"),
2529 Line::from("this many days old (0=off). Combined with the lag."),
2530 ],
2531 ConfigKey::OnnxInstallMethod => vec![
2532 Line::from("ONNX Runtime install method."),
2533 Line::from("migraphx = PyPI onnxruntime-migraphx (default, working)."),
2534 Line::from("prebuilt = legacy onnxruntime-rocm (may mismatch ROCm)."),
2535 Line::from("source = build from source against /opt/rocm (heavy)."),
2536 ],
2537 ConfigKey::OnnxVersion => vec![
2538 Line::from("ONNX Runtime version override."),
2539 Line::from("default = pinned 1.27.1 (tracks the PyPI release line;"),
2540 Line::from("kept in lock-step with the manifest target)."),
2541 ],
2542 ConfigKey::Save => vec![
2543 Line::from("Persist current settings to config.json."),
2544 Line::from("Dirty=yes means there are unsaved changes."),
2545 ],
2546 };
2547 lines.push(Line::from(""));
2548 lines.push(Line::from(TELEMETRY_PRIVACY_NOTE));
2549 lines
2550 }
2551
2552 fn move_config_selection(&mut self, delta: i32) {
2553 let len = self.config_items().len() as i32;
2554 let mut idx = self.config_selection as i32 + delta;
2555 if idx < 0 {
2556 idx = len - 1;
2557 }
2558 if idx >= len {
2559 idx = 0;
2560 }
2561 self.config_selection = idx as usize;
2562 }
2563
2564 fn move_confirm_selection(&mut self, delta: i32) {
2565 let len = 4i32;
2566 let mut idx = self.confirm_selection as i32 + delta;
2567 if idx < 0 {
2568 idx = len - 1;
2569 }
2570 if idx >= len {
2571 idx = 0;
2572 }
2573 self.confirm_selection = idx as usize;
2574 }
2575
2576 fn adjust_confirm_selection(&mut self, delta: i32) {
2577 match self.confirm_selection {
2578 0 => {
2579 self.config.batch_mode = if delta < 0 {
2580 true
2581 } else if delta > 0 {
2582 false
2583 } else {
2584 !self.config.batch_mode
2585 };
2586 self.config_dirty = true;
2587 }
2588 1 => {
2589 self.cycle_install_method(delta);
2590 self.config_dirty = true;
2591 }
2592 _ => {}
2593 }
2594 }
2595
2596 fn activate_confirm_selection(&mut self) {
2597 match self.confirm_selection {
2598 0 => self.adjust_confirm_selection(0),
2599 1 => self.adjust_confirm_selection(1),
2600 2 => self.stage = Stage::Configuration,
2601 3 => self.start_installation(),
2602 _ => {}
2603 }
2604 }
2605
2606 fn cycle_install_method(&mut self, delta: i32) {
2607 let methods = ["auto", "global", "venv"];
2608 let current = methods
2609 .iter()
2610 .position(|m| *m == self.config.install_method)
2611 .unwrap_or(0);
2612 let next = (current as i32 + delta).rem_euclid(methods.len() as i32) as usize;
2613 self.config.install_method = methods[next].into();
2614 }
2615
2616 fn move_preflight_selection(&mut self, delta: i32) {
2617 let len = self.preflight.checks.len() as i32;
2618 if len == 0 {
2619 self.preflight_selection = 0;
2620 return;
2621 }
2622 let mut idx = self.preflight_selection as i32 + delta;
2623 if idx < 0 {
2624 idx = len - 1;
2625 }
2626 if idx >= len {
2627 idx = 0;
2628 }
2629 self.preflight_selection = idx as usize;
2630 }
2631
2632 fn activate_config_selection(&mut self) {
2633 match self.selected_config_key() {
2634 ConfigKey::BatchMode => {
2635 self.config.batch_mode = !self.config.batch_mode;
2636 self.config_dirty = true;
2637 }
2638 ConfigKey::AutoConfirm => {
2639 self.config.auto_confirm = !self.config.auto_confirm;
2640 self.config_dirty = true;
2641 }
2642 ConfigKey::StarRepo => {
2643 self.config.star_repos = !self.config.star_repos;
2644 self.config_dirty = true;
2645 }
2646 ConfigKey::ForceReinstall => {
2647 self.config.force_reinstall = !self.config.force_reinstall;
2648 self.config_dirty = true;
2649 }
2650 ConfigKey::Theme => {
2651 self.config.theme = if self.config.theme == "dark" {
2652 "light".into()
2653 } else {
2654 "dark".into()
2655 };
2656 self.config_dirty = true;
2657 }
2658 ConfigKey::PerfProfile => {
2659 let profiles = ["balanced", "performance", "efficiency"];
2660 let current = profiles
2661 .iter()
2662 .position(|p| *p == self.config.performance_profile)
2663 .unwrap_or(0);
2664 let next = (current + 1) % profiles.len();
2665 self.config.performance_profile = profiles[next].into();
2666 self.config_dirty = true;
2667 }
2668 ConfigKey::VllmVersionLag => {
2669 self.config.vllm_version_lag = (self.config.vllm_version_lag + 1) % 6;
2671 self.config_dirty = true;
2672 }
2673 ConfigKey::VllmVersionAge => {
2674 let ages = [0, 7, 14, 30, 60, 90];
2676 let cur = ages
2677 .iter()
2678 .position(|&a| a == self.config.vllm_version_min_age_days)
2679 .unwrap_or(0);
2680 self.config.vllm_version_min_age_days = ages[(cur + 1) % ages.len()];
2681 self.config_dirty = true;
2682 }
2683 ConfigKey::OnnxInstallMethod => {
2684 let methods = ["migraphx", "prebuilt", "source"];
2686 let cur = methods
2687 .iter()
2688 .position(|m| *m == self.config.onnx_install_method)
2689 .unwrap_or(0);
2690 self.config.onnx_install_method = methods[(cur + 1) % methods.len()].into();
2691 self.config_dirty = true;
2692 }
2693 ConfigKey::OnnxVersion => {
2694 let cur = self.config.onnx_version.as_deref();
2696 let next = match cur {
2697 None => Some("1.25.0"),
2698 _ => None, };
2700 self.config.onnx_version = next.map(str::to_string);
2701 self.config_dirty = true;
2702 }
2703 ConfigKey::Save => self.save_config(),
2704 ConfigKey::RocmPath => {} }
2706 }
2707
2708 fn save_config(&mut self) {
2709 let existing = std::fs::read_to_string(&self.config.config_path)
2710 .ok()
2711 .and_then(|raw| serde_json::from_str(&raw).ok());
2712 match self.config.save(existing) {
2713 Ok(_) => {
2714 self.push_log("Configuration saved".into());
2715 self.config_dirty = false;
2716 }
2717 Err(err) => {
2718 self.errors.push(format!("Config save failed: {err}"));
2719 }
2720 }
2721 }
2722
2723 fn recovery_items(&self) -> Vec<String> {
2724 vec![
2725 "Re-run hardware detection".into(),
2726 "Re-run preflight checks".into(),
2727 "Return to component selection".into(),
2728 "Exit installer".into(),
2729 ]
2730 }
2731
2732 fn move_recovery_selection(&mut self, delta: i32) {
2733 let len = self.recovery_items().len() as i32;
2734 let mut idx = self.recovery_selection as i32 + delta;
2735 if idx < 0 {
2736 idx = len - 1;
2737 }
2738 if idx >= len {
2739 idx = 0;
2740 }
2741 self.recovery_selection = idx as usize;
2742 }
2743
2744 fn activate_recovery_selection(&mut self) {
2745 match self.recovery_selection {
2746 0 => {
2747 self.stage = Stage::HardwareDetect;
2748 self.start_hardware_detection();
2749 }
2750 1 => {
2751 self.preflight = run_preflight_checks(
2752 &self.hardware.system,
2753 &self.hardware.gpu,
2754 self.sudo_password.as_deref(),
2755 );
2756 self.preflight_selection = 0;
2757 self.stage = Stage::Preflight;
2758 }
2759 2 => {
2760 self.start_component_status_detection();
2761 }
2762 3 => self.should_exit = true,
2763 _ => {}
2764 }
2765 }
2766
2767 fn start_installation(&mut self) {
2768 let selected = self.selected_components();
2769 if selected.is_empty() {
2770 self.errors
2771 .push("No components selected. Go back and select at least one component.".into());
2772 self.stage = Stage::Recovery;
2773 return;
2774 }
2775 let any_needs_sudo = selected.iter().any(|c| c.needs_sudo);
2777 #[cfg(unix)]
2778 let is_non_root = unsafe { libc::geteuid() != 0 };
2779 #[cfg(not(unix))]
2780 let is_non_root = false;
2781 if is_non_root && any_needs_sudo && self.sudo_password.is_none() {
2782 self.errors
2783 .push("Sudo password required for selected components. Press Esc to go back to Welcome screen and enter your sudo password.".into());
2784 self.stage = Stage::Recovery;
2785 return;
2786 }
2787
2788 if self.config.force_reinstall {
2790 std::env::set_var("FORCE", "true");
2791 std::env::set_var("PYTORCH_REINSTALL", "true");
2792 std::env::set_var("MLSTACK_FORCE_REINSTALL", "true");
2793 } else {
2794 std::env::remove_var("FORCE");
2795 std::env::remove_var("PYTORCH_REINSTALL");
2796 std::env::remove_var("MLSTACK_FORCE_REINSTALL");
2797 }
2798
2799 std::env::set_var(
2804 "MLSTACK_ONNX_INSTALL_METHOD",
2805 &self.config.onnx_install_method,
2806 );
2807 match &self.config.onnx_version {
2808 Some(v) => std::env::set_var("MLSTACK_ONNX_VERSION", v),
2809 None => std::env::remove_var("MLSTACK_ONNX_VERSION"),
2810 }
2811
2812 self.stage = Stage::Installing;
2813 self.install_status.progress = 0.0;
2814 self.install_status.message = "Starting installation".into();
2815 self.mark_install_activity();
2816 let (tx, rx) = mpsc::channel();
2817 let (input_tx, input_rx) = mpsc::channel();
2818 let sudo_password = self.sudo_password.clone();
2819 let config = self.config.clone();
2820 self.install_input_sender = Some(input_tx);
2821 self.install_input_buffer.clear();
2822 self.install_log_popup = false;
2823 self.install_log_scroll = 0;
2824 self.install_log_file_path = None;
2825 thread::spawn(move || {
2826 run_installation(selected, config, sudo_password, tx, input_rx);
2827 });
2828 self.install_receiver = Some(rx);
2829 }
2830
2831 fn poll_installer(&mut self) {
2832 let events: Vec<_> = if let Some(receiver) = &self.install_receiver {
2833 std::iter::from_fn(|| receiver.try_recv().ok()).collect()
2834 } else {
2835 vec![]
2836 };
2837
2838 for event in events {
2839 match event {
2840 InstallerEvent::Log(line, is_transient) => {
2841 self.mark_install_activity();
2842 if let Some(progress) = parse_log_progress(&line) {
2843 self.apply_log_progress(progress, &line);
2844 }
2845 self.push_log_ext(line, is_transient);
2846 }
2847 InstallerEvent::Progress {
2848 component_id,
2849 progress,
2850 message,
2851 } => {
2852 self.mark_install_activity();
2853 if component_id == "__overall__" {
2854 self.install_status.progress = progress.clamp(0.0, 1.0);
2855 } else {
2856 self.update_component_progress(&component_id, progress);
2857 self.recalculate_overall_progress();
2858 }
2859 self.install_status.message = message.clone();
2860 self.push_log_ext(message, true);
2862 }
2863 InstallerEvent::ComponentStart { component_id, name } => {
2864 self.mark_install_activity();
2865 if let Some(comp) = self.components.iter_mut().find(|c| c.id == component_id) {
2866 self.install_status.message = if comp.category == Category::Performance {
2867 format!("Running {}", name)
2868 } else {
2869 format!("Installing {}", name)
2870 };
2871 comp.progress = 0.05;
2872 comp.installed = false;
2873 } else {
2874 self.install_status.message = format!("Installing {}", name);
2875 }
2876 self.recalculate_overall_progress();
2877 self.push_log(self.install_status.message.clone());
2878 }
2879 InstallerEvent::ComponentComplete {
2880 component_id,
2881 success,
2882 message,
2883 } => {
2884 self.mark_install_activity();
2885 if let Some(comp) = self.components.iter_mut().find(|c| c.id == component_id) {
2886 comp.installed = success;
2887 comp.progress = 1.0;
2888 }
2889 self.recalculate_overall_progress();
2890 if !success {
2891 self.errors.push(message.clone());
2892 }
2893 self.push_log(message);
2894 }
2895 InstallerEvent::VerificationReport {
2896 component_id,
2897 lines,
2898 } => {
2899 self.mark_install_activity();
2900 let cleaned = lines
2901 .into_iter()
2902 .map(|line| Self::sanitize_line(&line))
2903 .collect();
2904 self.verification_reports.insert(component_id, cleaned);
2905 }
2906 InstallerEvent::Finished { success } => {
2907 self.mark_install_activity();
2908 self.install_status.completed = success;
2909 self.install_input_sender = None;
2910 self.install_input_buffer.clear();
2911 self.recalculate_overall_progress();
2912 self.install_status.progress = 1.0;
2913 if success {
2914 self.telemetry_prompt_pending = true;
2918 self.telemetry_prompt_active = true;
2919 } else {
2920 self.stage = Stage::Complete;
2921 }
2922 }
2923 }
2924 }
2925 }
2926
2927 fn update_component_progress(&mut self, component_id: &str, progress: f32) {
2928 if let Some(component) = self.components.iter_mut().find(|c| c.id == component_id) {
2929 let next = progress.clamp(0.0, 1.0);
2930 if next > component.progress {
2931 component.progress = next;
2932 }
2933 }
2934 }
2935
2936 fn apply_log_progress(&mut self, progress: f32, line: &str) {
2937 let next = progress.clamp(0.0, 1.0);
2938 if let Some(component) = self
2939 .components
2940 .iter_mut()
2941 .find(|c| c.selected && c.progress > 0.0 && c.progress < 1.0)
2942 {
2943 if next > component.progress {
2944 component.progress = next;
2945 }
2946 self.recalculate_overall_progress();
2947 } else if next > self.install_status.progress {
2948 self.install_status.progress = next;
2949 }
2950
2951 let percent = (next * 100.0).round() as i32;
2952 self.install_status.message =
2953 format!("Build activity: {percent}% {}", trim_status_line(line));
2954 }
2955
2956 fn mark_install_activity(&mut self) {
2957 self.install_activity_tick = self.tick_count;
2958 }
2959
2960 fn recalculate_overall_progress(&mut self) {
2961 let selected: Vec<&Component> = self.components.iter().filter(|c| c.selected).collect();
2962 if selected.is_empty() {
2963 self.install_status.progress = 0.0;
2964 return;
2965 }
2966 let total: f32 = selected
2967 .iter()
2968 .map(|comp| comp.progress.clamp(0.0, 1.0))
2969 .sum();
2970 self.install_status.progress = (total / selected.len() as f32).clamp(0.0, 1.0);
2971 }
2972
2973 fn install_task_status(&self, component: &Component) -> TaskStatus {
2974 if component.progress == 0.0 {
2975 TaskStatus::Pending
2976 } else if component.progress < 1.0 {
2977 TaskStatus::Running
2978 } else if component.installed {
2979 TaskStatus::Done
2980 } else {
2981 TaskStatus::Failed
2982 }
2983 }
2984
2985 fn verification_task_status(&self, component_id: &str) -> TaskStatus {
2986 match self.verification_status(component_id) {
2987 "verified" => TaskStatus::Done,
2988 "failed" => TaskStatus::Failed,
2989 "missing" => TaskStatus::Failed,
2990 "unknown" => TaskStatus::Pending,
2991 "none" => TaskStatus::Pending,
2992 _ => TaskStatus::Pending,
2993 }
2994 }
2995
2996 fn verify_task_status(&self, component: &Component) -> TaskStatus {
2997 if component.category == Category::Maintenance {
2998 return self.verification_task_status(&component.id);
2999 }
3000 if self.verification_reports.contains_key(&component.id) {
3001 return self.verification_task_status(&component.id);
3002 }
3003 if component.progress >= 1.0 && !component.installed {
3004 return TaskStatus::Skipped;
3005 }
3006 if component.progress >= 0.8 && component.progress < 1.0 {
3007 return TaskStatus::Running;
3008 }
3009 TaskStatus::Pending
3010 }
3011
3012 fn checklist_lines(&self, max_lines: usize) -> Vec<Line<'_>> {
3013 if max_lines == 0 {
3014 return Vec::new();
3015 }
3016 let selected: Vec<&Component> = self.components.iter().filter(|c| c.selected).collect();
3017 let mut lines = Vec::new();
3018 let mut remaining = max_lines;
3019
3020 lines.push(Line::from(Span::styled(
3021 "Checklist",
3022 Style::default()
3023 .fg(Color::Cyan)
3024 .add_modifier(Modifier::BOLD),
3025 )));
3026 remaining = remaining.saturating_sub(1);
3027
3028 let mut truncated = false;
3029 for comp in selected {
3030 if remaining == 0 {
3031 truncated = true;
3032 break;
3033 }
3034 lines.push(Line::from(Span::styled(
3035 format!("• {}", comp.name),
3036 Style::default()
3037 .fg(Color::White)
3038 .add_modifier(Modifier::BOLD),
3039 )));
3040 remaining = remaining.saturating_sub(1);
3041
3042 if remaining == 0 {
3043 truncated = true;
3044 break;
3045 }
3046
3047 if comp.category != Category::Maintenance || !comp.id.starts_with("verify-") {
3048 let task_label = if comp.category == Category::Performance {
3049 "Benchmark"
3050 } else if comp.category == Category::Maintenance {
3051 "Repair"
3052 } else {
3053 "Install"
3054 };
3055 lines.push(self.task_line(task_label, self.install_task_status(comp)));
3056 remaining = remaining.saturating_sub(1);
3057 }
3058
3059 if remaining == 0 {
3060 truncated = true;
3061 break;
3062 }
3063
3064 lines.push(self.task_line("Verify", self.verify_task_status(comp)));
3065 remaining = remaining.saturating_sub(1);
3066 }
3067
3068 if truncated {
3069 lines.push(Line::from(" …"));
3070 }
3071
3072 lines
3073 }
3074
3075 fn install_stage_title(&self) -> Text<'_> {
3076 let lines = match self.install_status.message.as_str() {
3077 m if m.contains("download") => vec![
3078 Line::from("Pre-built download in progress"),
3079 Line::from("Progress is tied to download completion."),
3080 ],
3081 m if m.contains("configure") => vec![
3082 Line::from("Source build stage: configure"),
3083 Line::from(self.llama_build_flags_line()),
3084 ],
3085 m if m.contains("compile") => vec![
3086 Line::from("Source build stage: compile"),
3087 Line::from(self.llama_build_flags_line()),
3088 ],
3089 m if m.contains("link") => vec![
3090 Line::from("Source build stage: link"),
3091 Line::from(self.llama_build_flags_line()),
3092 ],
3093 _ => vec![
3094 Line::from("Installing llama.cpp Turbo Quant"),
3095 Line::from(self.llama_build_flags_line()),
3096 ],
3097 };
3098 let mut lines = lines;
3099 lines.push(Line::from(""));
3100 lines.push(Line::from(self.telemetry_inline_prompt()));
3101 Text::from(lines)
3102 }
3103
3104 fn llama_build_flags_line(&self) -> String {
3105 let config = LlamaCppConfig {
3106 gpu_arch: self.hardware.gpu.architecture.clone(),
3107 ..Default::default()
3108 };
3109 let flags = LlamaCppInstaller::new(config).cmake_flags().join(" ");
3110 format!("Build flags: {}", flags)
3111 }
3112
3113 fn telemetry_inline_prompt(&self) -> String {
3114 let status = self
3115 .telemetry_gate
3116 .as_ref()
3117 .map(|gate| {
3118 if gate.is_enabled() {
3119 TELEMETRY_STATUS_ENABLED
3120 } else {
3121 TELEMETRY_STATUS_DISABLED
3122 }
3123 })
3124 .unwrap_or(TELEMETRY_STATUS_DISABLED);
3125 format!(
3126 "{} | {} / {} | {}",
3127 TELEMETRY_DESCRIPTION, TELEMETRY_ENABLE_LABEL, TELEMETRY_DISABLE_LABEL, status
3128 )
3129 }
3130
3131 fn task_line(&self, label: &str, status: TaskStatus) -> Line<'_> {
3132 Line::from(vec![
3133 Span::styled(
3134 format!(" {}", status.icon()),
3135 Style::default().fg(status.color()),
3136 ),
3137 Span::raw(format!(" {} ({})", label, status.label())),
3138 ])
3139 }
3140
3141 fn send_install_input(&self, payload: String) {
3142 if let Some(sender) = &self.install_input_sender {
3143 let _ = sender.send(payload);
3144 }
3145 }
3146
3147 fn flush_install_input(&mut self) {
3148 if self.install_input_mode == InputMode::Raw {
3149 self.send_install_input("\n".to_string());
3150 return;
3151 }
3152
3153 if self.install_input_buffer.is_empty() {
3154 self.send_install_input("\n".to_string());
3155 return;
3156 }
3157
3158 let mut payload = self.install_input_buffer.clone();
3159 payload.push('\n');
3160 self.send_install_input(payload);
3161 self.install_input_buffer.clear();
3162 }
3163
3164 fn start_hardware_detection(&mut self) {
3165 let (tx, rx) = mpsc::channel();
3166 thread::spawn(move || {
3167 let result = detect_hardware();
3168 let _ = tx.send(result);
3169 });
3170 self.hardware_receiver = Some(rx);
3171 self.hardware.status = "Detecting hardware...".into();
3172 self.hardware.progress = 0.1;
3173 }
3174
3175 fn poll_hardware(&mut self) {
3176 let results: Vec<_> = if let Some(receiver) = &self.hardware_receiver {
3177 std::iter::from_fn(|| receiver.try_recv().ok()).collect()
3178 } else {
3179 vec![]
3180 };
3181
3182 for result in results {
3183 match result {
3184 Ok(state) => {
3185 self.hardware = state;
3186 }
3187 Err(err) => {
3188 self.errors.push(err.to_string());
3189 }
3190 }
3191 }
3192 }
3193
3194 fn start_component_status_detection(&mut self) {
3195 let (tx, rx) = mpsc::channel();
3196 let mut components = self.components.clone();
3197 let force_reinstall = self.config.force_reinstall;
3198
3199 self.component_detection_statuses = components
3200 .iter()
3201 .map(|component| {
3202 if component.category == Category::Maintenance {
3203 ComponentDetectionStatus::Skipped
3204 } else {
3205 ComponentDetectionStatus::Pending
3206 }
3207 })
3208 .collect();
3209 self.component_detection_done = false;
3210 self.component_status_receiver = Some(rx);
3211 self.stage = Stage::ComponentDetect;
3212
3213 thread::spawn(move || {
3214 let python_candidates = python_interpreters();
3215 for (index, component) in components.iter_mut().enumerate() {
3216 if component.category == Category::Maintenance {
3217 let _ = tx.send(ComponentStatusEvent::Skipped(index));
3218 continue;
3219 }
3220
3221 let _ = tx.send(ComponentStatusEvent::Started(index));
3222 component.installed = is_component_installed(component, &python_candidates);
3223 if component.installed && !force_reinstall {
3224 component.selected = false;
3228 }
3229 let _ = tx.send(ComponentStatusEvent::Finished {
3230 index,
3231 installed: component.installed,
3232 });
3233 }
3234 let _ = tx.send(ComponentStatusEvent::Complete(components));
3235 });
3236 }
3237
3238 fn poll_component_status_detection(&mut self) {
3239 let events: Vec<_> = if let Some(receiver) = &self.component_status_receiver {
3240 std::iter::from_fn(|| receiver.try_recv().ok()).collect()
3241 } else {
3242 Vec::new()
3243 };
3244
3245 for event in events {
3246 match event {
3247 ComponentStatusEvent::Started(index) => {
3248 if let Some(status) = self.component_detection_statuses.get_mut(index) {
3249 *status = ComponentDetectionStatus::Running;
3250 }
3251 }
3252 ComponentStatusEvent::Finished { index, installed } => {
3253 if let Some(status) = self.component_detection_statuses.get_mut(index) {
3254 *status = if installed {
3255 ComponentDetectionStatus::Installed
3256 } else {
3257 ComponentDetectionStatus::NotInstalled
3258 };
3259 }
3260 }
3261 ComponentStatusEvent::Skipped(index) => {
3262 if let Some(status) = self.component_detection_statuses.get_mut(index) {
3263 *status = ComponentDetectionStatus::Skipped;
3264 }
3265 }
3266 ComponentStatusEvent::Complete(components) => {
3267 self.components = components;
3268 self.component_status_receiver = None;
3269 self.component_detection_done = true;
3270 }
3271 }
3272 }
3273 }
3274
3275 fn selected_components(&self) -> Vec<Component> {
3276 self.components
3277 .iter()
3278 .filter(|c| c.selected)
3279 .cloned()
3280 .collect()
3281 }
3282
3283 fn run_mode(&self) -> RunMode {
3288 let selected: Vec<&Component> = self.components.iter().filter(|c| c.selected).collect();
3289 if selected.is_empty() {
3290 return RunMode::Install;
3291 }
3292 if selected.iter().all(|c| c.category == Category::Performance) {
3293 RunMode::Benchmark
3294 } else if selected
3295 .iter()
3296 .all(|c| c.category == Category::Maintenance && c.id.starts_with("verify-"))
3297 {
3298 RunMode::Verify
3299 } else {
3300 RunMode::Install
3301 }
3302 }
3303
3304 fn flow_noun(&self) -> &'static str {
3306 match self.run_mode() {
3307 RunMode::Install => "Installation",
3308 RunMode::Benchmark => "Benchmark",
3309 RunMode::Verify => "Verification",
3310 }
3311 }
3312
3313 fn action_label(&self) -> &'static str {
3315 match self.run_mode() {
3316 RunMode::Install => "Start Installation",
3317 RunMode::Benchmark => "Run Benchmarks",
3318 RunMode::Verify => "Run Verification",
3319 }
3320 }
3321
3322 fn selection_label(&self) -> &'static str {
3324 match self.run_mode() {
3325 RunMode::Install => "Selected Components",
3326 RunMode::Benchmark => "Selected Benchmarks",
3327 RunMode::Verify => "Selected Checks",
3328 }
3329 }
3330
3331 fn summary_title(&self) -> &'static str {
3333 match self.run_mode() {
3334 RunMode::Install => "Installation Summary",
3335 RunMode::Benchmark => "Benchmark Results",
3336 RunMode::Verify => "Verification Summary",
3337 }
3338 }
3339
3340 fn success_label(&self) -> &'static str {
3342 match self.run_mode() {
3343 RunMode::Install => "Installed",
3344 RunMode::Benchmark => "Completed",
3345 RunMode::Verify => "Passed",
3346 }
3347 }
3348
3349 fn success_status(&self) -> &'static str {
3351 match self.run_mode() {
3352 RunMode::Install => "installed",
3353 RunMode::Benchmark => "completed",
3354 RunMode::Verify => "passed",
3355 }
3356 }
3357
3358 fn current_category(&self) -> Category {
3359 match self.selected_category {
3360 0 => Category::Environment,
3361 1 => Category::Foundation,
3362 2 => Category::Core,
3363 3 => Category::Extension,
3364 4 => Category::UiUx,
3365 5 => Category::Maintenance,
3366 _ => Category::Performance,
3367 }
3368 }
3369
3370 fn sanitize_line(value: &str) -> String {
3371 let mut clean = String::with_capacity(value.len());
3372 let mut in_escape = false;
3373 for c in value.chars() {
3374 if c == '\x1b' {
3375 in_escape = true;
3376 continue;
3377 }
3378 if in_escape {
3379 if ('@'..='~').contains(&c) || c == 'm' {
3380 in_escape = false;
3381 }
3382 continue;
3383 }
3384 if c == '\t' {
3385 clean.push(' ');
3386 } else if !c.is_control() {
3387 clean.push(c);
3388 }
3389 }
3390 clean
3391 }
3392
3393 fn push_log(&mut self, line: String) {
3394 self.push_log_ext(line, false);
3395 }
3396
3397 fn push_log_ext(&mut self, line: String, is_transient: bool) {
3398 let timestamp = Local::now().format("%H:%M:%S");
3399 let clean_line = Self::sanitize_line(&line);
3400 if clean_line.trim().is_empty() {
3401 return;
3402 }
3403 if let Some(path) = clean_line.split("Log file:").nth(1) {
3404 let candidate = path.trim();
3405 if !candidate.is_empty() {
3406 self.install_log_file_path = Some(candidate.to_string());
3407 }
3408 }
3409 let entry = format!("[{}] {}", timestamp, clean_line);
3410
3411 if is_transient && self.last_line_transient && !self.logs.is_empty() {
3412 let last_idx = self.logs.len() - 1;
3414 self.logs[last_idx] = entry.clone();
3415 } else {
3416 self.logs.push(entry.clone());
3417 }
3418
3419 self.last_line_transient = is_transient;
3420
3421 if self.logs.len() > 2000 {
3422 self.logs.drain(0..500);
3423 }
3424
3425 if !is_transient {
3427 let log_dir = std::path::Path::new(&self.config.log_dir);
3428 let log_path = log_dir.join("rusty-stack.log");
3429 if std::fs::create_dir_all(log_dir).is_ok() {
3430 let _ = std::fs::OpenOptions::new()
3431 .create(true)
3432 .append(true)
3433 .open(log_path)
3434 .and_then(|mut file| {
3435 use std::io::Write;
3436 writeln!(file, "{}", entry)
3437 });
3438 }
3439 }
3440 }
3441
3442 fn latest_install_log_path(&self) -> Option<String> {
3443 if let Some(path) = &self.install_log_file_path {
3444 return Some(path.clone());
3445 }
3446 if self.config.log_dir.trim().is_empty() {
3447 return None;
3448 }
3449 Some(
3450 std::path::Path::new(&self.config.log_dir)
3451 .join("rusty-stack.log")
3452 .display()
3453 .to_string(),
3454 )
3455 }
3456
3457 fn partition_categories(&self) -> (Vec<Component>, Vec<Component>) {
3458 let env = self
3459 .components
3460 .iter()
3461 .filter(|c| c.category == Category::Environment && c.selected)
3462 .cloned()
3463 .collect::<Vec<_>>();
3464 let verification = self
3465 .components
3466 .iter()
3467 .filter(|c| c.category == Category::Maintenance && c.selected)
3468 .cloned()
3469 .collect::<Vec<_>>();
3470 (env, verification)
3471 }
3472
3473 fn env_summary(&self) -> Vec<(String, String)> {
3474 let mut entries = Vec::new();
3475 let env_path = std::path::Path::new(&self.config.install_path).join(".mlstack_env");
3476 let fallback =
3477 std::path::Path::new(&std::env::var("HOME").unwrap_or_default()).join(".mlstack_env");
3478 let path = if env_path.exists() {
3479 env_path
3480 } else {
3481 fallback
3482 };
3483
3484 if let Ok(contents) = std::fs::read_to_string(&path) {
3485 for line in contents.lines() {
3486 let trimmed = line.trim();
3487 if trimmed.is_empty() || trimmed.starts_with('#') {
3488 continue;
3489 }
3490 if let Some(export_idx) = trimmed.rfind("export ") {
3492 let rest = &trimmed[export_idx + 7..]; if let Some((key, value)) = rest.split_once('=') {
3494 let key = key.trim();
3495 if key.is_empty() || key.contains(' ') || key.contains('$') {
3496 continue;
3497 }
3498 let value = value.trim();
3499 let value = Self::strip_shell_value(value);
3501 let value = value
3502 .strip_prefix('"')
3503 .and_then(|v| v.strip_suffix('"'))
3504 .unwrap_or(&value);
3505 entries.push((key.to_string(), value.to_string()));
3506 }
3507 }
3508 }
3509 }
3510
3511 let keys = [
3512 "ROCM_VERSION",
3513 "ROCM_CHANNEL",
3514 "GPU_ARCH",
3515 "ROCM_HOME",
3516 "ROCM_PATH",
3517 "HIP_PATH",
3518 "HIP_VISIBLE_DEVICES",
3519 "CUDA_VISIBLE_DEVICES",
3520 "PYTORCH_ROCM_DEVICE",
3521 ];
3522
3523 let mut filtered = Vec::new();
3524 for key in keys {
3525 if let Some((_, value)) = entries.iter().find(|(k, _)| k == key) {
3526 filtered.push((key.to_string(), Self::sanitize_line(value)));
3527 } else if let Ok(value) = std::env::var(key) {
3528 filtered.push((key.to_string(), Self::sanitize_line(&value)));
3529 }
3530 }
3531 filtered
3532 }
3533
3534 fn strip_shell_value(value: &str) -> String {
3542 let mut v = value.to_string();
3543 if let Some(idx) = v.find("; fi") {
3545 v.truncate(idx);
3546 }
3547 if let Some(idx) = v.find("&& ") {
3549 v.truncate(idx);
3550 }
3551 v.trim().to_string()
3552 }
3553
3554 fn verification_report_lines(&self) -> Vec<Line<'_>> {
3555 let mut lines = Vec::new();
3556 for comp in self.components.iter().filter(|c| c.selected) {
3557 if let Some(report) = self.verification_reports.get(&comp.id) {
3558 lines.push(Line::from(Span::styled(
3559 format!("{} Report:", comp.name),
3560 Style::default()
3561 .fg(Color::Cyan)
3562 .add_modifier(Modifier::BOLD),
3563 )));
3564 for entry in report {
3565 if entry.trim().is_empty() {
3566 lines.push(Line::from(""));
3567 } else {
3568 let style = if entry.contains("Verified") {
3569 Style::default().fg(Color::Green)
3570 } else if entry.contains("Failed") {
3571 Style::default().fg(Color::Red)
3572 } else if entry.contains("Missing") || entry.contains("Skipped") {
3573 Style::default().fg(Color::Yellow)
3574 } else {
3575 Style::default()
3576 };
3577 lines.push(Line::from(Span::styled(format!(" {}", entry), style)));
3578 }
3579 }
3580 lines.push(Line::from(""));
3581 }
3582 }
3583 lines
3584 }
3585
3586 fn verification_status(&self, component_id: &str) -> &'static str {
3587 let Some(report) = self.verification_reports.get(component_id) else {
3588 return "none";
3589 };
3590 let mut has_verified = false;
3591 for line in report {
3592 if line.contains("Failed") {
3593 return "failed";
3594 }
3595 if line.contains("Missing") {
3596 return "missing";
3597 }
3598 if line.contains("Verified") {
3599 has_verified = true;
3600 }
3601 }
3602 if has_verified {
3603 "verified"
3604 } else {
3605 "unknown"
3606 }
3607 }
3608
3609 fn count_log_keywords(&self) -> (usize, usize) {
3610 let mut benchmarks = 0usize;
3611 let mut tests = 0usize;
3612 for entry in &self.logs {
3613 let lower = entry.to_lowercase();
3614 if lower.contains("benchmark") {
3615 benchmarks += 1;
3616 }
3617 if lower.contains("test") || lower.contains("verify") {
3618 tests += 1;
3619 }
3620 }
3621 for report in self.verification_reports.values() {
3622 for entry in report {
3623 let lower = entry.to_lowercase();
3624 if lower.contains("benchmark") {
3625 benchmarks += 1;
3626 }
3627 if lower.contains("test") || lower.contains("verify") {
3628 tests += 1;
3629 }
3630 }
3631 }
3632 (benchmarks, tests)
3633 }
3634
3635 fn partition_components(&self) -> (Vec<Component>, Vec<Component>, Vec<Component>) {
3636 let mut installed = Vec::new();
3637 let mut failed = Vec::new();
3638 let mut skipped = Vec::new();
3639 for comp in self.components.iter().filter(|c| c.selected) {
3640 if comp.installed {
3641 installed.push(comp.clone());
3642 } else if comp.progress > 0.0 {
3643 failed.push(comp.clone());
3644 } else {
3645 skipped.push(comp.clone());
3646 }
3647 }
3648 (installed, failed, skipped)
3649 }
3650}
3651
3652#[derive(Clone, Copy, PartialEq, Eq)]
3657enum ConfigKey {
3658 RocmPath,
3659 BatchMode,
3660 AutoConfirm,
3661 StarRepo,
3662 ForceReinstall,
3663 Theme,
3664 PerfProfile,
3665 VllmVersionLag,
3666 VllmVersionAge,
3667 OnnxInstallMethod,
3668 OnnxVersion,
3669 Save,
3670}
3671
3672fn config_applies(key: ConfigKey, mode: RunMode) -> bool {
3676 match mode {
3677 RunMode::Install => true,
3678 RunMode::Benchmark | RunMode::Verify => matches!(
3679 key,
3680 ConfigKey::BatchMode | ConfigKey::AutoConfirm | ConfigKey::Theme | ConfigKey::Save
3681 ),
3682 }
3683}
3684
3685fn on_off(value: bool) -> &'static str {
3686 if value {
3687 "on"
3688 } else {
3689 "off"
3690 }
3691}
3692
3693fn parse_log_progress(line: &str) -> Option<f32> {
3694 let open = line.find('[')?;
3695 let percent = line[open + 1..].find('%')? + open + 1;
3696 let value = line[open + 1..percent].trim().parse::<f32>().ok()?;
3697 Some((value / 100.0).clamp(0.0, 1.0))
3698}
3699
3700fn trim_status_line(line: &str) -> String {
3701 const MAX: usize = 72;
3702 let trimmed = line.trim();
3703 if trimmed.chars().count() <= MAX {
3704 return trimmed.to_string();
3705 }
3706 let mut out: String = trimmed.chars().take(MAX.saturating_sub(1)).collect();
3707 out.push('…');
3708 out
3709}
3710
3711fn show_on_component_detection_screen(component: &Component) -> bool {
3712 !matches!(
3713 component.category,
3714 Category::Maintenance | Category::Performance
3715 )
3716}
3717
3718#[cfg(test)]
3719mod tests {
3720 use super::*;
3721 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
3722
3723 #[test]
3724 fn preflight_enter_starts_component_detection_screen() {
3725 let mut app = App::new("/tmp".into());
3726 app.entering_password = false;
3727 app.components.clear();
3728 app.stage = Stage::Preflight;
3729 app.preflight.can_continue = true;
3730
3731 app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
3732
3733 assert_eq!(app.stage, Stage::ComponentDetect);
3734 assert!(app.component_status_receiver.is_some());
3735 }
3736
3737 #[test]
3738 fn component_detection_screen_hides_action_categories() {
3739 let maintenance = Component {
3740 id: "rccl-repair".into(),
3741 name: "Repair RCCL Multi-GPU".into(),
3742 description: String::new(),
3743 script: String::new(),
3744 category: Category::Maintenance,
3745 required: false,
3746 selected: false,
3747 installed: false,
3748 progress: 0.0,
3749 estimate: String::new(),
3750 needs_sudo: false,
3751 experimental: false,
3752 note: None,
3753 };
3754 let mut performance = maintenance.clone();
3755 performance.id = "all-benchmarks".into();
3756 performance.name = "Full Suite Benchmark".into();
3757 performance.category = Category::Performance;
3758 let mut installable = maintenance.clone();
3759 installable.id = "fastvideo".into();
3760 installable.name = "FastVideo".into();
3761 installable.category = Category::Extension;
3762
3763 assert!(!show_on_component_detection_screen(&maintenance));
3764 assert!(!show_on_component_detection_screen(&performance));
3765 assert!(show_on_component_detection_screen(&installable));
3766 }
3767
3768 #[test]
3769 fn explicit_overall_progress_event_updates_overall_bar() {
3770 let mut app = App::new("/tmp".into());
3771 app.entering_password = false;
3772 app.components.clear();
3773 app.components.push(Component {
3774 id: "rccl-repair".into(),
3775 name: "Repair RCCL Multi-GPU".into(),
3776 description: String::new(),
3777 script: String::new(),
3778 category: Category::Maintenance,
3779 required: false,
3780 selected: true,
3781 installed: false,
3782 progress: 0.05,
3783 estimate: String::new(),
3784 needs_sudo: false,
3785 experimental: false,
3786 note: None,
3787 });
3788 let (tx, rx) = mpsc::channel();
3789 app.install_receiver = Some(rx);
3790
3791 tx.send(InstallerEvent::Progress {
3792 component_id: "__overall__".into(),
3793 progress: 0.73,
3794 message: "RCCL build: 73%".into(),
3795 })
3796 .unwrap();
3797 app.poll_installer();
3798
3799 assert!((app.install_status.progress - 0.73).abs() < f32::EPSILON);
3800 assert_eq!(app.install_status.message, "RCCL build: 73%");
3801 }
3802
3803 #[test]
3804 fn build_log_percentage_updates_running_component_progress() {
3805 let mut app = App::new("/tmp".into());
3806 app.entering_password = false;
3807 app.components.clear();
3808 app.components.push(Component {
3809 id: "rccl-repair".into(),
3810 name: "Repair RCCL Multi-GPU".into(),
3811 description: String::new(),
3812 script: String::new(),
3813 category: Category::Maintenance,
3814 required: false,
3815 selected: true,
3816 installed: false,
3817 progress: 0.05,
3818 estimate: String::new(),
3819 needs_sudo: false,
3820 experimental: false,
3821 note: None,
3822 });
3823 let (tx, rx) = mpsc::channel();
3824 app.install_receiver = Some(rx);
3825
3826 tx.send(InstallerEvent::Log(
3827 "[ 70%] Building CXX object CMakeFiles/rccl.dir/foo.cpp.o".into(),
3828 true,
3829 ))
3830 .unwrap();
3831 app.poll_installer();
3832
3833 assert!((app.components[0].progress - 0.70).abs() < f32::EPSILON);
3834 assert!((app.install_status.progress - 0.70).abs() < f32::EPSILON);
3835 assert!(app.install_status.message.contains("70%"));
3836 }
3837
3838 #[test]
3839 fn component_detection_complete_waits_for_enter() {
3840 let mut app = App::new("/tmp".into());
3841 app.entering_password = false;
3842 app.components.clear();
3843 app.stage = Stage::ComponentDetect;
3844 let (tx, rx) = mpsc::channel();
3845 app.component_status_receiver = Some(rx);
3846
3847 tx.send(ComponentStatusEvent::Complete(Vec::new())).unwrap();
3848 app.poll_component_status_detection();
3849
3850 assert_eq!(app.stage, Stage::ComponentDetect);
3851
3852 app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
3853
3854 assert_eq!(app.stage, Stage::ComponentSelect);
3855 }
3856}