返回 CodeWhale
skills_manager.rs
根目录 / crates / tui / src / tui / views / skills_manager.rs
1 //! Unified `/skills` manager — audit inventory + mutation actions.
2 //!
3 //! This view never writes files. Keys emit [`ViewEvent::SkillMutationRequested`];
4 //! the host runs [`crate::skills::mutation`] and rebuilds the view.
5
6 use crossterm::event::{KeyCode, KeyEvent};
7 use ratatui::{
8 buffer::Buffer,
9 layout::Rect,
10 style::{Modifier, Style},
11 text::{Line, Span},
12 widgets::{Block, Borders, Clear, Paragraph, Widget, Wrap},
13 };
14
15 use super::{
16 ActionHint, EmptyState, ListDetailLayout, ModalKind, ModalView, ViewAction, ViewEvent,
17 render_modal_footer, render_underwater_surface, truncate_view_text,
18 };
19 use crate::palette;
20 use crate::skills::audit::{
21 AuditedSkill, AuditedSkillId, DigestState, IntegrityState, ParserState, PrecedenceState,
22 ProvenanceState, SkillActionKind, SkillAuditMode, SkillAuditSnapshot, SkillSourceKind,
23 TrustState, expand_owned_scan_to_compatible, scan_with_configured,
24 };
25 use crate::skills::mutation::{ConflictPolicy, SkillMutationRequest, SkillTargetScope};
26 use crate::skills::roots::SkillRootKind;
27 use crate::tui::app::App;
28 use crate::tui::menu_style;
29
30 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
31 enum ManagerMode {
32 OwnedOnly,
33 Compatible,
34 }
35
36 impl ManagerMode {
37 fn audit_mode(self) -> SkillAuditMode {
38 match self {
39 Self::OwnedOnly => SkillAuditMode::OwnedOnly,
40 Self::Compatible => SkillAuditMode::Compatible,
41 }
42 }
43
44 fn label(self) -> &'static str {
45 match self {
46 Self::OwnedOnly => "owned",
47 Self::Compatible => "compatible",
48 }
49 }
50 }
51
52 #[derive(Debug, Clone, PartialEq, Eq)]
53 enum PendingConfirm {
54 Remove {
55 skill_id: AuditedSkillId,
56 digest: Option<String>,
57 },
58 ImportReplace {
59 skill_id: AuditedSkillId,
60 digest: String,
61 },
62 }
63
64 pub struct SkillsManagerView {
65 mode: ManagerMode,
66 skills: Vec<AuditedSkill>,
67 selected: usize,
68 detail_scroll: usize,
69 import_scope: SkillTargetScope,
70 pending: Option<PendingConfirm>,
71 status: Option<String>,
72 }
73
74 impl SkillsManagerView {
75 #[must_use]
76 pub fn new(app: &App) -> Self {
77 Self::from_scan(
78 app,
79 ManagerMode::OwnedOnly,
80 SkillTargetScope::Global,
81 None,
82 None,
83 )
84 }
85
86 #[must_use]
87 pub fn rebuild_preserving(
88 app: &App,
89 previous: &Self,
90 status: Option<String>,
91 focus: Option<&AuditedSkillId>,
92 ) -> Self {
93 let mut view = Self::from_scan(
94 app,
95 previous.mode,
96 previous.import_scope,
97 status,
98 focus.or_else(|| previous.selected_skill().map(|s| &s.id)),
99 );
100 view.detail_scroll = 0;
101 view
102 }
103
104 fn from_scan(
105 app: &App,
106 mode: ManagerMode,
107 import_scope: SkillTargetScope,
108 status: Option<String>,
109 focus: Option<&AuditedSkillId>,
110 ) -> Self {
111 let snap = scan_snapshot(app, mode);
112 let mut view = Self {
113 mode,
114 skills: snap.skills,
115 selected: 0,
116 detail_scroll: 0,
117 import_scope,
118 pending: None,
119 status,
120 };
121 if let Some(id) = focus
122 && let Some(idx) = view.skills.iter().position(|s| &s.id == id)
123 {
124 view.selected = idx;
125 }
126 view.clamp_selection();
127 view
128 }
129
130 fn selected_skill(&self) -> Option<&AuditedSkill> {
131 self.skills.get(self.selected)
132 }
133
134 fn clamp_selection(&mut self) {
135 if self.skills.is_empty() {
136 self.selected = 0;
137 return;
138 }
139 self.selected = self.selected.min(self.skills.len() - 1);
140 }
141
142 fn move_sel(&mut self, delta: isize) {
143 if self.skills.is_empty() {
144 return;
145 }
146 let len = self.skills.len() as isize;
147 let next = (self.selected as isize + delta).rem_euclid(len) as usize;
148 self.selected = next;
149 self.detail_scroll = 0;
150 self.pending = None;
151 }
152
153 fn toggle_mode(&mut self, app: &App) {
154 let next_mode = match self.mode {
155 ManagerMode::OwnedOnly => ManagerMode::Compatible,
156 ManagerMode::Compatible => ManagerMode::OwnedOnly,
157 };
158 let focus = self.selected_skill().map(|s| s.id.clone());
159 let home = crate::config::effective_home_dir();
160 let snap = if self.mode == ManagerMode::OwnedOnly {
161 expand_owned_scan_to_compatible(
162 &app.workspace,
163 home.as_deref(),
164 Some(&app.skills_dir),
165 &self.skills,
166 None,
167 )
168 } else {
169 scan_with_configured(
170 &app.workspace,
171 home.as_deref(),
172 Some(&app.skills_dir),
173 next_mode.audit_mode(),
174 None,
175 )
176 };
177 self.mode = next_mode;
178 self.skills = snap.skills;
179 self.pending = None;
180 self.detail_scroll = 0;
181 self.status = Some(format!("Scan mode: {}", self.mode.label()));
182 if let Some(id) = focus {
183 if let Some(idx) = self.skills.iter().position(|s| s.id == id) {
184 self.selected = idx;
185 } else {
186 self.selected = 0;
187 }
188 } else {
189 self.selected = 0;
190 }
191 self.clamp_selection();
192 }
193
194 fn cycle_import_scope(&mut self) {
195 self.import_scope = match self.import_scope {
196 SkillTargetScope::Global => SkillTargetScope::Project,
197 SkillTargetScope::Project => SkillTargetScope::Global,
198 };
199 self.status = Some(format!("Import target: {}", scope_label(self.import_scope)));
200 }
201
202 fn emit_action(&mut self, kind: SkillActionKind) -> ViewAction {
203 let Some(skill) = self.selected_skill().cloned() else {
204 return ViewAction::None;
205 };
206 if !skill.available_actions.contains(&kind) {
207 self.status = Some(format!(
208 "{} is not available for '{}'",
209 action_label(kind),
210 skill.name
211 ));
212 return ViewAction::None;
213 }
214
215 match kind {
216 SkillActionKind::Remove => {
217 let digest = match &skill.digest {
218 DigestState::Known(d) => Some(d.clone()),
219 DigestState::Unknown(_) => None,
220 };
221 self.pending = Some(PendingConfirm::Remove {
222 skill_id: skill.id.clone(),
223 digest,
224 });
225 self.status = Some(format!(
226 "Remove '{}'? Press Enter to confirm, Esc to cancel.",
227 skill.name
228 ));
229 ViewAction::None
230 }
231 SkillActionKind::Import => {
232 let DigestState::Known(digest) = &skill.digest else {
233 self.status = Some("Import requires a known package digest".into());
234 return ViewAction::None;
235 };
236 let want_kind = match self.import_scope {
237 SkillTargetScope::Project => SkillRootKind::CodeWhaleProject,
238 SkillTargetScope::Global => SkillRootKind::CodeWhaleGlobal,
239 };
240 // Only treat same-scope owned peers as replace conflicts — the
241 // mutation controller replaces inside `import_scope` alone.
242 let owned_conflict = self.skills.iter().any(|peer| {
243 peer.id.canonical_name == skill.id.canonical_name
244 && peer.root.is_writable_owned()
245 && peer.root.kind == want_kind
246 && peer.id != skill.id
247 && match &peer.digest {
248 DigestState::Known(other) => other != digest,
249 DigestState::Unknown(_) => true,
250 }
251 });
252 if owned_conflict {
253 self.pending = Some(PendingConfirm::ImportReplace {
254 skill_id: skill.id.clone(),
255 digest: digest.clone(),
256 });
257 self.status = Some(format!(
258 "'{}' conflicts with {} owned copy. Enter = replace, Esc = cancel.",
259 skill.name,
260 scope_label(self.import_scope)
261 ));
262 return ViewAction::None;
263 }
264 ViewAction::Emit(ViewEvent::SkillMutationRequested {
265 request: SkillMutationRequest::ImportExternal {
266 source_id: skill.id.clone(),
267 expected_digest: digest.clone(),
268 target: self.import_scope,
269 conflict_policy: ConflictPolicy::Reject,
270 },
271 })
272 }
273 SkillActionKind::Update => ViewAction::Emit(ViewEvent::SkillMutationRequested {
274 request: SkillMutationRequest::Update {
275 skill_id: skill.id.clone(),
276 expected_digest: known_digest(&skill),
277 },
278 }),
279 SkillActionKind::Trust => {
280 let Some(digest) = known_digest(&skill) else {
281 self.status = Some("Trust requires a known package digest".into());
282 return ViewAction::None;
283 };
284 ViewAction::Emit(ViewEvent::SkillMutationRequested {
285 request: SkillMutationRequest::Trust {
286 skill_id: skill.id.clone(),
287 expected_digest: digest,
288 },
289 })
290 }
291 SkillActionKind::Install => {
292 self.status = Some(
293 "Install from registry: /skill install [--project|--global] <spec>".into(),
294 );
295 ViewAction::None
296 }
297 }
298 }
299
300 fn confirm_pending(&mut self) -> ViewAction {
301 match self.pending.take() {
302 Some(PendingConfirm::Remove { skill_id, digest }) => {
303 ViewAction::Emit(ViewEvent::SkillMutationRequested {
304 request: SkillMutationRequest::Remove {
305 skill_id,
306 expected_digest: digest,
307 },
308 })
309 }
310 Some(PendingConfirm::ImportReplace { skill_id, digest }) => {
311 ViewAction::Emit(ViewEvent::SkillMutationRequested {
312 request: SkillMutationRequest::ImportExternal {
313 source_id: skill_id,
314 expected_digest: digest,
315 target: self.import_scope,
316 conflict_policy: ConflictPolicy::ReplaceConfirmed,
317 },
318 })
319 }
320 None => {
321 // Primary action for selection.
322 let Some(skill) = self.selected_skill() else {
323 return ViewAction::None;
324 };
325 let Some(kind) = skill.available_actions.first().copied() else {
326 self.status = Some(format!("No actions available for '{}'", skill.name));
327 return ViewAction::None;
328 };
329 self.emit_action(kind)
330 }
331 }
332 }
333
334 fn footer_hints(&self) -> Vec<ActionHint> {
335 if self.pending.is_some() {
336 return vec![
337 ActionHint::new("Enter", "confirm"),
338 ActionHint::new("Esc", "cancel"),
339 ];
340 }
341 let mut hints = vec![
342 ActionHint::new("↑/↓", "move"),
343 ActionHint::new("Enter", "action"),
344 ActionHint::new("c", "scan mode"),
345 ActionHint::new("s", "import scope"),
346 ActionHint::new("Esc", "close"),
347 ];
348 if let Some(skill) = self.selected_skill() {
349 for action in &skill.available_actions {
350 match action {
351 SkillActionKind::Import => hints.insert(2, ActionHint::new("i", "import")),
352 SkillActionKind::Update => hints.insert(2, ActionHint::new("u", "update")),
353 SkillActionKind::Remove => hints.insert(2, ActionHint::new("r", "remove")),
354 SkillActionKind::Trust => hints.insert(2, ActionHint::new("t", "trust")),
355 SkillActionKind::Install => {}
356 }
357 }
358 }
359 hints
360 }
361
362 fn render_list(&self, area: Rect, buf: &mut Buffer) {
363 let block = Block::default()
364 .title(Line::from(Span::styled(
365 format!(" Skills ({}) ", self.skills.len()),
366 Style::default()
367 .fg(palette::WHALE_ACTION)
368 .add_modifier(Modifier::BOLD),
369 )))
370 .borders(Borders::ALL)
371 .border_style(Style::default().fg(palette::BORDER_COLOR))
372 .style(Style::default().bg(palette::WHALE_BG));
373 let inner = block.inner(area);
374 block.render(area, buf);
375
376 if self.skills.is_empty() {
377 EmptyState::new(
378 "No skills in this scan",
379 "Press c to include compatible roots, or /skill install <spec>.",
380 )
381 .render(inner, buf);
382 return;
383 }
384
385 let visible = usize::from(inner.height).max(1);
386 let offset = self.selected.saturating_add(1).saturating_sub(visible);
387 let end = (offset + visible).min(self.skills.len());
388
389 for (row, idx) in (offset..end).enumerate() {
390 let skill = &self.skills[idx];
391 let y = inner.y + row as u16;
392 if y >= inner.y + inner.height {
393 break;
394 }
395 let selected = idx == self.selected;
396 let style = if selected {
397 menu_style::selected_row_style()
398 } else {
399 Style::default().fg(palette::TEXT_PRIMARY)
400 };
401 let mark = crate::tui::glyphs::selection_marker(selected);
402 let line = format!(
403 "{mark} {:<6} {} {} {}",
404 skill_tier_label(skill),
405 truncate_view_text(&skill.name, 15),
406 precedence_label(&skill.precedence),
407 source_label(skill.source_kind),
408 );
409 buf.set_stringn(inner.x, y, line, usize::from(inner.width), style);
410 }
411 }
412
413 fn render_detail(&self, area: Rect, buf: &mut Buffer) {
414 let block = Block::default()
415 .title(Line::from(Span::styled(
416 " Details ",
417 Style::default()
418 .fg(palette::WHALE_ACTION)
419 .add_modifier(Modifier::BOLD),
420 )))
421 .borders(Borders::ALL)
422 .border_style(Style::default().fg(palette::BORDER_COLOR))
423 .style(Style::default().bg(palette::WHALE_BG));
424 let inner = block.inner(area);
425 block.render(area, buf);
426
427 let Some(skill) = self.selected_skill() else {
428 EmptyState::new(
429 "Nothing selected",
430 "Install or import a skill to get started.",
431 )
432 .render(inner, buf);
433 return;
434 };
435
436 let mut lines = vec![
437 Line::from(Span::styled(
438 skill.name.clone(),
439 Style::default()
440 .fg(palette::WHALE_INFO)
441 .add_modifier(Modifier::BOLD),
442 )),
443 Line::from(""),
444 kv_line("Path", &skill.safe_display_path),
445 kv_line("Tier", skill_tier_label(skill)),
446 kv_line("Source", source_label(skill.source_kind)),
447 kv_line("Status", &precedence_label(&skill.precedence)),
448 kv_line("Integrity", integrity_label(&skill.integrity)),
449 kv_line("Trust", trust_label(&skill.trust)),
450 kv_line("Parser", &parser_label(&skill.parser)),
451 kv_line("Digest", &digest_label(&skill.digest)),
452 kv_line("Provenance", &provenance_label(&skill.provenance)),
453 kv_line("Readiness", "unknown"),
454 kv_line("Import to", scope_label(self.import_scope)),
455 kv_line("Scan", self.mode.label()),
456 ];
457
458 if let Some(desc) = skill.description.as_deref() {
459 lines.push(Line::from(""));
460 lines.push(Line::from(Span::styled(
461 "Description",
462 Style::default().fg(palette::TEXT_MUTED),
463 )));
464 lines.push(Line::from(truncate_view_text(desc, 240)));
465 }
466
467 lines.push(Line::from(""));
468 lines.push(Line::from(Span::styled(
469 "Actions",
470 Style::default().fg(palette::TEXT_MUTED),
471 )));
472 if skill.available_actions.is_empty() {
473 lines.push(Line::from(Span::styled(
474 "(none — read-only or not managed)",
475 Style::default().fg(palette::TEXT_MUTED),
476 )));
477 } else {
478 let labels: Vec<&str> = skill
479 .available_actions
480 .iter()
481 .map(|a| action_label(*a))
482 .collect();
483 lines.push(Line::from(labels.join(", ")));
484 }
485
486 if !skill.warnings.is_empty() {
487 lines.push(Line::from(""));
488 lines.push(Line::from(Span::styled(
489 "Warnings",
490 Style::default().fg(palette::STATUS_WARNING),
491 )));
492 for w in &skill.warnings {
493 match w {
494 crate::skills::audit::SkillAuditWarning::Message(msg) => {
495 lines.push(Line::from(format!("• {msg}")));
496 }
497 }
498 }
499 }
500
501 if let Some(status) = &self.status {
502 lines.push(Line::from(""));
503 lines.push(Line::from(Span::styled(
504 status.clone(),
505 Style::default().fg(palette::WHALE_ACTION),
506 )));
507 }
508
509 let scroll = self.detail_scroll.min(lines.len().saturating_sub(1));
510 let visible: Vec<Line> = lines.into_iter().skip(scroll).collect();
511 Paragraph::new(visible)
512 .wrap(Wrap { trim: false })
513 .style(Style::default().fg(palette::TEXT_PRIMARY))
514 .render(inner, buf);
515 }
516 }
517
518 fn scan_snapshot(app: &App, mode: ManagerMode) -> SkillAuditSnapshot {
519 scan_with_configured(
520 &app.workspace,
521 crate::config::effective_home_dir().as_deref(),
522 Some(&app.skills_dir),
523 mode.audit_mode(),
524 None,
525 )
526 }
527
528 fn known_digest(skill: &AuditedSkill) -> Option<String> {
529 match &skill.digest {
530 DigestState::Known(d) => Some(d.clone()),
531 DigestState::Unknown(_) => None,
532 }
533 }
534
535 fn scope_label(scope: SkillTargetScope) -> &'static str {
536 match scope {
537 SkillTargetScope::Project => "project",
538 SkillTargetScope::Global => "global",
539 }
540 }
541
542 fn action_label(kind: SkillActionKind) -> &'static str {
543 match kind {
544 SkillActionKind::Install => "install",
545 SkillActionKind::Import => "import",
546 SkillActionKind::Update => "update",
547 SkillActionKind::Remove => "remove",
548 SkillActionKind::Trust => "trust",
549 }
550 }
551
552 fn source_label(kind: SkillSourceKind) -> &'static str {
553 match kind {
554 SkillSourceKind::CodeWhaleManaged => "managed",
555 SkillSourceKind::CodeWhaleManual => "manual",
556 SkillSourceKind::CompatibleExternal => "external",
557 SkillSourceKind::BuiltIn => "built-in",
558 SkillSourceKind::ReviewedPluginSnapshot => "plugin",
559 SkillSourceKind::RegistryCache => "cache",
560 }
561 }
562
563 fn skill_tier_label(skill: &AuditedSkill) -> &'static str {
564 if skill.source_kind != SkillSourceKind::BuiltIn {
565 return "custom";
566 }
567 crate::skills::bundled_skill_tier(&skill.name)
568 .map_or("custom", crate::skills::BundledSkillTier::label)
569 }
570
571 fn precedence_label(state: &PrecedenceState) -> String {
572 match state {
573 PrecedenceState::Active => "active".into(),
574 PrecedenceState::ShadowedBy(id) => format!("shadowed:{}", id.canonical_name),
575 PrecedenceState::InactiveSource => "inactive".into(),
576 PrecedenceState::Unknown => "unknown".into(),
577 }
578 }
579
580 fn integrity_label(state: &IntegrityState) -> &'static str {
581 match state {
582 IntegrityState::Healthy => "healthy",
583 IntegrityState::LocalContentDrift => "drift",
584 IntegrityState::BrokenManagedInstall => "broken",
585 IntegrityState::LegacyMetadataUnknown => "legacy",
586 IntegrityState::Unknown => "unknown",
587 }
588 }
589
590 fn trust_label(state: &TrustState) -> &'static str {
591 match state {
592 TrustState::TrustedForDigest(_) => "trusted",
593 TrustState::TrustStale => "stale",
594 TrustState::LegacyAdvisory => "legacy",
595 TrustState::Untrusted => "untrusted",
596 TrustState::NotApplicable => "n/a",
597 TrustState::Unknown => "unknown",
598 }
599 }
600
601 fn parser_label(state: &ParserState) -> String {
602 match state {
603 ParserState::Valid => "valid".into(),
604 ParserState::Warning(ws) => format!("warning({})", ws.len()),
605 ParserState::Broken(msg) => format!("broken:{msg}"),
606 ParserState::Oversized => "oversized".into(),
607 }
608 }
609
610 fn digest_label(state: &DigestState) -> String {
611 match state {
612 DigestState::Known(d) => {
613 if d.len() > 12 {
614 format!("{}…", &d[..12])
615 } else {
616 d.clone()
617 }
618 }
619 DigestState::Unknown(reason) => format!("unknown:{reason:?}"),
620 }
621 }
622
623 fn provenance_label(state: &ProvenanceState) -> String {
624 match state {
625 ProvenanceState::Managed { spec, .. } => spec.clone().unwrap_or_else(|| "managed".into()),
626 ProvenanceState::Manual => "manual".into(),
627 ProvenanceState::External => "external".into(),
628 ProvenanceState::BuiltIn => "built-in".into(),
629 ProvenanceState::Plugin => "plugin".into(),
630 ProvenanceState::Cache => "cache".into(),
631 ProvenanceState::Unknown => "unknown".into(),
632 }
633 }
634
635 fn kv_line(key: &str, value: &str) -> Line<'static> {
636 Line::from(vec![
637 Span::styled(
638 format!("{key:<11}"),
639 Style::default().fg(palette::TEXT_MUTED),
640 ),
641 Span::raw(value.to_string()),
642 ])
643 }
644
645 impl ModalView for SkillsManagerView {
646 fn kind(&self) -> ModalKind {
647 ModalKind::SkillsManager
648 }
649
650 fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
651 self
652 }
653
654 fn handle_key(&mut self, key: KeyEvent) -> ViewAction {
655 if self.pending.is_some() {
656 return match key.code {
657 KeyCode::Esc => {
658 self.pending = None;
659 self.status = Some("Cancelled.".into());
660 ViewAction::None
661 }
662 KeyCode::Enter => self.confirm_pending(),
663 _ => ViewAction::None,
664 };
665 }
666
667 match key.code {
668 KeyCode::Esc => ViewAction::Close,
669 KeyCode::Char('q') | KeyCode::Char('Q') if key.modifiers.is_empty() => {
670 ViewAction::Close
671 }
672 KeyCode::Up | KeyCode::Char('k') | KeyCode::Char('K') => {
673 self.move_sel(-1);
674 ViewAction::None
675 }
676 KeyCode::Down | KeyCode::Char('j') | KeyCode::Char('J') => {
677 self.move_sel(1);
678 ViewAction::None
679 }
680 KeyCode::PageUp => {
681 self.detail_scroll = self.detail_scroll.saturating_sub(8);
682 ViewAction::None
683 }
684 KeyCode::PageDown => {
685 self.detail_scroll = self.detail_scroll.saturating_add(8);
686 ViewAction::None
687 }
688 KeyCode::Home => {
689 self.detail_scroll = 0;
690 ViewAction::None
691 }
692 KeyCode::Enter => self.confirm_pending(),
693 KeyCode::Char('i') | KeyCode::Char('I') if key.modifiers.is_empty() => {
694 self.emit_action(SkillActionKind::Import)
695 }
696 KeyCode::Char('u') | KeyCode::Char('U') if key.modifiers.is_empty() => {
697 self.emit_action(SkillActionKind::Update)
698 }
699 KeyCode::Char('r') | KeyCode::Char('R') if key.modifiers.is_empty() => {
700 self.emit_action(SkillActionKind::Remove)
701 }
702 KeyCode::Char('t') | KeyCode::Char('T') if key.modifiers.is_empty() => {
703 self.emit_action(SkillActionKind::Trust)
704 }
705 KeyCode::Char('c') | KeyCode::Char('C') if key.modifiers.is_empty() => {
706 ViewAction::Emit(ViewEvent::SkillsManagerToggleCompatible)
707 }
708 KeyCode::Char('s') | KeyCode::Char('S') if key.modifiers.is_empty() => {
709 self.cycle_import_scope();
710 ViewAction::None
711 }
712 _ => ViewAction::None,
713 }
714 }
715
716 fn render(&self, area: Rect, buf: &mut Buffer) {
717 Clear.render(area, buf);
718 let body = render_underwater_surface(area, buf, "Skills Manager");
719 let hints = self.footer_hints();
720 let content = render_modal_footer(body, buf, &hints);
721
722 let header_h = 2u16.min(content.height);
723 let header = Rect {
724 x: content.x,
725 y: content.y,
726 width: content.width,
727 height: header_h,
728 };
729 let mode_line = format!(
730 " scan={} import-target={} {}",
731 self.mode.label(),
732 scope_label(self.import_scope),
733 self.status.as_deref().unwrap_or("idle")
734 );
735 buf.set_stringn(
736 header.x,
737 header.y,
738 truncate_view_text(&mode_line, usize::from(header.width)),
739 usize::from(header.width),
740 Style::default().fg(palette::TEXT_SECONDARY),
741 );
742
743 let panel = Rect {
744 x: content.x,
745 y: content.y.saturating_add(header_h),
746 width: content.width,
747 height: content.height.saturating_sub(header_h),
748 };
749 let layout = ListDetailLayout::split(panel, 34);
750 self.render_list(layout.list, buf);
751 self.render_detail(layout.detail, buf);
752 }
753 }
754
755 /// Apply scan-mode toggle using the live app workspace (host-driven).
756 pub fn apply_toggle_compatible(view: &mut SkillsManagerView, app: &App) {
757 view.toggle_mode(app);
758 }
759
760 #[cfg(test)]
761 mod tests {
762 use super::*;
763 use crate::config::Config;
764 use crate::tui::app::{App, TuiOptions};
765 use crossterm::event::{KeyEventKind, KeyModifiers};
766 use std::ffi::OsString;
767 use std::fs;
768 use tempfile::TempDir;
769
770 fn key(code: KeyCode) -> KeyEvent {
771 KeyEvent {
772 code,
773 modifiers: KeyModifiers::NONE,
774 kind: KeyEventKind::Press,
775 state: crossterm::event::KeyEventState::NONE,
776 }
777 }
778
779 struct IsolatedHome {
780 _lock: crate::test_support::TestEnvLock,
781 home_prev: Option<OsString>,
782 userprofile_prev: Option<OsString>,
783 }
784
785 impl IsolatedHome {
786 fn new(tmpdir: &TempDir) -> Self {
787 let lock = crate::test_support::lock_test_env();
788 let home = tmpdir.path().join("home");
789 fs::create_dir_all(&home).unwrap();
790 let home_prev = std::env::var_os("HOME");
791 let userprofile_prev = std::env::var_os("USERPROFILE");
792 // SAFETY: serialized by TestEnvLock in this crate's tests.
793 unsafe {
794 std::env::set_var("HOME", &home);
795 std::env::set_var("USERPROFILE", &home);
796 }
797 Self {
798 _lock: lock,
799 home_prev,
800 userprofile_prev,
801 }
802 }
803 }
804
805 impl Drop for IsolatedHome {
806 fn drop(&mut self) {
807 unsafe {
808 match &self.home_prev {
809 Some(v) => std::env::set_var("HOME", v),
810 None => std::env::remove_var("HOME"),
811 }
812 match &self.userprofile_prev {
813 Some(v) => std::env::set_var("USERPROFILE", v),
814 None => std::env::remove_var("USERPROFILE"),
815 }
816 }
817 }
818 }
819
820 fn app_in(tmp: &TempDir) -> App {
821 let workspace = tmp.path().join("ws");
822 fs::create_dir_all(&workspace).unwrap();
823 let options = TuiOptions {
824 skills_dir: tmp.path().join("skills"),
825 memory_path: tmp.path().join("memory.md"),
826 notes_path: tmp.path().join("notes.txt"),
827 mcp_config_path: tmp.path().join("mcp.json"),
828 ..crate::test_support::test_tui_options(workspace)
829 };
830 App::new(options, &Config::default())
831 }
832
833 #[test]
834 fn opens_owned_scan_and_closes_on_esc() {
835 let tmp = TempDir::new().unwrap();
836 let _home = IsolatedHome::new(&tmp);
837 let app = app_in(&tmp);
838 let mut view = SkillsManagerView::new(&app);
839 assert_eq!(view.mode, ManagerMode::OwnedOnly);
840 assert_eq!(view.kind(), ModalKind::SkillsManager);
841 assert!(matches!(
842 view.handle_key(key(KeyCode::Esc)),
843 ViewAction::Close
844 ));
845 }
846
847 #[test]
848 fn remove_requires_confirm_then_emits() {
849 let tmp = TempDir::new().unwrap();
850 let _home = IsolatedHome::new(&tmp);
851 let workspace = tmp.path().join("ws");
852 let skill_dir = workspace.join(".codewhale").join("skills").join("demo");
853 fs::create_dir_all(&skill_dir).unwrap();
854 fs::write(
855 skill_dir.join("SKILL.md"),
856 "---\nname: demo\ndescription: d\n---\nbody\n",
857 )
858 .unwrap();
859 let digest = crate::skills::audit::compute_package_digest(&skill_dir).unwrap();
860 crate::skills::install::write_installed_from_v2(
861 &skill_dir,
862 "github:o/r",
863 None,
864 "src",
865 &digest,
866 "demo",
867 )
868 .unwrap();
869
870 let mut app = app_in(&tmp);
871 app.workspace = workspace;
872 let mut view = SkillsManagerView::new(&app);
873 assert!(!view.skills.is_empty());
874 let action = view.handle_key(key(KeyCode::Char('r')));
875 assert!(matches!(action, ViewAction::None));
876 assert!(view.pending.is_some());
877 let action = view.handle_key(key(KeyCode::Enter));
878 assert!(matches!(
879 action,
880 ViewAction::Emit(ViewEvent::SkillMutationRequested {
881 request: SkillMutationRequest::Remove { .. },
882 })
883 ));
884 }
885
886 #[test]
887 fn render_fits_narrow_terminal() {
888 let tmp = TempDir::new().unwrap();
889 let _home = IsolatedHome::new(&tmp);
890 let app = app_in(&tmp);
891 let view = SkillsManagerView::new(&app);
892 let area = Rect::new(0, 0, 80, 24);
893 let mut buf = Buffer::empty(area);
894 view.render(area, &mut buf);
895 let mut found = false;
896 for y in 0..area.height {
897 let mut row = String::new();
898 for x in 0..area.width {
899 row.push(
900 buf.cell((x, y))
901 .map(|c| c.symbol().chars().next().unwrap_or(' '))
902 .unwrap_or(' '),
903 );
904 }
905 if row.contains("Skills") {
906 found = true;
907 break;
908 }
909 }
910 assert!(found, "expected Skills title on 80x24 surface");
911 }
912
913 #[test]
914 fn selected_skill_row_uses_charter_pointer_and_selection_bg() {
915 let tmp = TempDir::new().unwrap();
916 let _home = IsolatedHome::new(&tmp);
917 let mut app = app_in(&tmp);
918 app.skills_dir = tmp.path().join("home").join(".codewhale").join("skills");
919 crate::skills::install_system_skills(&app.skills_dir).unwrap();
920
921 let view = SkillsManagerView::new(&app);
922 assert!(!view.skills.is_empty());
923 let area = Rect::new(0, 0, 100, 24);
924 let mut buf = Buffer::empty(area);
925 view.render(area, &mut buf);
926
927 let selected_row_painted = area
928 .positions()
929 .any(|position| buf[position].bg == palette::SELECTION_BG);
930 assert!(
931 selected_row_painted,
932 "selected skill row must use the shared selection highlight"
933 );
934 let text = area
935 .positions()
936 .map(|position| buf[position].symbol())
937 .collect::<String>();
938 assert!(
939 text.contains(crate::tui::glyphs::SELECTION),
940 "selected skill row must carry the charter pointer: {text}"
941 );
942 }
943
944 #[test]
945 fn manager_labels_exact_built_ins_by_tier_and_user_skills_as_custom() {
946 let tmp = TempDir::new().unwrap();
947 let _home = IsolatedHome::new(&tmp);
948 let mut app = app_in(&tmp);
949 app.skills_dir = tmp.path().join("home").join(".codewhale").join("skills");
950 crate::skills::install_system_skills(&app.skills_dir).unwrap();
951 write_skill_pkg(&app.skills_dir.join("my-workflow"), "my-workflow", "mine");
952
953 let view = SkillsManagerView::new(&app);
954 let best = view
955 .skills
956 .iter()
957 .find(|skill| skill.name == "best-of-n")
958 .expect("best-of-n row");
959 let pdf = view
960 .skills
961 .iter()
962 .find(|skill| skill.name == "pdf")
963 .expect("pdf row");
964 let custom = view
965 .skills
966 .iter()
967 .find(|skill| skill.name == "my-workflow")
968 .expect("custom row");
969
970 assert_eq!(skill_tier_label(best), "core");
971 assert_eq!(skill_tier_label(pdf), "tools");
972 assert_eq!(skill_tier_label(custom), "custom");
973 }
974
975 #[test]
976 fn import_replace_confirm_is_scoped_to_import_target() {
977 let tmp = TempDir::new().unwrap();
978 let _home = IsolatedHome::new(&tmp);
979 let workspace = tmp.path().join("ws");
980 let home = tmp.path().join("home");
981 fs::create_dir_all(&home).unwrap();
982
983 // Project-owned conflict only — Global import must not prompt replace.
984 write_skill_pkg(
985 &workspace.join(".codewhale").join("skills").join("shared"),
986 "shared",
987 "owned-project",
988 );
989 write_skill_pkg(
990 &workspace.join(".claude").join("skills").join("shared"),
991 "shared",
992 "external-different",
993 );
994
995 let mut app = app_in(&tmp);
996 app.workspace = workspace;
997 // Force HOME for scan so global root is the isolated home.
998 let mut view = SkillsManagerView::from_scan(
999 &app,
1000 ManagerMode::Compatible,
1001 SkillTargetScope::Global,
1002 None,
1003 None,
1004 );
1005 // Select the external row.
1006 let ext_idx = view
1007 .skills
1008 .iter()
1009 .position(|s| s.source_kind == SkillSourceKind::CompatibleExternal)
1010 .expect("external skill");
1011 view.selected = ext_idx;
1012
1013 let action = view.handle_key(key(KeyCode::Char('i')));
1014 assert!(
1015 matches!(
1016 action,
1017 ViewAction::Emit(ViewEvent::SkillMutationRequested {
1018 request: SkillMutationRequest::ImportExternal {
1019 conflict_policy: ConflictPolicy::Reject,
1020 ..
1021 },
1022 })
1023 ),
1024 "global import must not treat project-owned peer as replace: {action:?}"
1025 );
1026 assert!(view.pending.is_none());
1027
1028 view.import_scope = SkillTargetScope::Project;
1029 let action = view.handle_key(key(KeyCode::Char('i')));
1030 assert!(matches!(action, ViewAction::None));
1031 assert!(
1032 matches!(view.pending, Some(PendingConfirm::ImportReplace { .. })),
1033 "project import should confirm replace against project-owned peer"
1034 );
1035 assert!(home.exists());
1036 }
1037
1038 #[test]
1039 fn compatible_scan_includes_configured_skills_dir() {
1040 let tmp = TempDir::new().unwrap();
1041 let _home = IsolatedHome::new(&tmp);
1042 let workspace = tmp.path().join("ws");
1043 let configured = tmp.path().join("custom-skills");
1044 write_skill_pkg(&configured.join("custom-one"), "custom-one", "from-config");
1045
1046 let mut app = app_in(&tmp);
1047 app.workspace = workspace;
1048 app.skills_dir = configured;
1049
1050 let view = SkillsManagerView::from_scan(
1051 &app,
1052 ManagerMode::Compatible,
1053 SkillTargetScope::Global,
1054 None,
1055 None,
1056 );
1057 assert!(
1058 view.skills.iter().any(|s| s.name == "custom-one"),
1059 "configured skills_dir rows must appear in compatible scan"
1060 );
1061 }
1062
1063 fn write_skill_pkg(dir: &std::path::Path, name: &str, body: &str) {
1064 fs::create_dir_all(dir).unwrap();
1065 fs::write(
1066 dir.join("SKILL.md"),
1067 format!("---\nname: {name}\ndescription: d\n---\n{body}\n"),
1068 )
1069 .unwrap();
1070 }
1071 }
1072
1072 lines RUST