| 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 |