diff --git a/crates/tui/src/skills/audit.rs b/crates/tui/src/skills/audit.rs index 2230fe7de..30a1b1a0a 100644 --- a/crates/tui/src/skills/audit.rs +++ b/crates/tui/src/skills/audit.rs @@ -246,6 +246,77 @@ pub fn scan_with_configured( } } +/// Expand an owned-only inventory to compatible roots without re-reading the +/// unchanged owned packages. +/// +/// The manager uses this for its interactive scan-mode toggle. Package audits +/// include bounded content hashing, so re-auditing every bundled owned skill +/// can make a simple keypress appear lost on a cold filesystem. Reusing rows by +/// root keeps the result ordered by catalog precedence while newly eligible +/// external roots are still read from disk. +#[must_use] +pub fn expand_owned_scan_to_compatible( + workspace: &Path, + home: Option<&Path>, + configured_skills_dir: Option<&Path>, + owned_skills: &[AuditedSkill], + readiness: Option<&dyn SkillReadinessProvider>, +) -> SkillAuditSnapshot { + let catalog = SkillRootCatalog::build(workspace, home, configured_skills_dir); + let root_refs: Vec = catalog + .audit_compatible_directories() + .into_iter() + .cloned() + .collect(); + let reusable_root_ids: HashSet = owned_skills + .iter() + .map(|skill| skill.id.root_id.clone()) + .collect(); + + let mut skills = Vec::new(); + for root in &root_refs { + if reusable_root_ids.contains(&root.id) { + skills.extend( + owned_skills + .iter() + .filter(|skill| skill.id.root_id == root.id) + .cloned(), + ); + } else { + skills.extend(scan_root(root, workspace, home)); + } + } + + // The owned rows carried their previous cross-root result. Recompute it + // against the expanded inventory so precedence/conflict/import actions are + // exactly the same as a fresh compatible scan. + for skill in &mut skills { + skill.precedence = if skill.root.active_for_runtime { + PrecedenceState::Unknown + } else { + PrecedenceState::InactiveSource + }; + skill.exact_duplicate_of = None; + skill.conflicts_with.clear(); + skill.import_candidate = false; + skill.available_actions.clear(); + } + classify_cross_root(&mut skills); + for skill in &mut skills { + skill.readiness = readiness + .and_then(|provider| provider.readiness_for(&skill.id)) + .unwrap_or(ReadinessState::Unknown); + skill.available_actions = action_policy(skill); + } + + SkillAuditSnapshot { + scan_mode: SkillAuditMode::Compatible, + roots: root_refs, + skills, + generated_at: SystemTime::now(), + } +} + /// Compute available mutations for one audited row (UI and controller share this). #[must_use] pub fn action_policy(skill: &AuditedSkill) -> Vec { @@ -1012,6 +1083,40 @@ mod tests { assert_eq!(owned.precedence, PrecedenceState::Active); } + #[test] + fn expanding_owned_scan_matches_fresh_compatible_scan() { + let tmp = TempDir::new().unwrap(); + let workspace = tmp.path().join("ws"); + let home = tmp.path().join("home"); + write_skill( + &workspace.join(".codewhale").join("skills"), + "shared", + "owned", + "owned-body", + ); + write_skill( + &workspace.join(".agents").join("skills"), + "shared", + "external conflict", + "external-body", + ); + write_skill( + &workspace.join(".codex").join("skills"), + "candidate", + "import candidate", + "candidate-body", + ); + + let owned = scan(&workspace, Some(&home), SkillAuditMode::OwnedOnly, None); + let expanded = + expand_owned_scan_to_compatible(&workspace, Some(&home), None, &owned.skills, None); + let fresh = scan(&workspace, Some(&home), SkillAuditMode::Compatible, None); + + assert_eq!(expanded.scan_mode, SkillAuditMode::Compatible); + assert_eq!(expanded.roots, fresh.roots); + assert_eq!(expanded.skills, fresh.skills); + } + #[test] fn detects_shadow_duplicate_and_conflict() { let tmp = TempDir::new().unwrap(); diff --git a/crates/tui/src/tui/views/skills_manager.rs b/crates/tui/src/tui/views/skills_manager.rs index e868973a5..a274a527f 100644 --- a/crates/tui/src/tui/views/skills_manager.rs +++ b/crates/tui/src/tui/views/skills_manager.rs @@ -20,7 +20,7 @@ use crate::palette; use crate::skills::audit::{ AuditedSkill, AuditedSkillId, DigestState, IntegrityState, ParserState, PrecedenceState, ProvenanceState, SkillActionKind, SkillAuditMode, SkillAuditSnapshot, SkillSourceKind, - TrustState, scan_with_configured, + TrustState, expand_owned_scan_to_compatible, scan_with_configured, }; use crate::skills::mutation::{ConflictPolicy, SkillMutationRequest, SkillTargetScope}; use crate::skills::roots::SkillRootKind; @@ -151,18 +151,30 @@ impl SkillsManagerView { } fn toggle_mode(&mut self, app: &App) { - self.mode = match self.mode { + let next_mode = match self.mode { ManagerMode::OwnedOnly => ManagerMode::Compatible, ManagerMode::Compatible => ManagerMode::OwnedOnly, }; - let snap = scan_with_configured( - &app.workspace, - crate::config::effective_home_dir().as_deref(), - Some(&app.skills_dir), - self.mode.audit_mode(), - None, - ); let focus = self.selected_skill().map(|s| s.id.clone()); + let home = crate::config::effective_home_dir(); + let snap = if self.mode == ManagerMode::OwnedOnly { + expand_owned_scan_to_compatible( + &app.workspace, + home.as_deref(), + Some(&app.skills_dir), + &self.skills, + None, + ) + } else { + scan_with_configured( + &app.workspace, + home.as_deref(), + Some(&app.skills_dir), + next_mode.audit_mode(), + None, + ) + }; + self.mode = next_mode; self.skills = snap.skills; self.pending = None; self.detail_scroll = 0;