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