From 520238fcdc1c695efdf28c3a649c0bd5a0ef0bf4 Mon Sep 17 00:00:00 2001 From: Hmbown <101357273+Hmbown@users.noreply.github.com> Date: Wed, 29 Jul 2026 09:42:36 -0700 Subject: [PATCH] fix(tui): reuse owned skill audit on mode toggle Expand the current owned inventory by scanning only newly eligible compatible roots. This keeps the interactive toggle responsive on cold Linux filesystems while preserving the same precedence and conflict result as a fresh compatible audit.\n\nAdd an equivalence regression and retain the isolated PTY acceptance. Signed-off-by: Hmbown <101357273+Hmbown@users.noreply.github.com> --- crates/tui/src/skills/audit.rs | 105 +++++++++++++++++++++ crates/tui/src/tui/views/skills_manager.rs | 30 ++++-- 2 files changed, 126 insertions(+), 9 deletions(-) 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;