Skip to main content

vfa_tui/
app.rs

1use std::collections::HashSet;
2use std::path::PathBuf;
3use std::time::{Duration, Instant};
4
5use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
6use ratatui::Frame;
7use uuid::Uuid;
8
9use crate::catalog::store::CatalogStore;
10use crate::models::export::{ExportCommand, ExportSelection};
11use crate::models::gate::{extract_validation_gates, GateStatus, ValidationGate};
12use crate::search::fuzzy::SearchEngine;
13use crate::security::sanitize::sanitize_subprocess_output;
14use crate::subprocess::{SubprocessExecutor, SubprocessHandle};
15
16use crate::ui::layout::compute_layout;
17use crate::ui::nav::{NavigationState, View, SIDEBAR_SECTIONS};
18use crate::ui::theme::{Theme, ThemeMode};
19use crate::ui::widgets::{
20    audit_log, coverage_grid, dep_graph, detail, help_bar, list_view, output, search, status_bar,
21    violations,
22};
23
24const MAX_SUBPROCESS_OUTPUT_LINES: usize = 10_000;
25const MAX_SEARCH_QUERY_LEN: usize = 256;
26/// Default timeout for validation gate subprocesses (300 seconds per requirement 6.5).
27const VALIDATION_GATE_TIMEOUT_SECS: u64 = 300;
28/// Label for the "Run All Validations" meta-entry in the validation list.
29const RUN_ALL_LABEL: &str = "validate (Run All)";
30
31/// State for the export command builder UI.
32pub struct ExportBuilderState {
33    pub platform: String,
34    pub selection: ExportSelection,
35    pub target_repo: String,
36    pub dry_run: bool,
37    pub force: bool,
38    pub no_skills: bool,
39    pub focused_field: usize,
40}
41
42impl ExportBuilderState {
43    pub fn new() -> Self {
44        Self {
45            platform: "kiro".to_string(),
46            selection: ExportSelection::All,
47            target_repo: String::new(),
48            dry_run: true,
49            force: false,
50            no_skills: false,
51            focused_field: 0,
52        }
53    }
54}
55
56impl Default for ExportBuilderState {
57    fn default() -> Self {
58        Self::new()
59    }
60}
61
62/// Main application state.
63pub struct App {
64    pub nav: NavigationState,
65    pub catalog: CatalogStore,
66    pub search_engine: SearchEngine,
67    pub search_query: String,
68    pub search_active: bool,
69    pub filtered_indices: Vec<usize>,
70    pub subprocess_output: Vec<output::OutputLine>,
71    pub subprocess_handle: Option<SubprocessHandle>,
72    pub validation_gates: Vec<ValidationGate>,
73    pub export_state: ExportBuilderState,
74    pub status_message: Option<(String, Instant)>,
75    pub session_id: Uuid,
76    pub should_quit: bool,
77    pub no_color: bool,
78    /// Resolved theme mode (Dark/Light) for the session; toggled at runtime
79    /// via the `t` keybinding (Req 35.6). Defaults to Dark; `main` overrides
80    /// it from the `--theme` flag / system detection.
81    pub theme_mode: ThemeMode,
82    pub workspace_root: PathBuf,
83    /// Name of the currently running validation gate (for status tracking).
84    /// When set, prevents concurrent execution of the same gate.
85    pub running_gate: Option<String>,
86    /// Timestamp when the currently running gate started (for animated indicator).
87    pub running_gate_start: Option<Instant>,
88    /// Pending subprocess spawn result — polled in `tick()` to avoid `block_on` inside tokio runtime.
89    pub pending_subprocess:
90        Option<tokio::sync::oneshot::Receiver<anyhow::Result<SubprocessHandle>>>,
91    // v0.2.0 enhancements
92    pub provider_filter: Option<String>,
93    pub harness_filter: Option<String>,
94    pub show_help_overlay: bool,
95    pub completion_suggestions: Vec<String>,
96    pub completion_index: usize,
97    /// Dirty flag for async event loop — render only when true (250ms tick resets).
98    pub dirty: bool,
99}
100
101impl App {
102    pub fn new(
103        catalog: CatalogStore,
104        workspace_root: PathBuf,
105        session_id: Uuid,
106        no_color: bool,
107    ) -> Self {
108        let validation_gates = extract_validation_gates(&workspace_root);
109        let mut search_engine = SearchEngine::new();
110        let filtered_indices = search_engine.search_agents("", &catalog.agents, None, None);
111        Self {
112            nav: NavigationState::new(),
113            catalog,
114            search_engine,
115            search_query: String::new(),
116            search_active: false,
117            filtered_indices,
118            subprocess_output: Vec::new(),
119            subprocess_handle: None,
120            validation_gates,
121            export_state: ExportBuilderState::new(),
122            status_message: None,
123            session_id,
124            should_quit: false,
125            no_color,
126            theme_mode: ThemeMode::Dark,
127            workspace_root,
128            running_gate: None,
129            running_gate_start: None,
130            pending_subprocess: None,
131            provider_filter: None,
132            harness_filter: None,
133            show_help_overlay: false,
134            completion_suggestions: Vec::new(),
135            completion_index: 0,
136            dirty: true,
137        }
138    }
139
140    /// Handle a key event. Dispatches based on search mode and current view.
141    pub fn handle_key_event(&mut self, key: KeyEvent) {
142        // Help overlay takes priority
143        if self.show_help_overlay {
144            match key.code {
145                KeyCode::Esc | KeyCode::Char('?') => {
146                    self.show_help_overlay = false;
147                }
148                _ => {}
149            }
150            return;
151        }
152
153        if self.search_active {
154            self.handle_search_key(key);
155            return;
156        }
157
158        // Export views have their own key handling
159        match &self.nav.current_view {
160            View::ExportBuilder => {
161                self.handle_export_builder_key(key);
162                return;
163            }
164            View::ExportConfirm => {
165                self.handle_export_confirm_key(key);
166                return;
167            }
168            View::ExportOutput => {
169                self.handle_export_output_key(key);
170                return;
171            }
172            _ => {}
173        }
174
175        match key.code {
176            KeyCode::Char('q') => self.should_quit = true,
177            KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
178                self.should_quit = true;
179            }
180            KeyCode::Char('?') => {
181                self.show_help_overlay = true;
182            }
183            KeyCode::Char('t') => {
184                // Runtime light/dark toggle (Req 35.6). Search mode is handled
185                // earlier and returns before reaching this match, so `t` is only
186                // a toggle outside of search.
187                self.theme_mode = match self.theme_mode {
188                    ThemeMode::Dark => ThemeMode::Light,
189                    ThemeMode::Light => ThemeMode::Dark,
190                };
191                self.dirty = true;
192            }
193            KeyCode::Char('p') if self.nav.current_view == View::AgentList => {
194                self.cycle_provider_filter();
195            }
196            KeyCode::Char('h') if self.nav.current_view == View::AgentList => {
197                self.cycle_harness_filter();
198            }
199            KeyCode::Esc => {
200                // Clear filters first, then pop view
201                if self.provider_filter.is_some() || self.harness_filter.is_some() {
202                    self.provider_filter = None;
203                    self.harness_filter = None;
204                    self.update_filtered();
205                } else if !self.nav.pop_view() {
206                    self.should_quit = true;
207                }
208                self.update_filtered();
209            }
210            KeyCode::Tab => {
211                self.nav.next_tab();
212                self.sync_view_to_tab();
213                self.dirty = true;
214            }
215            KeyCode::BackTab => {
216                self.nav.prev_tab();
217                self.sync_view_to_tab();
218                self.dirty = true;
219            }
220            KeyCode::Char('j') | KeyCode::Down => self.handle_down(),
221            KeyCode::Char('k') | KeyCode::Up => self.handle_up(),
222            KeyCode::Char('g') => self.nav.select_first(),
223            KeyCode::Char('G') => {
224                let max = self.current_list_len();
225                self.nav.select_last(max);
226            }
227            KeyCode::Char('/') => {
228                // Only activate search on views where filtering works (agent list)
229                if self.nav.current_view == View::AgentList {
230                    self.search_active = true;
231                    self.search_query.clear();
232                }
233            }
234            KeyCode::Enter => self.handle_enter(),
235            _ => {}
236        }
237    }
238
239    /// Handle key events during active search mode.
240    fn handle_search_key(&mut self, key: KeyEvent) {
241        match key.code {
242            KeyCode::Esc => {
243                self.search_active = false;
244            }
245            KeyCode::Enter => {
246                self.search_active = false;
247                self.update_filtered();
248            }
249            KeyCode::Backspace => {
250                self.search_query.pop();
251                self.update_filtered();
252            }
253            KeyCode::Char(c) if self.search_query.len() < MAX_SEARCH_QUERY_LEN => {
254                self.search_query.push(c);
255                self.update_filtered();
256            }
257            _ => {}
258        }
259    }
260
261    /// Number of fields in the export builder form.
262    const EXPORT_FIELD_COUNT: usize = 6;
263
264    /// Handle key events in the ExportBuilder view.
265    /// Supports j/k for field navigation, Enter to edit/toggle/confirm,
266    /// Tab to accept completion, and Esc to go back.
267    fn handle_export_builder_key(&mut self, key: KeyEvent) {
268        match key.code {
269            KeyCode::Char('q') => self.should_quit = true,
270            KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
271                self.should_quit = true;
272            }
273            KeyCode::Esc => {
274                if !self.nav.pop_view() {
275                    self.should_quit = true;
276                }
277            }
278            KeyCode::Tab => {
279                // Accept completion suggestion if available
280                if !self.completion_suggestions.is_empty() {
281                    let suggestion = self.completion_suggestions[self.completion_index].clone();
282                    match self.export_state.focused_field {
283                        0 => self.export_state.platform = suggestion,
284                        1 => {
285                            self.export_state.selection = ExportSelection::Role(suggestion);
286                        }
287                        _ => {}
288                    }
289                    self.completion_suggestions.clear();
290                    self.completion_index = 0;
291                } else {
292                    // Switch to next sidebar section
293                    let next = (self.nav.sidebar_index + 1) % SIDEBAR_SECTIONS.len();
294                    self.nav.set_sidebar_index(next);
295                    self.search_query.clear();
296                    self.provider_filter = None;
297                    self.harness_filter = None;
298                    self.update_filtered();
299                }
300            }
301            KeyCode::Char('j') | KeyCode::Down => {
302                // Navigate to next field, or next completion suggestion
303                if !self.completion_suggestions.is_empty() {
304                    self.completion_index =
305                        (self.completion_index + 1) % self.completion_suggestions.len();
306                } else {
307                    let next = self.export_state.focused_field + 1;
308                    if next < Self::EXPORT_FIELD_COUNT {
309                        self.export_state.focused_field = next;
310                    }
311                    // Clear completions when navigating fields
312                    self.completion_suggestions.clear();
313                    self.completion_index = 0;
314                }
315            }
316            KeyCode::Char('k') | KeyCode::Up => {
317                // Navigate to previous field, or previous completion suggestion
318                if !self.completion_suggestions.is_empty() {
319                    self.completion_index = if self.completion_index == 0 {
320                        self.completion_suggestions.len() - 1
321                    } else {
322                        self.completion_index - 1
323                    };
324                } else {
325                    if self.export_state.focused_field > 0 {
326                        self.export_state.focused_field -= 1;
327                    }
328                    // Clear completions when navigating fields
329                    self.completion_suggestions.clear();
330                    self.completion_index = 0;
331                }
332            }
333            KeyCode::Enter => {
334                self.handle_export_builder_enter();
335            }
336            KeyCode::Char(' ') => {
337                // Toggle boolean fields with space
338                match self.export_state.focused_field {
339                    3 => self.export_state.dry_run = !self.export_state.dry_run,
340                    4 => self.export_state.force = !self.export_state.force,
341                    5 => self.export_state.no_skills = !self.export_state.no_skills,
342                    _ => {}
343                }
344            }
345            KeyCode::Backspace => {
346                // Edit text fields with backspace
347                match self.export_state.focused_field {
348                    0 => {
349                        self.export_state.platform.pop();
350                        self.update_completion_suggestions();
351                    }
352                    2 => {
353                        self.export_state.target_repo.pop();
354                    }
355                    _ => {}
356                }
357            }
358            KeyCode::Char(c) => {
359                // Type into text fields
360                match self.export_state.focused_field {
361                    0 => {
362                        self.export_state.platform.push(c);
363                        self.update_completion_suggestions();
364                    }
365                    2 => {
366                        self.export_state.target_repo.push(c);
367                    }
368                    _ => {}
369                }
370            }
371            _ => {}
372        }
373    }
374
375    /// Handle Enter key in the export builder — either cycle selection or confirm.
376    fn handle_export_builder_enter(&mut self) {
377        match self.export_state.focused_field {
378            1 => {
379                // Cycle through selection methods: All -> Role -> Provider -> Agents -> All
380                self.export_state.selection = match &self.export_state.selection {
381                    ExportSelection::All => {
382                        let roles = self.catalog.role_ids();
383                        if let Some(first) = roles.first() {
384                            ExportSelection::Role(first.clone())
385                        } else {
386                            ExportSelection::Provider("aws".to_string())
387                        }
388                    }
389                    ExportSelection::Role(current) => {
390                        let roles = self.catalog.role_ids();
391                        let idx = roles.iter().position(|r| r == current);
392                        match idx {
393                            Some(i) if i + 1 < roles.len() => {
394                                ExportSelection::Role(roles[i + 1].clone())
395                            }
396                            _ => {
397                                let providers = self.catalog.provider_names();
398                                if let Some(first) = providers.first() {
399                                    ExportSelection::Provider(first.clone())
400                                } else {
401                                    ExportSelection::All
402                                }
403                            }
404                        }
405                    }
406                    ExportSelection::Provider(current) => {
407                        let providers = self.catalog.provider_names();
408                        let idx = providers.iter().position(|p| p == current);
409                        match idx {
410                            Some(i) if i + 1 < providers.len() => {
411                                ExportSelection::Provider(providers[i + 1].clone())
412                            }
413                            _ => ExportSelection::All,
414                        }
415                    }
416                    ExportSelection::Agents(_) => ExportSelection::All,
417                };
418            }
419            3 => self.export_state.dry_run = !self.export_state.dry_run,
420            4 => self.export_state.force = !self.export_state.force,
421            5 => self.export_state.no_skills = !self.export_state.no_skills,
422            _ => {
423                // On any other field, if we have enough info, go to confirm
424                if !self.export_state.platform.is_empty()
425                    && !self.export_state.target_repo.is_empty()
426                {
427                    // Validate target path exists and is writable
428                    let target = PathBuf::from(&self.export_state.target_repo);
429                    if !target.exists() {
430                        self.status_message = Some((
431                            format!(
432                                "Error: target path does not exist: {}",
433                                self.export_state.target_repo
434                            ),
435                            Instant::now(),
436                        ));
437                        return;
438                    }
439                    if !target.is_dir() {
440                        self.status_message = Some((
441                            format!(
442                                "Error: target path is not a directory: {}",
443                                self.export_state.target_repo
444                            ),
445                            Instant::now(),
446                        ));
447                        return;
448                    }
449                    // Check writable by attempting to access metadata
450                    if target.metadata().is_err() {
451                        self.status_message = Some((
452                            format!(
453                                "Error: target path is not accessible: {}",
454                                self.export_state.target_repo
455                            ),
456                            Instant::now(),
457                        ));
458                        return;
459                    }
460
461                    // Build the command and validate arguments
462                    let cmd = ExportCommand {
463                        platform: self.export_state.platform.clone(),
464                        selection: self.export_state.selection.clone(),
465                        target_repo: target,
466                        dry_run: self.export_state.dry_run,
467                        force: self.export_state.force,
468                        no_skills: self.export_state.no_skills,
469                    };
470
471                    if let Err(e) = cmd.validate() {
472                        self.status_message =
473                            Some((format!("Validation error: {e}"), Instant::now()));
474                        return;
475                    }
476
477                    self.nav.push_view(View::ExportConfirm);
478                } else if self.export_state.platform.is_empty() {
479                    self.status_message =
480                        Some(("Platform is required".to_string(), Instant::now()));
481                } else {
482                    self.status_message =
483                        Some(("Target repo path is required".to_string(), Instant::now()));
484                }
485            }
486        }
487    }
488
489    /// Handle key events in the ExportConfirm view.
490    /// Enter executes the export, Esc cancels back to builder.
491    fn handle_export_confirm_key(&mut self, key: KeyEvent) {
492        match key.code {
493            KeyCode::Char('q') => self.should_quit = true,
494            KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
495                self.should_quit = true;
496            }
497            KeyCode::Esc => {
498                // Cancel back to builder, preserving selections
499                self.nav.pop_view();
500            }
501            KeyCode::Enter => {
502                // Execute the export command
503                self.execute_export();
504            }
505            _ => {}
506        }
507    }
508
509    /// Handle key events in the ExportOutput view.
510    /// Esc cancels/goes back, Ctrl+C during execution cancels subprocess.
511    fn handle_export_output_key(&mut self, key: KeyEvent) {
512        match key.code {
513            KeyCode::Char('q') if self.subprocess_handle.is_none() => {
514                self.should_quit = true;
515            }
516            KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
517                if self.subprocess_handle.is_some() {
518                    // Cancel the running subprocess
519                    self.cancel_export_subprocess();
520                } else {
521                    self.should_quit = true;
522                }
523            }
524            KeyCode::Esc => {
525                if self.subprocess_handle.is_some() {
526                    // Cancel the running subprocess and go back to builder
527                    self.cancel_export_subprocess();
528                }
529                // Go back to builder, preserving selections
530                self.nav.pop_view();
531            }
532            KeyCode::Char('j') | KeyCode::Down => {
533                self.nav.detail_scroll = self.nav.detail_scroll.saturating_add(1);
534            }
535            KeyCode::Char('k') | KeyCode::Up => {
536                self.nav.detail_scroll = self.nav.detail_scroll.saturating_sub(1);
537            }
538            _ => {}
539        }
540    }
541
542    /// Execute the export command as a subprocess.
543    fn execute_export(&mut self) {
544        let target = PathBuf::from(&self.export_state.target_repo);
545        let cmd = ExportCommand {
546            platform: self.export_state.platform.clone(),
547            selection: self.export_state.selection.clone(),
548            target_repo: target,
549            dry_run: self.export_state.dry_run,
550            force: self.export_state.force,
551            no_skills: self.export_state.no_skills,
552        };
553
554        // Clear previous output
555        self.subprocess_output.clear();
556
557        let args = cmd.to_args();
558        let workspace = self.workspace_root.clone();
559
560        // Spawn the subprocess asynchronously via oneshot channel to avoid
561        // `block_on` panic inside an existing tokio runtime.
562        let (tx, rx) = tokio::sync::oneshot::channel();
563        let all_args = [
564            vec!["scripts/export-marketplace-agents.mjs".to_string()],
565            args,
566        ]
567        .concat();
568        tokio::spawn(async move {
569            let result =
570                SubprocessExecutor::spawn("node", &all_args, &workspace, Duration::from_secs(300))
571                    .await;
572            let _ = tx.send(result);
573        });
574        // Store receiver to poll in tick()
575        self.pending_subprocess = Some(rx);
576        self.nav.push_view(View::ExportOutput);
577    }
578
579    /// Cancel the export subprocess.
580    fn cancel_export_subprocess(&mut self) {
581        if let Some(mut handle) = self.subprocess_handle.take() {
582            tokio::spawn(async move {
583                handle.cancel().await.ok();
584            });
585            self.status_message = Some(("Export cancelled by user".to_string(), Instant::now()));
586        }
587    }
588
589    fn handle_down(&mut self) {
590        match &self.nav.current_view {
591            View::AgentDetail(_)
592            | View::SkillDetail(_)
593            | View::McpDetail(_)
594            | View::RuleDetail(_)
595            | View::IntegrityDetail(_)
596            | View::RoleDetail(_) => {
597                self.nav.detail_scroll = self.nav.detail_scroll.saturating_add(1);
598            }
599            _ => {
600                let max = self.current_list_len();
601                self.nav.select_next(max);
602            }
603        }
604    }
605
606    fn handle_up(&mut self) {
607        match &self.nav.current_view {
608            View::AgentDetail(_)
609            | View::SkillDetail(_)
610            | View::McpDetail(_)
611            | View::RuleDetail(_)
612            | View::IntegrityDetail(_)
613            | View::RoleDetail(_) => {
614                self.nav.detail_scroll = self.nav.detail_scroll.saturating_sub(1);
615            }
616            _ => self.nav.select_prev(),
617        }
618    }
619
620    fn handle_enter(&mut self) {
621        let idx = self.nav.selected_index();
622        match self.nav.current_view.clone() {
623            View::AgentList => {
624                if let Some(&real_idx) = self.filtered_indices.get(idx) {
625                    if let Some(agent) = self.catalog.agents.get(real_idx) {
626                        self.nav.push_view(View::AgentDetail(agent.id.clone()));
627                    }
628                }
629            }
630            View::SkillList => {
631                if let Some(skill) = self.catalog.skills.get(idx) {
632                    self.nav.push_view(View::SkillDetail(skill.id.clone()));
633                }
634            }
635            View::RoleList => {
636                let roles: Vec<String> = self.get_role_list().iter().map(|r| r.0.clone()).collect();
637                if let Some(role_id) = roles.get(idx) {
638                    self.nav.push_view(View::RoleDetail(role_id.clone()));
639                }
640            }
641            View::ProviderList => {
642                let providers = self.get_provider_list();
643                if let Some((provider, _)) = providers.get(idx) {
644                    self.nav.push_view(View::ProviderAgents(provider.clone()));
645                }
646            }
647            View::McpList => {
648                if let Some(mcp) = self.catalog.mcp_refs.get(idx) {
649                    self.nav.push_view(View::McpDetail(mcp.id.clone()));
650                }
651            }
652            View::RuleList => {
653                if let Some(rule) = self.catalog.rules.get(idx) {
654                    self.nav.push_view(View::RuleDetail(rule.id.clone()));
655                }
656            }
657            View::ProviderAgents(ref provider) => {
658                let agents = self.catalog.agents_by_provider(provider);
659                if let Some(agent) = agents.get(idx) {
660                    self.nav.push_view(View::AgentDetail(agent.id.clone()));
661                }
662            }
663            View::IntegrityOverview => {
664                if let Some(integrity) = &self.catalog.integrity {
665                    // idx 0 is the summary line, idx 1..=trees.len() are trees,
666                    // last entry (if root_files exist) is root_files
667                    let tree_count = integrity.trees.len();
668                    if idx == 0 {
669                        // Summary line — no drill-down
670                    } else if idx <= tree_count {
671                        if let Some(tree) = integrity.trees.get(idx - 1) {
672                            self.nav.push_view(View::IntegrityDetail(tree.tree.clone()));
673                        }
674                    } else if !integrity.root_files.is_empty() && idx == tree_count + 1 {
675                        self.nav
676                            .push_view(View::IntegrityDetail("__root_files__".to_string()));
677                    }
678                }
679            }
680            View::ValidationList => {
681                self.handle_validation_enter(idx);
682            }
683            _ => {}
684        }
685    }
686
687    /// Handle Enter key on the ValidationList view.
688    ///
689    /// The validation list has N+1 entries: the first entry is "Run All Validations"
690    /// (invokes `npm run validate`), followed by individual gates.
691    fn handle_validation_enter(&mut self, idx: usize) {
692        // Index 0 = "Run All Validations", 1..N = individual gates
693        if idx == 0 {
694            // "Run All Validations" — check if any gate is already running
695            if self.running_gate.is_some() {
696                self.status_message = Some((
697                    "A validation gate is already running".to_string(),
698                    Instant::now(),
699                ));
700                return;
701            }
702            self.spawn_validation_gate(RUN_ALL_LABEL.to_string(), "validate".to_string());
703        } else {
704            let gate_idx = idx - 1;
705            if let Some(gate) = self.validation_gates.get(gate_idx) {
706                // Prevent concurrent execution of the same gate (Requirement 6.6, 6.7)
707                if gate.status == GateStatus::Running {
708                    self.status_message = Some((
709                        format!("{} is already running", gate.script_name),
710                        Instant::now(),
711                    ));
712                    return;
713                }
714                // Also prevent if any gate is running (single subprocess at a time)
715                if self.running_gate.is_some() {
716                    self.status_message = Some((
717                        "A validation gate is already running".to_string(),
718                        Instant::now(),
719                    ));
720                    return;
721                }
722                let script_name = gate.script_name.clone();
723                self.spawn_validation_gate(script_name.clone(), script_name);
724            }
725        }
726    }
727
728    /// Spawn a validation gate subprocess.
729    ///
730    /// `gate_label` is the name used for tracking (stored in `running_gate`).
731    /// `script_name` is the npm script name to invoke (e.g., "validate:lint" or "validate").
732    fn spawn_validation_gate(&mut self, gate_label: String, script_name: String) {
733        // Mark the gate as Running
734        if gate_label == RUN_ALL_LABEL {
735            // Mark all gates as Running for "Run All"
736            for gate in &mut self.validation_gates {
737                gate.status = GateStatus::Running;
738            }
739        } else {
740            for gate in &mut self.validation_gates {
741                if gate.script_name == gate_label {
742                    gate.status = GateStatus::Running;
743                    break;
744                }
745            }
746        }
747
748        // Clear previous output
749        self.subprocess_output.clear();
750
751        // Spawn the subprocess asynchronously via oneshot channel to avoid
752        // `block_on` panic inside an existing tokio runtime.
753        let workspace = self.workspace_root.clone();
754        let timeout = Duration::from_secs(VALIDATION_GATE_TIMEOUT_SECS);
755        let args = vec!["run".to_string(), script_name.clone()];
756
757        let (tx, rx) = tokio::sync::oneshot::channel();
758        // Only spawn when a Tokio runtime is in context — always true inside the
759        // real TUI event loop (`run_tui_async`). Synchronous tests that drive
760        // `handle_key_event` directly have no reactor, so skip the spawn there
761        // rather than panicking ("there is no reactor running").
762        if let Ok(handle) = tokio::runtime::Handle::try_current() {
763            handle.spawn(async move {
764                let result =
765                    crate::subprocess::SubprocessExecutor::spawn("npm", &args, &workspace, timeout)
766                        .await;
767                let _ = tx.send(result);
768            });
769            // Store receiver to poll in tick().
770            self.pending_subprocess = Some(rx);
771        }
772
773        // Set gate tracking state immediately (deterministic regardless of spawn).
774        self.running_gate = Some(gate_label.clone());
775        self.running_gate_start = Some(Instant::now());
776        self.nav.push_view(View::ValidationOutput(gate_label));
777        tracing::info!(gate = %script_name, "validation gate started");
778    }
779
780    /// Mark a gate (or all gates for "Run All") as failed.
781    fn mark_gate_failed(&mut self, gate_label: &str) {
782        if gate_label == RUN_ALL_LABEL {
783            for gate in &mut self.validation_gates {
784                if gate.status == GateStatus::Running {
785                    gate.status = GateStatus::Failed;
786                }
787            }
788        } else {
789            for gate in &mut self.validation_gates {
790                if gate.script_name == *gate_label {
791                    gate.status = GateStatus::Failed;
792                    break;
793                }
794            }
795        }
796        self.running_gate = None;
797        self.running_gate_start = None;
798    }
799
800    /// Mark the app as dirty (needs rendering) — used by async event loop.
801    pub fn mark_dirty(&mut self) {
802        self.dirty = true;
803    }
804
805    /// Keep the legacy `current_view` consistent with the active v2 tab so key
806    /// dispatch (Enter / j / k / g) targets what the tab actually renders.
807    ///
808    /// Only the tabs backed by a legacy view need syncing: `ValidationGates`
809    /// renders the validation list, and `CatalogBrowser` renders the legacy
810    /// catalog browser. Other v2 tabs render non-interactive widgets, so the
811    /// legacy view is left untouched.
812    fn sync_view_to_tab(&mut self) {
813        use crate::ui::nav::Tab;
814        // Land the CatalogBrowser tab on a catalog list view, but keep an
815        // existing catalog sub-view (guard, not a nested `if`, to satisfy
816        // clippy::collapsible_match).
817        match self.nav.current_tab {
818            Tab::ValidationGates => self.nav.current_view = View::ValidationList,
819            Tab::CatalogBrowser
820                if !matches!(
821                    self.nav.current_view,
822                    View::AgentList
823                        | View::SkillList
824                        | View::McpList
825                        | View::RuleList
826                        | View::RoleList
827                        | View::ProviderList
828                        | View::IntegrityOverview
829                ) =>
830            {
831                self.nav.current_view = View::AgentList;
832            }
833            _ => {}
834        }
835    }
836
837    /// Reload the entire catalog from the workspace root and refresh the
838    /// filtered view (Task 9.1 — live reload on filesystem change).
839    ///
840    /// Best-effort: catalog load errors are surfaced via the status message but
841    /// never panic, so a malformed edit keeps the previous render usable.
842    pub fn reload_catalog(&mut self) {
843        self.catalog = CatalogStore::load(&self.workspace_root);
844        self.update_filtered();
845        let msg = if self.catalog.load_errors.is_empty() {
846            "Catalog reloaded".to_string()
847        } else {
848            format!(
849                "Catalog reloaded with {} error(s)",
850                self.catalog.load_errors.len()
851            )
852        };
853        self.status_message = Some((msg, Instant::now()));
854        self.mark_dirty();
855    }
856
857    /// Reload a single changed catalog file in place (Task 9.1).
858    ///
859    /// Returns the [`ReloadOutcome`] so callers (and tests) can observe whether
860    /// the file was reloaded, retained on parse error, or unchanged.
861    pub fn reload_catalog_file(
862        &mut self,
863        path: &std::path::Path,
864    ) -> crate::catalog::store::ReloadOutcome {
865        let outcome = self.catalog.reload_file(path);
866        self.update_filtered();
867        self.mark_dirty();
868        outcome
869    }
870
871    /// Tick: clear expired status messages, drain subprocess output with sanitization,
872    /// and check subprocess timeout and completion.
873    pub fn tick(&mut self) {
874        if let Some((_, created)) = &self.status_message {
875            if created.elapsed().as_secs() > 10 {
876                self.status_message = None;
877            }
878        }
879
880        // Poll pending subprocess spawn result (from async tokio::spawn).
881        if let Some(ref mut rx) = self.pending_subprocess {
882            match rx.try_recv() {
883                Ok(Ok(subprocess_handle)) => {
884                    self.subprocess_handle = Some(subprocess_handle);
885                    self.pending_subprocess = None;
886                }
887                Ok(Err(e)) => {
888                    // Subprocess spawn failed
889                    if let Some(ref gate_label) = self.running_gate.clone() {
890                        self.mark_gate_failed(gate_label);
891                        tracing::error!(gate = %gate_label, error = %e, "validation gate spawn failed");
892                    }
893                    self.status_message =
894                        Some((format!("Failed to start subprocess: {e}"), Instant::now()));
895                    self.pending_subprocess = None;
896                }
897                Err(tokio::sync::oneshot::error::TryRecvError::Empty) => {
898                    // Not ready yet — keep waiting
899                }
900                Err(tokio::sync::oneshot::error::TryRecvError::Closed) => {
901                    // Sender dropped without sending — treat as error
902                    if let Some(ref gate_label) = self.running_gate.clone() {
903                        self.mark_gate_failed(gate_label);
904                    }
905                    self.status_message = Some((
906                        "Subprocess spawn channel closed unexpectedly".to_string(),
907                        Instant::now(),
908                    ));
909                    self.pending_subprocess = None;
910                }
911            }
912        }
913
914        // Drain subprocess output lines, sanitizing before storage
915        if let Some(handle) = &mut self.subprocess_handle {
916            while let Some(line) = handle.try_recv_stdout() {
917                self.subprocess_output.push(output::OutputLine {
918                    content: crate::security::redact::redact_secrets(&sanitize_subprocess_output(
919                        &line.content,
920                    )),
921                    stream: line.stream,
922                });
923            }
924            while let Some(line) = handle.try_recv_stderr() {
925                self.subprocess_output.push(output::OutputLine {
926                    content: crate::security::redact::redact_secrets(&sanitize_subprocess_output(
927                        &line.content,
928                    )),
929                    stream: line.stream,
930                });
931            }
932
933            // Poll for subprocess exit (non-blocking)
934            handle.try_poll_exit();
935
936            // Check timeout synchronously by comparing elapsed time
937            if handle.is_running() && handle.is_timed_out() {
938                // Mark any active validation gate as timed out
939                for gate in &mut self.validation_gates {
940                    if gate.status == GateStatus::Running {
941                        gate.status = GateStatus::TimedOut;
942                    }
943                }
944                self.running_gate = None;
945                self.running_gate_start = None;
946                self.status_message = Some(("Subprocess timed out".to_string(), Instant::now()));
947                self.subprocess_handle = None;
948            } else if !handle.is_running() {
949                // Subprocess has finished — update gate status based on exit code
950                let exit_code = handle.exit_code();
951                let duration = self.running_gate_start.map(|s| s.elapsed());
952
953                if let Some(ref gate_label) = self.running_gate.clone() {
954                    if gate_label == RUN_ALL_LABEL {
955                        // "Run All" — mark all gates based on overall exit code
956                        let status = if exit_code == Some(0) {
957                            GateStatus::Passed
958                        } else {
959                            GateStatus::Failed
960                        };
961                        for gate in &mut self.validation_gates {
962                            if gate.status == GateStatus::Running {
963                                gate.status = status.clone();
964                                gate.last_exit_code = exit_code;
965                                gate.last_duration = duration;
966                            }
967                        }
968                    } else {
969                        // Individual gate
970                        for gate in &mut self.validation_gates {
971                            if gate.script_name == *gate_label && gate.status == GateStatus::Running
972                            {
973                                gate.status = if exit_code == Some(0) {
974                                    GateStatus::Passed
975                                } else {
976                                    GateStatus::Failed
977                                };
978                                gate.last_exit_code = exit_code;
979                                gate.last_duration = duration;
980                                break;
981                            }
982                        }
983                    }
984                    tracing::info!(
985                        gate = %gate_label,
986                        exit_code = ?exit_code,
987                        duration_ms = ?duration.map(|d| d.as_millis()),
988                        "validation gate completed"
989                    );
990                } else {
991                    // Export subprocess completed (no running_gate set)
992                    if exit_code != Some(0) {
993                        self.status_message = Some((
994                            format!("Export failed with exit code {}", exit_code.unwrap_or(-1)),
995                            Instant::now(),
996                        ));
997                        tracing::warn!(
998                            exit_code = ?exit_code,
999                            "export command failed"
1000                        );
1001                    } else {
1002                        self.status_message =
1003                            Some(("Export completed successfully".to_string(), Instant::now()));
1004                        tracing::info!("export command completed successfully");
1005                    }
1006                }
1007
1008                self.running_gate = None;
1009                self.running_gate_start = None;
1010                // Final drain after process exits — capture any remaining buffered lines
1011                if let Some(ref mut handle) = self.subprocess_handle {
1012                    while let Some(line) = handle.try_recv_stdout() {
1013                        self.subprocess_output.push(output::OutputLine {
1014                            content: crate::security::redact::redact_secrets(
1015                                &sanitize_subprocess_output(&line.content),
1016                            ),
1017                            stream: line.stream,
1018                        });
1019                    }
1020                    while let Some(line) = handle.try_recv_stderr() {
1021                        self.subprocess_output.push(output::OutputLine {
1022                            content: crate::security::redact::redact_secrets(
1023                                &sanitize_subprocess_output(&line.content),
1024                            ),
1025                            stream: line.stream,
1026                        });
1027                    }
1028                }
1029                self.subprocess_handle = None;
1030            }
1031        }
1032
1033        // Cap subprocess output to prevent unbounded memory growth
1034        if self.subprocess_output.len() > MAX_SUBPROCESS_OUTPUT_LINES {
1035            let overflow = self.subprocess_output.len() - MAX_SUBPROCESS_OUTPUT_LINES;
1036            self.subprocess_output.drain(0..overflow);
1037        }
1038    }
1039
1040    /// Render the full UI — v2 primary surface: tab bar + tab body.
1041    pub fn render(&mut self, frame: &mut Frame) {
1042        use crate::ui::nav::Tab;
1043        use ratatui::layout::{Constraint, Direction, Layout};
1044
1045        let theme = Theme::new(self.no_color, self.theme_mode);
1046        let area = frame.area();
1047
1048        // Split into: tab bar (3 rows), body (flexible), status (1), help (1).
1049        let chunks = Layout::default()
1050            .direction(Direction::Vertical)
1051            .constraints([
1052                Constraint::Length(3), // tab bar
1053                Constraint::Min(0),    // tab body
1054                Constraint::Length(1), // status bar
1055                Constraint::Length(1), // help bar
1056            ])
1057            .split(area);
1058
1059        // Render the tab bar.
1060        self.render_tab_bar(chunks[0], frame, &theme);
1061
1062        // Render the active tab body.
1063        // Tabs that need &mut self are dispatched here; all others delegate to
1064        // render_tab (which is &self).
1065        let body = chunks[1];
1066        match self.nav.current_tab {
1067            Tab::CatalogBrowser => {
1068                // Legacy sidebar + main-content layout so all legacy render helpers
1069                // remain reachable and exercised when the user visits this tab.
1070                let legacy = compute_layout(body);
1071                self.render_sidebar(&legacy.sidebar, frame, &theme);
1072                self.render_main_content(&legacy.main_content, frame, &theme);
1073            }
1074            Tab::ValidationGates => {
1075                // Validation-gate list uses stateful rendering (&mut self) and is
1076                // wired to this tab so render_validation_list stays reachable.
1077                self.render_validation_list(body, frame, &theme);
1078            }
1079            _ => self.render_tab(body, frame, &theme),
1080        }
1081
1082        // Status bar.
1083        let (visible, total) = self.get_counts();
1084        let filter_str = if let Some((msg, _)) = &self.status_message {
1085            msg.clone()
1086        } else if self.search_query.is_empty() {
1087            String::new()
1088        } else {
1089            self.search_query.clone()
1090        };
1091        let session_str = self.session_id.to_string();
1092        status_bar::render_status_bar(
1093            visible,
1094            total,
1095            &filter_str,
1096            &session_str,
1097            chunks[2],
1098            frame,
1099            &theme,
1100        );
1101
1102        help_bar::render_help_bar(&self.nav.current_view, chunks[3], frame, &theme);
1103
1104        // Render help overlay on top if active.
1105        if self.show_help_overlay {
1106            self.render_help_overlay(frame, &theme);
1107        }
1108    }
1109
1110    /// Render the v2 tab bar showing all tabs with the current tab highlighted.
1111    fn render_tab_bar(&self, area: ratatui::layout::Rect, frame: &mut Frame, theme: &Theme) {
1112        use crate::ui::nav::Tab;
1113        use ratatui::text::Line;
1114        use ratatui::widgets::{Block, Borders, Tabs};
1115
1116        let titles: Vec<Line> = Tab::ALL.iter().map(|t| Line::from(t.label())).collect();
1117        let tabs = Tabs::new(titles)
1118            .block(
1119                Block::default()
1120                    .borders(Borders::ALL)
1121                    .title(" Operator Console "),
1122            )
1123            .select(self.nav.current_tab.index())
1124            .style(theme.list_item())
1125            .highlight_style(theme.list_selected());
1126        frame.render_widget(tabs, area);
1127    }
1128
1129    /// Render the active v2 operator-console tab into `area` (Task 11.3).
1130    ///
1131    /// Dispatches to the v2 widgets based on `self.nav.current_tab`. The
1132    /// Dependencies tab renders the real catalog dependency graph and Overview a
1133    /// catalog summary; tabs whose live data depends on a workspace scan/index
1134    /// (coverage, violations, audit) render their widget with empty data until
1135    /// that pipeline is wired into the TUI flow.
1136    pub fn render_tab(&self, area: ratatui::layout::Rect, frame: &mut Frame, theme: &Theme) {
1137        use crate::ui::nav::Tab;
1138        match self.nav.current_tab {
1139            Tab::Dependencies => {
1140                let graph = crate::federation::dep_graph::DependencyGraph::build(&self.catalog);
1141                let state = dep_graph::DepGraphState::default();
1142                dep_graph::render_dep_graph(&graph, &state, area, frame, theme);
1143            }
1144            Tab::CoverageMatrix => {
1145                let matrix = crate::models::coverage::CoverageMatrix {
1146                    rows: Vec::new(),
1147                    columns: Vec::new(),
1148                    cells: std::collections::HashMap::new(),
1149                    workspace_scores: std::collections::HashMap::new(),
1150                };
1151                let state = coverage_grid::CoverageGridState::default();
1152                let filter = coverage_grid::CoverageGridFilter::default();
1153                coverage_grid::render_coverage_grid(&matrix, &state, &filter, area, frame, theme);
1154            }
1155            Tab::PolicyViolations => {
1156                let state = violations::ViolationsState::default();
1157                violations::render_violations(&[], &[], &state, area, frame, theme);
1158            }
1159            Tab::AuditLog => {
1160                let state = audit_log::AuditLogState::default();
1161                audit_log::render_audit_log(&[], &state, area, frame, theme);
1162            }
1163            _ => self.render_tab_overview(area, frame, theme),
1164        }
1165    }
1166
1167    /// Render the Overview tab — a catalog summary (Task 11.3).
1168    fn render_tab_overview(&self, area: ratatui::layout::Rect, frame: &mut Frame, theme: &Theme) {
1169        use ratatui::widgets::{Block, Borders, Paragraph, Wrap};
1170        let body = format!(
1171            "Vanguard Frontier — Operator Console\n\n\
1172             Agents:    {}\n\
1173             Skills:    {}\n\
1174             MCP refs:  {}\n\
1175             Rules:     {}\n\
1176             Providers: {}\n\n\
1177             Tab/Shift-Tab to switch tabs.",
1178            self.catalog.agent_count(),
1179            self.catalog.skill_count(),
1180            self.catalog.mcp_refs.len(),
1181            self.catalog.rules.len(),
1182            self.catalog.provider_count(),
1183        );
1184        let paragraph = Paragraph::new(body)
1185            .style(theme.list_item())
1186            .block(Block::default().borders(Borders::ALL).title(" Overview "))
1187            .wrap(Wrap { trim: false });
1188        frame.render_widget(paragraph, area);
1189    }
1190}
1191
1192// Legacy sidebar / main-content render helpers — called from render() when the
1193// CatalogBrowser or ValidationGates tab is active (so they are never dead code).
1194impl App {
1195    fn render_sidebar(&mut self, area: &ratatui::layout::Rect, frame: &mut Frame, theme: &Theme) {
1196        let items: Vec<String> = SIDEBAR_SECTIONS
1197            .iter()
1198            .map(|(s, _)| s.to_string())
1199            .collect();
1200        let mut sidebar_state = ratatui::widgets::ListState::default();
1201        sidebar_state.select(Some(self.nav.sidebar_index));
1202        list_view::render_list_view(&items, &mut sidebar_state, "Catalog", *area, frame, theme);
1203    }
1204
1205    fn render_main_content(
1206        &mut self,
1207        area: &ratatui::layout::Rect,
1208        frame: &mut Frame,
1209        theme: &Theme,
1210    ) {
1211        // Render filter chips at the top if any filters are active (agent list view)
1212        let (content_area, has_chips) = if self.nav.current_view == View::AgentList
1213            && (self.provider_filter.is_some()
1214                || self.harness_filter.is_some()
1215                || !self.search_query.is_empty())
1216        {
1217            let chips_height = 1u16;
1218            let chunks = ratatui::layout::Layout::default()
1219                .direction(ratatui::layout::Direction::Vertical)
1220                .constraints([
1221                    ratatui::layout::Constraint::Length(chips_height),
1222                    ratatui::layout::Constraint::Min(0),
1223                ])
1224                .split(*area);
1225            self.render_filter_chips(chunks[0], frame, theme);
1226            (chunks[1], true)
1227        } else {
1228            (*area, false)
1229        };
1230        let _ = has_chips;
1231
1232        match self.nav.current_view.clone() {
1233            View::AgentList => self.render_agent_list(content_area, frame, theme),
1234            View::AgentDetail(ref id) => {
1235                self.render_agent_detail_view(id, content_area, frame, theme)
1236            }
1237            View::SkillList => self.render_skill_list(content_area, frame, theme),
1238            View::SkillDetail(ref id) => {
1239                self.render_skill_detail_view(id, content_area, frame, theme)
1240            }
1241            View::RoleList => self.render_role_list(content_area, frame, theme),
1242            View::RoleDetail(ref id) => {
1243                self.render_role_detail_view(id, content_area, frame, theme)
1244            }
1245            View::ProviderList => self.render_provider_list(content_area, frame, theme),
1246            View::ProviderAgents(ref p) => {
1247                self.render_provider_agents(p, content_area, frame, theme)
1248            }
1249            View::McpList => self.render_mcp_list(content_area, frame, theme),
1250            View::McpDetail(ref id) => self.render_mcp_detail_view(id, content_area, frame, theme),
1251            View::RuleList => self.render_rule_list(content_area, frame, theme),
1252            View::RuleDetail(ref id) => {
1253                self.render_rule_detail_view(id, content_area, frame, theme)
1254            }
1255            View::ValidationList => self.render_validation_list(content_area, frame, theme),
1256            View::ValidationOutput(ref name) => {
1257                let name = name.clone();
1258                self.render_validation_output(&name, content_area, frame, theme);
1259            }
1260            View::ExportBuilder => self.render_export_builder(content_area, frame, theme),
1261            View::ExportConfirm => self.render_export_confirm(content_area, frame, theme),
1262            View::ExportOutput => self.render_export_output(content_area, frame, theme),
1263            View::IntegrityOverview => self.render_integrity_view(content_area, frame, theme),
1264            View::IntegrityDetail(ref tree) => {
1265                let tree = tree.clone();
1266                self.render_integrity_detail(&tree, content_area, frame, theme);
1267            }
1268        }
1269
1270        if self.search_active || !self.search_query.is_empty() {
1271            let search_area = ratatui::layout::Rect {
1272                x: content_area.x,
1273                y: content_area.y,
1274                width: content_area.width.min(40),
1275                height: 3,
1276            };
1277            search::render_search_input(
1278                &self.search_query,
1279                self.search_active,
1280                search_area,
1281                frame,
1282                theme,
1283            );
1284        }
1285    }
1286
1287    fn render_agent_list(&mut self, area: ratatui::layout::Rect, frame: &mut Frame, theme: &Theme) {
1288        let items: Vec<String> = self
1289            .filtered_indices
1290            .iter()
1291            .filter_map(|&i| {
1292                self.catalog.agents.get(i).map(|a| {
1293                    format!(
1294                        "{} - {}",
1295                        a.id,
1296                        a.summary.chars().take(60).collect::<String>()
1297                    )
1298                })
1299            })
1300            .collect();
1301        list_view::render_list_view(
1302            &items,
1303            &mut self.nav.list_state,
1304            "Agents",
1305            area,
1306            frame,
1307            theme,
1308        );
1309    }
1310
1311    fn render_agent_detail_view(
1312        &self,
1313        id: &str,
1314        area: ratatui::layout::Rect,
1315        frame: &mut Frame,
1316        theme: &Theme,
1317    ) {
1318        if let Some(agent) = self.catalog.agents.iter().find(|a| a.id == id) {
1319            let roles = self.catalog.roles_containing_agent(id);
1320            detail::render_agent_detail(agent, &roles, area, frame, self.nav.detail_scroll, theme);
1321        }
1322    }
1323
1324    fn render_skill_list(&mut self, area: ratatui::layout::Rect, frame: &mut Frame, theme: &Theme) {
1325        let items: Vec<String> = self
1326            .catalog
1327            .skills
1328            .iter()
1329            .map(|s| {
1330                format!(
1331                    "{} - {}",
1332                    s.id,
1333                    s.summary.chars().take(60).collect::<String>()
1334                )
1335            })
1336            .collect();
1337        list_view::render_list_view(
1338            &items,
1339            &mut self.nav.list_state,
1340            "Skills",
1341            area,
1342            frame,
1343            theme,
1344        );
1345    }
1346
1347    fn render_skill_detail_view(
1348        &self,
1349        id: &str,
1350        area: ratatui::layout::Rect,
1351        frame: &mut Frame,
1352        theme: &Theme,
1353    ) {
1354        if let Some(skill) = self.catalog.skills.iter().find(|s| s.id == id) {
1355            let related = self.catalog.agents_with_skill(id);
1356            detail::render_skill_detail(
1357                skill,
1358                &related,
1359                area,
1360                frame,
1361                self.nav.detail_scroll,
1362                theme,
1363            );
1364        }
1365    }
1366
1367    fn render_role_list(&mut self, area: ratatui::layout::Rect, frame: &mut Frame, theme: &Theme) {
1368        let roles = self.get_role_list();
1369        let items: Vec<String> = roles
1370            .iter()
1371            .map(|(id, label, count)| format!("{id} ({label}) - {count} agents"))
1372            .collect();
1373        list_view::render_list_view(
1374            &items,
1375            &mut self.nav.list_state,
1376            "Roles",
1377            area,
1378            frame,
1379            theme,
1380        );
1381    }
1382
1383    fn render_role_detail_view(
1384        &self,
1385        id: &str,
1386        area: ratatui::layout::Rect,
1387        frame: &mut Frame,
1388        theme: &Theme,
1389    ) {
1390        let agents = self.catalog.agents_for_role(id);
1391        let items: Vec<ratatui::text::Line> = agents
1392            .iter()
1393            .map(|a| ratatui::text::Line::from(format!("  - {}", a.id)))
1394            .collect();
1395        let mut lines = vec![ratatui::text::Line::from(format!("Role: {id}"))];
1396        if let Some(role) = self.catalog.roles.get(id) {
1397            lines.push(ratatui::text::Line::from(format!("Label: {}", role.label)));
1398            lines.push(ratatui::text::Line::from(format!(
1399                "Description: {}",
1400                role.description
1401            )));
1402        }
1403        lines.push(ratatui::text::Line::from(format!(
1404            "Agents ({}): ",
1405            agents.len()
1406        )));
1407        lines.extend(items);
1408        let paragraph = ratatui::widgets::Paragraph::new(lines)
1409            .block(
1410                ratatui::widgets::Block::default()
1411                    .borders(ratatui::widgets::Borders::ALL)
1412                    .title("Role Detail")
1413                    .border_style(theme.border_style()),
1414            )
1415            .wrap(ratatui::widgets::Wrap { trim: false })
1416            .scroll((self.nav.detail_scroll, 0));
1417        frame.render_widget(paragraph, area);
1418    }
1419
1420    fn render_provider_list(
1421        &mut self,
1422        area: ratatui::layout::Rect,
1423        frame: &mut Frame,
1424        theme: &Theme,
1425    ) {
1426        let providers = self.get_provider_list();
1427        let max_count = providers.iter().map(|(_, c)| *c).max().unwrap_or(1);
1428        let bar_width = 20usize;
1429        let items: Vec<String> = providers
1430            .iter()
1431            .map(|(p, count)| {
1432                let filled = if max_count > 0 {
1433                    ((*count as f64 / max_count as f64) * bar_width as f64).round() as usize
1434                } else {
1435                    0
1436                };
1437                let empty = bar_width.saturating_sub(filled);
1438                let bar: String = "█".repeat(filled) + &"░".repeat(empty);
1439                format!("{p} ({count} agents) {bar}")
1440            })
1441            .collect();
1442        list_view::render_list_view(
1443            &items,
1444            &mut self.nav.list_state,
1445            "Providers",
1446            area,
1447            frame,
1448            theme,
1449        );
1450    }
1451
1452    fn render_provider_agents(
1453        &mut self,
1454        provider: &str,
1455        area: ratatui::layout::Rect,
1456        frame: &mut Frame,
1457        theme: &Theme,
1458    ) {
1459        let agents = self.catalog.agents_by_provider(provider);
1460        let items: Vec<String> = agents
1461            .iter()
1462            .map(|a| {
1463                format!(
1464                    "{} - {}",
1465                    a.id,
1466                    a.summary.chars().take(60).collect::<String>()
1467                )
1468            })
1469            .collect();
1470        let title = format!("Agents: {provider}");
1471        list_view::render_list_view(&items, &mut self.nav.list_state, &title, area, frame, theme);
1472    }
1473
1474    fn render_mcp_list(&mut self, area: ratatui::layout::Rect, frame: &mut Frame, theme: &Theme) {
1475        let items: Vec<String> = self
1476            .catalog
1477            .mcp_refs
1478            .iter()
1479            .map(|m| {
1480                format!(
1481                    "{} - {}",
1482                    m.id,
1483                    m.summary.chars().take(60).collect::<String>()
1484                )
1485            })
1486            .collect();
1487        list_view::render_list_view(
1488            &items,
1489            &mut self.nav.list_state,
1490            "MCP References",
1491            area,
1492            frame,
1493            theme,
1494        );
1495    }
1496
1497    fn render_mcp_detail_view(
1498        &self,
1499        id: &str,
1500        area: ratatui::layout::Rect,
1501        frame: &mut Frame,
1502        theme: &Theme,
1503    ) {
1504        if let Some(mcp) = self.catalog.mcp_refs.iter().find(|m| m.id == id) {
1505            detail::render_mcp_detail(mcp, area, frame, self.nav.detail_scroll, theme);
1506        }
1507    }
1508
1509    fn render_rule_list(&mut self, area: ratatui::layout::Rect, frame: &mut Frame, theme: &Theme) {
1510        let items: Vec<String> = self
1511            .catalog
1512            .rules
1513            .iter()
1514            .map(|r| {
1515                format!(
1516                    "{} - {}",
1517                    r.id,
1518                    r.summary.chars().take(60).collect::<String>()
1519                )
1520            })
1521            .collect();
1522        list_view::render_list_view(
1523            &items,
1524            &mut self.nav.list_state,
1525            "Rules",
1526            area,
1527            frame,
1528            theme,
1529        );
1530    }
1531
1532    fn render_rule_detail_view(
1533        &self,
1534        id: &str,
1535        area: ratatui::layout::Rect,
1536        frame: &mut Frame,
1537        theme: &Theme,
1538    ) {
1539        if let Some(rule) = self.catalog.rules.iter().find(|r| r.id == id) {
1540            detail::render_rule_detail(rule, area, frame, self.nav.detail_scroll, theme);
1541        }
1542    }
1543
1544    fn render_validation_list(
1545        &mut self,
1546        area: ratatui::layout::Rect,
1547        frame: &mut Frame,
1548        theme: &Theme,
1549    ) {
1550        use ratatui::style::Modifier;
1551        use ratatui::text::{Line, Span};
1552        use ratatui::widgets::{Block, Borders, List, ListItem};
1553
1554        let mut list_items: Vec<ListItem> = Vec::new();
1555
1556        // First entry: "Run All Validations"
1557        let run_all_style = if self.running_gate.as_deref() == Some(RUN_ALL_LABEL) {
1558            theme.gate_running()
1559        } else {
1560            theme.gate_not_run()
1561        };
1562        let run_all_indicator = if self.running_gate.as_deref() == Some(RUN_ALL_LABEL) {
1563            self.animated_spinner()
1564        } else {
1565            String::new()
1566        };
1567        list_items.push(ListItem::new(Line::from(vec![Span::styled(
1568            format!("▶ Run All Validations{}", run_all_indicator),
1569            run_all_style,
1570        )])));
1571
1572        // Individual gates
1573        for g in &self.validation_gates {
1574            let status_str = format!("{:?}", g.status);
1575            let timing_str = g
1576                .last_duration
1577                .map(|d| format!(" ({:.1}s)", d.as_secs_f64()))
1578                .unwrap_or_default();
1579            let style = match g.status {
1580                GateStatus::NotRun => theme.gate_not_run(),
1581                GateStatus::Running => theme.gate_running(),
1582                GateStatus::Passed => theme.gate_passed(),
1583                GateStatus::Failed => theme.gate_failed(),
1584                GateStatus::TimedOut => theme.gate_timed_out(),
1585            };
1586            let spinner = if g.status == GateStatus::Running {
1587                self.animated_spinner()
1588            } else {
1589                String::new()
1590            };
1591            let line = Line::from(vec![Span::styled(
1592                format!("{} [{}]{}{spinner}", g.script_name, status_str, timing_str),
1593                style,
1594            )]);
1595            list_items.push(ListItem::new(line));
1596        }
1597
1598        let list = List::new(list_items)
1599            .block(
1600                Block::default()
1601                    .borders(Borders::ALL)
1602                    .title("Validation Gates")
1603                    .border_style(theme.border_style()),
1604            )
1605            .highlight_style(theme.list_selected().add_modifier(Modifier::BOLD))
1606            .highlight_symbol("> ");
1607
1608        frame.render_stateful_widget(list, area, &mut self.nav.list_state);
1609    }
1610
1611    /// Generate an animated spinner character based on elapsed time.
1612    fn animated_spinner(&self) -> String {
1613        let frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
1614        let elapsed = self
1615            .running_gate_start
1616            .map(|s| s.elapsed().as_millis() as usize)
1617            .unwrap_or(0);
1618        let idx = (elapsed / 100) % frames.len();
1619        format!(" {}", frames[idx])
1620    }
1621
1622    fn render_validation_output(
1623        &self,
1624        _name: &str,
1625        area: ratatui::layout::Rect,
1626        frame: &mut Frame,
1627        theme: &Theme,
1628    ) {
1629        output::render_output_scrolled(
1630            &self.subprocess_output,
1631            "Validation Output",
1632            area,
1633            frame,
1634            self.nav.detail_scroll,
1635            theme,
1636        );
1637    }
1638
1639    fn render_export_builder(&self, area: ratatui::layout::Rect, frame: &mut Frame, theme: &Theme) {
1640        use ratatui::text::{Line, Span};
1641        use ratatui::widgets::{Block, Borders, Paragraph, Wrap};
1642
1643        let selection_str = match &self.export_state.selection {
1644            ExportSelection::All => "All".to_string(),
1645            ExportSelection::Role(r) => format!("Role: {r}"),
1646            ExportSelection::Provider(p) => format!("Provider: {p}"),
1647            ExportSelection::Agents(ids) => format!("Agents: {}", ids.join(", ")),
1648        };
1649
1650        let focused = self.export_state.focused_field;
1651        let fields = [
1652            format!("Platform: {}", self.export_state.platform),
1653            format!("Selection: {selection_str}"),
1654            format!("Target Repo: {}", self.export_state.target_repo),
1655            format!("Dry Run: {}", self.export_state.dry_run),
1656            format!("Force: {}", self.export_state.force),
1657            format!("No Skills: {}", self.export_state.no_skills),
1658        ];
1659
1660        let mut lines: Vec<Line> = fields
1661            .iter()
1662            .enumerate()
1663            .map(|(i, f)| {
1664                if i == focused {
1665                    Line::from(Span::styled(format!("> {f}"), theme.list_selected()))
1666                } else {
1667                    Line::from(f.as_str().to_string())
1668                }
1669            })
1670            .collect();
1671
1672        lines.push(Line::from(""));
1673        lines.push(Line::from("[Enter to confirm, Esc to cancel]"));
1674
1675        // Tab completion suggestions
1676        if !self.completion_suggestions.is_empty() {
1677            lines.push(Line::from(""));
1678            lines.push(Line::from(Span::styled(
1679                "Suggestions:",
1680                theme.help_overlay_section(),
1681            )));
1682            for (i, suggestion) in self.completion_suggestions.iter().enumerate() {
1683                let style = if i == self.completion_index {
1684                    theme.completion_highlight()
1685                } else {
1686                    theme.completion_normal()
1687                };
1688                lines.push(Line::from(Span::styled(format!("  {suggestion}"), style)));
1689            }
1690        }
1691
1692        let paragraph = Paragraph::new(lines)
1693            .block(
1694                Block::default()
1695                    .borders(Borders::ALL)
1696                    .title("Export Builder")
1697                    .border_style(theme.border_style()),
1698            )
1699            .wrap(Wrap { trim: false });
1700        frame.render_widget(paragraph, area);
1701    }
1702
1703    fn render_export_confirm(&self, area: ratatui::layout::Rect, frame: &mut Frame, theme: &Theme) {
1704        use ratatui::text::Line;
1705        use ratatui::widgets::{Block, Borders, Paragraph, Wrap};
1706
1707        let target = PathBuf::from(&self.export_state.target_repo);
1708        let cmd = ExportCommand {
1709            platform: self.export_state.platform.clone(),
1710            selection: self.export_state.selection.clone(),
1711            target_repo: target,
1712            dry_run: self.export_state.dry_run,
1713            force: self.export_state.force,
1714            no_skills: self.export_state.no_skills,
1715        };
1716
1717        let command_preview = cmd.display_command();
1718
1719        let lines = vec![
1720            Line::from("Confirm export execution?"),
1721            Line::from(""),
1722            Line::from("Command:"),
1723            Line::from(format!("  {command_preview}")),
1724            Line::from(""),
1725            Line::from(format!("Dry Run: {}", self.export_state.dry_run)),
1726            Line::from(""),
1727            Line::from("[Enter to execute, Esc to cancel]"),
1728        ];
1729        let paragraph = Paragraph::new(lines)
1730            .block(
1731                Block::default()
1732                    .borders(Borders::ALL)
1733                    .title("Confirm Export")
1734                    .border_style(theme.border_style()),
1735            )
1736            .wrap(Wrap { trim: false });
1737        frame.render_widget(paragraph, area);
1738    }
1739
1740    fn render_export_output(&self, area: ratatui::layout::Rect, frame: &mut Frame, theme: &Theme) {
1741        // If dry-run, parse output for tree structure
1742        if self.export_state.dry_run && !self.subprocess_output.is_empty() {
1743            use ratatui::style::Color;
1744            use ratatui::text::{Line, Span};
1745            use ratatui::widgets::{Block, Borders, Paragraph, Wrap};
1746
1747            let mut tree_lines: Vec<Line> = Vec::new();
1748            let mut agent_entries: Vec<String> = Vec::new();
1749            let mut skill_entries: Vec<String> = Vec::new();
1750
1751            for ol in &self.subprocess_output {
1752                let trimmed = ol.content.trim();
1753                if let Some(rest) = trimmed.strip_prefix("export agent:") {
1754                    agent_entries.push(rest.trim().to_string());
1755                } else if let Some(rest) = trimmed.strip_prefix("export skill:") {
1756                    skill_entries.push(rest.trim().to_string());
1757                }
1758            }
1759
1760            if !agent_entries.is_empty() || !skill_entries.is_empty() {
1761                tree_lines.push(Line::from(Span::styled(
1762                    "Dry-Run Export Tree:",
1763                    theme.help_overlay_title(),
1764                )));
1765                tree_lines.push(Line::from(""));
1766
1767                if !agent_entries.is_empty() {
1768                    tree_lines.push(Line::from(Span::styled("├── agents/", theme.detail_key())));
1769                    for (i, entry) in agent_entries.iter().enumerate() {
1770                        let prefix = if i == agent_entries.len() - 1 && skill_entries.is_empty() {
1771                            "│   └── "
1772                        } else {
1773                            "│   ├── "
1774                        };
1775                        let style = if theme.no_color {
1776                            ratatui::style::Style::default()
1777                        } else {
1778                            ratatui::style::Style::default().fg(Color::White)
1779                        };
1780                        tree_lines
1781                            .push(Line::from(Span::styled(format!("{prefix}{entry}"), style)));
1782                    }
1783                }
1784
1785                if !skill_entries.is_empty() {
1786                    tree_lines.push(Line::from(Span::styled("└── skills/", theme.detail_key())));
1787                    for (i, entry) in skill_entries.iter().enumerate() {
1788                        let prefix = if i == skill_entries.len() - 1 {
1789                            "    └── "
1790                        } else {
1791                            "    ├── "
1792                        };
1793                        let style = if theme.no_color {
1794                            ratatui::style::Style::default()
1795                        } else {
1796                            ratatui::style::Style::default().fg(Color::White)
1797                        };
1798                        tree_lines
1799                            .push(Line::from(Span::styled(format!("{prefix}{entry}"), style)));
1800                    }
1801                }
1802
1803                tree_lines.push(Line::from(""));
1804                tree_lines.push(Line::from(format!(
1805                    "Total: {} agents, {} skills",
1806                    agent_entries.len(),
1807                    skill_entries.len()
1808                )));
1809
1810                // Append raw output below the tree
1811                tree_lines.push(Line::from(""));
1812                tree_lines.push(Line::from("─── Raw Output ───"));
1813                for ol in &self.subprocess_output {
1814                    let style = if theme.no_color {
1815                        ratatui::style::Style::default()
1816                    } else {
1817                        match ol.stream {
1818                            crate::subprocess::OutputStream::Stdout => {
1819                                ratatui::style::Style::default().fg(Color::White)
1820                            }
1821                            crate::subprocess::OutputStream::Stderr => {
1822                                ratatui::style::Style::default().fg(Color::Red)
1823                            }
1824                        }
1825                    };
1826                    tree_lines.push(Line::from(Span::styled(ol.content.clone(), style)));
1827                }
1828
1829                let paragraph = Paragraph::new(tree_lines)
1830                    .block(
1831                        Block::default()
1832                            .borders(Borders::ALL)
1833                            .title("Export Output (Dry-Run)")
1834                            .border_style(theme.border_style()),
1835                    )
1836                    .wrap(Wrap { trim: false })
1837                    .scroll((self.nav.detail_scroll, 0));
1838                frame.render_widget(paragraph, area);
1839            } else {
1840                output::render_output_scrolled(
1841                    &self.subprocess_output,
1842                    "Export Output",
1843                    area,
1844                    frame,
1845                    self.nav.detail_scroll,
1846                    theme,
1847                );
1848            }
1849        } else {
1850            output::render_output_scrolled(
1851                &self.subprocess_output,
1852                "Export Output",
1853                area,
1854                frame,
1855                self.nav.detail_scroll,
1856                theme,
1857            );
1858        }
1859    }
1860
1861    fn render_integrity_view(
1862        &mut self,
1863        area: ratatui::layout::Rect,
1864        frame: &mut Frame,
1865        theme: &Theme,
1866    ) {
1867        let items: Vec<String> = match &self.catalog.integrity {
1868            Some(integrity) => {
1869                let total_files: usize =
1870                    integrity.trees.iter().map(|t| t.files.len()).sum::<usize>()
1871                        + integrity.root_files.len();
1872                let mut entries = vec![format!(
1873                    "Manifest v{} | {} | {} files | SHA: {}...",
1874                    integrity.manifest_version,
1875                    integrity.algorithm,
1876                    total_files,
1877                    &integrity.aggregate_sha256[..8.min(integrity.aggregate_sha256.len())]
1878                )];
1879                for t in &integrity.trees {
1880                    entries.push(format!(
1881                        "  [tree] {} ({} files, SHA: {}...)",
1882                        t.tree,
1883                        t.files.len(),
1884                        &t.aggregate_sha256[..8.min(t.aggregate_sha256.len())]
1885                    ));
1886                }
1887                if !integrity.root_files.is_empty() {
1888                    entries.push(format!(
1889                        "  [root] root_files ({} files)",
1890                        integrity.root_files.len()
1891                    ));
1892                }
1893                entries
1894            }
1895            None => vec!["No integrity data available".to_string()],
1896        };
1897        list_view::render_list_view(
1898            &items,
1899            &mut self.nav.list_state,
1900            "Asset Integrity",
1901            area,
1902            frame,
1903            theme,
1904        );
1905    }
1906
1907    fn render_integrity_detail(
1908        &self,
1909        tree_name: &str,
1910        area: ratatui::layout::Rect,
1911        frame: &mut Frame,
1912        theme: &Theme,
1913    ) {
1914        let lines: Vec<ratatui::text::Line> = if let Some(integrity) = &self.catalog.integrity {
1915            if tree_name == "__root_files__" {
1916                // Requirement 21.5: Display root files with path, SHA-256, and size
1917                let mut l = vec![
1918                    ratatui::text::Line::from("Root Files"),
1919                    ratatui::text::Line::from(format!("Files: {}", integrity.root_files.len())),
1920                    ratatui::text::Line::from(""),
1921                ];
1922                for f in &integrity.root_files {
1923                    l.push(ratatui::text::Line::from(format!("  {}", f.path)));
1924                    l.push(ratatui::text::Line::from(format!(
1925                        "    SHA-256: {}",
1926                        f.sha256
1927                    )));
1928                    l.push(ratatui::text::Line::from(format!(
1929                        "    Size: {} bytes",
1930                        f.bytes
1931                    )));
1932                }
1933                l
1934            } else if let Some(tree) = integrity.trees.iter().find(|t| t.tree == tree_name) {
1935                // Requirement 21.3, 21.4: Display tree files with path, SHA-256, size,
1936                // and the parent tree's aggregate SHA-256 hash
1937                let mut l = vec![
1938                    ratatui::text::Line::from(format!("Tree: {}", tree.tree)),
1939                    ratatui::text::Line::from(format!(
1940                        "Aggregate SHA-256: {}",
1941                        tree.aggregate_sha256
1942                    )),
1943                    ratatui::text::Line::from(format!("Files: {}", tree.files.len())),
1944                    ratatui::text::Line::from(""),
1945                ];
1946                for f in &tree.files {
1947                    l.push(ratatui::text::Line::from(format!("  {}", f.path)));
1948                    l.push(ratatui::text::Line::from(format!(
1949                        "    SHA-256: {}",
1950                        f.sha256
1951                    )));
1952                    l.push(ratatui::text::Line::from(format!(
1953                        "    Size: {} bytes",
1954                        f.bytes
1955                    )));
1956                }
1957                l
1958            } else {
1959                vec![ratatui::text::Line::from("Tree not found")]
1960            }
1961        } else {
1962            vec![ratatui::text::Line::from("No integrity data")]
1963        };
1964        let paragraph = ratatui::widgets::Paragraph::new(lines)
1965            .block(
1966                ratatui::widgets::Block::default()
1967                    .borders(ratatui::widgets::Borders::ALL)
1968                    .title("Integrity Detail")
1969                    .border_style(theme.border_style()),
1970            )
1971            .wrap(ratatui::widgets::Wrap { trim: false })
1972            .scroll((self.nav.detail_scroll, 0));
1973        frame.render_widget(paragraph, area);
1974    }
1975
1976    /// Update filtered indices based on current view and search query.
1977    fn update_filtered(&mut self) {
1978        if self.nav.current_view == View::AgentList {
1979            self.filtered_indices = self.search_engine.search_agents(
1980                &self.search_query,
1981                &self.catalog.agents,
1982                self.provider_filter.as_deref(),
1983                self.harness_filter.as_deref(),
1984            );
1985        }
1986    }
1987
1988    /// Cycle through provider filter values (None -> first provider -> second -> ... -> None).
1989    fn cycle_provider_filter(&mut self) {
1990        let providers = self.catalog.provider_names();
1991        if providers.is_empty() {
1992            return;
1993        }
1994        self.provider_filter = match &self.provider_filter {
1995            None => Some(providers[0].clone()),
1996            Some(current) => {
1997                let idx = providers.iter().position(|p| p == current);
1998                match idx {
1999                    Some(i) if i + 1 < providers.len() => Some(providers[i + 1].clone()),
2000                    _ => None,
2001                }
2002            }
2003        };
2004        self.update_filtered();
2005    }
2006
2007    /// Cycle through harness filter values (None -> first harness -> second -> ... -> None).
2008    fn cycle_harness_filter(&mut self) {
2009        let harnesses = self.catalog.harness_names();
2010        if harnesses.is_empty() {
2011            return;
2012        }
2013        self.harness_filter = match &self.harness_filter {
2014            None => Some(harnesses[0].clone()),
2015            Some(current) => {
2016                let idx = harnesses.iter().position(|h| h == current);
2017                match idx {
2018                    Some(i) if i + 1 < harnesses.len() => Some(harnesses[i + 1].clone()),
2019                    _ => None,
2020                }
2021            }
2022        };
2023        self.update_filtered();
2024    }
2025
2026    /// Render filter chips showing active filters.
2027    fn render_filter_chips(&self, area: ratatui::layout::Rect, frame: &mut Frame, theme: &Theme) {
2028        use ratatui::text::{Line, Span};
2029        use ratatui::widgets::Paragraph;
2030
2031        let mut spans: Vec<Span> = Vec::new();
2032        if let Some(ref pf) = self.provider_filter {
2033            spans.push(Span::styled(
2034                format!(" [provider:{pf}] "),
2035                theme.filter_chip(),
2036            ));
2037            spans.push(Span::raw(" "));
2038        }
2039        if let Some(ref hf) = self.harness_filter {
2040            spans.push(Span::styled(
2041                format!(" [harness:{hf}] "),
2042                theme.filter_chip(),
2043            ));
2044            spans.push(Span::raw(" "));
2045        }
2046        if !self.search_query.is_empty() {
2047            spans.push(Span::styled(
2048                format!(" [query:\"{}\"] ", self.search_query),
2049                theme.filter_chip(),
2050            ));
2051        }
2052
2053        let line = Line::from(spans);
2054        let paragraph = Paragraph::new(vec![line]);
2055        frame.render_widget(paragraph, area);
2056    }
2057
2058    /// Render the full-screen help overlay showing all keybindings.
2059    fn render_help_overlay(&self, frame: &mut Frame, theme: &Theme) {
2060        use ratatui::layout::Rect;
2061        use ratatui::text::{Line, Span};
2062        use ratatui::widgets::{Block, Borders, Clear, Paragraph, Wrap};
2063
2064        let area = frame.area();
2065        // Center the overlay with some padding
2066        let overlay = Rect {
2067            x: area.x + 2,
2068            y: area.y + 1,
2069            width: area.width.saturating_sub(4),
2070            height: area.height.saturating_sub(2),
2071        };
2072
2073        frame.render_widget(Clear, overlay);
2074
2075        let lines = vec![
2076            Line::from(Span::styled(
2077                "Keyboard Shortcuts",
2078                theme.help_overlay_title(),
2079            )),
2080            Line::from(""),
2081            Line::from(Span::styled("Navigation", theme.help_overlay_section())),
2082            Line::from("  j / ↓         Move down"),
2083            Line::from("  k / ↑         Move up"),
2084            Line::from("  g             Jump to top"),
2085            Line::from("  G             Jump to bottom"),
2086            Line::from("  Enter         Select / drill in"),
2087            Line::from("  Esc           Back / clear filters"),
2088            Line::from("  Tab           Next section"),
2089            Line::from("  Shift+Tab     Previous section"),
2090            Line::from(""),
2091            Line::from(Span::styled(
2092                "Search & Filter",
2093                theme.help_overlay_section(),
2094            )),
2095            Line::from("  /             Activate search"),
2096            Line::from("  p             Cycle provider filter (agent list)"),
2097            Line::from("  h             Cycle harness filter (agent list)"),
2098            Line::from(""),
2099            Line::from(Span::styled("General", theme.help_overlay_section())),
2100            Line::from("  ?             Toggle this help overlay"),
2101            Line::from("  t             Toggle light / dark theme"),
2102            Line::from("  q             Quit"),
2103            Line::from("  Ctrl+C        Quit"),
2104            Line::from(""),
2105            Line::from(Span::styled("Export Builder", theme.help_overlay_section())),
2106            Line::from("  j/k           Move between fields"),
2107            Line::from("  Enter         Confirm export"),
2108            Line::from("  Esc           Cancel"),
2109            Line::from(""),
2110            Line::from(Span::styled("Press ? or Esc to close", theme.help_bar())),
2111        ];
2112
2113        let paragraph = Paragraph::new(lines)
2114            .block(
2115                Block::default()
2116                    .borders(Borders::ALL)
2117                    .title("Help")
2118                    .border_style(theme.help_overlay_title()),
2119            )
2120            .wrap(Wrap { trim: false });
2121
2122        frame.render_widget(paragraph, overlay);
2123    }
2124
2125    /// Update tab completion suggestions based on current export builder field.
2126    pub fn update_completion_suggestions(&mut self) {
2127        self.completion_suggestions.clear();
2128        self.completion_index = 0;
2129
2130        if self.nav.current_view != View::ExportBuilder {
2131            return;
2132        }
2133
2134        match self.export_state.focused_field {
2135            0 => {
2136                // Platform field
2137                let platforms = self.catalog.platform_names();
2138                self.completion_suggestions = platforms
2139                    .iter()
2140                    .filter(|p| p.starts_with(&self.export_state.platform))
2141                    .map(|p| p.to_string())
2142                    .collect();
2143            }
2144            1 => {
2145                // Selection field — show roles
2146                let roles = self.catalog.role_ids();
2147                self.completion_suggestions = roles;
2148            }
2149            _ => {}
2150        }
2151    }
2152
2153    /// Get the length of the current list for navigation.
2154    fn current_list_len(&self) -> usize {
2155        match &self.nav.current_view {
2156            View::AgentList => self.filtered_indices.len(),
2157            View::SkillList => self.catalog.skills.len(),
2158            View::RoleList => self.catalog.roles.len(),
2159            View::ProviderList => self.get_provider_list().len(),
2160            View::McpList => self.catalog.mcp_refs.len(),
2161            View::RuleList => self.catalog.rules.len(),
2162            View::ValidationList => self.validation_gates.len() + 1, // +1 for "Run All"
2163            View::ProviderAgents(p) => self.catalog.agents_by_provider(p).len(),
2164            View::IntegrityOverview => self
2165                .catalog
2166                .integrity
2167                .as_ref()
2168                .map(|i| {
2169                    // 1 summary + trees + optional root_files entry
2170                    1 + i.trees.len() + if i.root_files.is_empty() { 0 } else { 1 }
2171                })
2172                .unwrap_or(0),
2173            _ => 0,
2174        }
2175    }
2176
2177    fn get_counts(&self) -> (usize, usize) {
2178        match &self.nav.current_view {
2179            View::AgentList => (self.filtered_indices.len(), self.catalog.agents.len()),
2180            View::SkillList => (self.catalog.skills.len(), self.catalog.skills.len()),
2181            View::McpList => (self.catalog.mcp_refs.len(), self.catalog.mcp_refs.len()),
2182            View::RuleList => (self.catalog.rules.len(), self.catalog.rules.len()),
2183            _ => (0, 0),
2184        }
2185    }
2186
2187    /// Get provider list with counts.
2188    pub fn get_provider_list(&self) -> Vec<(String, usize)> {
2189        let mut providers: HashSet<String> = HashSet::new();
2190        for agent in &self.catalog.agents {
2191            let p = serde_json::to_value(agent.provider)
2192                .ok()
2193                .and_then(|v| v.as_str().map(|s| s.to_string()))
2194                .unwrap_or_else(|| format!("{:?}", agent.provider));
2195            providers.insert(p);
2196        }
2197        let mut list: Vec<(String, usize)> = providers
2198            .into_iter()
2199            .map(|p| {
2200                let count = self.catalog.agents_by_provider(&p).len();
2201                (p, count)
2202            })
2203            .collect();
2204        list.sort_by_key(|a| a.0.clone());
2205        list
2206    }
2207
2208    /// Get role list with labels and agent counts.
2209    pub fn get_role_list(&self) -> Vec<(String, String, usize)> {
2210        let mut list: Vec<(String, String, usize)> = self
2211            .catalog
2212            .roles
2213            .iter()
2214            .map(|(id, role)| (id.clone(), role.label.clone(), role.agents.len()))
2215            .collect();
2216        list.sort_by_key(|a| a.0.clone());
2217        list
2218    }
2219}
2220
2221#[cfg(test)]
2222mod tests {
2223    use super::*;
2224    use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers};
2225    use std::path::Path;
2226
2227    fn workspace_root() -> PathBuf {
2228        let manifest_dir = env!("CARGO_MANIFEST_DIR");
2229        Path::new(manifest_dir)
2230            .parent()
2231            .unwrap()
2232            .parent()
2233            .unwrap()
2234            .to_path_buf()
2235    }
2236
2237    fn make_app() -> App {
2238        let ws = workspace_root();
2239        let catalog = CatalogStore::load(&ws);
2240        App::new(catalog, ws, Uuid::new_v4(), true)
2241    }
2242
2243    fn key_event(code: KeyCode) -> KeyEvent {
2244        KeyEvent {
2245            code,
2246            modifiers: KeyModifiers::NONE,
2247            kind: KeyEventKind::Press,
2248            state: KeyEventState::NONE,
2249        }
2250    }
2251
2252    #[test]
2253    fn app_new_starts_at_agent_list() {
2254        let app = make_app();
2255        assert_eq!(app.nav.current_view, View::AgentList);
2256        assert!(!app.should_quit);
2257        assert!(!app.filtered_indices.is_empty());
2258    }
2259
2260    #[test]
2261    fn app_quit_on_q() {
2262        let mut app = make_app();
2263        app.handle_key_event(key_event(KeyCode::Char('q')));
2264        assert!(app.should_quit);
2265    }
2266
2267    #[test]
2268    fn app_quit_on_ctrl_c() {
2269        let mut app = make_app();
2270        let key = KeyEvent {
2271            code: KeyCode::Char('c'),
2272            modifiers: KeyModifiers::CONTROL,
2273            kind: KeyEventKind::Press,
2274            state: KeyEventState::NONE,
2275        };
2276        app.handle_key_event(key);
2277        assert!(app.should_quit);
2278    }
2279
2280    #[test]
2281    fn app_t_toggles_theme_mode() {
2282        // Req 35.6 / Task 15.3: `t` toggles the session theme mode and marks
2283        // the UI dirty so it re-renders with the new palette.
2284        let mut app = make_app();
2285        assert_eq!(app.theme_mode, ThemeMode::Dark);
2286        app.dirty = false;
2287
2288        app.handle_key_event(key_event(KeyCode::Char('t')));
2289        assert_eq!(app.theme_mode, ThemeMode::Light);
2290        assert!(app.dirty, "theme toggle must set dirty flag");
2291
2292        app.handle_key_event(key_event(KeyCode::Char('t')));
2293        assert_eq!(app.theme_mode, ThemeMode::Dark);
2294    }
2295
2296    #[test]
2297    fn app_t_does_not_toggle_theme_in_search_mode() {
2298        // In search mode, `t` is literal search input, not a theme toggle.
2299        let mut app = make_app();
2300        app.handle_key_event(key_event(KeyCode::Char('/')));
2301        assert!(app.search_active);
2302        app.handle_key_event(key_event(KeyCode::Char('t')));
2303        assert_eq!(app.theme_mode, ThemeMode::Dark);
2304        assert_eq!(app.search_query, "t");
2305    }
2306
2307    #[test]
2308    fn app_tab_advances_current_tab() {
2309        // v2: Tab key cycles the operator-console tab bar, not the sidebar.
2310        let mut app = make_app();
2311        let initial_tab = app.nav.current_tab.clone();
2312        app.handle_key_event(key_event(KeyCode::Tab));
2313        // current_tab must have advanced by one step.
2314        assert_ne!(
2315            app.nav.current_tab, initial_tab,
2316            "Tab should advance the active operator-console tab"
2317        );
2318        assert!(app.dirty, "Tab must set dirty flag");
2319    }
2320
2321    #[test]
2322    fn sync_view_to_tab_aligns_legacy_view() {
2323        use crate::ui::nav::Tab;
2324        let mut app = make_app();
2325        // Arriving at the Gates tab from a non-catalog view must retarget key
2326        // dispatch to the validation list (not whatever legacy view was active).
2327        app.nav.current_view = View::AgentList;
2328        app.nav.current_tab = Tab::ValidationGates;
2329        app.sync_view_to_tab();
2330        assert_eq!(app.nav.current_view, View::ValidationList);
2331
2332        // CatalogBrowser lands on a catalog list view.
2333        app.nav.current_view = View::ValidationList;
2334        app.nav.current_tab = Tab::CatalogBrowser;
2335        app.sync_view_to_tab();
2336        assert_eq!(app.nav.current_view, View::AgentList);
2337    }
2338
2339    #[test]
2340    fn app_backtab_retreats_current_tab() {
2341        // v2: BackTab cycles the operator-console tab bar backwards.
2342        let mut app = make_app();
2343        // Advance to tab 1 first so we can retreat back.
2344        app.nav.next_tab();
2345        let tab_at_1 = app.nav.current_tab.clone();
2346        let key = KeyEvent {
2347            code: KeyCode::BackTab,
2348            modifiers: KeyModifiers::NONE,
2349            kind: KeyEventKind::Press,
2350            state: KeyEventState::NONE,
2351        };
2352        app.handle_key_event(key);
2353        assert_ne!(
2354            app.nav.current_tab, tab_at_1,
2355            "BackTab should retreat the active operator-console tab"
2356        );
2357        assert!(app.dirty, "BackTab must set dirty flag");
2358    }
2359
2360    #[test]
2361    fn app_j_navigates_down() {
2362        let mut app = make_app();
2363        assert_eq!(app.nav.selected_index(), 0);
2364        app.handle_key_event(key_event(KeyCode::Char('j')));
2365        assert_eq!(app.nav.selected_index(), 1);
2366    }
2367
2368    #[test]
2369    fn app_k_navigates_up() {
2370        let mut app = make_app();
2371        app.handle_key_event(key_event(KeyCode::Char('j')));
2372        app.handle_key_event(key_event(KeyCode::Char('k')));
2373        assert_eq!(app.nav.selected_index(), 0);
2374    }
2375
2376    #[test]
2377    fn app_enter_drills_into_agent_detail() {
2378        let mut app = make_app();
2379        app.handle_key_event(key_event(KeyCode::Enter));
2380        assert!(matches!(app.nav.current_view, View::AgentDetail(_)));
2381    }
2382
2383    #[test]
2384    fn app_escape_goes_back() {
2385        let mut app = make_app();
2386        app.handle_key_event(key_event(KeyCode::Enter));
2387        assert!(matches!(app.nav.current_view, View::AgentDetail(_)));
2388        app.handle_key_event(key_event(KeyCode::Esc));
2389        assert_eq!(app.nav.current_view, View::AgentList);
2390    }
2391
2392    #[test]
2393    fn app_search_activation() {
2394        let mut app = make_app();
2395        app.handle_key_event(key_event(KeyCode::Char('/')));
2396        assert!(app.search_active);
2397        assert!(app.search_query.is_empty());
2398    }
2399
2400    #[test]
2401    fn app_search_typing() {
2402        let mut app = make_app();
2403        app.handle_key_event(key_event(KeyCode::Char('/')));
2404        app.handle_key_event(key_event(KeyCode::Char('a')));
2405        app.handle_key_event(key_event(KeyCode::Char('w')));
2406        app.handle_key_event(key_event(KeyCode::Char('s')));
2407        assert_eq!(app.search_query, "aws");
2408    }
2409
2410    #[test]
2411    fn app_search_backspace() {
2412        let mut app = make_app();
2413        app.handle_key_event(key_event(KeyCode::Char('/')));
2414        app.handle_key_event(key_event(KeyCode::Char('x')));
2415        app.handle_key_event(key_event(KeyCode::Backspace));
2416        assert!(app.search_query.is_empty());
2417    }
2418
2419    #[test]
2420    fn app_search_escape_deactivates() {
2421        let mut app = make_app();
2422        app.handle_key_event(key_event(KeyCode::Char('/')));
2423        app.handle_key_event(key_event(KeyCode::Esc));
2424        assert!(!app.search_active);
2425    }
2426
2427    #[test]
2428    fn app_g_goes_to_top() {
2429        let mut app = make_app();
2430        app.handle_key_event(key_event(KeyCode::Char('j')));
2431        app.handle_key_event(key_event(KeyCode::Char('j')));
2432        app.handle_key_event(key_event(KeyCode::Char('g')));
2433        assert_eq!(app.nav.selected_index(), 0);
2434    }
2435
2436    #[test]
2437    fn app_big_g_goes_to_bottom() {
2438        let mut app = make_app();
2439        app.handle_key_event(key_event(KeyCode::Char('G')));
2440        let max = app.current_list_len();
2441        assert_eq!(app.nav.selected_index(), max - 1);
2442    }
2443
2444    #[test]
2445    fn app_empty_catalog_does_not_panic() {
2446        let catalog = CatalogStore {
2447            agents: Vec::new(),
2448            skills: Vec::new(),
2449            roles: std::collections::HashMap::new(),
2450            role_catalog_version: String::new(),
2451            role_catalog_description: String::new(),
2452            mcp_refs: Vec::new(),
2453            rules: Vec::new(),
2454            integrity: None,
2455            load_errors: Vec::new(),
2456            content_hashes: std::collections::HashMap::new(),
2457            catalog_root: std::path::PathBuf::from("."),
2458        };
2459        let app = App::new(catalog, PathBuf::from("/tmp"), Uuid::new_v4(), true);
2460        assert!(app.filtered_indices.is_empty());
2461        assert!(app.provider_filter.is_none());
2462        assert!(app.harness_filter.is_none());
2463        assert!(app.running_gate.is_none());
2464        assert!(app.running_gate_start.is_none());
2465        assert!(!app.show_help_overlay);
2466    }
2467
2468    #[test]
2469    fn app_tick_clears_old_messages() {
2470        let mut app = make_app();
2471        app.status_message = Some((
2472            "test".to_string(),
2473            Instant::now() - std::time::Duration::from_secs(20),
2474        ));
2475        app.tick();
2476        assert!(app.status_message.is_none());
2477    }
2478
2479    #[test]
2480    fn app_tick_keeps_recent_messages() {
2481        let mut app = make_app();
2482        app.status_message = Some(("test".to_string(), Instant::now()));
2483        app.tick();
2484        assert!(app.status_message.is_some());
2485    }
2486
2487    #[test]
2488    fn app_get_provider_list_non_empty() {
2489        let app = make_app();
2490        let providers = app.get_provider_list();
2491        assert!(!providers.is_empty());
2492    }
2493
2494    #[test]
2495    fn app_get_role_list_non_empty() {
2496        let app = make_app();
2497        let roles = app.get_role_list();
2498        assert!(!roles.is_empty());
2499    }
2500
2501    #[test]
2502    fn app_subprocess_output_capped_at_max() {
2503        let mut app = make_app();
2504        // Fill beyond the limit
2505        for i in 0..(MAX_SUBPROCESS_OUTPUT_LINES + 500) {
2506            app.subprocess_output.push(output::OutputLine {
2507                content: format!("line {i}"),
2508                stream: crate::subprocess::OutputStream::Stdout,
2509            });
2510        }
2511        app.tick();
2512        assert_eq!(app.subprocess_output.len(), MAX_SUBPROCESS_OUTPUT_LINES);
2513        // Verify oldest lines were removed (first line should be "line 500")
2514        assert_eq!(app.subprocess_output[0].content, "line 500");
2515    }
2516
2517    #[test]
2518    fn app_search_query_length_capped() {
2519        let mut app = make_app();
2520        app.handle_key_event(key_event(KeyCode::Char('/')));
2521        // Type more than MAX_SEARCH_QUERY_LEN characters
2522        for _ in 0..(MAX_SEARCH_QUERY_LEN + 50) {
2523            app.handle_key_event(key_event(KeyCode::Char('x')));
2524        }
2525        assert_eq!(app.search_query.len(), MAX_SEARCH_QUERY_LEN);
2526    }
2527
2528    #[test]
2529    fn export_builder_defaults_to_dry_run() {
2530        let app = make_app();
2531        assert!(app.export_state.dry_run);
2532    }
2533
2534    #[test]
2535    fn export_builder_field_navigation() {
2536        let mut app = make_app();
2537        // Navigate to Export section
2538        app.nav.set_sidebar_index(7); // Export is index 7
2539        assert_eq!(app.nav.current_view, View::ExportBuilder);
2540        assert_eq!(app.export_state.focused_field, 0);
2541
2542        // Navigate down
2543        app.handle_key_event(key_event(KeyCode::Char('j')));
2544        assert_eq!(app.export_state.focused_field, 1);
2545
2546        app.handle_key_event(key_event(KeyCode::Char('j')));
2547        assert_eq!(app.export_state.focused_field, 2);
2548
2549        // Navigate up
2550        app.handle_key_event(key_event(KeyCode::Char('k')));
2551        assert_eq!(app.export_state.focused_field, 1);
2552    }
2553
2554    #[test]
2555    fn export_builder_toggle_dry_run() {
2556        let mut app = make_app();
2557        app.nav.set_sidebar_index(7);
2558        // Navigate to dry_run field (index 3)
2559        app.export_state.focused_field = 3;
2560        assert!(app.export_state.dry_run);
2561
2562        // Toggle with space
2563        app.handle_key_event(key_event(KeyCode::Char(' ')));
2564        assert!(!app.export_state.dry_run);
2565
2566        // Toggle back
2567        app.handle_key_event(key_event(KeyCode::Char(' ')));
2568        assert!(app.export_state.dry_run);
2569    }
2570
2571    #[test]
2572    fn export_builder_toggle_force() {
2573        let mut app = make_app();
2574        app.nav.set_sidebar_index(7);
2575        app.export_state.focused_field = 4;
2576        assert!(!app.export_state.force);
2577
2578        app.handle_key_event(key_event(KeyCode::Char(' ')));
2579        assert!(app.export_state.force);
2580    }
2581
2582    #[test]
2583    fn export_builder_toggle_no_skills() {
2584        let mut app = make_app();
2585        app.nav.set_sidebar_index(7);
2586        app.export_state.focused_field = 5;
2587        assert!(!app.export_state.no_skills);
2588
2589        app.handle_key_event(key_event(KeyCode::Char(' ')));
2590        assert!(app.export_state.no_skills);
2591    }
2592
2593    #[test]
2594    fn export_builder_type_platform() {
2595        let mut app = make_app();
2596        app.nav.set_sidebar_index(7);
2597        app.export_state.focused_field = 0;
2598        app.export_state.platform.clear();
2599
2600        app.handle_key_event(key_event(KeyCode::Char('c')));
2601        app.handle_key_event(key_event(KeyCode::Char('u')));
2602        app.handle_key_event(key_event(KeyCode::Char('r')));
2603        assert_eq!(app.export_state.platform, "cur");
2604
2605        app.handle_key_event(key_event(KeyCode::Backspace));
2606        assert_eq!(app.export_state.platform, "cu");
2607    }
2608
2609    #[test]
2610    fn export_builder_type_target_repo() {
2611        let mut app = make_app();
2612        app.nav.set_sidebar_index(7);
2613        app.export_state.focused_field = 2;
2614
2615        app.handle_key_event(key_event(KeyCode::Char('/')));
2616        app.handle_key_event(key_event(KeyCode::Char('t')));
2617        app.handle_key_event(key_event(KeyCode::Char('m')));
2618        app.handle_key_event(key_event(KeyCode::Char('p')));
2619        assert_eq!(app.export_state.target_repo, "/tmp");
2620    }
2621
2622    #[test]
2623    fn export_builder_cycle_selection() {
2624        let mut app = make_app();
2625        app.nav.set_sidebar_index(7);
2626        app.export_state.focused_field = 1;
2627
2628        // Start at All
2629        assert_eq!(app.export_state.selection, ExportSelection::All);
2630
2631        // Enter cycles to Role
2632        app.handle_key_event(key_event(KeyCode::Enter));
2633        assert!(matches!(
2634            app.export_state.selection,
2635            ExportSelection::Role(_)
2636        ));
2637    }
2638
2639    #[test]
2640    fn export_builder_enter_validates_empty_platform() {
2641        let mut app = make_app();
2642        app.nav.set_sidebar_index(7);
2643        app.export_state.focused_field = 0;
2644        app.export_state.platform.clear();
2645        app.export_state.target_repo = "/tmp".to_string();
2646
2647        app.handle_key_event(key_event(KeyCode::Enter));
2648        // Should show error about platform being required
2649        assert!(app.status_message.is_some());
2650        assert!(app.status_message.as_ref().unwrap().0.contains("Platform"));
2651    }
2652
2653    #[test]
2654    fn export_builder_enter_validates_empty_target() {
2655        let mut app = make_app();
2656        app.nav.set_sidebar_index(7);
2657        app.export_state.focused_field = 0;
2658        app.export_state.platform = "kiro".to_string();
2659        app.export_state.target_repo.clear();
2660
2661        app.handle_key_event(key_event(KeyCode::Enter));
2662        // Should show error about target repo being required
2663        assert!(app.status_message.is_some());
2664        assert!(app
2665            .status_message
2666            .as_ref()
2667            .unwrap()
2668            .0
2669            .contains("Target repo"));
2670    }
2671
2672    #[test]
2673    fn export_builder_enter_validates_nonexistent_path() {
2674        let mut app = make_app();
2675        app.nav.set_sidebar_index(7);
2676        app.export_state.focused_field = 0;
2677        app.export_state.platform = "kiro".to_string();
2678        app.export_state.target_repo = "/nonexistent/path/xyz123".to_string();
2679
2680        app.handle_key_event(key_event(KeyCode::Enter));
2681        // Should show error about path not existing
2682        assert!(app.status_message.is_some());
2683        assert!(app
2684            .status_message
2685            .as_ref()
2686            .unwrap()
2687            .0
2688            .contains("does not exist"));
2689    }
2690
2691    #[test]
2692    fn export_builder_enter_navigates_to_confirm_with_valid_path() {
2693        let mut app = make_app();
2694        app.nav.set_sidebar_index(7);
2695        app.export_state.focused_field = 0;
2696        app.export_state.platform = "kiro".to_string();
2697        app.export_state.target_repo = "/tmp".to_string();
2698
2699        app.handle_key_event(key_event(KeyCode::Enter));
2700        // Should navigate to ExportConfirm
2701        assert_eq!(app.nav.current_view, View::ExportConfirm);
2702    }
2703
2704    #[test]
2705    fn export_confirm_esc_goes_back_to_builder() {
2706        let mut app = make_app();
2707        app.nav.set_sidebar_index(7);
2708        app.export_state.platform = "kiro".to_string();
2709        app.export_state.target_repo = "/tmp".to_string();
2710        app.export_state.focused_field = 0;
2711        app.handle_key_event(key_event(KeyCode::Enter));
2712        assert_eq!(app.nav.current_view, View::ExportConfirm);
2713
2714        // Esc should go back to builder, preserving selections
2715        app.handle_key_event(key_event(KeyCode::Esc));
2716        assert_eq!(app.nav.current_view, View::ExportBuilder);
2717        assert_eq!(app.export_state.platform, "kiro");
2718        assert_eq!(app.export_state.target_repo, "/tmp");
2719    }
2720
2721    #[test]
2722    fn export_builder_esc_goes_back() {
2723        let mut app = make_app();
2724        app.nav.set_sidebar_index(7);
2725        assert_eq!(app.nav.current_view, View::ExportBuilder);
2726
2727        app.handle_key_event(key_event(KeyCode::Esc));
2728        // Should quit since there's no history
2729        assert!(app.should_quit);
2730    }
2731
2732    #[test]
2733    fn export_builder_field_stops_at_boundary() {
2734        let mut app = make_app();
2735        app.nav.set_sidebar_index(7);
2736        app.export_state.focused_field = 5; // last field
2737
2738        app.handle_key_event(key_event(KeyCode::Char('j')));
2739        assert_eq!(app.export_state.focused_field, 5); // stays at boundary
2740
2741        app.export_state.focused_field = 0;
2742        app.handle_key_event(key_event(KeyCode::Char('k')));
2743        assert_eq!(app.export_state.focused_field, 0); // stays at 0
2744    }
2745}