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