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