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