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;
28const VALIDATION_GATE_TIMEOUT_SECS: u64 = 300;
30const RUN_ALL_LABEL: &str = "validate (Run All)";
32
33pub 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
64const MODEL_POLICY_REASONING_FALLBACK: &[&str] =
72 &["none", "minimal", "low", "medium", "high", "xhigh", "max"];
73
74const MODEL_POLICY_REASONING_UNCHANGED: &str = "(unchanged)";
77
78#[derive(Debug, Clone, Copy, PartialEq, Eq)]
82pub enum ModelPolicyStage {
83 Apply,
85 IntegrityRefresh,
87}
88
89pub struct ModelPolicyBuilderState {
91 pub scope: ModelScope,
92 pub harness_index: usize,
94 pub model: String,
99 pub model_choices: Vec<ModelChoice>,
102 pub model_choice_index: Option<usize>,
105 pub reasoning_index: usize,
107 pub reasoning_cycle: Vec<String>,
112 pub dry_run: bool,
113 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 pub fn harness(&self) -> &'static str {
136 CAPABLE_HARNESSES[self.harness_index % CAPABLE_HARNESSES.len()]
137 }
138
139 pub fn refresh_choices(&mut self, registry: Option<&ModelRegistry>) {
153 let harness = self.harness();
154
155 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 if harness_has_reasoning {
203 cycle.push("auto".to_string());
204 cycle.extend(efforts);
205 }
206 self.reasoning_index = cycle.iter().position(|e| *e == previous).unwrap_or(0);
210 self.reasoning_cycle = cycle;
211 }
212
213 pub fn reasoning_supported(&self) -> bool {
217 self.reasoning_cycle.len() > 1
218 }
219
220 pub fn reasoning_clear_only(&self) -> bool {
224 self.reasoning_cycle.len() == 2
225 }
226
227 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 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 pub fn selected_choice(&self) -> Option<&ModelChoice> {
259 self.model_choice_index
260 .and_then(|i| self.model_choices.get(i))
261 }
262
263 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
289pub 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 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 pub theme_mode: ThemeMode,
313 pub workspace_root: PathBuf,
314 pub running_gate: Option<String>,
317 pub running_gate_start: Option<Instant>,
319 pub pending_subprocess:
321 Option<tokio::sync::oneshot::Receiver<anyhow::Result<SubprocessHandle>>>,
322 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 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 pub fn handle_key_event(&mut self, key: KeyEvent) {
375 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 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 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 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 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 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 const EXPORT_FIELD_COUNT: usize = 6;
511
512 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 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 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 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 self.completion_suggestions.clear();
561 self.completion_index = 0;
562 }
563 }
564 KeyCode::Char('k') | KeyCode::Up => {
565 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 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 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 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 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 fn handle_export_builder_enter(&mut self) {
625 match self.export_state.focused_field {
626 1 => {
627 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 if !self.export_state.platform.is_empty()
673 && !self.export_state.target_repo.is_empty()
674 {
675 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 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 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 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 self.nav.pop_view();
748 }
749 KeyCode::Enter => {
750 self.execute_export();
752 }
753 _ => {}
754 }
755 }
756
757 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 self.cancel_export_subprocess();
768 } else {
769 self.should_quit = true;
770 }
771 }
772 KeyCode::Esc => {
773 if self.subprocess_handle.is_some() {
774 self.cancel_export_subprocess();
776 }
777 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 fn execute_export(&mut self) {
792 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 self.subprocess_output.clear();
817
818 let args = cmd.to_args();
819 let workspace = self.workspace_root.clone();
820
821 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 self.pending_subprocess = Some(rx);
837 self.nav.push_view(View::ExportOutput);
838 }
839
840 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 const MODEL_POLICY_FIELD_COUNT: usize = 7;
853
854 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 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 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 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 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 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 fn refresh_model_policy_choices(&mut self) {
990 self.model_policy_state
991 .refresh_choices(self.catalog.model_registry.as_ref());
992 }
993
994 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 fn cycle_model_policy_field(&mut self) {
1006 match self.model_policy_state.focused_field {
1007 0 => {
1008 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 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 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 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 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 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 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 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 fn execute_model_policy(&mut self) {
1182 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 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 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 let tree_count = integrity.trees.len();
1340 if idx == 0 {
1341 } 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 fn handle_validation_enter(&mut self, idx: usize) {
1364 if idx == 0 {
1366 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 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 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 fn spawn_validation_gate(&mut self, gate_label: String, script_name: String) {
1405 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 if gate_label == RUN_ALL_LABEL {
1420 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 self.subprocess_output.clear();
1435
1436 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 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 self.pending_subprocess = Some(rx);
1456 }
1457
1458 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 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 pub fn mark_dirty(&mut self) {
1487 self.dirty = true;
1488 }
1489
1490 fn sync_view_to_tab(&mut self) {
1498 use crate::ui::nav::Tab;
1499 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 pub fn reload_catalog(&mut self) {
1529 self.catalog = CatalogStore::load(&self.workspace_root);
1530 self.update_filtered();
1531 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 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 self.refresh_model_policy_choices();
1560 self.mark_dirty();
1561 outcome
1562 }
1563
1564 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 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 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 }
1594 Err(tokio::sync::oneshot::error::TryRecvError::Closed) => {
1595 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 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 handle.try_poll_exit();
1630
1631 if handle.is_running() && handle.is_timed_out() {
1633 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 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 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 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 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 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 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 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 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 let chunks = Layout::default()
1805 .direction(Direction::Vertical)
1806 .constraints([
1807 Constraint::Length(3), Constraint::Min(0), Constraint::Length(1), Constraint::Length(1), ])
1812 .split(area);
1813
1814 self.render_tab_bar(chunks[0], frame, &theme);
1816
1817 let body = chunks[1];
1821 match self.nav.current_tab {
1822 Tab::CatalogBrowser => {
1823 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 self.render_validation_list(body, frame, &theme);
1833 }
1834 _ => self.render_tab(body, frame, &theme),
1835 }
1836
1837 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 if self.show_help_overlay {
1861 self.render_help_overlay(frame, &theme);
1862 }
1863 }
1864
1865 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 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 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
1947fn 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
1983impl 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 let roles = self.catalog.role_ids();
3186 self.completion_suggestions = roles;
3187 }
3188 _ => {}
3189 }
3190 }
3191
3192 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, 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 + 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 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 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 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 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 let mut app = make_app();
3352 let initial_tab = app.nav.current_tab.clone();
3353 app.handle_key_event(key_event(KeyCode::Tab));
3354 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 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 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 let mut app = make_app();
3384 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 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 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 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 app.nav.set_sidebar_index(7); assert_eq!(app.nav.current_view, View::ExportBuilder);
3584 assert_eq!(app.export_state.focused_field, 0);
3585
3586 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 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 app.export_state.focused_field = 3;
3604 assert!(app.export_state.dry_run);
3605
3606 app.handle_key_event(key_event(KeyCode::Char(' ')));
3608 assert!(!app.export_state.dry_run);
3609
3610 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 assert_eq!(app.export_state.selection, ExportSelection::All);
3674
3675 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 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 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 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 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 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 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; app.handle_key_event(key_event(KeyCode::Char('j')));
3783 assert_eq!(app.export_state.focused_field, 5); 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); }
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 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 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 app.running_gate = Some("validate".to_string());
3814 app.handle_key_event(key_event(KeyCode::Char('m')));
3815 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 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 assert_eq!(app.nav.current_view, View::ModelPolicyConfirm);
3844 assert!(app.model_policy_stage.is_none());
3845 }
3846
3847 #[test]
3854 fn execute_export_refused_while_model_policy_stage_pending() {
3855 let mut app = make_app();
3856 app.nav.set_sidebar_index(7); 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 app.model_policy_stage = Some(ModelPolicyStage::Apply);
3867 app.handle_key_event(key_event(KeyCode::Enter));
3868
3869 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); assert_eq!(app.nav.current_view, View::ValidationList);
3883
3884 app.handle_validation_enter(0);
3889
3890 assert!(app.running_gate.is_none());
3891 assert!(app.status_message.is_some());
3892 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}