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