Skip to main content

rusty_stack/
app.rs

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            // Fallback to a non-persisted config if directory is locked
195            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        // Create a more visually appealing layout with color coding and symbols
675        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        // Header with status
685        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        // Hardware information
705        let info_chunks = Layout::default()
706            .direction(Direction::Horizontal)
707            .constraints([Constraint::Percentage(40), Constraint::Percentage(60)])
708            .split(chunks[1]);
709
710        // Left side: System info
711        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        // Right side: GPU info
759        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        // Footer with instructions
842        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                // Category-specific icon
1190                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            // Category-specific status display
1282            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                    // Core components show their status based on installation
1292                    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                    // Verify / repair are actions: "Completed" after a successful run.
1307                    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                        // Was attempted but failed
1318                        ("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            // Show category-specific info
1350            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        // Install-specific context — not meaningful for benchmark/verify runs.
1503        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), // [0] progress gauge
1568                    Constraint::Length(2), // [1] install path
1569                    Constraint::Length(6), // [2] install details
1570                    Constraint::Min(5),    // [3] stage title / telemetry prompt
1571                    Constraint::Min(10),   // [4] body (log + sidebar)
1572                ]
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        // UI/UX Applications section with post-install instructions
1894        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; // Environment, Foundation, Core, Extension, UiUx, Verification, Performance
2306        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        // Flash Attention backends are mutually exclusive: both install the
2337        // identical `flash_attn` package, so the last-installed overwrites the
2338        // other (the .backend marker tracks which is active). Selecting one
2339        // deselects the other so the choice is explicit, never accidental.
2340        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        // Bulk select may have picked both FA backends — keep at most one.
2349        self.enforce_flash_attn_exclusivity(None);
2350    }
2351
2352    /// Ensure at most one Flash Attention backend is selected.
2353    /// - `Some(idx)`: the just-toggled component; only act if it's a FA backend
2354    ///   that was turned ON (then deselect the sibling).
2355    /// - `None` (bulk toggle): if more than one FA backend ended up selected,
2356    ///   keep the first in list order and deselect the rest.
2357    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                // cycle 0 → 1 → 2 → 3 → 4 → 5 → 0
2670                self.config.vllm_version_lag = (self.config.vllm_version_lag + 1) % 6;
2671                self.config_dirty = true;
2672            }
2673            ConfigKey::VllmVersionAge => {
2674                // cycle off + common age windows (days)
2675                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                // cycle migraphx → prebuilt → source → migraphx
2685                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                // cycle default → 1.25.0 (legacy) → default
2695                let cur = self.config.onnx_version.as_deref();
2696                let next = match cur {
2697                    None => Some("1.25.0"),
2698                    _ => None, // back to default (pinned 1.27.1)
2699                };
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 => {} // display-only, no toggle
2705        }
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        // Only require sudo password if at least one selected component needs it
2776        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        // Set FORCE=true if force_reinstall is enabled
2789        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        // ONNX install options (TUI config → env → dispatch). The dispatch reads
2800        // MLSTACK_ONNX_INSTALL_METHOD / MLSTACK_ONNX_VERSION. Defaults (migraphx /
2801        // pinned 1.27.1) match the dispatch defaults, so this only changes behavior
2802        // when the user cycles them on the Configuration screen.
2803        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                    // Progress messages are usually transient
2861                    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                        // Stay in Installing to present the telemetry opt-in prompt.
2915                        // The prompt is rendered by draw_installing() and resolved in
2916                        // handle_key() (Y/n/Enter) which transitions to Benchmarks.
2917                        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                    // Only auto-deselect when force reinstall is OFF.
3225                    // When force reinstall is ON, keep installed components selected
3226                    // so the installer will properly purge and reinstall them.
3227                    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    /// Derive the run mode from the selected components so the flow verbiage
3284    /// matches what is actually happening. A pure-Performance selection runs
3285    /// benchmarks; pure verify-* Maintenance selection runs verification;
3286    /// repair actions and installable components run as install/action flows.
3287    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    /// "Installation" / "Benchmark" / "Verification" — the flow noun for titles.
3305    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    /// The primary action button label on the confirm screen.
3314    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    /// Heading for the selected-components list.
3323    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    /// The summary screen title.
3332    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    /// Noun for a component that completed successfully on the summary screen.
3341    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    /// Lowercase status word shown per-component on the summary screen.
3350    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            // Replace the last line if it was also transient
3413            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        // Only write non-transient logs to disk to keep it clean
3426        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                // Handle both direct exports and if-guarded exports
3491                if let Some(export_idx) = trimmed.rfind("export ") {
3492                    let rest = &trimmed[export_idx + 7..]; // skip "export "
3493                    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                        // Strip shell suffixes (; fi, &&, etc.) from value
3500                        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    /// Strip shell command suffixes from an env file value.
3535    ///
3536    /// The `.mlstack_env` file generated by `env_setup.rs` uses if-guard patterns:
3537    /// `if [ -z "${VAR:-}" ]; then export VAR=value; fi`
3538    ///
3539    /// When parsing the `export VAR=value` part from inside the if-guard,
3540    /// the value may contain the trailing `; fi`. This function strips it.
3541    fn strip_shell_value(value: &str) -> String {
3542        let mut v = value.to_string();
3543        // Strip trailing "; fi" (from if-guard lines)
3544        if let Some(idx) = v.find("; fi") {
3545            v.truncate(idx);
3546        }
3547        // Strip trailing "&& ..." (from chained commands)
3548        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/// Stable identity for a config row, independent of which rows the current
3653/// run-mode filters into the visible list. Toggle/help key off this, not list
3654/// position, so hiding install-only rows for benchmark/verify can't desync the
3655/// action from the highlighted row.
3656#[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
3672/// Which config rows apply to the current run mode. Benchmark/verify runs hide
3673/// install-only rows (ROCm path, star repo, force reinstall, perf profile)
3674/// since they aren't meaningful for those runs.
3675fn 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}