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::models::model_policy::{ModelPolicyCommand, ModelScope, CAPABLE_HARNESSES};
13use crate::search::fuzzy::SearchEngine;
14use crate::security::sanitize::sanitize_subprocess_output;
15use crate::subprocess::{SubprocessExecutor, SubprocessHandle};
16
17use crate::ui::layout::compute_layout;
18use crate::ui::nav::{NavigationState, View, SIDEBAR_SECTIONS};
19use crate::ui::theme::{Theme, ThemeMode};
20use crate::ui::widgets::{
21    audit_log, coverage_grid, dep_graph, detail, help_bar, list_view, output, search, status_bar,
22    violations,
23};
24
25const MAX_SUBPROCESS_OUTPUT_LINES: usize = 10_000;
26const MAX_SEARCH_QUERY_LEN: usize = 256;
27/// Default timeout for validation gate subprocesses (300 seconds per requirement 6.5).
28const VALIDATION_GATE_TIMEOUT_SECS: u64 = 300;
29/// Label for the "Run All Validations" meta-entry in the validation list.
30const RUN_ALL_LABEL: &str = "validate (Run All)";
31
32/// State for the export command builder UI.
33pub struct ExportBuilderState {
34    pub platform: String,
35    pub selection: ExportSelection,
36    pub target_repo: String,
37    pub dry_run: bool,
38    pub force: bool,
39    pub no_skills: bool,
40    pub focused_field: usize,
41}
42
43impl ExportBuilderState {
44    pub fn new() -> Self {
45        Self {
46            platform: "kiro".to_string(),
47            selection: ExportSelection::All,
48            target_repo: String::new(),
49            dry_run: true,
50            force: false,
51            no_skills: false,
52            focused_field: 0,
53        }
54    }
55}
56
57impl Default for ExportBuilderState {
58    fn default() -> Self {
59        Self::new()
60    }
61}
62
63/// Reasoning cycle for the model policy builder. Index 0 leaves the field
64/// untouched (no `--reasoning` flag); the remaining entries are sent verbatim
65/// to scripts/model-policy.mjs ("auto" clears the field in the harness file).
66/// Union vocabulary across harnesses (codex `model_reasoning_effort`,
67/// claude-code `effort`); the verified per-harness/per-model subset lives in
68/// catalog/model-registry.json and is enforced by scripts/model-policy.mjs.
69const MODEL_POLICY_REASONING_CYCLE: &[&str] = &[
70    "(unchanged)",
71    "auto",
72    "none",
73    "minimal",
74    "low",
75    "medium",
76    "high",
77    "xhigh",
78    "max",
79];
80
81/// Stage of the model-policy publish pipeline. After a successful non-dry-run
82/// apply the TUI automatically chains `npm run asset-integrity:write` so the
83/// integrity manifest can never silently drift from the projected files.
84#[derive(Debug, Clone, Copy, PartialEq, Eq)]
85pub enum ModelPolicyStage {
86    /// `node scripts/model-policy.mjs set …` is running.
87    Apply,
88    /// `npm run asset-integrity:write` chained after a successful apply.
89    IntegrityRefresh,
90}
91
92/// State for the model policy builder UI.
93pub struct ModelPolicyBuilderState {
94    pub scope: ModelScope,
95    /// Index into [`CAPABLE_HARNESSES`].
96    pub harness_index: usize,
97    /// Model name or "auto"; empty leaves the model field of the rule untouched.
98    pub model: String,
99    /// Index into [`MODEL_POLICY_REASONING_CYCLE`].
100    pub reasoning_index: usize,
101    pub dry_run: bool,
102    /// Chain `npm run asset-integrity:write` after a successful apply.
103    pub refresh_integrity: bool,
104    pub focused_field: usize,
105}
106
107impl ModelPolicyBuilderState {
108    pub fn new() -> Self {
109        Self {
110            scope: ModelScope::All,
111            harness_index: 0,
112            model: String::new(),
113            reasoning_index: 0,
114            dry_run: true,
115            refresh_integrity: true,
116            focused_field: 0,
117        }
118    }
119
120    /// The harness id currently selected.
121    pub fn harness(&self) -> &'static str {
122        CAPABLE_HARNESSES[self.harness_index % CAPABLE_HARNESSES.len()]
123    }
124
125    /// Build the subprocess command from the current form state.
126    pub fn command(&self) -> ModelPolicyCommand {
127        ModelPolicyCommand {
128            scope: self.scope.clone(),
129            harness: self.harness().to_string(),
130            model: if self.model.is_empty() {
131                None
132            } else {
133                Some(self.model.clone())
134            },
135            reasoning: if self.reasoning_index == 0 {
136                None
137            } else {
138                Some(MODEL_POLICY_REASONING_CYCLE[self.reasoning_index].to_string())
139            },
140            dry_run: self.dry_run,
141        }
142    }
143}
144
145impl Default for ModelPolicyBuilderState {
146    fn default() -> Self {
147        Self::new()
148    }
149}
150
151/// Main application state.
152pub struct App {
153    pub nav: NavigationState,
154    pub catalog: CatalogStore,
155    pub search_engine: SearchEngine,
156    pub search_query: String,
157    pub search_active: bool,
158    pub filtered_indices: Vec<usize>,
159    pub subprocess_output: Vec<output::OutputLine>,
160    pub subprocess_handle: Option<SubprocessHandle>,
161    pub validation_gates: Vec<ValidationGate>,
162    pub export_state: ExportBuilderState,
163    pub model_policy_state: ModelPolicyBuilderState,
164    /// Set while a model-policy subprocess (apply or chained integrity
165    /// refresh) is running; drives completion handling in `tick()`.
166    pub model_policy_stage: Option<ModelPolicyStage>,
167    pub status_message: Option<(String, Instant)>,
168    pub session_id: Uuid,
169    pub should_quit: bool,
170    pub no_color: bool,
171    /// Resolved theme mode (Dark/Light) for the session; toggled at runtime
172    /// via the `t` keybinding (Req 35.6). Defaults to Dark; `main` overrides
173    /// it from the `--theme` flag / system detection.
174    pub theme_mode: ThemeMode,
175    pub workspace_root: PathBuf,
176    /// Name of the currently running validation gate (for status tracking).
177    /// When set, prevents concurrent execution of the same gate.
178    pub running_gate: Option<String>,
179    /// Timestamp when the currently running gate started (for animated indicator).
180    pub running_gate_start: Option<Instant>,
181    /// Pending subprocess spawn result — polled in `tick()` to avoid `block_on` inside tokio runtime.
182    pub pending_subprocess:
183        Option<tokio::sync::oneshot::Receiver<anyhow::Result<SubprocessHandle>>>,
184    // v0.2.0 enhancements
185    pub provider_filter: Option<String>,
186    pub harness_filter: Option<String>,
187    pub show_help_overlay: bool,
188    pub completion_suggestions: Vec<String>,
189    pub completion_index: usize,
190    /// Dirty flag for async event loop — render only when true (250ms tick resets).
191    pub dirty: bool,
192}
193
194impl App {
195    pub fn new(
196        catalog: CatalogStore,
197        workspace_root: PathBuf,
198        session_id: Uuid,
199        no_color: bool,
200    ) -> Self {
201        let validation_gates = extract_validation_gates(&workspace_root);
202        let mut search_engine = SearchEngine::new();
203        let filtered_indices = search_engine.search_agents("", &catalog.agents, None, None);
204        Self {
205            nav: NavigationState::new(),
206            catalog,
207            search_engine,
208            search_query: String::new(),
209            search_active: false,
210            filtered_indices,
211            subprocess_output: Vec::new(),
212            subprocess_handle: None,
213            validation_gates,
214            export_state: ExportBuilderState::new(),
215            model_policy_state: ModelPolicyBuilderState::new(),
216            model_policy_stage: None,
217            status_message: None,
218            session_id,
219            should_quit: false,
220            no_color,
221            theme_mode: ThemeMode::Dark,
222            workspace_root,
223            running_gate: None,
224            running_gate_start: None,
225            pending_subprocess: None,
226            provider_filter: None,
227            harness_filter: None,
228            show_help_overlay: false,
229            completion_suggestions: Vec::new(),
230            completion_index: 0,
231            dirty: true,
232        }
233    }
234
235    /// Handle a key event. Dispatches based on search mode and current view.
236    pub fn handle_key_event(&mut self, key: KeyEvent) {
237        // Help overlay takes priority
238        if self.show_help_overlay {
239            match key.code {
240                KeyCode::Esc | KeyCode::Char('?') => {
241                    self.show_help_overlay = false;
242                }
243                _ => {}
244            }
245            return;
246        }
247
248        if self.search_active {
249            self.handle_search_key(key);
250            return;
251        }
252
253        // Export views have their own key handling
254        match &self.nav.current_view {
255            View::ExportBuilder => {
256                self.handle_export_builder_key(key);
257                return;
258            }
259            View::ExportConfirm => {
260                self.handle_export_confirm_key(key);
261                return;
262            }
263            View::ExportOutput => {
264                self.handle_export_output_key(key);
265                return;
266            }
267            View::ModelPolicyBuilder => {
268                self.handle_model_policy_builder_key(key);
269                return;
270            }
271            View::ModelPolicyConfirm => {
272                self.handle_model_policy_confirm_key(key);
273                return;
274            }
275            View::ModelPolicyOutput => {
276                self.handle_model_policy_output_key(key);
277                return;
278            }
279            _ => {}
280        }
281
282        match key.code {
283            KeyCode::Char('q') => self.should_quit = true,
284            KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
285                self.should_quit = true;
286            }
287            KeyCode::Char('?') => {
288                self.show_help_overlay = true;
289            }
290            KeyCode::Char('t') => {
291                // Runtime light/dark toggle (Req 35.6). Search mode is handled
292                // earlier and returns before reaching this match, so `t` is only
293                // a toggle outside of search.
294                self.theme_mode = match self.theme_mode {
295                    ThemeMode::Dark => ThemeMode::Light,
296                    ThemeMode::Light => ThemeMode::Dark,
297                };
298                self.dirty = true;
299            }
300            KeyCode::Char('p') if self.nav.current_view == View::AgentList => {
301                self.cycle_provider_filter();
302            }
303            KeyCode::Char('h') if self.nav.current_view == View::AgentList => {
304                self.cycle_harness_filter();
305            }
306            KeyCode::Char('m') => {
307                self.open_model_policy_builder();
308            }
309            KeyCode::Esc => {
310                // Clear filters first, then pop view
311                if self.provider_filter.is_some() || self.harness_filter.is_some() {
312                    self.provider_filter = None;
313                    self.harness_filter = None;
314                    self.update_filtered();
315                } else if !self.nav.pop_view() {
316                    self.should_quit = true;
317                }
318                self.update_filtered();
319            }
320            KeyCode::Tab => {
321                self.nav.next_tab();
322                self.sync_view_to_tab();
323                self.dirty = true;
324            }
325            KeyCode::BackTab => {
326                self.nav.prev_tab();
327                self.sync_view_to_tab();
328                self.dirty = true;
329            }
330            KeyCode::Char('j') | KeyCode::Down => self.handle_down(),
331            KeyCode::Char('k') | KeyCode::Up => self.handle_up(),
332            KeyCode::Char('g') => self.nav.select_first(),
333            KeyCode::Char('G') => {
334                let max = self.current_list_len();
335                self.nav.select_last(max);
336            }
337            KeyCode::Char('/') => {
338                // Only activate search on views where filtering works (agent list)
339                if self.nav.current_view == View::AgentList {
340                    self.search_active = true;
341                    self.search_query.clear();
342                }
343            }
344            KeyCode::Enter => self.handle_enter(),
345            _ => {}
346        }
347    }
348
349    /// Handle key events during active search mode.
350    fn handle_search_key(&mut self, key: KeyEvent) {
351        match key.code {
352            KeyCode::Esc => {
353                self.search_active = false;
354            }
355            KeyCode::Enter => {
356                self.search_active = false;
357                self.update_filtered();
358            }
359            KeyCode::Backspace => {
360                self.search_query.pop();
361                self.update_filtered();
362            }
363            KeyCode::Char(c) if self.search_query.len() < MAX_SEARCH_QUERY_LEN => {
364                self.search_query.push(c);
365                self.update_filtered();
366            }
367            _ => {}
368        }
369    }
370
371    /// Number of fields in the export builder form.
372    const EXPORT_FIELD_COUNT: usize = 6;
373
374    /// Handle key events in the ExportBuilder view.
375    /// Supports j/k for field navigation, Enter to edit/toggle/confirm,
376    /// Tab to accept completion, and Esc to go back.
377    fn handle_export_builder_key(&mut self, key: KeyEvent) {
378        match key.code {
379            KeyCode::Char('q') => self.should_quit = true,
380            KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
381                self.should_quit = true;
382            }
383            KeyCode::Esc => {
384                if !self.nav.pop_view() {
385                    self.should_quit = true;
386                }
387            }
388            KeyCode::Tab => {
389                // Accept completion suggestion if available
390                if !self.completion_suggestions.is_empty() {
391                    let suggestion = self.completion_suggestions[self.completion_index].clone();
392                    match self.export_state.focused_field {
393                        0 => self.export_state.platform = suggestion,
394                        1 => {
395                            self.export_state.selection = ExportSelection::Role(suggestion);
396                        }
397                        _ => {}
398                    }
399                    self.completion_suggestions.clear();
400                    self.completion_index = 0;
401                } else {
402                    // Switch to next sidebar section
403                    let next = (self.nav.sidebar_index + 1) % SIDEBAR_SECTIONS.len();
404                    self.nav.set_sidebar_index(next);
405                    self.search_query.clear();
406                    self.provider_filter = None;
407                    self.harness_filter = None;
408                    self.update_filtered();
409                }
410            }
411            KeyCode::Char('j') | KeyCode::Down => {
412                // Navigate to next field, or next completion suggestion
413                if !self.completion_suggestions.is_empty() {
414                    self.completion_index =
415                        (self.completion_index + 1) % self.completion_suggestions.len();
416                } else {
417                    let next = self.export_state.focused_field + 1;
418                    if next < Self::EXPORT_FIELD_COUNT {
419                        self.export_state.focused_field = next;
420                    }
421                    // Clear completions when navigating fields
422                    self.completion_suggestions.clear();
423                    self.completion_index = 0;
424                }
425            }
426            KeyCode::Char('k') | KeyCode::Up => {
427                // Navigate to previous field, or previous completion suggestion
428                if !self.completion_suggestions.is_empty() {
429                    self.completion_index = if self.completion_index == 0 {
430                        self.completion_suggestions.len() - 1
431                    } else {
432                        self.completion_index - 1
433                    };
434                } else {
435                    if self.export_state.focused_field > 0 {
436                        self.export_state.focused_field -= 1;
437                    }
438                    // Clear completions when navigating fields
439                    self.completion_suggestions.clear();
440                    self.completion_index = 0;
441                }
442            }
443            KeyCode::Enter => {
444                self.handle_export_builder_enter();
445            }
446            KeyCode::Char(' ') => {
447                // Toggle boolean fields with space
448                match self.export_state.focused_field {
449                    3 => self.export_state.dry_run = !self.export_state.dry_run,
450                    4 => self.export_state.force = !self.export_state.force,
451                    5 => self.export_state.no_skills = !self.export_state.no_skills,
452                    _ => {}
453                }
454            }
455            KeyCode::Backspace => {
456                // Edit text fields with backspace
457                match self.export_state.focused_field {
458                    0 => {
459                        self.export_state.platform.pop();
460                        self.update_completion_suggestions();
461                    }
462                    2 => {
463                        self.export_state.target_repo.pop();
464                    }
465                    _ => {}
466                }
467            }
468            KeyCode::Char(c) => {
469                // Type into text fields
470                match self.export_state.focused_field {
471                    0 => {
472                        self.export_state.platform.push(c);
473                        self.update_completion_suggestions();
474                    }
475                    2 => {
476                        self.export_state.target_repo.push(c);
477                    }
478                    _ => {}
479                }
480            }
481            _ => {}
482        }
483    }
484
485    /// Handle Enter key in the export builder — either cycle selection or confirm.
486    fn handle_export_builder_enter(&mut self) {
487        match self.export_state.focused_field {
488            1 => {
489                // Cycle through selection methods: All -> Role -> Provider -> Agents -> All
490                self.export_state.selection = match &self.export_state.selection {
491                    ExportSelection::All => {
492                        let roles = self.catalog.role_ids();
493                        if let Some(first) = roles.first() {
494                            ExportSelection::Role(first.clone())
495                        } else {
496                            ExportSelection::Provider("aws".to_string())
497                        }
498                    }
499                    ExportSelection::Role(current) => {
500                        let roles = self.catalog.role_ids();
501                        let idx = roles.iter().position(|r| r == current);
502                        match idx {
503                            Some(i) if i + 1 < roles.len() => {
504                                ExportSelection::Role(roles[i + 1].clone())
505                            }
506                            _ => {
507                                let providers = self.catalog.provider_names();
508                                if let Some(first) = providers.first() {
509                                    ExportSelection::Provider(first.clone())
510                                } else {
511                                    ExportSelection::All
512                                }
513                            }
514                        }
515                    }
516                    ExportSelection::Provider(current) => {
517                        let providers = self.catalog.provider_names();
518                        let idx = providers.iter().position(|p| p == current);
519                        match idx {
520                            Some(i) if i + 1 < providers.len() => {
521                                ExportSelection::Provider(providers[i + 1].clone())
522                            }
523                            _ => ExportSelection::All,
524                        }
525                    }
526                    ExportSelection::Agents(_) => ExportSelection::All,
527                };
528            }
529            3 => self.export_state.dry_run = !self.export_state.dry_run,
530            4 => self.export_state.force = !self.export_state.force,
531            5 => self.export_state.no_skills = !self.export_state.no_skills,
532            _ => {
533                // On any other field, if we have enough info, go to confirm
534                if !self.export_state.platform.is_empty()
535                    && !self.export_state.target_repo.is_empty()
536                {
537                    // Validate target path exists and is writable
538                    let target = PathBuf::from(&self.export_state.target_repo);
539                    if !target.exists() {
540                        self.status_message = Some((
541                            format!(
542                                "Error: target path does not exist: {}",
543                                self.export_state.target_repo
544                            ),
545                            Instant::now(),
546                        ));
547                        return;
548                    }
549                    if !target.is_dir() {
550                        self.status_message = Some((
551                            format!(
552                                "Error: target path is not a directory: {}",
553                                self.export_state.target_repo
554                            ),
555                            Instant::now(),
556                        ));
557                        return;
558                    }
559                    // Check writable by attempting to access metadata
560                    if target.metadata().is_err() {
561                        self.status_message = Some((
562                            format!(
563                                "Error: target path is not accessible: {}",
564                                self.export_state.target_repo
565                            ),
566                            Instant::now(),
567                        ));
568                        return;
569                    }
570
571                    // Build the command and validate arguments
572                    let cmd = ExportCommand {
573                        platform: self.export_state.platform.clone(),
574                        selection: self.export_state.selection.clone(),
575                        target_repo: target,
576                        dry_run: self.export_state.dry_run,
577                        force: self.export_state.force,
578                        no_skills: self.export_state.no_skills,
579                    };
580
581                    if let Err(e) = cmd.validate() {
582                        self.status_message =
583                            Some((format!("Validation error: {e}"), Instant::now()));
584                        return;
585                    }
586
587                    self.nav.push_view(View::ExportConfirm);
588                } else if self.export_state.platform.is_empty() {
589                    self.status_message =
590                        Some(("Platform is required".to_string(), Instant::now()));
591                } else {
592                    self.status_message =
593                        Some(("Target repo path is required".to_string(), Instant::now()));
594                }
595            }
596        }
597    }
598
599    /// Handle key events in the ExportConfirm view.
600    /// Enter executes the export, Esc cancels back to builder.
601    fn handle_export_confirm_key(&mut self, key: KeyEvent) {
602        match key.code {
603            KeyCode::Char('q') => self.should_quit = true,
604            KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
605                self.should_quit = true;
606            }
607            KeyCode::Esc => {
608                // Cancel back to builder, preserving selections
609                self.nav.pop_view();
610            }
611            KeyCode::Enter => {
612                // Execute the export command
613                self.execute_export();
614            }
615            _ => {}
616        }
617    }
618
619    /// Handle key events in the ExportOutput view.
620    /// Esc cancels/goes back, Ctrl+C during execution cancels subprocess.
621    fn handle_export_output_key(&mut self, key: KeyEvent) {
622        match key.code {
623            KeyCode::Char('q') if self.subprocess_handle.is_none() => {
624                self.should_quit = true;
625            }
626            KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
627                if self.subprocess_handle.is_some() {
628                    // Cancel the running subprocess
629                    self.cancel_export_subprocess();
630                } else {
631                    self.should_quit = true;
632                }
633            }
634            KeyCode::Esc => {
635                if self.subprocess_handle.is_some() {
636                    // Cancel the running subprocess and go back to builder
637                    self.cancel_export_subprocess();
638                }
639                // Go back to builder, preserving selections
640                self.nav.pop_view();
641            }
642            KeyCode::Char('j') | KeyCode::Down => {
643                self.nav.detail_scroll = self.nav.detail_scroll.saturating_add(1);
644            }
645            KeyCode::Char('k') | KeyCode::Up => {
646                self.nav.detail_scroll = self.nav.detail_scroll.saturating_sub(1);
647            }
648            _ => {}
649        }
650    }
651
652    /// Execute the export command as a subprocess.
653    fn execute_export(&mut self) {
654        // Defense in depth: never launch over a live gate/subprocess — this
655        // would otherwise silently overwrite `pending_subprocess`/
656        // `subprocess_handle` out from under a model-policy stage or a
657        // running validation gate, losing track of that operation's exit
658        // status (or clobbering its handle so it can no longer be polled or
659        // cancelled). Mirrors execute_model_policy's own guard.
660        if self.subprocess_busy() {
661            self.status_message = Some((
662                "A subprocess is already running; wait for it to finish".to_string(),
663                Instant::now(),
664            ));
665            return;
666        }
667        let target = PathBuf::from(&self.export_state.target_repo);
668        let cmd = ExportCommand {
669            platform: self.export_state.platform.clone(),
670            selection: self.export_state.selection.clone(),
671            target_repo: target,
672            dry_run: self.export_state.dry_run,
673            force: self.export_state.force,
674            no_skills: self.export_state.no_skills,
675        };
676
677        // Clear previous output
678        self.subprocess_output.clear();
679
680        let args = cmd.to_args();
681        let workspace = self.workspace_root.clone();
682
683        // Spawn the subprocess asynchronously via oneshot channel to avoid
684        // `block_on` panic inside an existing tokio runtime.
685        let (tx, rx) = tokio::sync::oneshot::channel();
686        let all_args = [
687            vec!["scripts/export-marketplace-agents.mjs".to_string()],
688            args,
689        ]
690        .concat();
691        tokio::spawn(async move {
692            let result =
693                SubprocessExecutor::spawn("node", &all_args, &workspace, Duration::from_secs(300))
694                    .await;
695            let _ = tx.send(result);
696        });
697        // Store receiver to poll in tick()
698        self.pending_subprocess = Some(rx);
699        self.nav.push_view(View::ExportOutput);
700    }
701
702    /// Cancel the export subprocess.
703    fn cancel_export_subprocess(&mut self) {
704        if let Some(mut handle) = self.subprocess_handle.take() {
705            tokio::spawn(async move {
706                handle.cancel().await.ok();
707            });
708            self.status_message = Some(("Export cancelled by user".to_string(), Instant::now()));
709        }
710    }
711
712    /// Number of fields in the model policy builder form (the last entry is
713    /// the `[ Continue ]` action row).
714    const MODEL_POLICY_FIELD_COUNT: usize = 7;
715
716    /// True when a subprocess (validation gate, export, or a model-policy
717    /// apply/integrity-refresh) is spawning or already running. Only one
718    /// subprocess may own `pending_subprocess`/`subprocess_handle` at a time,
719    /// so new launches must be refused while any of these is set — otherwise a
720    /// second spawn overwrites the first handle and its exit code is recorded
721    /// against the wrong operation (e.g. a model-policy run clobbering a
722    /// validation gate's result).
723    fn subprocess_busy(&self) -> bool {
724        self.running_gate.is_some()
725            || self.subprocess_handle.is_some()
726            || self.pending_subprocess.is_some()
727            || self.model_policy_stage.is_some()
728    }
729
730    /// Open the model policy builder, pre-filling the scope from the current
731    /// view (agent detail → that agent, provider/role views → that provider
732    /// or role, otherwise all agents).
733    fn open_model_policy_builder(&mut self) {
734        if self.subprocess_busy() {
735            self.status_message = Some((
736                "Cannot open the model policy builder while another task is running".to_string(),
737                Instant::now(),
738            ));
739            return;
740        }
741        let idx = self.nav.selected_index();
742        let scope = match self.nav.current_view.clone() {
743            View::AgentDetail(id) => ModelScope::Agent(id),
744            View::AgentList => self
745                .filtered_indices
746                .get(idx)
747                .and_then(|&real_idx| self.catalog.agents.get(real_idx))
748                .map(|a| ModelScope::Agent(a.id.clone()))
749                .unwrap_or(ModelScope::All),
750            View::ProviderAgents(provider) => ModelScope::Provider(provider),
751            View::ProviderList => self
752                .get_provider_list()
753                .get(idx)
754                .map(|(p, _)| ModelScope::Provider(p.clone()))
755                .unwrap_or(ModelScope::All),
756            View::RoleDetail(role) => ModelScope::Role(role),
757            View::RoleList => self
758                .get_role_list()
759                .get(idx)
760                .map(|r| ModelScope::Role(r.0.clone()))
761                .unwrap_or(ModelScope::All),
762            _ => ModelScope::All,
763        };
764        self.model_policy_state = ModelPolicyBuilderState::new();
765        self.model_policy_state.scope = scope;
766        self.nav.push_view(View::ModelPolicyBuilder);
767    }
768
769    /// Handle key events in the ModelPolicyBuilder view.
770    fn handle_model_policy_builder_key(&mut self, key: KeyEvent) {
771        match key.code {
772            KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
773                self.should_quit = true;
774            }
775            KeyCode::Esc => {
776                if !self.nav.pop_view() {
777                    self.should_quit = true;
778                }
779            }
780            KeyCode::Down => {
781                let next = self.model_policy_state.focused_field + 1;
782                if next < Self::MODEL_POLICY_FIELD_COUNT {
783                    self.model_policy_state.focused_field = next;
784                }
785            }
786            KeyCode::Up => {
787                if self.model_policy_state.focused_field > 0 {
788                    self.model_policy_state.focused_field -= 1;
789                }
790            }
791            // j/k navigate fields unless a text field is focused (then they type).
792            KeyCode::Char('j') if !self.model_policy_text_field_focused() => {
793                let next = self.model_policy_state.focused_field + 1;
794                if next < Self::MODEL_POLICY_FIELD_COUNT {
795                    self.model_policy_state.focused_field = next;
796                }
797            }
798            KeyCode::Char('k') if !self.model_policy_text_field_focused() => {
799                if self.model_policy_state.focused_field > 0 {
800                    self.model_policy_state.focused_field -= 1;
801                }
802            }
803            KeyCode::Enter => self.handle_model_policy_builder_enter(),
804            KeyCode::Char(' ') => self.cycle_model_policy_field(),
805            KeyCode::Backspace => match self.model_policy_state.focused_field {
806                0 => {
807                    match &mut self.model_policy_state.scope {
808                        ModelScope::All => {}
809                        ModelScope::Provider(id) | ModelScope::Role(id) | ModelScope::Agent(id) => {
810                            id.pop();
811                        }
812                    };
813                }
814                2 => {
815                    self.model_policy_state.model.pop();
816                }
817                _ => {}
818            },
819            KeyCode::Char(c) => match self.model_policy_state.focused_field {
820                0 => match &mut self.model_policy_state.scope {
821                    ModelScope::All => {}
822                    ModelScope::Provider(id) | ModelScope::Role(id) | ModelScope::Agent(id) => {
823                        id.push(c);
824                    }
825                },
826                2 => self.model_policy_state.model.push(c),
827                _ => {}
828            },
829            _ => {}
830        }
831        self.dirty = true;
832    }
833
834    /// True when the focused model-policy builder field accepts free text
835    /// (the scope id for provider/role/agent scopes, or the model name).
836    fn model_policy_text_field_focused(&self) -> bool {
837        match self.model_policy_state.focused_field {
838            0 => !matches!(self.model_policy_state.scope, ModelScope::All),
839            2 => true,
840            _ => false,
841        }
842    }
843
844    /// Cycle/toggle the focused model-policy builder field.
845    fn cycle_model_policy_field(&mut self) {
846        match self.model_policy_state.focused_field {
847            0 => {
848                // Cycle scope kind: All -> Provider -> Role -> Agent -> All.
849                self.model_policy_state.scope = match &self.model_policy_state.scope {
850                    ModelScope::All => ModelScope::Provider(
851                        self.get_provider_list()
852                            .first()
853                            .map(|(p, _)| p.clone())
854                            .unwrap_or_default(),
855                    ),
856                    ModelScope::Provider(_) => ModelScope::Role(
857                        self.get_role_list()
858                            .first()
859                            .map(|r| r.0.clone())
860                            .unwrap_or_default(),
861                    ),
862                    ModelScope::Role(_) => ModelScope::Agent(
863                        self.catalog
864                            .agents
865                            .first()
866                            .map(|a| a.id.clone())
867                            .unwrap_or_default(),
868                    ),
869                    ModelScope::Agent(_) => ModelScope::All,
870                };
871            }
872            1 => {
873                self.model_policy_state.harness_index =
874                    (self.model_policy_state.harness_index + 1) % CAPABLE_HARNESSES.len();
875                if self.model_policy_state.harness() != "codex" {
876                    // Reasoning effort is codex-only; reset to "(unchanged)".
877                    self.model_policy_state.reasoning_index = 0;
878                }
879            }
880            3 => {
881                if self.model_policy_state.harness() == "codex" {
882                    self.model_policy_state.reasoning_index =
883                        (self.model_policy_state.reasoning_index + 1)
884                            % MODEL_POLICY_REASONING_CYCLE.len();
885                } else {
886                    self.status_message = Some((
887                        "Reasoning effort is only supported by the codex harness".to_string(),
888                        Instant::now(),
889                    ));
890                }
891            }
892            4 => self.model_policy_state.dry_run = !self.model_policy_state.dry_run,
893            5 => {
894                self.model_policy_state.refresh_integrity =
895                    !self.model_policy_state.refresh_integrity;
896            }
897            _ => {}
898        }
899    }
900
901    /// Handle Enter in the model policy builder — cycle/toggle the focused
902    /// field, or validate and continue to the confirm view on `[ Continue ]`.
903    fn handle_model_policy_builder_enter(&mut self) {
904        if self.model_policy_state.focused_field != Self::MODEL_POLICY_FIELD_COUNT - 1 {
905            self.cycle_model_policy_field();
906            return;
907        }
908        // Validate scope references against the loaded catalog before
909        // shelling out — the script re-validates, but fail fast in the UI.
910        let scope_error = match &self.model_policy_state.scope {
911            ModelScope::All => None,
912            ModelScope::Provider(id) => {
913                if self.get_provider_list().iter().any(|(p, _)| p == id) {
914                    None
915                } else {
916                    Some(format!("Unknown provider: {id}"))
917                }
918            }
919            ModelScope::Role(id) => {
920                if self.catalog.roles.contains_key(id) {
921                    None
922                } else {
923                    Some(format!("Unknown role: {id}"))
924                }
925            }
926            ModelScope::Agent(id) => {
927                if self.catalog.agent_by_id(id).is_some() {
928                    None
929                } else {
930                    Some(format!("Unknown agent: {id}"))
931                }
932            }
933        };
934        if let Some(msg) = scope_error {
935            self.status_message = Some((msg, Instant::now()));
936            return;
937        }
938        let cmd = self.model_policy_state.command();
939        if let Err(e) = cmd.validate() {
940            self.status_message = Some((format!("Validation error: {e}"), Instant::now()));
941            return;
942        }
943        self.nav.push_view(View::ModelPolicyConfirm);
944    }
945
946    /// Handle key events in the ModelPolicyConfirm view.
947    fn handle_model_policy_confirm_key(&mut self, key: KeyEvent) {
948        match key.code {
949            KeyCode::Char('q') => self.should_quit = true,
950            KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
951                self.should_quit = true;
952            }
953            KeyCode::Esc => {
954                self.nav.pop_view();
955            }
956            KeyCode::Enter => self.execute_model_policy(),
957            _ => {}
958        }
959    }
960
961    /// Handle key events in the ModelPolicyOutput view.
962    ///
963    /// All three "leave" keys must gate on [`Self::subprocess_busy`], not
964    /// just `subprocess_handle.is_some()`: after `execute_model_policy` (or
965    /// the auto-chained integrity refresh) spawns the subprocess, there is a
966    /// window — until `tick()` drains `pending_subprocess` — where a real
967    /// child process is already running but `subprocess_handle` is still
968    /// `None`. Popping the view or quitting during that window would let
969    /// `pending_subprocess` (and the `SubprocessHandle` it eventually
970    /// delivers, which has `kill_on_drop(true)`) get dropped without a
971    /// graceful SIGTERM, SIGKILLing an in-flight catalog write mid-file.
972    fn handle_model_policy_output_key(&mut self, key: KeyEvent) {
973        match key.code {
974            KeyCode::Char('q') if !self.subprocess_busy() => {
975                self.should_quit = true;
976            }
977            KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
978                if self.subprocess_busy() {
979                    self.cancel_model_policy_subprocess();
980                } else {
981                    self.should_quit = true;
982                }
983            }
984            KeyCode::Esc => {
985                if self.subprocess_busy() {
986                    // Attempt a graceful cancel (a no-op until tick() has
987                    // adopted a real subprocess_handle to SIGTERM) and stay
988                    // on this view rather than silently popping — popping
989                    // away would reach ModelPolicyConfirm/other views whose
990                    // 'q' handlers quit unconditionally, which is the same
991                    // ungraceful-kill hazard one level removed.
992                    self.cancel_model_policy_subprocess();
993                } else {
994                    self.nav.pop_view();
995                }
996            }
997            KeyCode::Char('j') | KeyCode::Down => {
998                self.nav.detail_scroll = self.nav.detail_scroll.saturating_add(1);
999            }
1000            KeyCode::Char('k') | KeyCode::Up => {
1001                self.nav.detail_scroll = self.nav.detail_scroll.saturating_sub(1);
1002            }
1003            _ => {}
1004        }
1005    }
1006
1007    /// Execute the model policy mutation as a subprocess:
1008    /// `node scripts/model-policy.mjs set …`.
1009    fn execute_model_policy(&mut self) {
1010        // Defense in depth: never launch over a live gate/subprocess, even if
1011        // the builder was somehow reached while one was running.
1012        if self.subprocess_busy() {
1013            self.status_message = Some((
1014                "A subprocess is already running; wait for it to finish".to_string(),
1015                Instant::now(),
1016            ));
1017            return;
1018        }
1019        let cmd = self.model_policy_state.command();
1020        self.subprocess_output.clear();
1021        self.nav.detail_scroll = 0;
1022
1023        let args = [vec!["scripts/model-policy.mjs".to_string()], cmd.to_args()].concat();
1024        let workspace = self.workspace_root.clone();
1025
1026        let (tx, rx) = tokio::sync::oneshot::channel();
1027        if let Ok(handle) = tokio::runtime::Handle::try_current() {
1028            handle.spawn(async move {
1029                let result =
1030                    SubprocessExecutor::spawn("node", &args, &workspace, Duration::from_secs(300))
1031                        .await;
1032                let _ = tx.send(result);
1033            });
1034            self.pending_subprocess = Some(rx);
1035        }
1036        self.model_policy_stage = Some(ModelPolicyStage::Apply);
1037        self.nav.push_view(View::ModelPolicyOutput);
1038        tracing::info!(
1039            command = %cmd.display_command(),
1040            dry_run = cmd.dry_run,
1041            "model policy set started"
1042        );
1043    }
1044
1045    /// Chain `npm run asset-integrity:write` after a successful policy apply
1046    /// so the integrity manifest matches the projected harness files.
1047    fn spawn_integrity_refresh(&mut self) {
1048        self.subprocess_output.push(output::OutputLine {
1049            content: "── refreshing asset-integrity manifest ──".to_string(),
1050            stream: crate::subprocess::OutputStream::Stdout,
1051        });
1052        let workspace = self.workspace_root.clone();
1053        let args = vec!["run".to_string(), "asset-integrity:write".to_string()];
1054        let (tx, rx) = tokio::sync::oneshot::channel();
1055        if let Ok(handle) = tokio::runtime::Handle::try_current() {
1056            handle.spawn(async move {
1057                let result =
1058                    SubprocessExecutor::spawn("npm", &args, &workspace, Duration::from_secs(300))
1059                        .await;
1060                let _ = tx.send(result);
1061            });
1062            self.pending_subprocess = Some(rx);
1063        }
1064        self.model_policy_stage = Some(ModelPolicyStage::IntegrityRefresh);
1065        tracing::info!("asset-integrity refresh started after model policy apply");
1066    }
1067
1068    /// Cancel the model policy subprocess.
1069    fn cancel_model_policy_subprocess(&mut self) {
1070        if let Some(mut handle) = self.subprocess_handle.take() {
1071            tokio::spawn(async move {
1072                handle.cancel().await.ok();
1073            });
1074            self.model_policy_stage = None;
1075            self.status_message = Some((
1076                "Model policy operation cancelled by user".to_string(),
1077                Instant::now(),
1078            ));
1079        }
1080    }
1081
1082    fn handle_down(&mut self) {
1083        match &self.nav.current_view {
1084            View::AgentDetail(_)
1085            | View::SkillDetail(_)
1086            | View::McpDetail(_)
1087            | View::RuleDetail(_)
1088            | View::WorkflowDetail(_)
1089            | View::IntegrityDetail(_)
1090            | View::RoleDetail(_) => {
1091                self.nav.detail_scroll = self.nav.detail_scroll.saturating_add(1);
1092            }
1093            _ => {
1094                let max = self.current_list_len();
1095                self.nav.select_next(max);
1096            }
1097        }
1098    }
1099
1100    fn handle_up(&mut self) {
1101        match &self.nav.current_view {
1102            View::AgentDetail(_)
1103            | View::SkillDetail(_)
1104            | View::McpDetail(_)
1105            | View::RuleDetail(_)
1106            | View::WorkflowDetail(_)
1107            | View::IntegrityDetail(_)
1108            | View::RoleDetail(_) => {
1109                self.nav.detail_scroll = self.nav.detail_scroll.saturating_sub(1);
1110            }
1111            _ => self.nav.select_prev(),
1112        }
1113    }
1114
1115    fn handle_enter(&mut self) {
1116        let idx = self.nav.selected_index();
1117        match self.nav.current_view.clone() {
1118            View::AgentList => {
1119                if let Some(&real_idx) = self.filtered_indices.get(idx) {
1120                    if let Some(agent) = self.catalog.agents.get(real_idx) {
1121                        self.nav.push_view(View::AgentDetail(agent.id.clone()));
1122                    }
1123                }
1124            }
1125            View::SkillList => {
1126                if let Some(skill) = self.catalog.skills.get(idx) {
1127                    self.nav.push_view(View::SkillDetail(skill.id.clone()));
1128                }
1129            }
1130            View::RoleList => {
1131                let roles: Vec<String> = self.get_role_list().iter().map(|r| r.0.clone()).collect();
1132                if let Some(role_id) = roles.get(idx) {
1133                    self.nav.push_view(View::RoleDetail(role_id.clone()));
1134                }
1135            }
1136            View::ProviderList => {
1137                let providers = self.get_provider_list();
1138                if let Some((provider, _)) = providers.get(idx) {
1139                    self.nav.push_view(View::ProviderAgents(provider.clone()));
1140                }
1141            }
1142            View::McpList => {
1143                if let Some(mcp) = self.catalog.mcp_refs.get(idx) {
1144                    self.nav.push_view(View::McpDetail(mcp.id.clone()));
1145                }
1146            }
1147            View::RuleList => {
1148                if let Some(rule) = self.catalog.rules.get(idx) {
1149                    self.nav.push_view(View::RuleDetail(rule.id.clone()));
1150                }
1151            }
1152            View::WorkflowList => {
1153                if let Some(wf) = self.workflows().get(idx) {
1154                    self.nav.push_view(View::WorkflowDetail(wf.id.clone()));
1155                }
1156            }
1157            View::ProviderAgents(ref provider) => {
1158                let agents = self.catalog.agents_by_provider(provider);
1159                if let Some(agent) = agents.get(idx) {
1160                    self.nav.push_view(View::AgentDetail(agent.id.clone()));
1161                }
1162            }
1163            View::IntegrityOverview => {
1164                if let Some(integrity) = &self.catalog.integrity {
1165                    // idx 0 is the summary line, idx 1..=trees.len() are trees,
1166                    // last entry (if root_files exist) is root_files
1167                    let tree_count = integrity.trees.len();
1168                    if idx == 0 {
1169                        // Summary line — no drill-down
1170                    } else if idx <= tree_count {
1171                        if let Some(tree) = integrity.trees.get(idx - 1) {
1172                            self.nav.push_view(View::IntegrityDetail(tree.tree.clone()));
1173                        }
1174                    } else if !integrity.root_files.is_empty() && idx == tree_count + 1 {
1175                        self.nav
1176                            .push_view(View::IntegrityDetail("__root_files__".to_string()));
1177                    }
1178                }
1179            }
1180            View::ValidationList => {
1181                self.handle_validation_enter(idx);
1182            }
1183            _ => {}
1184        }
1185    }
1186
1187    /// Handle Enter key on the ValidationList view.
1188    ///
1189    /// The validation list has N+1 entries: the first entry is "Run All Validations"
1190    /// (invokes `npm run validate`), followed by individual gates.
1191    fn handle_validation_enter(&mut self, idx: usize) {
1192        // Index 0 = "Run All Validations", 1..N = individual gates
1193        if idx == 0 {
1194            // "Run All Validations" — check if any gate is already running
1195            if self.running_gate.is_some() {
1196                self.status_message = Some((
1197                    "A validation gate is already running".to_string(),
1198                    Instant::now(),
1199                ));
1200                return;
1201            }
1202            self.spawn_validation_gate(RUN_ALL_LABEL.to_string(), "validate".to_string());
1203        } else {
1204            let gate_idx = idx - 1;
1205            if let Some(gate) = self.validation_gates.get(gate_idx) {
1206                // Prevent concurrent execution of the same gate (Requirement 6.6, 6.7)
1207                if gate.status == GateStatus::Running {
1208                    self.status_message = Some((
1209                        format!("{} is already running", gate.script_name),
1210                        Instant::now(),
1211                    ));
1212                    return;
1213                }
1214                // Also prevent if any gate is running (single subprocess at a time)
1215                if self.running_gate.is_some() {
1216                    self.status_message = Some((
1217                        "A validation gate is already running".to_string(),
1218                        Instant::now(),
1219                    ));
1220                    return;
1221                }
1222                let script_name = gate.script_name.clone();
1223                self.spawn_validation_gate(script_name.clone(), script_name);
1224            }
1225        }
1226    }
1227
1228    /// Spawn a validation gate subprocess.
1229    ///
1230    /// `gate_label` is the name used for tracking (stored in `running_gate`).
1231    /// `script_name` is the npm script name to invoke (e.g., "validate:lint" or "validate").
1232    fn spawn_validation_gate(&mut self, gate_label: String, script_name: String) {
1233        // Defense in depth: never launch over a live gate/subprocess, mirrors
1234        // execute_model_policy's guard. Callers already check `running_gate`,
1235        // but that's `None` during a model-policy stage's pending/running
1236        // window, so without this a gate launch here would silently
1237        // overwrite `pending_subprocess`/`subprocess_handle` out from under
1238        // that operation.
1239        if self.subprocess_busy() {
1240            self.status_message = Some((
1241                "A subprocess is already running; wait for it to finish".to_string(),
1242                Instant::now(),
1243            ));
1244            return;
1245        }
1246        // Mark the gate as Running
1247        if gate_label == RUN_ALL_LABEL {
1248            // Mark all gates as Running for "Run All"
1249            for gate in &mut self.validation_gates {
1250                gate.status = GateStatus::Running;
1251            }
1252        } else {
1253            for gate in &mut self.validation_gates {
1254                if gate.script_name == gate_label {
1255                    gate.status = GateStatus::Running;
1256                    break;
1257                }
1258            }
1259        }
1260
1261        // Clear previous output
1262        self.subprocess_output.clear();
1263
1264        // Spawn the subprocess asynchronously via oneshot channel to avoid
1265        // `block_on` panic inside an existing tokio runtime.
1266        let workspace = self.workspace_root.clone();
1267        let timeout = Duration::from_secs(VALIDATION_GATE_TIMEOUT_SECS);
1268        let args = vec!["run".to_string(), script_name.clone()];
1269
1270        let (tx, rx) = tokio::sync::oneshot::channel();
1271        // Only spawn when a Tokio runtime is in context — always true inside the
1272        // real TUI event loop (`run_tui_async`). Synchronous tests that drive
1273        // `handle_key_event` directly have no reactor, so skip the spawn there
1274        // rather than panicking ("there is no reactor running").
1275        if let Ok(handle) = tokio::runtime::Handle::try_current() {
1276            handle.spawn(async move {
1277                let result =
1278                    crate::subprocess::SubprocessExecutor::spawn("npm", &args, &workspace, timeout)
1279                        .await;
1280                let _ = tx.send(result);
1281            });
1282            // Store receiver to poll in tick().
1283            self.pending_subprocess = Some(rx);
1284        }
1285
1286        // Set gate tracking state immediately (deterministic regardless of spawn).
1287        self.running_gate = Some(gate_label.clone());
1288        self.running_gate_start = Some(Instant::now());
1289        self.nav.push_view(View::ValidationOutput(gate_label));
1290        tracing::info!(gate = %script_name, "validation gate started");
1291    }
1292
1293    /// Mark a gate (or all gates for "Run All") as failed.
1294    fn mark_gate_failed(&mut self, gate_label: &str) {
1295        if gate_label == RUN_ALL_LABEL {
1296            for gate in &mut self.validation_gates {
1297                if gate.status == GateStatus::Running {
1298                    gate.status = GateStatus::Failed;
1299                }
1300            }
1301        } else {
1302            for gate in &mut self.validation_gates {
1303                if gate.script_name == *gate_label {
1304                    gate.status = GateStatus::Failed;
1305                    break;
1306                }
1307            }
1308        }
1309        self.running_gate = None;
1310        self.running_gate_start = None;
1311    }
1312
1313    /// Mark the app as dirty (needs rendering) — used by async event loop.
1314    pub fn mark_dirty(&mut self) {
1315        self.dirty = true;
1316    }
1317
1318    /// Keep the legacy `current_view` consistent with the active v2 tab so key
1319    /// dispatch (Enter / j / k / g) targets what the tab actually renders.
1320    ///
1321    /// Only the tabs backed by a legacy view need syncing: `ValidationGates`
1322    /// renders the validation list, and `CatalogBrowser` renders the legacy
1323    /// catalog browser. Other v2 tabs render non-interactive widgets, so the
1324    /// legacy view is left untouched.
1325    fn sync_view_to_tab(&mut self) {
1326        use crate::ui::nav::Tab;
1327        // Land the CatalogBrowser tab on a catalog list view, but keep an
1328        // existing catalog sub-view (guard, not a nested `if`, to satisfy
1329        // clippy::collapsible_match).
1330        match self.nav.current_tab {
1331            Tab::ValidationGates => self.nav.current_view = View::ValidationList,
1332            Tab::CatalogBrowser
1333                if !matches!(
1334                    self.nav.current_view,
1335                    View::AgentList
1336                        | View::SkillList
1337                        | View::McpList
1338                        | View::RuleList
1339                        | View::WorkflowList
1340                        | View::RoleList
1341                        | View::ProviderList
1342                        | View::IntegrityOverview
1343                ) =>
1344            {
1345                self.nav.current_view = View::AgentList;
1346            }
1347            _ => {}
1348        }
1349    }
1350
1351    /// Reload the entire catalog from the workspace root and refresh the
1352    /// filtered view (Task 9.1 — live reload on filesystem change).
1353    ///
1354    /// Best-effort: catalog load errors are surfaced via the status message but
1355    /// never panic, so a malformed edit keeps the previous render usable.
1356    pub fn reload_catalog(&mut self) {
1357        self.catalog = CatalogStore::load(&self.workspace_root);
1358        self.update_filtered();
1359        let msg = if self.catalog.load_errors.is_empty() {
1360            "Catalog reloaded".to_string()
1361        } else {
1362            format!(
1363                "Catalog reloaded with {} error(s)",
1364                self.catalog.load_errors.len()
1365            )
1366        };
1367        self.status_message = Some((msg, Instant::now()));
1368        self.mark_dirty();
1369    }
1370
1371    /// Reload a single changed catalog file in place (Task 9.1).
1372    ///
1373    /// Returns the [`ReloadOutcome`] so callers (and tests) can observe whether
1374    /// the file was reloaded, retained on parse error, or unchanged.
1375    pub fn reload_catalog_file(
1376        &mut self,
1377        path: &std::path::Path,
1378    ) -> crate::catalog::store::ReloadOutcome {
1379        let outcome = self.catalog.reload_file(path);
1380        self.update_filtered();
1381        self.mark_dirty();
1382        outcome
1383    }
1384
1385    /// Tick: clear expired status messages, drain subprocess output with sanitization,
1386    /// and check subprocess timeout and completion.
1387    pub fn tick(&mut self) {
1388        if let Some((_, created)) = &self.status_message {
1389            if created.elapsed().as_secs() > 10 {
1390                self.status_message = None;
1391            }
1392        }
1393
1394        // Poll pending subprocess spawn result (from async tokio::spawn).
1395        if let Some(ref mut rx) = self.pending_subprocess {
1396            match rx.try_recv() {
1397                Ok(Ok(subprocess_handle)) => {
1398                    self.subprocess_handle = Some(subprocess_handle);
1399                    self.pending_subprocess = None;
1400                }
1401                Ok(Err(e)) => {
1402                    // Subprocess spawn failed
1403                    if let Some(ref gate_label) = self.running_gate.clone() {
1404                        self.mark_gate_failed(gate_label);
1405                        tracing::error!(gate = %gate_label, error = %e, "validation gate spawn failed");
1406                    }
1407                    self.model_policy_stage = None;
1408                    self.status_message =
1409                        Some((format!("Failed to start subprocess: {e}"), Instant::now()));
1410                    self.pending_subprocess = None;
1411                }
1412                Err(tokio::sync::oneshot::error::TryRecvError::Empty) => {
1413                    // Not ready yet — keep waiting
1414                }
1415                Err(tokio::sync::oneshot::error::TryRecvError::Closed) => {
1416                    // Sender dropped without sending — treat as error
1417                    if let Some(ref gate_label) = self.running_gate.clone() {
1418                        self.mark_gate_failed(gate_label);
1419                    }
1420                    self.model_policy_stage = None;
1421                    self.status_message = Some((
1422                        "Subprocess spawn channel closed unexpectedly".to_string(),
1423                        Instant::now(),
1424                    ));
1425                    self.pending_subprocess = None;
1426                }
1427            }
1428        }
1429
1430        // Drain subprocess output lines, sanitizing before storage
1431        if let Some(handle) = &mut self.subprocess_handle {
1432            while let Some(line) = handle.try_recv_stdout() {
1433                self.subprocess_output.push(output::OutputLine {
1434                    content: crate::security::redact::redact_secrets(&sanitize_subprocess_output(
1435                        &line.content,
1436                    )),
1437                    stream: line.stream,
1438                });
1439            }
1440            while let Some(line) = handle.try_recv_stderr() {
1441                self.subprocess_output.push(output::OutputLine {
1442                    content: crate::security::redact::redact_secrets(&sanitize_subprocess_output(
1443                        &line.content,
1444                    )),
1445                    stream: line.stream,
1446                });
1447            }
1448
1449            // Poll for subprocess exit (non-blocking)
1450            handle.try_poll_exit();
1451
1452            // Check timeout synchronously by comparing elapsed time
1453            if handle.is_running() && handle.is_timed_out() {
1454                // Mark any active validation gate as timed out
1455                for gate in &mut self.validation_gates {
1456                    if gate.status == GateStatus::Running {
1457                        gate.status = GateStatus::TimedOut;
1458                    }
1459                }
1460                self.running_gate = None;
1461                self.running_gate_start = None;
1462                self.status_message = Some(("Subprocess timed out".to_string(), Instant::now()));
1463                self.subprocess_handle = None;
1464            } else if !handle.is_running() {
1465                // Subprocess has finished — update gate status based on exit code
1466                let exit_code = handle.exit_code();
1467                let duration = self.running_gate_start.map(|s| s.elapsed());
1468
1469                if let Some(ref gate_label) = self.running_gate.clone() {
1470                    if gate_label == RUN_ALL_LABEL {
1471                        // "Run All" — mark all gates based on overall exit code
1472                        let status = if exit_code == Some(0) {
1473                            GateStatus::Passed
1474                        } else {
1475                            GateStatus::Failed
1476                        };
1477                        for gate in &mut self.validation_gates {
1478                            if gate.status == GateStatus::Running {
1479                                gate.status = status.clone();
1480                                gate.last_exit_code = exit_code;
1481                                gate.last_duration = duration;
1482                            }
1483                        }
1484                    } else {
1485                        // Individual gate
1486                        for gate in &mut self.validation_gates {
1487                            if gate.script_name == *gate_label && gate.status == GateStatus::Running
1488                            {
1489                                gate.status = if exit_code == Some(0) {
1490                                    GateStatus::Passed
1491                                } else {
1492                                    GateStatus::Failed
1493                                };
1494                                gate.last_exit_code = exit_code;
1495                                gate.last_duration = duration;
1496                                break;
1497                            }
1498                        }
1499                    }
1500                    tracing::info!(
1501                        gate = %gate_label,
1502                        exit_code = ?exit_code,
1503                        duration_ms = ?duration.map(|d| d.as_millis()),
1504                        "validation gate completed"
1505                    );
1506                } else if let Some(stage) = self.model_policy_stage {
1507                    match stage {
1508                        ModelPolicyStage::Apply => {
1509                            if exit_code == Some(0) {
1510                                if self.model_policy_state.dry_run {
1511                                    self.model_policy_stage = None;
1512                                    self.status_message = Some((
1513                                        "Dry-run complete — review the planned changes above"
1514                                            .to_string(),
1515                                        Instant::now(),
1516                                    ));
1517                                    tracing::info!("model policy dry-run completed");
1518                                } else if self.model_policy_state.refresh_integrity {
1519                                    // Chain the integrity manifest refresh so the
1520                                    // published tree and manifest stay in lockstep.
1521                                    self.spawn_integrity_refresh();
1522                                } else {
1523                                    self.model_policy_stage = None;
1524                                    self.status_message = Some((
1525                                        "Model policy applied — run asset-integrity:write before committing"
1526                                            .to_string(),
1527                                        Instant::now(),
1528                                    ));
1529                                    tracing::info!(
1530                                        "model policy applied (integrity refresh skipped)"
1531                                    );
1532                                }
1533                            } else {
1534                                self.model_policy_stage = None;
1535                                self.status_message = Some((
1536                                    format!(
1537                                        "Model policy update failed with exit code {}",
1538                                        exit_code.unwrap_or(-1)
1539                                    ),
1540                                    Instant::now(),
1541                                ));
1542                                tracing::warn!(exit_code = ?exit_code, "model policy set failed");
1543                            }
1544                        }
1545                        ModelPolicyStage::IntegrityRefresh => {
1546                            self.model_policy_stage = None;
1547                            if exit_code == Some(0) {
1548                                self.status_message = Some((
1549                                    "Model policy applied and integrity manifest refreshed"
1550                                        .to_string(),
1551                                    Instant::now(),
1552                                ));
1553                                tracing::info!("model policy apply + integrity refresh completed");
1554                            } else {
1555                                self.status_message = Some((
1556                                    format!(
1557                                        "Integrity refresh failed with exit code {} — run npm run asset-integrity:write manually",
1558                                        exit_code.unwrap_or(-1)
1559                                    ),
1560                                    Instant::now(),
1561                                ));
1562                                tracing::warn!(exit_code = ?exit_code, "integrity refresh failed");
1563                            }
1564                        }
1565                    }
1566                } else {
1567                    // Export subprocess completed (no running_gate set)
1568                    if exit_code != Some(0) {
1569                        self.status_message = Some((
1570                            format!("Export failed with exit code {}", exit_code.unwrap_or(-1)),
1571                            Instant::now(),
1572                        ));
1573                        tracing::warn!(
1574                            exit_code = ?exit_code,
1575                            "export command failed"
1576                        );
1577                    } else {
1578                        self.status_message =
1579                            Some(("Export completed successfully".to_string(), Instant::now()));
1580                        tracing::info!("export command completed successfully");
1581                    }
1582                }
1583
1584                self.running_gate = None;
1585                self.running_gate_start = None;
1586                // Final drain after process exits — capture any remaining buffered lines
1587                if let Some(ref mut handle) = self.subprocess_handle {
1588                    while let Some(line) = handle.try_recv_stdout() {
1589                        self.subprocess_output.push(output::OutputLine {
1590                            content: crate::security::redact::redact_secrets(
1591                                &sanitize_subprocess_output(&line.content),
1592                            ),
1593                            stream: line.stream,
1594                        });
1595                    }
1596                    while let Some(line) = handle.try_recv_stderr() {
1597                        self.subprocess_output.push(output::OutputLine {
1598                            content: crate::security::redact::redact_secrets(
1599                                &sanitize_subprocess_output(&line.content),
1600                            ),
1601                            stream: line.stream,
1602                        });
1603                    }
1604                }
1605                self.subprocess_handle = None;
1606            }
1607        }
1608
1609        // Cap subprocess output to prevent unbounded memory growth
1610        if self.subprocess_output.len() > MAX_SUBPROCESS_OUTPUT_LINES {
1611            let overflow = self.subprocess_output.len() - MAX_SUBPROCESS_OUTPUT_LINES;
1612            self.subprocess_output.drain(0..overflow);
1613        }
1614    }
1615
1616    /// Render the full UI — v2 primary surface: tab bar + tab body.
1617    pub fn render(&mut self, frame: &mut Frame) {
1618        use crate::ui::nav::Tab;
1619        use ratatui::layout::{Constraint, Direction, Layout};
1620
1621        let theme = Theme::new(self.no_color, self.theme_mode);
1622        let area = frame.area();
1623
1624        // Split into: tab bar (3 rows), body (flexible), status (1), help (1).
1625        let chunks = Layout::default()
1626            .direction(Direction::Vertical)
1627            .constraints([
1628                Constraint::Length(3), // tab bar
1629                Constraint::Min(0),    // tab body
1630                Constraint::Length(1), // status bar
1631                Constraint::Length(1), // help bar
1632            ])
1633            .split(area);
1634
1635        // Render the tab bar.
1636        self.render_tab_bar(chunks[0], frame, &theme);
1637
1638        // Render the active tab body.
1639        // Tabs that need &mut self are dispatched here; all others delegate to
1640        // render_tab (which is &self).
1641        let body = chunks[1];
1642        match self.nav.current_tab {
1643            Tab::CatalogBrowser => {
1644                // Legacy sidebar + main-content layout so all legacy render helpers
1645                // remain reachable and exercised when the user visits this tab.
1646                let legacy = compute_layout(body);
1647                self.render_sidebar(&legacy.sidebar, frame, &theme);
1648                self.render_main_content(&legacy.main_content, frame, &theme);
1649            }
1650            Tab::ValidationGates => {
1651                // Validation-gate list uses stateful rendering (&mut self) and is
1652                // wired to this tab so render_validation_list stays reachable.
1653                self.render_validation_list(body, frame, &theme);
1654            }
1655            _ => self.render_tab(body, frame, &theme),
1656        }
1657
1658        // Status bar.
1659        let (visible, total) = self.get_counts();
1660        let filter_str = if let Some((msg, _)) = &self.status_message {
1661            msg.clone()
1662        } else if self.search_query.is_empty() {
1663            String::new()
1664        } else {
1665            self.search_query.clone()
1666        };
1667        let session_str = self.session_id.to_string();
1668        status_bar::render_status_bar(
1669            visible,
1670            total,
1671            &filter_str,
1672            &session_str,
1673            chunks[2],
1674            frame,
1675            &theme,
1676        );
1677
1678        help_bar::render_help_bar(&self.nav.current_view, chunks[3], frame, &theme);
1679
1680        // Render help overlay on top if active.
1681        if self.show_help_overlay {
1682            self.render_help_overlay(frame, &theme);
1683        }
1684    }
1685
1686    /// Render the v2 tab bar showing all tabs with the current tab highlighted.
1687    fn render_tab_bar(&self, area: ratatui::layout::Rect, frame: &mut Frame, theme: &Theme) {
1688        use crate::ui::nav::Tab;
1689        use ratatui::text::Line;
1690        use ratatui::widgets::{Block, Borders, Tabs};
1691
1692        let titles: Vec<Line> = Tab::ALL.iter().map(|t| Line::from(t.label())).collect();
1693        let tabs = Tabs::new(titles)
1694            .block(
1695                Block::default()
1696                    .borders(Borders::ALL)
1697                    .title(" Operator Console "),
1698            )
1699            .select(self.nav.current_tab.index())
1700            .style(theme.list_item())
1701            .highlight_style(theme.list_selected());
1702        frame.render_widget(tabs, area);
1703    }
1704
1705    /// Render the active v2 operator-console tab into `area` (Task 11.3).
1706    ///
1707    /// Dispatches to the v2 widgets based on `self.nav.current_tab`. The
1708    /// Dependencies tab renders the real catalog dependency graph and Overview a
1709    /// catalog summary; tabs whose live data depends on a workspace scan/index
1710    /// (coverage, violations, audit) render their widget with empty data until
1711    /// that pipeline is wired into the TUI flow.
1712    pub fn render_tab(&self, area: ratatui::layout::Rect, frame: &mut Frame, theme: &Theme) {
1713        use crate::ui::nav::Tab;
1714        match self.nav.current_tab {
1715            Tab::Dependencies => {
1716                let graph = crate::federation::dep_graph::DependencyGraph::build(&self.catalog);
1717                let state = dep_graph::DepGraphState::default();
1718                dep_graph::render_dep_graph(&graph, &state, area, frame, theme);
1719            }
1720            Tab::CoverageMatrix => {
1721                let matrix = crate::models::coverage::CoverageMatrix {
1722                    rows: Vec::new(),
1723                    columns: Vec::new(),
1724                    cells: std::collections::HashMap::new(),
1725                    workspace_scores: std::collections::HashMap::new(),
1726                };
1727                let state = coverage_grid::CoverageGridState::default();
1728                let filter = coverage_grid::CoverageGridFilter::default();
1729                coverage_grid::render_coverage_grid(&matrix, &state, &filter, area, frame, theme);
1730            }
1731            Tab::PolicyViolations => {
1732                let state = violations::ViolationsState::default();
1733                violations::render_violations(&[], &[], &state, area, frame, theme);
1734            }
1735            Tab::AuditLog => {
1736                let state = audit_log::AuditLogState::default();
1737                audit_log::render_audit_log(&[], &state, area, frame, theme);
1738            }
1739            _ => self.render_tab_overview(area, frame, theme),
1740        }
1741    }
1742
1743    /// Render the Overview tab — a catalog summary (Task 11.3).
1744    fn render_tab_overview(&self, area: ratatui::layout::Rect, frame: &mut Frame, theme: &Theme) {
1745        use ratatui::widgets::{Block, Borders, Paragraph, Wrap};
1746        let body = format!(
1747            "Vanguard Frontier — Operator Console\n\n\
1748             Agents:    {}\n\
1749             Skills:    {}\n\
1750             MCP refs:  {}\n\
1751             Rules:     {}\n\
1752             Providers: {}\n\n\
1753             Tab/Shift-Tab to switch tabs.",
1754            self.catalog.agent_count(),
1755            self.catalog.skill_count(),
1756            self.catalog.mcp_refs.len(),
1757            self.catalog.rules.len(),
1758            self.catalog.provider_count(),
1759        );
1760        let paragraph = Paragraph::new(body)
1761            .style(theme.list_item())
1762            .block(Block::default().borders(Borders::ALL).title(" Overview "))
1763            .wrap(Wrap { trim: false });
1764        frame.render_widget(paragraph, area);
1765    }
1766}
1767
1768/// Build the (harness, description) rows for the agent-detail "Models"
1769/// section from resolved model assignments (`catalog/model-assignments.json`
1770/// via `CatalogStore::model_assignments_for_agent`).
1771///
1772/// When an assignment's `model_warning` is set (provider lifecycle:
1773/// `status: "retiring"` on the pinned model, or `"retired"` with a
1774/// successor substituted into `model`), an additional synthetic row is
1775/// emitted directly under that harness's row with label `"warning"` and a
1776/// `"warning: "`-prefixed value carrying the engine-composed text verbatim —
1777/// the TUI never derives lifecycle wording itself, it only renders what
1778/// `scripts/model-policy.mjs` already decided. `detail::render_agent_detail`
1779/// recognizes the `"warning"` label and styles that row in the theme's
1780/// warning colour.
1781fn build_model_lines(assignments: &[&crate::models::ModelAssignment]) -> Vec<(String, String)> {
1782    let mut lines = Vec::new();
1783    for a in assignments {
1784        let model = a.model.as_deref().unwrap_or("auto (harness default)");
1785        let mut description = model.to_string();
1786        if let Some(provider) = a.model_provider.as_deref() {
1787            description.push_str(&format!(" via {provider}"));
1788        }
1789        if a.harness == "codex" {
1790            let reasoning = a.reasoning_effort.as_deref().unwrap_or("auto");
1791            description.push_str(&format!(" · reasoning={reasoning}"));
1792        }
1793        if a.model_source != "default" {
1794            description.push_str(&format!(" · rule: {}", a.model_source));
1795        }
1796        lines.push((a.harness.clone(), description));
1797        if let Some(warning) = a.model_warning.as_deref() {
1798            lines.push(("warning".to_string(), format!("warning: {warning}")));
1799        }
1800    }
1801    lines
1802}
1803
1804// Legacy sidebar / main-content render helpers — called from render() when the
1805// CatalogBrowser or ValidationGates tab is active (so they are never dead code).
1806impl App {
1807    fn render_sidebar(&mut self, area: &ratatui::layout::Rect, frame: &mut Frame, theme: &Theme) {
1808        let items: Vec<String> = SIDEBAR_SECTIONS
1809            .iter()
1810            .map(|(s, _)| s.to_string())
1811            .collect();
1812        let mut sidebar_state = ratatui::widgets::ListState::default();
1813        sidebar_state.select(Some(self.nav.sidebar_index));
1814        list_view::render_list_view(&items, &mut sidebar_state, "Catalog", *area, frame, theme);
1815    }
1816
1817    fn render_main_content(
1818        &mut self,
1819        area: &ratatui::layout::Rect,
1820        frame: &mut Frame,
1821        theme: &Theme,
1822    ) {
1823        // Render filter chips at the top if any filters are active (agent list view)
1824        let (content_area, has_chips) = if self.nav.current_view == View::AgentList
1825            && (self.provider_filter.is_some()
1826                || self.harness_filter.is_some()
1827                || !self.search_query.is_empty())
1828        {
1829            let chips_height = 1u16;
1830            let chunks = ratatui::layout::Layout::default()
1831                .direction(ratatui::layout::Direction::Vertical)
1832                .constraints([
1833                    ratatui::layout::Constraint::Length(chips_height),
1834                    ratatui::layout::Constraint::Min(0),
1835                ])
1836                .split(*area);
1837            self.render_filter_chips(chunks[0], frame, theme);
1838            (chunks[1], true)
1839        } else {
1840            (*area, false)
1841        };
1842        let _ = has_chips;
1843
1844        match self.nav.current_view.clone() {
1845            View::AgentList => self.render_agent_list(content_area, frame, theme),
1846            View::AgentDetail(ref id) => {
1847                self.render_agent_detail_view(id, content_area, frame, theme)
1848            }
1849            View::SkillList => self.render_skill_list(content_area, frame, theme),
1850            View::SkillDetail(ref id) => {
1851                self.render_skill_detail_view(id, content_area, frame, theme)
1852            }
1853            View::RoleList => self.render_role_list(content_area, frame, theme),
1854            View::RoleDetail(ref id) => {
1855                self.render_role_detail_view(id, content_area, frame, theme)
1856            }
1857            View::ProviderList => self.render_provider_list(content_area, frame, theme),
1858            View::ProviderAgents(ref p) => {
1859                self.render_provider_agents(p, content_area, frame, theme)
1860            }
1861            View::McpList => self.render_mcp_list(content_area, frame, theme),
1862            View::McpDetail(ref id) => self.render_mcp_detail_view(id, content_area, frame, theme),
1863            View::RuleList => self.render_rule_list(content_area, frame, theme),
1864            View::WorkflowList => self.render_workflow_list(content_area, frame, theme),
1865            View::WorkflowDetail(ref id) => {
1866                self.render_workflow_detail_view(id, content_area, frame, theme)
1867            }
1868            View::RuleDetail(ref id) => {
1869                self.render_rule_detail_view(id, content_area, frame, theme)
1870            }
1871            View::ValidationList => self.render_validation_list(content_area, frame, theme),
1872            View::ValidationOutput(ref name) => {
1873                let name = name.clone();
1874                self.render_validation_output(&name, content_area, frame, theme);
1875            }
1876            View::ExportBuilder => self.render_export_builder(content_area, frame, theme),
1877            View::ExportConfirm => self.render_export_confirm(content_area, frame, theme),
1878            View::ExportOutput => self.render_export_output(content_area, frame, theme),
1879            View::ModelPolicyBuilder => {
1880                self.render_model_policy_builder(content_area, frame, theme)
1881            }
1882            View::ModelPolicyConfirm => {
1883                self.render_model_policy_confirm(content_area, frame, theme)
1884            }
1885            View::ModelPolicyOutput => self.render_model_policy_output(content_area, frame, theme),
1886            View::IntegrityOverview => self.render_integrity_view(content_area, frame, theme),
1887            View::IntegrityDetail(ref tree) => {
1888                let tree = tree.clone();
1889                self.render_integrity_detail(&tree, content_area, frame, theme);
1890            }
1891        }
1892
1893        if self.search_active || !self.search_query.is_empty() {
1894            let search_area = ratatui::layout::Rect {
1895                x: content_area.x,
1896                y: content_area.y,
1897                width: content_area.width.min(40),
1898                height: 3,
1899            };
1900            search::render_search_input(
1901                &self.search_query,
1902                self.search_active,
1903                search_area,
1904                frame,
1905                theme,
1906            );
1907        }
1908    }
1909
1910    fn render_agent_list(&mut self, area: ratatui::layout::Rect, frame: &mut Frame, theme: &Theme) {
1911        let items: Vec<String> = self
1912            .filtered_indices
1913            .iter()
1914            .filter_map(|&i| {
1915                self.catalog.agents.get(i).map(|a| {
1916                    format!(
1917                        "{} - {}",
1918                        a.id,
1919                        a.summary.chars().take(60).collect::<String>()
1920                    )
1921                })
1922            })
1923            .collect();
1924        list_view::render_list_view(
1925            &items,
1926            &mut self.nav.list_state,
1927            "Agents",
1928            area,
1929            frame,
1930            theme,
1931        );
1932    }
1933
1934    fn render_agent_detail_view(
1935        &self,
1936        id: &str,
1937        area: ratatui::layout::Rect,
1938        frame: &mut Frame,
1939        theme: &Theme,
1940    ) {
1941        if let Some(agent) = self.catalog.agents.iter().find(|a| a.id == id) {
1942            let roles = self.catalog.roles_containing_agent(id);
1943            let assignments = self.catalog.model_assignments_for_agent(id);
1944            let model_lines = build_model_lines(&assignments);
1945            detail::render_agent_detail(
1946                agent,
1947                &roles,
1948                &model_lines,
1949                area,
1950                frame,
1951                self.nav.detail_scroll,
1952                theme,
1953            );
1954        }
1955    }
1956
1957    fn render_skill_list(&mut self, area: ratatui::layout::Rect, frame: &mut Frame, theme: &Theme) {
1958        let items: Vec<String> = self
1959            .catalog
1960            .skills
1961            .iter()
1962            .map(|s| {
1963                format!(
1964                    "{} - {}",
1965                    s.id,
1966                    s.summary.chars().take(60).collect::<String>()
1967                )
1968            })
1969            .collect();
1970        list_view::render_list_view(
1971            &items,
1972            &mut self.nav.list_state,
1973            "Skills",
1974            area,
1975            frame,
1976            theme,
1977        );
1978    }
1979
1980    fn render_skill_detail_view(
1981        &self,
1982        id: &str,
1983        area: ratatui::layout::Rect,
1984        frame: &mut Frame,
1985        theme: &Theme,
1986    ) {
1987        if let Some(skill) = self.catalog.skills.iter().find(|s| s.id == id) {
1988            let related = self.catalog.agents_with_skill(id);
1989            detail::render_skill_detail(
1990                skill,
1991                &related,
1992                area,
1993                frame,
1994                self.nav.detail_scroll,
1995                theme,
1996            );
1997        }
1998    }
1999
2000    fn render_role_list(&mut self, area: ratatui::layout::Rect, frame: &mut Frame, theme: &Theme) {
2001        let roles = self.get_role_list();
2002        let items: Vec<String> = roles
2003            .iter()
2004            .map(|(id, label, count)| format!("{id} ({label}) - {count} agents"))
2005            .collect();
2006        list_view::render_list_view(
2007            &items,
2008            &mut self.nav.list_state,
2009            "Roles",
2010            area,
2011            frame,
2012            theme,
2013        );
2014    }
2015
2016    fn render_role_detail_view(
2017        &self,
2018        id: &str,
2019        area: ratatui::layout::Rect,
2020        frame: &mut Frame,
2021        theme: &Theme,
2022    ) {
2023        let agents = self.catalog.agents_for_role(id);
2024        let items: Vec<ratatui::text::Line> = agents
2025            .iter()
2026            .map(|a| ratatui::text::Line::from(format!("  - {}", a.id)))
2027            .collect();
2028        let mut lines = vec![ratatui::text::Line::from(format!("Role: {id}"))];
2029        if let Some(role) = self.catalog.roles.get(id) {
2030            lines.push(ratatui::text::Line::from(format!("Label: {}", role.label)));
2031            lines.push(ratatui::text::Line::from(format!(
2032                "Description: {}",
2033                role.description
2034            )));
2035        }
2036        lines.push(ratatui::text::Line::from(format!(
2037            "Agents ({}): ",
2038            agents.len()
2039        )));
2040        lines.extend(items);
2041        let paragraph = ratatui::widgets::Paragraph::new(lines)
2042            .block(
2043                ratatui::widgets::Block::default()
2044                    .borders(ratatui::widgets::Borders::ALL)
2045                    .title("Role Detail")
2046                    .border_style(theme.border_style()),
2047            )
2048            .wrap(ratatui::widgets::Wrap { trim: false })
2049            .scroll((self.nav.detail_scroll, 0));
2050        frame.render_widget(paragraph, area);
2051    }
2052
2053    fn render_provider_list(
2054        &mut self,
2055        area: ratatui::layout::Rect,
2056        frame: &mut Frame,
2057        theme: &Theme,
2058    ) {
2059        let providers = self.get_provider_list();
2060        let max_count = providers.iter().map(|(_, c)| *c).max().unwrap_or(1);
2061        let bar_width = 20usize;
2062        let items: Vec<String> = providers
2063            .iter()
2064            .map(|(p, count)| {
2065                let filled = if max_count > 0 {
2066                    ((*count as f64 / max_count as f64) * bar_width as f64).round() as usize
2067                } else {
2068                    0
2069                };
2070                let empty = bar_width.saturating_sub(filled);
2071                let bar: String = "█".repeat(filled) + &"░".repeat(empty);
2072                format!("{p} ({count} agents) {bar}")
2073            })
2074            .collect();
2075        list_view::render_list_view(
2076            &items,
2077            &mut self.nav.list_state,
2078            "Providers",
2079            area,
2080            frame,
2081            theme,
2082        );
2083    }
2084
2085    fn render_provider_agents(
2086        &mut self,
2087        provider: &str,
2088        area: ratatui::layout::Rect,
2089        frame: &mut Frame,
2090        theme: &Theme,
2091    ) {
2092        let agents = self.catalog.agents_by_provider(provider);
2093        let items: Vec<String> = agents
2094            .iter()
2095            .map(|a| {
2096                format!(
2097                    "{} - {}",
2098                    a.id,
2099                    a.summary.chars().take(60).collect::<String>()
2100                )
2101            })
2102            .collect();
2103        let title = format!("Agents: {provider}");
2104        list_view::render_list_view(&items, &mut self.nav.list_state, &title, area, frame, theme);
2105    }
2106
2107    fn render_mcp_list(&mut self, area: ratatui::layout::Rect, frame: &mut Frame, theme: &Theme) {
2108        let items: Vec<String> = self
2109            .catalog
2110            .mcp_refs
2111            .iter()
2112            .map(|m| {
2113                format!(
2114                    "{} - {}",
2115                    m.id,
2116                    m.summary.chars().take(60).collect::<String>()
2117                )
2118            })
2119            .collect();
2120        list_view::render_list_view(
2121            &items,
2122            &mut self.nav.list_state,
2123            "MCP References",
2124            area,
2125            frame,
2126            theme,
2127        );
2128    }
2129
2130    fn render_mcp_detail_view(
2131        &self,
2132        id: &str,
2133        area: ratatui::layout::Rect,
2134        frame: &mut Frame,
2135        theme: &Theme,
2136    ) {
2137        if let Some(mcp) = self.catalog.mcp_refs.iter().find(|m| m.id == id) {
2138            detail::render_mcp_detail(mcp, area, frame, self.nav.detail_scroll, theme);
2139        }
2140    }
2141
2142    fn render_rule_list(&mut self, area: ratatui::layout::Rect, frame: &mut Frame, theme: &Theme) {
2143        let items: Vec<String> = self
2144            .catalog
2145            .rules
2146            .iter()
2147            .map(|r| {
2148                format!(
2149                    "{} - {}",
2150                    r.id,
2151                    r.summary.chars().take(60).collect::<String>()
2152                )
2153            })
2154            .collect();
2155        list_view::render_list_view(
2156            &items,
2157            &mut self.nav.list_state,
2158            "Rules",
2159            area,
2160            frame,
2161            theme,
2162        );
2163    }
2164
2165    /// Workflows from the generated catalog, or an empty slice when the catalog file is
2166    /// absent — a checkout without any workflow renders an empty list rather than an
2167    /// error, matching how the loader treats a missing file.
2168    fn workflows(&self) -> &[crate::models::Workflow] {
2169        self.catalog
2170            .workflows
2171            .as_ref()
2172            .map(|c| c.workflows.as_slice())
2173            .unwrap_or(&[])
2174    }
2175
2176    fn render_workflow_list(
2177        &mut self,
2178        area: ratatui::layout::Rect,
2179        frame: &mut Frame,
2180        theme: &Theme,
2181    ) {
2182        let items: Vec<String> = self
2183            .workflows()
2184            .iter()
2185            .map(|w| {
2186                format!(
2187                    "{} [{}] - {}",
2188                    w.invocation(),
2189                    w.model_tiers().join("/"),
2190                    w.description.chars().take(60).collect::<String>()
2191                )
2192            })
2193            .collect();
2194        list_view::render_list_view(
2195            &items,
2196            &mut self.nav.list_state,
2197            "Workflows",
2198            area,
2199            frame,
2200            theme,
2201        );
2202    }
2203
2204    fn render_workflow_detail_view(
2205        &self,
2206        id: &str,
2207        area: ratatui::layout::Rect,
2208        frame: &mut Frame,
2209        theme: &Theme,
2210    ) {
2211        if let Some(wf) = self.workflows().iter().find(|w| w.id == id) {
2212            detail::render_workflow_detail(wf, area, frame, self.nav.detail_scroll, theme);
2213        }
2214    }
2215
2216    fn render_rule_detail_view(
2217        &self,
2218        id: &str,
2219        area: ratatui::layout::Rect,
2220        frame: &mut Frame,
2221        theme: &Theme,
2222    ) {
2223        if let Some(rule) = self.catalog.rules.iter().find(|r| r.id == id) {
2224            detail::render_rule_detail(rule, area, frame, self.nav.detail_scroll, theme);
2225        }
2226    }
2227
2228    fn render_validation_list(
2229        &mut self,
2230        area: ratatui::layout::Rect,
2231        frame: &mut Frame,
2232        theme: &Theme,
2233    ) {
2234        use ratatui::style::Modifier;
2235        use ratatui::text::{Line, Span};
2236        use ratatui::widgets::{Block, Borders, List, ListItem};
2237
2238        let mut list_items: Vec<ListItem> = Vec::new();
2239
2240        // First entry: "Run All Validations"
2241        let run_all_style = if self.running_gate.as_deref() == Some(RUN_ALL_LABEL) {
2242            theme.gate_running()
2243        } else {
2244            theme.gate_not_run()
2245        };
2246        let run_all_indicator = if self.running_gate.as_deref() == Some(RUN_ALL_LABEL) {
2247            self.animated_spinner()
2248        } else {
2249            String::new()
2250        };
2251        list_items.push(ListItem::new(Line::from(vec![Span::styled(
2252            format!("▶ Run All Validations{}", run_all_indicator),
2253            run_all_style,
2254        )])));
2255
2256        // Individual gates
2257        for g in &self.validation_gates {
2258            let status_str = format!("{:?}", g.status);
2259            let timing_str = g
2260                .last_duration
2261                .map(|d| format!(" ({:.1}s)", d.as_secs_f64()))
2262                .unwrap_or_default();
2263            let style = match g.status {
2264                GateStatus::NotRun => theme.gate_not_run(),
2265                GateStatus::Running => theme.gate_running(),
2266                GateStatus::Passed => theme.gate_passed(),
2267                GateStatus::Failed => theme.gate_failed(),
2268                GateStatus::TimedOut => theme.gate_timed_out(),
2269            };
2270            let spinner = if g.status == GateStatus::Running {
2271                self.animated_spinner()
2272            } else {
2273                String::new()
2274            };
2275            let line = Line::from(vec![Span::styled(
2276                format!("{} [{}]{}{spinner}", g.script_name, status_str, timing_str),
2277                style,
2278            )]);
2279            list_items.push(ListItem::new(line));
2280        }
2281
2282        let list = List::new(list_items)
2283            .block(
2284                Block::default()
2285                    .borders(Borders::ALL)
2286                    .title("Validation Gates")
2287                    .border_style(theme.border_style()),
2288            )
2289            .highlight_style(theme.list_selected().add_modifier(Modifier::BOLD))
2290            .highlight_symbol("> ");
2291
2292        frame.render_stateful_widget(list, area, &mut self.nav.list_state);
2293    }
2294
2295    /// Generate an animated spinner character based on elapsed time.
2296    fn animated_spinner(&self) -> String {
2297        let frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
2298        let elapsed = self
2299            .running_gate_start
2300            .map(|s| s.elapsed().as_millis() as usize)
2301            .unwrap_or(0);
2302        let idx = (elapsed / 100) % frames.len();
2303        format!(" {}", frames[idx])
2304    }
2305
2306    fn render_validation_output(
2307        &self,
2308        _name: &str,
2309        area: ratatui::layout::Rect,
2310        frame: &mut Frame,
2311        theme: &Theme,
2312    ) {
2313        output::render_output_scrolled(
2314            &self.subprocess_output,
2315            "Validation Output",
2316            area,
2317            frame,
2318            self.nav.detail_scroll,
2319            theme,
2320        );
2321    }
2322
2323    fn render_export_builder(&self, area: ratatui::layout::Rect, frame: &mut Frame, theme: &Theme) {
2324        use ratatui::text::{Line, Span};
2325        use ratatui::widgets::{Block, Borders, Paragraph, Wrap};
2326
2327        let selection_str = match &self.export_state.selection {
2328            ExportSelection::All => "All".to_string(),
2329            ExportSelection::Role(r) => format!("Role: {r}"),
2330            ExportSelection::Provider(p) => format!("Provider: {p}"),
2331            ExportSelection::Agents(ids) => format!("Agents: {}", ids.join(", ")),
2332        };
2333
2334        let focused = self.export_state.focused_field;
2335        let fields = [
2336            format!("Platform: {}", self.export_state.platform),
2337            format!("Selection: {selection_str}"),
2338            format!("Target Repo: {}", self.export_state.target_repo),
2339            format!("Dry Run: {}", self.export_state.dry_run),
2340            format!("Force: {}", self.export_state.force),
2341            format!("No Skills: {}", self.export_state.no_skills),
2342        ];
2343
2344        let mut lines: Vec<Line> = fields
2345            .iter()
2346            .enumerate()
2347            .map(|(i, f)| {
2348                if i == focused {
2349                    Line::from(Span::styled(format!("> {f}"), theme.list_selected()))
2350                } else {
2351                    Line::from(f.as_str().to_string())
2352                }
2353            })
2354            .collect();
2355
2356        lines.push(Line::from(""));
2357        lines.push(Line::from("[Enter to confirm, Esc to cancel]"));
2358
2359        // Tab completion suggestions
2360        if !self.completion_suggestions.is_empty() {
2361            lines.push(Line::from(""));
2362            lines.push(Line::from(Span::styled(
2363                "Suggestions:",
2364                theme.help_overlay_section(),
2365            )));
2366            for (i, suggestion) in self.completion_suggestions.iter().enumerate() {
2367                let style = if i == self.completion_index {
2368                    theme.completion_highlight()
2369                } else {
2370                    theme.completion_normal()
2371                };
2372                lines.push(Line::from(Span::styled(format!("  {suggestion}"), style)));
2373            }
2374        }
2375
2376        let paragraph = Paragraph::new(lines)
2377            .block(
2378                Block::default()
2379                    .borders(Borders::ALL)
2380                    .title("Export Builder")
2381                    .border_style(theme.border_style()),
2382            )
2383            .wrap(Wrap { trim: false });
2384        frame.render_widget(paragraph, area);
2385    }
2386
2387    fn render_export_confirm(&self, area: ratatui::layout::Rect, frame: &mut Frame, theme: &Theme) {
2388        use ratatui::text::Line;
2389        use ratatui::widgets::{Block, Borders, Paragraph, Wrap};
2390
2391        let target = PathBuf::from(&self.export_state.target_repo);
2392        let cmd = ExportCommand {
2393            platform: self.export_state.platform.clone(),
2394            selection: self.export_state.selection.clone(),
2395            target_repo: target,
2396            dry_run: self.export_state.dry_run,
2397            force: self.export_state.force,
2398            no_skills: self.export_state.no_skills,
2399        };
2400
2401        let command_preview = cmd.display_command();
2402
2403        let lines = vec![
2404            Line::from("Confirm export execution?"),
2405            Line::from(""),
2406            Line::from("Command:"),
2407            Line::from(format!("  {command_preview}")),
2408            Line::from(""),
2409            Line::from(format!("Dry Run: {}", self.export_state.dry_run)),
2410            Line::from(""),
2411            Line::from("[Enter to execute, Esc to cancel]"),
2412        ];
2413        let paragraph = Paragraph::new(lines)
2414            .block(
2415                Block::default()
2416                    .borders(Borders::ALL)
2417                    .title("Confirm Export")
2418                    .border_style(theme.border_style()),
2419            )
2420            .wrap(Wrap { trim: false });
2421        frame.render_widget(paragraph, area);
2422    }
2423
2424    fn render_export_output(&self, area: ratatui::layout::Rect, frame: &mut Frame, theme: &Theme) {
2425        // If dry-run, parse output for tree structure
2426        if self.export_state.dry_run && !self.subprocess_output.is_empty() {
2427            use ratatui::style::Color;
2428            use ratatui::text::{Line, Span};
2429            use ratatui::widgets::{Block, Borders, Paragraph, Wrap};
2430
2431            let mut tree_lines: Vec<Line> = Vec::new();
2432            let mut agent_entries: Vec<String> = Vec::new();
2433            let mut skill_entries: Vec<String> = Vec::new();
2434
2435            for ol in &self.subprocess_output {
2436                let trimmed = ol.content.trim();
2437                if let Some(rest) = trimmed.strip_prefix("export agent:") {
2438                    agent_entries.push(rest.trim().to_string());
2439                } else if let Some(rest) = trimmed.strip_prefix("export skill:") {
2440                    skill_entries.push(rest.trim().to_string());
2441                }
2442            }
2443
2444            if !agent_entries.is_empty() || !skill_entries.is_empty() {
2445                tree_lines.push(Line::from(Span::styled(
2446                    "Dry-Run Export Tree:",
2447                    theme.help_overlay_title(),
2448                )));
2449                tree_lines.push(Line::from(""));
2450
2451                if !agent_entries.is_empty() {
2452                    tree_lines.push(Line::from(Span::styled("├── agents/", theme.detail_key())));
2453                    for (i, entry) in agent_entries.iter().enumerate() {
2454                        let prefix = if i == agent_entries.len() - 1 && skill_entries.is_empty() {
2455                            "│   └── "
2456                        } else {
2457                            "│   ├── "
2458                        };
2459                        let style = if theme.no_color {
2460                            ratatui::style::Style::default()
2461                        } else {
2462                            ratatui::style::Style::default().fg(Color::White)
2463                        };
2464                        tree_lines
2465                            .push(Line::from(Span::styled(format!("{prefix}{entry}"), style)));
2466                    }
2467                }
2468
2469                if !skill_entries.is_empty() {
2470                    tree_lines.push(Line::from(Span::styled("└── skills/", theme.detail_key())));
2471                    for (i, entry) in skill_entries.iter().enumerate() {
2472                        let prefix = if i == skill_entries.len() - 1 {
2473                            "    └── "
2474                        } else {
2475                            "    ├── "
2476                        };
2477                        let style = if theme.no_color {
2478                            ratatui::style::Style::default()
2479                        } else {
2480                            ratatui::style::Style::default().fg(Color::White)
2481                        };
2482                        tree_lines
2483                            .push(Line::from(Span::styled(format!("{prefix}{entry}"), style)));
2484                    }
2485                }
2486
2487                tree_lines.push(Line::from(""));
2488                tree_lines.push(Line::from(format!(
2489                    "Total: {} agents, {} skills",
2490                    agent_entries.len(),
2491                    skill_entries.len()
2492                )));
2493
2494                // Append raw output below the tree
2495                tree_lines.push(Line::from(""));
2496                tree_lines.push(Line::from("─── Raw Output ───"));
2497                for ol in &self.subprocess_output {
2498                    let style = if theme.no_color {
2499                        ratatui::style::Style::default()
2500                    } else {
2501                        match ol.stream {
2502                            crate::subprocess::OutputStream::Stdout => {
2503                                ratatui::style::Style::default().fg(Color::White)
2504                            }
2505                            crate::subprocess::OutputStream::Stderr => {
2506                                ratatui::style::Style::default().fg(Color::Red)
2507                            }
2508                        }
2509                    };
2510                    tree_lines.push(Line::from(Span::styled(ol.content.clone(), style)));
2511                }
2512
2513                let paragraph = Paragraph::new(tree_lines)
2514                    .block(
2515                        Block::default()
2516                            .borders(Borders::ALL)
2517                            .title("Export Output (Dry-Run)")
2518                            .border_style(theme.border_style()),
2519                    )
2520                    .wrap(Wrap { trim: false })
2521                    .scroll((self.nav.detail_scroll, 0));
2522                frame.render_widget(paragraph, area);
2523            } else {
2524                output::render_output_scrolled(
2525                    &self.subprocess_output,
2526                    "Export Output",
2527                    area,
2528                    frame,
2529                    self.nav.detail_scroll,
2530                    theme,
2531                );
2532            }
2533        } else {
2534            output::render_output_scrolled(
2535                &self.subprocess_output,
2536                "Export Output",
2537                area,
2538                frame,
2539                self.nav.detail_scroll,
2540                theme,
2541            );
2542        }
2543    }
2544
2545    fn render_model_policy_builder(
2546        &self,
2547        area: ratatui::layout::Rect,
2548        frame: &mut Frame,
2549        theme: &Theme,
2550    ) {
2551        use ratatui::text::{Line, Span};
2552        use ratatui::widgets::{Block, Borders, Paragraph, Wrap};
2553
2554        let state = &self.model_policy_state;
2555        let harness = state.harness();
2556        let model_display = if state.model.is_empty() {
2557            "(unchanged — type a model name, or \"auto\" to clear)".to_string()
2558        } else {
2559            state.model.clone()
2560        };
2561        let reasoning_display = if harness == "codex" {
2562            MODEL_POLICY_REASONING_CYCLE[state.reasoning_index].to_string()
2563        } else {
2564            "n/a (codex only)".to_string()
2565        };
2566
2567        let focused = state.focused_field;
2568        let fields = [
2569            format!("Scope: {}", state.scope.display()),
2570            format!("Harness: {harness}"),
2571            format!("Model: {model_display}"),
2572            format!("Reasoning: {reasoning_display}"),
2573            format!("Dry Run: {}", state.dry_run),
2574            format!("Refresh Integrity: {}", state.refresh_integrity),
2575            "[ Continue ]".to_string(),
2576        ];
2577
2578        let mut lines: Vec<Line> = fields
2579            .iter()
2580            .enumerate()
2581            .map(|(i, f)| {
2582                if i == focused {
2583                    Line::from(Span::styled(format!("> {f}"), theme.list_selected()))
2584                } else {
2585                    Line::from(f.as_str().to_string())
2586                }
2587            })
2588            .collect();
2589
2590        lines.push(Line::from(""));
2591        lines.push(Line::from(
2592            "Space/Enter cycles the field; type into Scope id and Model.",
2593        ));
2594        lines.push(Line::from(
2595            "\"auto\" removes the field so the harness default applies.",
2596        ));
2597        lines.push(Line::from("[Enter on Continue to preview, Esc to cancel]"));
2598
2599        let paragraph = Paragraph::new(lines)
2600            .block(
2601                Block::default()
2602                    .borders(Borders::ALL)
2603                    .title("Model Policy Builder")
2604                    .border_style(theme.border_style()),
2605            )
2606            .wrap(Wrap { trim: false });
2607        frame.render_widget(paragraph, area);
2608    }
2609
2610    fn render_model_policy_confirm(
2611        &self,
2612        area: ratatui::layout::Rect,
2613        frame: &mut Frame,
2614        theme: &Theme,
2615    ) {
2616        use ratatui::text::Line;
2617        use ratatui::widgets::{Block, Borders, Paragraph, Wrap};
2618
2619        let cmd = self.model_policy_state.command();
2620        let mut lines = vec![
2621            Line::from("Confirm model policy update?"),
2622            Line::from(""),
2623            Line::from("Command:"),
2624            Line::from(format!("  {}", cmd.display_command())),
2625            Line::from(""),
2626            Line::from(format!("Dry Run: {}", self.model_policy_state.dry_run)),
2627            Line::from(format!(
2628                "Refresh integrity manifest after apply: {}",
2629                self.model_policy_state.refresh_integrity
2630            )),
2631        ];
2632        if matches!(self.model_policy_state.scope, ModelScope::All)
2633            && !self.model_policy_state.dry_run
2634        {
2635            lines.push(Line::from(""));
2636            lines.push(Line::from(
2637                "WARNING: scope is ALL agents — this rewrites every capable harness file.",
2638            ));
2639        }
2640        lines.push(Line::from(""));
2641        lines.push(Line::from("[Enter to execute, Esc to cancel]"));
2642
2643        let paragraph = Paragraph::new(lines)
2644            .block(
2645                Block::default()
2646                    .borders(Borders::ALL)
2647                    .title("Confirm Model Policy")
2648                    .border_style(theme.border_style()),
2649            )
2650            .wrap(Wrap { trim: false });
2651        frame.render_widget(paragraph, area);
2652    }
2653
2654    fn render_model_policy_output(
2655        &self,
2656        area: ratatui::layout::Rect,
2657        frame: &mut Frame,
2658        theme: &Theme,
2659    ) {
2660        let title = if self.model_policy_state.dry_run {
2661            "Model Policy Output (Dry-Run)"
2662        } else {
2663            "Model Policy Output"
2664        };
2665        output::render_output_scrolled(
2666            &self.subprocess_output,
2667            title,
2668            area,
2669            frame,
2670            self.nav.detail_scroll,
2671            theme,
2672        );
2673    }
2674
2675    fn render_integrity_view(
2676        &mut self,
2677        area: ratatui::layout::Rect,
2678        frame: &mut Frame,
2679        theme: &Theme,
2680    ) {
2681        let items: Vec<String> = match &self.catalog.integrity {
2682            Some(integrity) => {
2683                let total_files: usize =
2684                    integrity.trees.iter().map(|t| t.files.len()).sum::<usize>()
2685                        + integrity.root_files.len();
2686                let mut entries = vec![format!(
2687                    "Manifest v{} | {} | {} files | SHA: {}...",
2688                    integrity.manifest_version,
2689                    integrity.algorithm,
2690                    total_files,
2691                    &integrity.aggregate_sha256[..8.min(integrity.aggregate_sha256.len())]
2692                )];
2693                for t in &integrity.trees {
2694                    entries.push(format!(
2695                        "  [tree] {} ({} files, SHA: {}...)",
2696                        t.tree,
2697                        t.files.len(),
2698                        &t.aggregate_sha256[..8.min(t.aggregate_sha256.len())]
2699                    ));
2700                }
2701                if !integrity.root_files.is_empty() {
2702                    entries.push(format!(
2703                        "  [root] root_files ({} files)",
2704                        integrity.root_files.len()
2705                    ));
2706                }
2707                entries
2708            }
2709            None => vec!["No integrity data available".to_string()],
2710        };
2711        list_view::render_list_view(
2712            &items,
2713            &mut self.nav.list_state,
2714            "Asset Integrity",
2715            area,
2716            frame,
2717            theme,
2718        );
2719    }
2720
2721    fn render_integrity_detail(
2722        &self,
2723        tree_name: &str,
2724        area: ratatui::layout::Rect,
2725        frame: &mut Frame,
2726        theme: &Theme,
2727    ) {
2728        let lines: Vec<ratatui::text::Line> = if let Some(integrity) = &self.catalog.integrity {
2729            if tree_name == "__root_files__" {
2730                // Requirement 21.5: Display root files with path, SHA-256, and size
2731                let mut l = vec![
2732                    ratatui::text::Line::from("Root Files"),
2733                    ratatui::text::Line::from(format!("Files: {}", integrity.root_files.len())),
2734                    ratatui::text::Line::from(""),
2735                ];
2736                for f in &integrity.root_files {
2737                    l.push(ratatui::text::Line::from(format!("  {}", f.path)));
2738                    l.push(ratatui::text::Line::from(format!(
2739                        "    SHA-256: {}",
2740                        f.sha256
2741                    )));
2742                    l.push(ratatui::text::Line::from(format!(
2743                        "    Size: {} bytes",
2744                        f.bytes
2745                    )));
2746                }
2747                l
2748            } else if let Some(tree) = integrity.trees.iter().find(|t| t.tree == tree_name) {
2749                // Requirement 21.3, 21.4: Display tree files with path, SHA-256, size,
2750                // and the parent tree's aggregate SHA-256 hash
2751                let mut l = vec![
2752                    ratatui::text::Line::from(format!("Tree: {}", tree.tree)),
2753                    ratatui::text::Line::from(format!(
2754                        "Aggregate SHA-256: {}",
2755                        tree.aggregate_sha256
2756                    )),
2757                    ratatui::text::Line::from(format!("Files: {}", tree.files.len())),
2758                    ratatui::text::Line::from(""),
2759                ];
2760                for f in &tree.files {
2761                    l.push(ratatui::text::Line::from(format!("  {}", f.path)));
2762                    l.push(ratatui::text::Line::from(format!(
2763                        "    SHA-256: {}",
2764                        f.sha256
2765                    )));
2766                    l.push(ratatui::text::Line::from(format!(
2767                        "    Size: {} bytes",
2768                        f.bytes
2769                    )));
2770                }
2771                l
2772            } else {
2773                vec![ratatui::text::Line::from("Tree not found")]
2774            }
2775        } else {
2776            vec![ratatui::text::Line::from("No integrity data")]
2777        };
2778        let paragraph = ratatui::widgets::Paragraph::new(lines)
2779            .block(
2780                ratatui::widgets::Block::default()
2781                    .borders(ratatui::widgets::Borders::ALL)
2782                    .title("Integrity Detail")
2783                    .border_style(theme.border_style()),
2784            )
2785            .wrap(ratatui::widgets::Wrap { trim: false })
2786            .scroll((self.nav.detail_scroll, 0));
2787        frame.render_widget(paragraph, area);
2788    }
2789
2790    /// Update filtered indices based on current view and search query.
2791    fn update_filtered(&mut self) {
2792        if self.nav.current_view == View::AgentList {
2793            self.filtered_indices = self.search_engine.search_agents(
2794                &self.search_query,
2795                &self.catalog.agents,
2796                self.provider_filter.as_deref(),
2797                self.harness_filter.as_deref(),
2798            );
2799        }
2800    }
2801
2802    /// Cycle through provider filter values (None -> first provider -> second -> ... -> None).
2803    fn cycle_provider_filter(&mut self) {
2804        let providers = self.catalog.provider_names();
2805        if providers.is_empty() {
2806            return;
2807        }
2808        self.provider_filter = match &self.provider_filter {
2809            None => Some(providers[0].clone()),
2810            Some(current) => {
2811                let idx = providers.iter().position(|p| p == current);
2812                match idx {
2813                    Some(i) if i + 1 < providers.len() => Some(providers[i + 1].clone()),
2814                    _ => None,
2815                }
2816            }
2817        };
2818        self.update_filtered();
2819    }
2820
2821    /// Cycle through harness filter values (None -> first harness -> second -> ... -> None).
2822    fn cycle_harness_filter(&mut self) {
2823        let harnesses = self.catalog.harness_names();
2824        if harnesses.is_empty() {
2825            return;
2826        }
2827        self.harness_filter = match &self.harness_filter {
2828            None => Some(harnesses[0].clone()),
2829            Some(current) => {
2830                let idx = harnesses.iter().position(|h| h == current);
2831                match idx {
2832                    Some(i) if i + 1 < harnesses.len() => Some(harnesses[i + 1].clone()),
2833                    _ => None,
2834                }
2835            }
2836        };
2837        self.update_filtered();
2838    }
2839
2840    /// Render filter chips showing active filters.
2841    fn render_filter_chips(&self, area: ratatui::layout::Rect, frame: &mut Frame, theme: &Theme) {
2842        use ratatui::text::{Line, Span};
2843        use ratatui::widgets::Paragraph;
2844
2845        let mut spans: Vec<Span> = Vec::new();
2846        if let Some(ref pf) = self.provider_filter {
2847            spans.push(Span::styled(
2848                format!(" [provider:{pf}] "),
2849                theme.filter_chip(),
2850            ));
2851            spans.push(Span::raw(" "));
2852        }
2853        if let Some(ref hf) = self.harness_filter {
2854            spans.push(Span::styled(
2855                format!(" [harness:{hf}] "),
2856                theme.filter_chip(),
2857            ));
2858            spans.push(Span::raw(" "));
2859        }
2860        if !self.search_query.is_empty() {
2861            spans.push(Span::styled(
2862                format!(" [query:\"{}\"] ", self.search_query),
2863                theme.filter_chip(),
2864            ));
2865        }
2866
2867        let line = Line::from(spans);
2868        let paragraph = Paragraph::new(vec![line]);
2869        frame.render_widget(paragraph, area);
2870    }
2871
2872    /// Render the full-screen help overlay showing all keybindings.
2873    fn render_help_overlay(&self, frame: &mut Frame, theme: &Theme) {
2874        use ratatui::layout::Rect;
2875        use ratatui::text::{Line, Span};
2876        use ratatui::widgets::{Block, Borders, Clear, Paragraph, Wrap};
2877
2878        let area = frame.area();
2879        // Center the overlay with some padding
2880        let overlay = Rect {
2881            x: area.x + 2,
2882            y: area.y + 1,
2883            width: area.width.saturating_sub(4),
2884            height: area.height.saturating_sub(2),
2885        };
2886
2887        frame.render_widget(Clear, overlay);
2888
2889        let lines = vec![
2890            Line::from(Span::styled(
2891                "Keyboard Shortcuts",
2892                theme.help_overlay_title(),
2893            )),
2894            Line::from(""),
2895            Line::from(Span::styled("Navigation", theme.help_overlay_section())),
2896            Line::from("  j / ↓         Move down"),
2897            Line::from("  k / ↑         Move up"),
2898            Line::from("  g             Jump to top"),
2899            Line::from("  G             Jump to bottom"),
2900            Line::from("  Enter         Select / drill in"),
2901            Line::from("  Esc           Back / clear filters"),
2902            Line::from("  Tab           Next section"),
2903            Line::from("  Shift+Tab     Previous section"),
2904            Line::from(""),
2905            Line::from(Span::styled(
2906                "Search & Filter",
2907                theme.help_overlay_section(),
2908            )),
2909            Line::from("  /             Activate search"),
2910            Line::from("  p             Cycle provider filter (agent list)"),
2911            Line::from("  h             Cycle harness filter (agent list)"),
2912            Line::from(""),
2913            Line::from(Span::styled("Model Policy", theme.help_overlay_section())),
2914            Line::from("  m             Assign models/reasoning (scope from current view)"),
2915            Line::from("  j/k           Move between fields"),
2916            Line::from("  Space/Enter   Cycle or toggle the focused field"),
2917            Line::from(""),
2918            Line::from(Span::styled("General", theme.help_overlay_section())),
2919            Line::from("  ?             Toggle this help overlay"),
2920            Line::from("  t             Toggle light / dark theme"),
2921            Line::from("  q             Quit"),
2922            Line::from("  Ctrl+C        Quit"),
2923            Line::from(""),
2924            Line::from(Span::styled("Export Builder", theme.help_overlay_section())),
2925            Line::from("  j/k           Move between fields"),
2926            Line::from("  Enter         Confirm export"),
2927            Line::from("  Esc           Cancel"),
2928            Line::from(""),
2929            Line::from(Span::styled("Press ? or Esc to close", theme.help_bar())),
2930        ];
2931
2932        let paragraph = Paragraph::new(lines)
2933            .block(
2934                Block::default()
2935                    .borders(Borders::ALL)
2936                    .title("Help")
2937                    .border_style(theme.help_overlay_title()),
2938            )
2939            .wrap(Wrap { trim: false });
2940
2941        frame.render_widget(paragraph, overlay);
2942    }
2943
2944    /// Update tab completion suggestions based on current export builder field.
2945    pub fn update_completion_suggestions(&mut self) {
2946        self.completion_suggestions.clear();
2947        self.completion_index = 0;
2948
2949        if self.nav.current_view != View::ExportBuilder {
2950            return;
2951        }
2952
2953        match self.export_state.focused_field {
2954            0 => {
2955                // Platform field
2956                let platforms = self.catalog.platform_names();
2957                self.completion_suggestions = platforms
2958                    .iter()
2959                    .filter(|p| p.starts_with(&self.export_state.platform))
2960                    .map(|p| p.to_string())
2961                    .collect();
2962            }
2963            1 => {
2964                // Selection field — show roles
2965                let roles = self.catalog.role_ids();
2966                self.completion_suggestions = roles;
2967            }
2968            _ => {}
2969        }
2970    }
2971
2972    /// Get the length of the current list for navigation.
2973    fn current_list_len(&self) -> usize {
2974        match &self.nav.current_view {
2975            View::AgentList => self.filtered_indices.len(),
2976            View::SkillList => self.catalog.skills.len(),
2977            View::RoleList => self.catalog.roles.len(),
2978            View::ProviderList => self.get_provider_list().len(),
2979            View::McpList => self.catalog.mcp_refs.len(),
2980            View::RuleList => self.catalog.rules.len(),
2981            View::WorkflowList => self.workflows().len(),
2982            View::ValidationList => self.validation_gates.len() + 1, // +1 for "Run All"
2983            View::ProviderAgents(p) => self.catalog.agents_by_provider(p).len(),
2984            View::IntegrityOverview => self
2985                .catalog
2986                .integrity
2987                .as_ref()
2988                .map(|i| {
2989                    // 1 summary + trees + optional root_files entry
2990                    1 + i.trees.len() + if i.root_files.is_empty() { 0 } else { 1 }
2991                })
2992                .unwrap_or(0),
2993            _ => 0,
2994        }
2995    }
2996
2997    fn get_counts(&self) -> (usize, usize) {
2998        match &self.nav.current_view {
2999            View::AgentList => (self.filtered_indices.len(), self.catalog.agents.len()),
3000            View::SkillList => (self.catalog.skills.len(), self.catalog.skills.len()),
3001            View::McpList => (self.catalog.mcp_refs.len(), self.catalog.mcp_refs.len()),
3002            View::RuleList => (self.catalog.rules.len(), self.catalog.rules.len()),
3003            View::WorkflowList => (self.workflows().len(), self.workflows().len()),
3004            _ => (0, 0),
3005        }
3006    }
3007
3008    /// Get provider list with counts.
3009    pub fn get_provider_list(&self) -> Vec<(String, usize)> {
3010        let mut providers: HashSet<String> = HashSet::new();
3011        for agent in &self.catalog.agents {
3012            let p = serde_json::to_value(agent.provider)
3013                .ok()
3014                .and_then(|v| v.as_str().map(|s| s.to_string()))
3015                .unwrap_or_else(|| format!("{:?}", agent.provider));
3016            providers.insert(p);
3017        }
3018        let mut list: Vec<(String, usize)> = providers
3019            .into_iter()
3020            .map(|p| {
3021                let count = self.catalog.agents_by_provider(&p).len();
3022                (p, count)
3023            })
3024            .collect();
3025        list.sort_by_key(|a| a.0.clone());
3026        list
3027    }
3028
3029    /// Get role list with labels and agent counts.
3030    pub fn get_role_list(&self) -> Vec<(String, String, usize)> {
3031        let mut list: Vec<(String, String, usize)> = self
3032            .catalog
3033            .roles
3034            .iter()
3035            .map(|(id, role)| (id.clone(), role.label.clone(), role.agents.len()))
3036            .collect();
3037        list.sort_by_key(|a| a.0.clone());
3038        list
3039    }
3040}
3041
3042#[cfg(test)]
3043mod tests {
3044    use super::*;
3045    use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers};
3046    use std::path::Path;
3047
3048    fn workspace_root() -> PathBuf {
3049        let manifest_dir = env!("CARGO_MANIFEST_DIR");
3050        Path::new(manifest_dir)
3051            .parent()
3052            .unwrap()
3053            .parent()
3054            .unwrap()
3055            .to_path_buf()
3056    }
3057
3058    fn make_app() -> App {
3059        let ws = workspace_root();
3060        let catalog = CatalogStore::load(&ws);
3061        App::new(catalog, ws, Uuid::new_v4(), true)
3062    }
3063
3064    fn key_event(code: KeyCode) -> KeyEvent {
3065        KeyEvent {
3066            code,
3067            modifiers: KeyModifiers::NONE,
3068            kind: KeyEventKind::Press,
3069            state: KeyEventState::NONE,
3070        }
3071    }
3072
3073    #[test]
3074    fn app_new_starts_at_agent_list() {
3075        let app = make_app();
3076        assert_eq!(app.nav.current_view, View::AgentList);
3077        assert!(!app.should_quit);
3078        assert!(!app.filtered_indices.is_empty());
3079    }
3080
3081    #[test]
3082    fn app_quit_on_q() {
3083        let mut app = make_app();
3084        app.handle_key_event(key_event(KeyCode::Char('q')));
3085        assert!(app.should_quit);
3086    }
3087
3088    #[test]
3089    fn app_quit_on_ctrl_c() {
3090        let mut app = make_app();
3091        let key = KeyEvent {
3092            code: KeyCode::Char('c'),
3093            modifiers: KeyModifiers::CONTROL,
3094            kind: KeyEventKind::Press,
3095            state: KeyEventState::NONE,
3096        };
3097        app.handle_key_event(key);
3098        assert!(app.should_quit);
3099    }
3100
3101    #[test]
3102    fn app_t_toggles_theme_mode() {
3103        // Req 35.6 / Task 15.3: `t` toggles the session theme mode and marks
3104        // the UI dirty so it re-renders with the new palette.
3105        let mut app = make_app();
3106        assert_eq!(app.theme_mode, ThemeMode::Dark);
3107        app.dirty = false;
3108
3109        app.handle_key_event(key_event(KeyCode::Char('t')));
3110        assert_eq!(app.theme_mode, ThemeMode::Light);
3111        assert!(app.dirty, "theme toggle must set dirty flag");
3112
3113        app.handle_key_event(key_event(KeyCode::Char('t')));
3114        assert_eq!(app.theme_mode, ThemeMode::Dark);
3115    }
3116
3117    #[test]
3118    fn app_t_does_not_toggle_theme_in_search_mode() {
3119        // In search mode, `t` is literal search input, not a theme toggle.
3120        let mut app = make_app();
3121        app.handle_key_event(key_event(KeyCode::Char('/')));
3122        assert!(app.search_active);
3123        app.handle_key_event(key_event(KeyCode::Char('t')));
3124        assert_eq!(app.theme_mode, ThemeMode::Dark);
3125        assert_eq!(app.search_query, "t");
3126    }
3127
3128    #[test]
3129    fn app_tab_advances_current_tab() {
3130        // v2: Tab key cycles the operator-console tab bar, not the sidebar.
3131        let mut app = make_app();
3132        let initial_tab = app.nav.current_tab.clone();
3133        app.handle_key_event(key_event(KeyCode::Tab));
3134        // current_tab must have advanced by one step.
3135        assert_ne!(
3136            app.nav.current_tab, initial_tab,
3137            "Tab should advance the active operator-console tab"
3138        );
3139        assert!(app.dirty, "Tab must set dirty flag");
3140    }
3141
3142    #[test]
3143    fn sync_view_to_tab_aligns_legacy_view() {
3144        use crate::ui::nav::Tab;
3145        let mut app = make_app();
3146        // Arriving at the Gates tab from a non-catalog view must retarget key
3147        // dispatch to the validation list (not whatever legacy view was active).
3148        app.nav.current_view = View::AgentList;
3149        app.nav.current_tab = Tab::ValidationGates;
3150        app.sync_view_to_tab();
3151        assert_eq!(app.nav.current_view, View::ValidationList);
3152
3153        // CatalogBrowser lands on a catalog list view.
3154        app.nav.current_view = View::ValidationList;
3155        app.nav.current_tab = Tab::CatalogBrowser;
3156        app.sync_view_to_tab();
3157        assert_eq!(app.nav.current_view, View::AgentList);
3158    }
3159
3160    #[test]
3161    fn app_backtab_retreats_current_tab() {
3162        // v2: BackTab cycles the operator-console tab bar backwards.
3163        let mut app = make_app();
3164        // Advance to tab 1 first so we can retreat back.
3165        app.nav.next_tab();
3166        let tab_at_1 = app.nav.current_tab.clone();
3167        let key = KeyEvent {
3168            code: KeyCode::BackTab,
3169            modifiers: KeyModifiers::NONE,
3170            kind: KeyEventKind::Press,
3171            state: KeyEventState::NONE,
3172        };
3173        app.handle_key_event(key);
3174        assert_ne!(
3175            app.nav.current_tab, tab_at_1,
3176            "BackTab should retreat the active operator-console tab"
3177        );
3178        assert!(app.dirty, "BackTab must set dirty flag");
3179    }
3180
3181    #[test]
3182    fn app_j_navigates_down() {
3183        let mut app = make_app();
3184        assert_eq!(app.nav.selected_index(), 0);
3185        app.handle_key_event(key_event(KeyCode::Char('j')));
3186        assert_eq!(app.nav.selected_index(), 1);
3187    }
3188
3189    #[test]
3190    fn app_k_navigates_up() {
3191        let mut app = make_app();
3192        app.handle_key_event(key_event(KeyCode::Char('j')));
3193        app.handle_key_event(key_event(KeyCode::Char('k')));
3194        assert_eq!(app.nav.selected_index(), 0);
3195    }
3196
3197    #[test]
3198    fn app_enter_drills_into_agent_detail() {
3199        let mut app = make_app();
3200        app.handle_key_event(key_event(KeyCode::Enter));
3201        assert!(matches!(app.nav.current_view, View::AgentDetail(_)));
3202    }
3203
3204    #[test]
3205    fn app_escape_goes_back() {
3206        let mut app = make_app();
3207        app.handle_key_event(key_event(KeyCode::Enter));
3208        assert!(matches!(app.nav.current_view, View::AgentDetail(_)));
3209        app.handle_key_event(key_event(KeyCode::Esc));
3210        assert_eq!(app.nav.current_view, View::AgentList);
3211    }
3212
3213    #[test]
3214    fn app_search_activation() {
3215        let mut app = make_app();
3216        app.handle_key_event(key_event(KeyCode::Char('/')));
3217        assert!(app.search_active);
3218        assert!(app.search_query.is_empty());
3219    }
3220
3221    #[test]
3222    fn app_search_typing() {
3223        let mut app = make_app();
3224        app.handle_key_event(key_event(KeyCode::Char('/')));
3225        app.handle_key_event(key_event(KeyCode::Char('a')));
3226        app.handle_key_event(key_event(KeyCode::Char('w')));
3227        app.handle_key_event(key_event(KeyCode::Char('s')));
3228        assert_eq!(app.search_query, "aws");
3229    }
3230
3231    #[test]
3232    fn app_search_backspace() {
3233        let mut app = make_app();
3234        app.handle_key_event(key_event(KeyCode::Char('/')));
3235        app.handle_key_event(key_event(KeyCode::Char('x')));
3236        app.handle_key_event(key_event(KeyCode::Backspace));
3237        assert!(app.search_query.is_empty());
3238    }
3239
3240    #[test]
3241    fn app_search_escape_deactivates() {
3242        let mut app = make_app();
3243        app.handle_key_event(key_event(KeyCode::Char('/')));
3244        app.handle_key_event(key_event(KeyCode::Esc));
3245        assert!(!app.search_active);
3246    }
3247
3248    #[test]
3249    fn app_g_goes_to_top() {
3250        let mut app = make_app();
3251        app.handle_key_event(key_event(KeyCode::Char('j')));
3252        app.handle_key_event(key_event(KeyCode::Char('j')));
3253        app.handle_key_event(key_event(KeyCode::Char('g')));
3254        assert_eq!(app.nav.selected_index(), 0);
3255    }
3256
3257    #[test]
3258    fn app_big_g_goes_to_bottom() {
3259        let mut app = make_app();
3260        app.handle_key_event(key_event(KeyCode::Char('G')));
3261        let max = app.current_list_len();
3262        assert_eq!(app.nav.selected_index(), max - 1);
3263    }
3264
3265    #[test]
3266    fn app_empty_catalog_does_not_panic() {
3267        let catalog = CatalogStore {
3268            agents: Vec::new(),
3269            skills: Vec::new(),
3270            roles: std::collections::HashMap::new(),
3271            role_catalog_version: String::new(),
3272            role_catalog_description: String::new(),
3273            mcp_refs: Vec::new(),
3274            rules: Vec::new(),
3275            integrity: None,
3276            model_assignments: None,
3277            workflows: None,
3278            load_errors: Vec::new(),
3279            content_hashes: std::collections::HashMap::new(),
3280            catalog_root: std::path::PathBuf::from("."),
3281        };
3282        let app = App::new(catalog, PathBuf::from("/tmp"), Uuid::new_v4(), true);
3283        assert!(app.filtered_indices.is_empty());
3284        assert!(app.provider_filter.is_none());
3285        assert!(app.harness_filter.is_none());
3286        assert!(app.running_gate.is_none());
3287        assert!(app.running_gate_start.is_none());
3288        assert!(!app.show_help_overlay);
3289    }
3290
3291    #[test]
3292    fn app_tick_clears_old_messages() {
3293        let mut app = make_app();
3294        app.status_message = Some((
3295            "test".to_string(),
3296            Instant::now() - std::time::Duration::from_secs(20),
3297        ));
3298        app.tick();
3299        assert!(app.status_message.is_none());
3300    }
3301
3302    #[test]
3303    fn app_tick_keeps_recent_messages() {
3304        let mut app = make_app();
3305        app.status_message = Some(("test".to_string(), Instant::now()));
3306        app.tick();
3307        assert!(app.status_message.is_some());
3308    }
3309
3310    #[test]
3311    fn app_get_provider_list_non_empty() {
3312        let app = make_app();
3313        let providers = app.get_provider_list();
3314        assert!(!providers.is_empty());
3315    }
3316
3317    #[test]
3318    fn app_get_role_list_non_empty() {
3319        let app = make_app();
3320        let roles = app.get_role_list();
3321        assert!(!roles.is_empty());
3322    }
3323
3324    #[test]
3325    fn app_subprocess_output_capped_at_max() {
3326        let mut app = make_app();
3327        // Fill beyond the limit
3328        for i in 0..(MAX_SUBPROCESS_OUTPUT_LINES + 500) {
3329            app.subprocess_output.push(output::OutputLine {
3330                content: format!("line {i}"),
3331                stream: crate::subprocess::OutputStream::Stdout,
3332            });
3333        }
3334        app.tick();
3335        assert_eq!(app.subprocess_output.len(), MAX_SUBPROCESS_OUTPUT_LINES);
3336        // Verify oldest lines were removed (first line should be "line 500")
3337        assert_eq!(app.subprocess_output[0].content, "line 500");
3338    }
3339
3340    #[test]
3341    fn app_search_query_length_capped() {
3342        let mut app = make_app();
3343        app.handle_key_event(key_event(KeyCode::Char('/')));
3344        // Type more than MAX_SEARCH_QUERY_LEN characters
3345        for _ in 0..(MAX_SEARCH_QUERY_LEN + 50) {
3346            app.handle_key_event(key_event(KeyCode::Char('x')));
3347        }
3348        assert_eq!(app.search_query.len(), MAX_SEARCH_QUERY_LEN);
3349    }
3350
3351    #[test]
3352    fn export_builder_defaults_to_dry_run() {
3353        let app = make_app();
3354        assert!(app.export_state.dry_run);
3355    }
3356
3357    #[test]
3358    fn export_builder_field_navigation() {
3359        let mut app = make_app();
3360        // Navigate to Export section
3361        app.nav.set_sidebar_index(7); // Export is index 7
3362        assert_eq!(app.nav.current_view, View::ExportBuilder);
3363        assert_eq!(app.export_state.focused_field, 0);
3364
3365        // Navigate down
3366        app.handle_key_event(key_event(KeyCode::Char('j')));
3367        assert_eq!(app.export_state.focused_field, 1);
3368
3369        app.handle_key_event(key_event(KeyCode::Char('j')));
3370        assert_eq!(app.export_state.focused_field, 2);
3371
3372        // Navigate up
3373        app.handle_key_event(key_event(KeyCode::Char('k')));
3374        assert_eq!(app.export_state.focused_field, 1);
3375    }
3376
3377    #[test]
3378    fn export_builder_toggle_dry_run() {
3379        let mut app = make_app();
3380        app.nav.set_sidebar_index(7);
3381        // Navigate to dry_run field (index 3)
3382        app.export_state.focused_field = 3;
3383        assert!(app.export_state.dry_run);
3384
3385        // Toggle with space
3386        app.handle_key_event(key_event(KeyCode::Char(' ')));
3387        assert!(!app.export_state.dry_run);
3388
3389        // Toggle back
3390        app.handle_key_event(key_event(KeyCode::Char(' ')));
3391        assert!(app.export_state.dry_run);
3392    }
3393
3394    #[test]
3395    fn export_builder_toggle_force() {
3396        let mut app = make_app();
3397        app.nav.set_sidebar_index(7);
3398        app.export_state.focused_field = 4;
3399        assert!(!app.export_state.force);
3400
3401        app.handle_key_event(key_event(KeyCode::Char(' ')));
3402        assert!(app.export_state.force);
3403    }
3404
3405    #[test]
3406    fn export_builder_toggle_no_skills() {
3407        let mut app = make_app();
3408        app.nav.set_sidebar_index(7);
3409        app.export_state.focused_field = 5;
3410        assert!(!app.export_state.no_skills);
3411
3412        app.handle_key_event(key_event(KeyCode::Char(' ')));
3413        assert!(app.export_state.no_skills);
3414    }
3415
3416    #[test]
3417    fn export_builder_type_platform() {
3418        let mut app = make_app();
3419        app.nav.set_sidebar_index(7);
3420        app.export_state.focused_field = 0;
3421        app.export_state.platform.clear();
3422
3423        app.handle_key_event(key_event(KeyCode::Char('c')));
3424        app.handle_key_event(key_event(KeyCode::Char('u')));
3425        app.handle_key_event(key_event(KeyCode::Char('r')));
3426        assert_eq!(app.export_state.platform, "cur");
3427
3428        app.handle_key_event(key_event(KeyCode::Backspace));
3429        assert_eq!(app.export_state.platform, "cu");
3430    }
3431
3432    #[test]
3433    fn export_builder_type_target_repo() {
3434        let mut app = make_app();
3435        app.nav.set_sidebar_index(7);
3436        app.export_state.focused_field = 2;
3437
3438        app.handle_key_event(key_event(KeyCode::Char('/')));
3439        app.handle_key_event(key_event(KeyCode::Char('t')));
3440        app.handle_key_event(key_event(KeyCode::Char('m')));
3441        app.handle_key_event(key_event(KeyCode::Char('p')));
3442        assert_eq!(app.export_state.target_repo, "/tmp");
3443    }
3444
3445    #[test]
3446    fn export_builder_cycle_selection() {
3447        let mut app = make_app();
3448        app.nav.set_sidebar_index(7);
3449        app.export_state.focused_field = 1;
3450
3451        // Start at All
3452        assert_eq!(app.export_state.selection, ExportSelection::All);
3453
3454        // Enter cycles to Role
3455        app.handle_key_event(key_event(KeyCode::Enter));
3456        assert!(matches!(
3457            app.export_state.selection,
3458            ExportSelection::Role(_)
3459        ));
3460    }
3461
3462    #[test]
3463    fn export_builder_enter_validates_empty_platform() {
3464        let mut app = make_app();
3465        app.nav.set_sidebar_index(7);
3466        app.export_state.focused_field = 0;
3467        app.export_state.platform.clear();
3468        app.export_state.target_repo = "/tmp".to_string();
3469
3470        app.handle_key_event(key_event(KeyCode::Enter));
3471        // Should show error about platform being required
3472        assert!(app.status_message.is_some());
3473        assert!(app.status_message.as_ref().unwrap().0.contains("Platform"));
3474    }
3475
3476    #[test]
3477    fn export_builder_enter_validates_empty_target() {
3478        let mut app = make_app();
3479        app.nav.set_sidebar_index(7);
3480        app.export_state.focused_field = 0;
3481        app.export_state.platform = "kiro".to_string();
3482        app.export_state.target_repo.clear();
3483
3484        app.handle_key_event(key_event(KeyCode::Enter));
3485        // Should show error about target repo being required
3486        assert!(app.status_message.is_some());
3487        assert!(app
3488            .status_message
3489            .as_ref()
3490            .unwrap()
3491            .0
3492            .contains("Target repo"));
3493    }
3494
3495    #[test]
3496    fn export_builder_enter_validates_nonexistent_path() {
3497        let mut app = make_app();
3498        app.nav.set_sidebar_index(7);
3499        app.export_state.focused_field = 0;
3500        app.export_state.platform = "kiro".to_string();
3501        app.export_state.target_repo = "/nonexistent/path/xyz123".to_string();
3502
3503        app.handle_key_event(key_event(KeyCode::Enter));
3504        // Should show error about path not existing
3505        assert!(app.status_message.is_some());
3506        assert!(app
3507            .status_message
3508            .as_ref()
3509            .unwrap()
3510            .0
3511            .contains("does not exist"));
3512    }
3513
3514    #[test]
3515    fn export_builder_enter_navigates_to_confirm_with_valid_path() {
3516        let mut app = make_app();
3517        app.nav.set_sidebar_index(7);
3518        app.export_state.focused_field = 0;
3519        app.export_state.platform = "kiro".to_string();
3520        app.export_state.target_repo = "/tmp".to_string();
3521
3522        app.handle_key_event(key_event(KeyCode::Enter));
3523        // Should navigate to ExportConfirm
3524        assert_eq!(app.nav.current_view, View::ExportConfirm);
3525    }
3526
3527    #[test]
3528    fn export_confirm_esc_goes_back_to_builder() {
3529        let mut app = make_app();
3530        app.nav.set_sidebar_index(7);
3531        app.export_state.platform = "kiro".to_string();
3532        app.export_state.target_repo = "/tmp".to_string();
3533        app.export_state.focused_field = 0;
3534        app.handle_key_event(key_event(KeyCode::Enter));
3535        assert_eq!(app.nav.current_view, View::ExportConfirm);
3536
3537        // Esc should go back to builder, preserving selections
3538        app.handle_key_event(key_event(KeyCode::Esc));
3539        assert_eq!(app.nav.current_view, View::ExportBuilder);
3540        assert_eq!(app.export_state.platform, "kiro");
3541        assert_eq!(app.export_state.target_repo, "/tmp");
3542    }
3543
3544    #[test]
3545    fn export_builder_esc_goes_back() {
3546        let mut app = make_app();
3547        app.nav.set_sidebar_index(7);
3548        assert_eq!(app.nav.current_view, View::ExportBuilder);
3549
3550        app.handle_key_event(key_event(KeyCode::Esc));
3551        // Should quit since there's no history
3552        assert!(app.should_quit);
3553    }
3554
3555    #[test]
3556    fn export_builder_field_stops_at_boundary() {
3557        let mut app = make_app();
3558        app.nav.set_sidebar_index(7);
3559        app.export_state.focused_field = 5; // last field
3560
3561        app.handle_key_event(key_event(KeyCode::Char('j')));
3562        assert_eq!(app.export_state.focused_field, 5); // stays at boundary
3563
3564        app.export_state.focused_field = 0;
3565        app.handle_key_event(key_event(KeyCode::Char('k')));
3566        assert_eq!(app.export_state.focused_field, 0); // stays at 0
3567    }
3568
3569    #[test]
3570    fn m_opens_model_policy_builder_with_agent_scope_from_list() {
3571        let mut app = make_app();
3572        assert_eq!(app.nav.current_view, View::AgentList);
3573        app.handle_key_event(key_event(KeyCode::Char('m')));
3574        assert_eq!(app.nav.current_view, View::ModelPolicyBuilder);
3575        // The highlighted agent (first in the filtered list) becomes the scope.
3576        match &app.model_policy_state.scope {
3577            crate::models::model_policy::ModelScope::Agent(id) => {
3578                assert!(!id.is_empty());
3579            }
3580            other => panic!("expected agent scope, got {other:?}"),
3581        }
3582        // Safe defaults: dry-run on, integrity refresh on.
3583        assert!(app.model_policy_state.dry_run);
3584        assert!(app.model_policy_state.refresh_integrity);
3585    }
3586
3587    #[test]
3588    fn m_refused_while_gate_running() {
3589        let mut app = make_app();
3590        assert_eq!(app.nav.current_view, View::AgentList);
3591        // Simulate a validation gate in flight.
3592        app.running_gate = Some("validate".to_string());
3593        app.handle_key_event(key_event(KeyCode::Char('m')));
3594        // The builder must not open over a running gate.
3595        assert_eq!(app.nav.current_view, View::AgentList);
3596        assert!(app.status_message.is_some());
3597    }
3598
3599    #[test]
3600    fn m_refused_while_subprocess_pending() {
3601        let mut app = make_app();
3602        let (_tx, rx) = tokio::sync::oneshot::channel();
3603        app.pending_subprocess = Some(rx);
3604        app.handle_key_event(key_event(KeyCode::Char('m')));
3605        assert_eq!(app.nav.current_view, View::AgentList);
3606        assert!(app.subprocess_busy());
3607    }
3608
3609    #[test]
3610    fn execute_model_policy_refused_while_busy() {
3611        let mut app = make_app();
3612        // Open the builder cleanly, then a gate starts before the operator
3613        // confirms — execution must refuse rather than clobber the gate handle.
3614        app.handle_key_event(key_event(KeyCode::Char('m')));
3615        app.model_policy_state.model = "auto".to_string();
3616        app.model_policy_state.focused_field = App::MODEL_POLICY_FIELD_COUNT - 1;
3617        app.handle_key_event(key_event(KeyCode::Enter));
3618        assert_eq!(app.nav.current_view, View::ModelPolicyConfirm);
3619        app.running_gate = Some("validate".to_string());
3620        app.handle_key_event(key_event(KeyCode::Enter));
3621        // Still on confirm; no model-policy stage was started.
3622        assert_eq!(app.nav.current_view, View::ModelPolicyConfirm);
3623        assert!(app.model_policy_stage.is_none());
3624    }
3625
3626    // -----------------------------------------------------------------------
3627    // SEC-6 regression tests: subprocess_busy() must gate every launch path
3628    // (not just execute_model_policy) and every cancel/quit branch in the
3629    // model-policy output view (not just subprocess_handle.is_some()).
3630    // -----------------------------------------------------------------------
3631
3632    #[test]
3633    fn execute_export_refused_while_model_policy_stage_pending() {
3634        let mut app = make_app();
3635        app.nav.set_sidebar_index(7); // Export
3636        app.export_state.platform = "kiro".to_string();
3637        app.export_state.target_repo = "/tmp".to_string();
3638        app.export_state.focused_field = 0;
3639        app.handle_key_event(key_event(KeyCode::Enter));
3640        assert_eq!(app.nav.current_view, View::ExportConfirm);
3641
3642        // A model-policy operation is mid-flight (the pending_subprocess
3643        // handoff window, or the subprocess itself) when the operator tries
3644        // to fire off an export from a different tab.
3645        app.model_policy_stage = Some(ModelPolicyStage::Apply);
3646        app.handle_key_event(key_event(KeyCode::Enter));
3647
3648        // Still on confirm; no export subprocess was spawned to clobber the
3649        // in-flight model-policy operation's pending_subprocess/subprocess_handle.
3650        assert_eq!(app.nav.current_view, View::ExportConfirm);
3651        assert!(app.pending_subprocess.is_none());
3652        assert!(app.status_message.is_some());
3653    }
3654
3655    #[test]
3656    fn validation_gate_refused_while_pending_subprocess_set() {
3657        let mut app = make_app();
3658        let (_tx, rx) = tokio::sync::oneshot::channel();
3659        app.pending_subprocess = Some(rx);
3660        app.nav.set_sidebar_index(6); // Validation
3661        assert_eq!(app.nav.current_view, View::ValidationList);
3662
3663        // "Run All Validations" — running_gate is still None at this point
3664        // (only pending_subprocess/model_policy_stage signal busy), so the
3665        // pre-existing `running_gate.is_some()` check alone would have let
3666        // this through and clobbered the pending handle.
3667        app.handle_validation_enter(0);
3668
3669        assert!(app.running_gate.is_none());
3670        assert!(app.status_message.is_some());
3671        // The original pending_subprocess must not have been overwritten by
3672        // a second spawn.
3673        assert!(app.pending_subprocess.is_some());
3674    }
3675
3676    #[test]
3677    fn model_policy_output_esc_and_q_refuse_while_busy() {
3678        let mut app = make_app();
3679        app.nav.push_view(View::ModelPolicyOutput);
3680        let (_tx, rx) = tokio::sync::oneshot::channel();
3681        app.pending_subprocess = Some(rx);
3682        app.model_policy_stage = Some(ModelPolicyStage::Apply);
3683
3684        app.handle_key_event(key_event(KeyCode::Esc));
3685        // Esc must not silently pop away from a pending policy/integrity
3686        // write — popping would reach a view (e.g. ModelPolicyConfirm)
3687        // whose own 'q' handler quits unconditionally, dropping the
3688        // eventual SubprocessHandle (kill_on_drop) mid-write.
3689        assert_eq!(app.nav.current_view, View::ModelPolicyOutput);
3690        assert!(app.pending_subprocess.is_some());
3691        assert!(app.model_policy_stage.is_some());
3692
3693        // 'q' must likewise refuse to quit while busy, instead of relying on
3694        // the stale `subprocess_handle.is_none()` check that was true during
3695        // this exact pending window.
3696        app.handle_key_event(key_event(KeyCode::Char('q')));
3697        assert!(!app.should_quit);
3698        assert!(app.pending_subprocess.is_some());
3699        assert!(app.model_policy_stage.is_some());
3700    }
3701
3702    #[test]
3703    fn m_prefills_provider_scope_from_provider_agents_view() {
3704        let mut app = make_app();
3705        app.nav.push_view(View::ProviderAgents("gcp".to_string()));
3706        app.handle_key_event(key_event(KeyCode::Char('m')));
3707        assert_eq!(app.nav.current_view, View::ModelPolicyBuilder);
3708        assert_eq!(
3709            app.model_policy_state.scope,
3710            crate::models::model_policy::ModelScope::Provider("gcp".to_string())
3711        );
3712    }
3713
3714    #[test]
3715    fn m_prefills_role_scope_from_role_detail_view() {
3716        let mut app = make_app();
3717        app.nav
3718            .push_view(View::RoleDetail("cloud-security-engineer".to_string()));
3719        app.handle_key_event(key_event(KeyCode::Char('m')));
3720        assert_eq!(
3721            app.model_policy_state.scope,
3722            crate::models::model_policy::ModelScope::Role("cloud-security-engineer".to_string())
3723        );
3724    }
3725
3726    #[test]
3727    fn model_policy_harness_cycle_resets_reasoning_off_codex() {
3728        let mut app = make_app();
3729        app.handle_key_event(key_event(KeyCode::Char('m')));
3730        assert_eq!(app.model_policy_state.harness(), "codex");
3731        // Cycle reasoning to a concrete value on codex.
3732        app.model_policy_state.focused_field = 3;
3733        app.handle_key_event(key_event(KeyCode::Char(' ')));
3734        assert_ne!(app.model_policy_state.reasoning_index, 0);
3735        // Cycling harness away from codex resets reasoning to "(unchanged)".
3736        app.model_policy_state.focused_field = 1;
3737        app.handle_key_event(key_event(KeyCode::Char(' ')));
3738        assert_eq!(app.model_policy_state.harness(), "claude-code");
3739        assert_eq!(app.model_policy_state.reasoning_index, 0);
3740    }
3741
3742    #[test]
3743    fn model_policy_continue_requires_model_or_reasoning() {
3744        let mut app = make_app();
3745        app.handle_key_event(key_event(KeyCode::Char('m')));
3746        app.model_policy_state.focused_field = App::MODEL_POLICY_FIELD_COUNT - 1;
3747        app.handle_key_event(key_event(KeyCode::Enter));
3748        // Nothing set — stays in the builder with a validation message.
3749        assert_eq!(app.nav.current_view, View::ModelPolicyBuilder);
3750        assert!(app.status_message.is_some());
3751    }
3752
3753    #[test]
3754    fn model_policy_continue_rejects_unknown_scope_id() {
3755        let mut app = make_app();
3756        app.nav.push_view(View::ProviderAgents("gcp".to_string()));
3757        app.handle_key_event(key_event(KeyCode::Char('m')));
3758        app.model_policy_state.scope =
3759            crate::models::model_policy::ModelScope::Provider("no-such-provider".to_string());
3760        app.model_policy_state.model = "gpt-5.4".to_string();
3761        app.model_policy_state.focused_field = App::MODEL_POLICY_FIELD_COUNT - 1;
3762        app.handle_key_event(key_event(KeyCode::Enter));
3763        assert_eq!(app.nav.current_view, View::ModelPolicyBuilder);
3764        let (msg, _) = app.status_message.clone().expect("status message");
3765        assert!(msg.contains("Unknown provider"));
3766    }
3767
3768    #[test]
3769    fn model_policy_continue_navigates_to_confirm() {
3770        let mut app = make_app();
3771        app.handle_key_event(key_event(KeyCode::Char('m')));
3772        app.model_policy_state.model = "auto".to_string();
3773        app.model_policy_state.focused_field = App::MODEL_POLICY_FIELD_COUNT - 1;
3774        app.handle_key_event(key_event(KeyCode::Enter));
3775        assert_eq!(app.nav.current_view, View::ModelPolicyConfirm);
3776        // Esc returns to the builder preserving state.
3777        app.handle_key_event(key_event(KeyCode::Esc));
3778        assert_eq!(app.nav.current_view, View::ModelPolicyBuilder);
3779        assert_eq!(app.model_policy_state.model, "auto");
3780    }
3781
3782    #[test]
3783    fn model_policy_typing_edits_model_field() {
3784        let mut app = make_app();
3785        app.handle_key_event(key_event(KeyCode::Char('m')));
3786        app.model_policy_state.focused_field = 2;
3787        for c in "haiku".chars() {
3788            app.handle_key_event(key_event(KeyCode::Char(c)));
3789        }
3790        assert_eq!(app.model_policy_state.model, "haiku");
3791        app.handle_key_event(key_event(KeyCode::Backspace));
3792        assert_eq!(app.model_policy_state.model, "haik");
3793        // j types into the text field instead of navigating.
3794        app.handle_key_event(key_event(KeyCode::Char('j')));
3795        assert_eq!(app.model_policy_state.model, "haikj");
3796        assert_eq!(app.model_policy_state.focused_field, 2);
3797    }
3798
3799    #[test]
3800    fn model_policy_command_builds_expected_args() {
3801        let mut state = ModelPolicyBuilderState::new();
3802        state.scope = crate::models::model_policy::ModelScope::Role("cloud-dba".to_string());
3803        state.model = "gpt-5.5".to_string();
3804        state.reasoning_index = 6; // "high"
3805        state.dry_run = false;
3806        let cmd = state.command();
3807        assert!(cmd.validate().is_ok());
3808        assert_eq!(
3809            cmd.to_args(),
3810            vec![
3811                "set",
3812                "--scope",
3813                "role=cloud-dba",
3814                "--harness",
3815                "codex",
3816                "--model",
3817                "gpt-5.5",
3818                "--reasoning",
3819                "high",
3820            ]
3821        );
3822    }
3823
3824    #[test]
3825    fn model_assignments_loaded_and_queryable() {
3826        let app = make_app();
3827        let assignments = app.catalog.model_assignments.as_ref();
3828        assert!(
3829            assignments.is_some(),
3830            "catalog/model-assignments.json should load"
3831        );
3832        // Every codex assignment carries a concrete model (seeded policy).
3833        let firebase = app
3834            .catalog
3835            .model_assignments_for_agent("gcp-firebase-developer-agent");
3836        assert!(firebase.iter().any(|a| a.harness == "codex"
3837            && a.model.as_deref() == Some("gpt-5.4")
3838            && a.reasoning_effort.as_deref() == Some("high")));
3839    }
3840
3841    fn assignment_fixture(model_warning: Option<&str>) -> crate::models::ModelAssignment {
3842        crate::models::ModelAssignment {
3843            agent_id: "aws-solution-architect-agent".to_string(),
3844            harness: "codex".to_string(),
3845            model: Some("gpt-5.5".to_string()),
3846            model_provider: None,
3847            model_fallback_from: model_warning.map(|_| "gpt-5-2025-08-07".to_string()),
3848            model_warning: model_warning.map(|s| s.to_string()),
3849            reasoning_effort: Some("high".to_string()),
3850            model_source: "agent:aws-solution-architect-agent".to_string(),
3851            reasoning_source: "agent:aws-solution-architect-agent".to_string(),
3852        }
3853    }
3854
3855    #[test]
3856    fn build_model_lines_omits_warning_row_when_absent() {
3857        let assignment = assignment_fixture(None);
3858        let lines = build_model_lines(&[&assignment]);
3859        assert_eq!(lines.len(), 1);
3860        assert_eq!(lines[0].0, "codex");
3861        assert!(!lines.iter().any(|(label, _)| label == "warning"));
3862    }
3863
3864    #[test]
3865    fn build_model_lines_emits_warning_row_when_present() {
3866        let warning = "model \"gpt-5-2025-08-07\" was retired by the provider — projecting documented successor \"gpt-5.5\"; migrate the policy rule";
3867        let assignment = assignment_fixture(Some(warning));
3868        let lines = build_model_lines(&[&assignment]);
3869        assert_eq!(lines.len(), 2);
3870        assert_eq!(lines[0].0, "codex");
3871        assert_eq!(lines[1].0, "warning");
3872        assert_eq!(lines[1].1, format!("warning: {warning}"));
3873    }
3874}