From 32970478c982be8eb3e069f0c29eea766dc315ba Mon Sep 17 00:00:00 2001 From: WTW0313 Date: Wed, 8 Jul 2026 13:58:31 +0800 Subject: [PATCH 1/4] feat: add skill usage tracing --- CONTEXT.md | 84 ++ ...rive-skill-usage-from-provider-sessions.md | 5 + ...ersist-minimal-skill-invocation-records.md | 5 + ...1-include-project-skills-in-skill-usage.md | 3 + ...bounded-incremental-skill-usage-imports.md | 7 + .../0043-use-jsonl-for-skill-usage-records.md | 3 + ...-skill-invocation-status-evidence-based.md | 5 + ...-require-consent-for-skill-usage-import.md | 3 + ...-retain-skill-usage-records-until-reset.md | 3 + ...ons-but-exclude-from-default-aggregates.md | 3 + ...p-historical-skills-in-usage-aggregates.md | 3 + docs/prds/skill-usage-tracing.md | 142 ++++ packages/core/src/config.ts | 38 + packages/core/src/index.ts | 1 + packages/core/src/manager.ts | 55 ++ packages/core/src/usage.ts | 748 ++++++++++++++++++ packages/core/tests/config.test.ts | 35 + packages/core/tests/usage.test.ts | 476 +++++++++++ packages/tui/src/app.tsx | 22 +- packages/tui/src/context/app-context.tsx | 62 +- packages/tui/src/hooks/use-skill-manager.ts | 29 +- packages/tui/src/lib/responsive-layout.ts | 5 +- packages/tui/src/lib/shortcuts.ts | 15 +- packages/tui/src/views/list-view.tsx | 1 + packages/tui/src/views/settings-view.tsx | 26 +- packages/tui/src/views/usage-view.tsx | 262 ++++++ packages/tui/tests/fixtures/inventory.ts | 22 +- packages/tui/tests/helpers/mock-manager.ts | 29 + packages/tui/tests/helpers/render-tui.tsx | 2 + packages/tui/tests/responsive-layout.test.ts | 11 +- packages/tui/tests/shortcuts.test.ts | 6 + .../__snapshots__/app-surface.test.tsx.snap | 4 +- packages/tui/tests/ui/app-surface.test.tsx | 280 ++++++- 33 files changed, 2367 insertions(+), 28 deletions(-) create mode 100644 docs/adr/0039-derive-skill-usage-from-provider-sessions.md create mode 100644 docs/adr/0040-persist-minimal-skill-invocation-records.md create mode 100644 docs/adr/0041-include-project-skills-in-skill-usage.md create mode 100644 docs/adr/0042-use-bounded-incremental-skill-usage-imports.md create mode 100644 docs/adr/0043-use-jsonl-for-skill-usage-records.md create mode 100644 docs/adr/0044-keep-skill-invocation-status-evidence-based.md create mode 100644 docs/adr/0045-require-consent-for-skill-usage-import.md create mode 100644 docs/adr/0046-retain-skill-usage-records-until-reset.md create mode 100644 docs/adr/0047-store-failed-invocations-but-exclude-from-default-aggregates.md create mode 100644 docs/adr/0048-keep-historical-skills-in-usage-aggregates.md create mode 100644 docs/prds/skill-usage-tracing.md create mode 100644 packages/core/src/usage.ts create mode 100644 packages/core/tests/usage.test.ts create mode 100644 packages/tui/src/views/usage-view.tsx diff --git a/CONTEXT.md b/CONTEXT.md index 8ed087f..bbd029e 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -52,6 +52,90 @@ _Avoid_: Installed skill, Skillpack-created skill The cross-provider view of discovered skills, their provenance, availability, and health. _Avoid_: Skill editor, authoring workspace +**Skill Invocation**: +A runtime event where a Skill Provider's agent session actually selects or loads a Skill for use during work. +_Avoid_: Skill scan, Skill Availability, installed skill + +**Skill Invocation Record**: +A minimal normalized fact derived from provider session artifacts about one Skill Invocation. +_Avoid_: Session transcript, prompt, response + +**Invocation Record ID**: +A deterministic identifier for a Skill Invocation Record, derived from provider session evidence so repeated imports can be idempotent. +_Avoid_: Random event ID, row number + +**Skill Usage Log**: +The append-only JSONL storage for Skill Invocation Records, partitioned by Session-Producing Provider and month. +_Avoid_: Session transcript store, provider log, database + +**Invocation Identity Confidence**: +The confidence Skillpack has when matching a Skill Invocation Record to a known Skill or Provider Instance, based on provider evidence such as path, resolved path, source provenance, or name. +_Avoid_: Skill Identity, duplicate confidence + +**Skill Invocation Status**: +The evidence-based runtime state for a Skill Invocation, indicating whether provider session artifacts say the skill was loaded, used, failed, or unknown. +_Avoid_: Skill Availability, Inventory Issue, inventory status + +**Counted Invocation**: +A Skill Invocation Record included in default Skill Usage aggregates. Failed invocations are excluded from default intensity and ranking counts but can be shown as separate failure context. +_Avoid_: Successful invocation, completed task + +**Historical Skill**: +A skill referenced by a Skill Invocation Record that is not currently discovered in Skill Inventory or Project Skills. +_Avoid_: Broken skill, Inventory Issue + +**Skill Usage**: +User-facing aggregates derived from Skill Invocations, used to understand which skills are being used across Skill Providers, including Project Skills when provider session evidence identifies them. +_Avoid_: Skill Inventory, Skill Availability, inventory status, session history, cross-provider skill rollup + +**Provider Skill Ranking**: +A Skill Usage aggregate that ranks skills within a Skill Provider by exact Counted Invocation count, with failed invocation count and recency as supporting context. +_Avoid_: Global leaderboard, inventory order + +**Skill Usage Heatmap**: +A Skill Usage aggregate that shows exact Counted Invocation volume over time. The overview heatmap compares Skill Providers by day using one scale across the selected range, while provider drilldowns can compare skills by day within one provider. +_Avoid_: Inventory status grid, provider availability matrix + +**Skill Usage View**: +The top-level TUI workflow for Skill Usage, combining a provider-by-day heatmap with Provider Skill Ranking for the selected Skill Provider and time range. +_Avoid_: Skill Inventory tab, update view, session browser + +**Skill Usage Import**: +The read-only process that derives Skill Invocation Records from provider-owned session artifacts. +_Avoid_: Runtime instrumentation, session sync + +**Usage Import Diagnostic**: +A non-inventory finding from Skill Usage Import, such as skipped ambiguous provider evidence or unsupported session artifacts. +_Avoid_: Inventory Issue, provider warning + +**Usage Provider Adapter**: +A provider-specific importer that reads known session artifact formats and derives valid Skill Invocation Records. +_Avoid_: Heuristic log parser, runtime hook + +**Usage Coverage State**: +Whether Skillpack can derive Skill Usage for a Skill Provider, distinguishing supported providers with zero Counted Invocations from providers that are unsupported or not configured for import. +_Avoid_: Skill Availability, zero usage + +**Usage Import Consent**: +The user's persisted opt-in that allows Skillpack to read provider-owned session artifacts for Skill Usage Import. +_Avoid_: Config default, provider permission + +**Skill Usage Reset**: +A user action that deletes Skillpack's derived Skill Usage Log and Usage Import Cursors without modifying provider-owned session artifacts. +_Avoid_: Provider history deletion, uninstall + +**Session-Producing Provider**: +A Skill Provider with agent session artifacts from which Skillpack can derive Skill Invocation Records. +_Avoid_: Shared skill location, install source + +**Usage Import Cursor**: +Provider-specific progress metadata that lets Skillpack incrementally import new session evidence without rereading all provider session artifacts on every startup. +_Avoid_: Session state, Skillpack-owned provider state + +**Usage Artifact Root**: +A provider-specific directory or file root Skillpack reads during Skill Usage Import to find supported session artifacts. +_Avoid_: Scan Root, skill directory + **Provider Instance**: A discovered skill as represented by one Skill Provider, including that provider's availability and provenance for the skill. _Avoid_: Skill Group row, duplicate diff --git a/docs/adr/0039-derive-skill-usage-from-provider-sessions.md b/docs/adr/0039-derive-skill-usage-from-provider-sessions.md new file mode 100644 index 0000000..de0203d --- /dev/null +++ b/docs/adr/0039-derive-skill-usage-from-provider-sessions.md @@ -0,0 +1,5 @@ +# Derive Skill Usage from provider sessions + +Skillpack will derive Skill Usage by reading provider-owned session artifacts read-only, not by adding runtime hooks or instrumentation to agent platforms. This keeps Skillpack aligned with provider-native state: providers remain the source of truth for Skill Invocations, while Skillpack normalizes their session evidence into usage history and aggregates. + +The first supported adapter is Codex. It reads the real Codex session JSONL event streams under `~/.codex/sessions/**/*.jsonl` and `~/.codex/archived_sessions/*.jsonl`, using `session_meta` and `turn_context` for session/turn/cwd context and `response_item` `function_call` tool calls for evidence that the agent directly read a `.../skills//SKILL.md` file. Codex usage is global across configured artifact roots and is not scoped to Skillpack's startup cwd. `~/.codex/logs_2.sqlite` is not a counting source because it includes rendered skill metadata and can confuse available skills with invoked skills. Claude remains unsupported for Skill Usage until its real session artifact format is implemented conservatively. diff --git a/docs/adr/0040-persist-minimal-skill-invocation-records.md b/docs/adr/0040-persist-minimal-skill-invocation-records.md new file mode 100644 index 0000000..8a7eebb --- /dev/null +++ b/docs/adr/0040-persist-minimal-skill-invocation-records.md @@ -0,0 +1,5 @@ +# Persist minimal Skill Invocation Records + +Skillpack will persist only minimal Skill Invocation Records derived from provider session artifacts: provider, skill identity evidence, session identity, timestamps, and Skill Invocation Status. It will not persist prompts, responses, tool arguments, file contents, or full transcripts, because usage analysis should not turn Skillpack into a secondary session-log store. + +Provider adapters may inspect session event envelopes and tool-call arguments transiently to find Skill Invocation evidence, but persisted records must remain aggregate-ready facts only. For Codex, the adapter persists the normalized skill name/path, session id, turn id, timestamp, identity confidence, and status derived from the tool call and its output, not the command string or command output. Codex records are deduplicated by `(turn_id, skill_name)`. diff --git a/docs/adr/0041-include-project-skills-in-skill-usage.md b/docs/adr/0041-include-project-skills-in-skill-usage.md new file mode 100644 index 0000000..e358cba --- /dev/null +++ b/docs/adr/0041-include-project-skills-in-skill-usage.md @@ -0,0 +1,3 @@ +# Include Project Skills in Skill Usage + +Skillpack will record Skill Invocations for any skill identified in provider session evidence, including Project Skills. Project Skills remain read-only and separate from the controllable Skill Inventory, but usage analysis needs to answer what was invoked in agent sessions rather than only what Skillpack can manage. diff --git a/docs/adr/0042-use-bounded-incremental-skill-usage-imports.md b/docs/adr/0042-use-bounded-incremental-skill-usage-imports.md new file mode 100644 index 0000000..e746f3d --- /dev/null +++ b/docs/adr/0042-use-bounded-incremental-skill-usage-imports.md @@ -0,0 +1,7 @@ +# Use bounded incremental Skill Usage imports + +Skillpack will run a cheap incremental Skill Usage Import on startup using provider-specific Usage Import Cursors, and expose deeper rescans from the Usage view for selected time ranges. This keeps usage data current during normal app launches without making inventory refresh depend on repeatedly parsing all provider session artifacts. + +Cursors include an importer version as well as source artifact size and mtime. When a parser bug is fixed or a provider adapter starts recognizing additional real session evidence, Skillpack can invalidate old cursors and rescan unchanged artifacts once without requiring users to reset derived usage data. + +Startup import failures are recorded in the Usage view state and surfaced when the user opens Skill Usage; they do not block normal inventory startup. diff --git a/docs/adr/0043-use-jsonl-for-skill-usage-records.md b/docs/adr/0043-use-jsonl-for-skill-usage-records.md new file mode 100644 index 0000000..a407245 --- /dev/null +++ b/docs/adr/0043-use-jsonl-for-skill-usage-records.md @@ -0,0 +1,3 @@ +# Use JSONL for Skill Usage Records + +Skillpack will store Skill Invocation Records in an append-only Skill Usage Log using JSONL files partitioned by Session-Producing Provider and month. JSONL avoids native database dependencies for the `npx`-distributed TUI, is easy to inspect and test, and is sufficient for the default 7-day top-10 usage aggregate; a future migration to SQLite can be considered if range queries become too slow. diff --git a/docs/adr/0044-keep-skill-invocation-status-evidence-based.md b/docs/adr/0044-keep-skill-invocation-status-evidence-based.md new file mode 100644 index 0000000..37cb5cc --- /dev/null +++ b/docs/adr/0044-keep-skill-invocation-status-evidence-based.md @@ -0,0 +1,5 @@ +# Keep Skill Invocation Status evidence-based + +Skillpack will normalize Skill Invocation Status to provider-evidence states such as loaded, used, failed, and unknown. It will not infer task success from later session outcomes, because usage analytics should report what provider session artifacts prove rather than imply that a skill solved the user's work. + +For Codex, a direct `SKILL.md` read with a zero exit code is recorded as `used`; a direct `SKILL.md` read with a non-zero exit code is recorded as `failed`; missing output evidence remains `unknown`. This means failed reads are preserved as facts but do not inflate normal invocation counts. diff --git a/docs/adr/0045-require-consent-for-skill-usage-import.md b/docs/adr/0045-require-consent-for-skill-usage-import.md new file mode 100644 index 0000000..e5ceb89 --- /dev/null +++ b/docs/adr/0045-require-consent-for-skill-usage-import.md @@ -0,0 +1,3 @@ +# Require consent for Skill Usage Import + +Skillpack will require Usage Import Consent before reading provider-owned session artifacts for Skill Usage Import. After consent is granted, bounded incremental imports may run on startup, but the first import must be explicit because provider session artifacts are private even when Skillpack persists only minimal Skill Invocation Records. diff --git a/docs/adr/0046-retain-skill-usage-records-until-reset.md b/docs/adr/0046-retain-skill-usage-records-until-reset.md new file mode 100644 index 0000000..472afdd --- /dev/null +++ b/docs/adr/0046-retain-skill-usage-records-until-reset.md @@ -0,0 +1,3 @@ +# Retain Skill Usage Records until reset + +Skillpack will retain Skill Invocation Records in the local Skill Usage Log until the user performs a Skill Usage Reset. Default Skill Usage views remain bounded to the recent 7-day top-10 output, but Skillpack will not silently expire derived usage data because that would make rankings hard to explain and could discard history the user expected to keep. diff --git a/docs/adr/0047-store-failed-invocations-but-exclude-from-default-aggregates.md b/docs/adr/0047-store-failed-invocations-but-exclude-from-default-aggregates.md new file mode 100644 index 0000000..b5b0e9a --- /dev/null +++ b/docs/adr/0047-store-failed-invocations-but-exclude-from-default-aggregates.md @@ -0,0 +1,3 @@ +# Store failed invocations but exclude from default aggregates + +Skillpack will persist failed Skill Invocation Records with `status: "failed"` so usage diagnostics can show failure context. Default Provider Skill Ranking and Skill Usage Heatmap intensity will exclude failed records from Counted Invocation totals, because repeated failures should not make a skill appear more used. diff --git a/docs/adr/0048-keep-historical-skills-in-usage-aggregates.md b/docs/adr/0048-keep-historical-skills-in-usage-aggregates.md new file mode 100644 index 0000000..e4e64fe --- /dev/null +++ b/docs/adr/0048-keep-historical-skills-in-usage-aggregates.md @@ -0,0 +1,3 @@ +# Keep Historical Skills in usage aggregates + +Skillpack will keep Historical Skills in Skill Usage aggregates when Skill Invocation Records reference skills that are no longer currently discovered. Historical Skills should appear in Usage views with clear labeling, but they should not appear in the controllable Skill Inventory unless a current Provider Instance or Project Skill is discovered. diff --git a/docs/prds/skill-usage-tracing.md b/docs/prds/skill-usage-tracing.md new file mode 100644 index 0000000..3a81a44 --- /dev/null +++ b/docs/prds/skill-usage-tracing.md @@ -0,0 +1,142 @@ +# PRD: Skill Usage Tracing + +## Problem Statement + +Users can see which skills exist and which Skill Providers can load them, but they cannot see which skills are actually invoked during agent sessions. This makes it hard to understand whether a skill is useful, whether a Skill Provider is actively using skills, or whether stale and historical skills still explain recent agent behavior. + +Users need a privacy-conscious Skill Usage view that derives aggregate usage from provider-owned session artifacts without turning Skillpack into a session transcript browser. + +## Solution + +Add Skill Usage tracing to Skillpack. + +Skillpack will read supported provider session artifacts after explicit Usage Import Consent, derive minimal Skill Invocation Records, persist them in a local append-only Skill Usage Log, and show aggregate Skill Usage in a dedicated TUI view. The default output is a 7-day Provider Skill Ranking capped to the top 10 skills, with failed reads shown separately from counted invocations. + +Skill Usage is separate from Skill Inventory. Inventory answers what is currently discovered and manageable. Usage answers what was invoked over time, including Project Skills and Historical Skills when provider session evidence identifies them. + +## User Stories + +1. As a Skillpack user, I want to see which Skill Providers invoked skills recently, so that I can understand where skills are actually being used. +2. As a Skillpack user, I want to see the top invoked skills for each Session-Producing Provider, so that I can identify the skills that matter most in practice. +3. As a Skillpack user, I want Skill Usage to be derived from agent sessions, so that the numbers reflect runtime behavior rather than installed inventory. +4. As a Skillpack user, I want Usage Import Consent before Skillpack reads session artifacts, so that private provider history is not inspected unexpectedly. +5. As a Skillpack user, I want consent to be remembered, so that bounded incremental imports can keep usage data current after I opt in. +6. As a Skillpack user, I want to reset Skill Usage data, so that I can delete Skillpack's derived telemetry without modifying provider-owned session artifacts. +7. As a Skillpack user, I want Skillpack to persist only minimal invocation facts, so that prompts, responses, tool arguments, file contents, and transcripts are not copied. +8. As a Skillpack user, I want failed invocations recorded but excluded from default rankings, so that broken skills do not look popular. +9. As a Skillpack user, I want failure counts shown as separate context, so that I can notice repeated failed usage without confusing it with normal activity. +10. As a Skillpack user, I want heatmap cells to show exact counts or capped labels, so that usage remains understandable without color. +11. As a Skillpack user, I want the selected heatmap cell to show its exact provider, date, and count, so that compact cells are still inspectable. +12. As a Skillpack user, I want the default usage window to be 7 days, so that the first view reflects recent runtime behavior. +13. As a Skillpack user, I want the Provider Skill Ranking capped to the top 10 skills, so that the output stays focused and scannable. +14. As a Skillpack user, I want Project Skills included when they are invoked, so that repository-owned skills are not omitted from usage analysis. +15. As a Skillpack user, I want Historical Skills kept in usage aggregates, so that deleted or moved skills do not disappear from historical totals. +16. As a Skillpack user, I want Historical Skills clearly labeled, so that I do not confuse them with current controllable Provider Instances. +17. As a Skillpack user, I want unsupported providers shown differently from zero-usage providers, so that missing coverage is not mistaken for inactivity. +18. As a Skillpack user, I want Usage Import Diagnostics when artifacts are skipped, so that I can understand why usage may be incomplete. +19. As a Skillpack user, I want Skillpack to skip ambiguous provider evidence, so that false invocation records are not created. +20. As a Skillpack user, I want Codex Usage Artifact Roots auto-detected, so that common Codex installations work without setup. +21. As a Skillpack user with custom provider locations, I want Usage Artifact Roots to be configurable, so that Skillpack can read my actual session locations. +22. As a Skillpack user, I want a top-level Usage view reachable by a direct shortcut, so that usage is a first-class workflow without crowding Inventory. +23. As a Skillpack user, I want no raw session browser in v1, so that Skillpack remains focused on aggregates and privacy. +24. As a maintainer, I want Skill Invocation Records to have deterministic Invocation Record IDs, so that repeated imports and deep rescans are idempotent. +25. As a maintainer, I want usage data partitioned by Session-Producing Provider and month, so that range queries stay bounded without a database dependency. +26. As a maintainer, I want Skill Invocation Status to be evidence-based, so that Skillpack does not overstate task success. +27. As a maintainer, I want provider-specific Usage Provider Adapters, so that each supported session artifact format is parsed conservatively. +28. As a maintainer, I want known unsupported formats to produce Usage Import Diagnostics, so that import gaps are visible without becoming Inventory Issues. +29. As a maintainer, I want Provider Skill Ranking to avoid cross-provider rollups, so that name-only relationships do not create misleading aggregate totals. +30. As a maintainer, I want the first implementation to prove Codex end to end before adding Claude, so that storage and UI contracts are validated on one concrete provider first. + +## Implementation Decisions + +- Skill Usage is derived read-only from provider-owned session artifacts. +- Runtime hooks and instrumentation are out of scope. +- Usage Import requires explicit Usage Import Consent before the first read of provider session artifacts. +- After consent, Skillpack may run bounded incremental Skill Usage Import on startup using Usage Import Cursors. +- The first shippable implementation should prove Codex end to end before adding Claude. +- The Codex adapter reads the real `~/.codex/sessions/**/*.jsonl` and `~/.codex/archived_sessions/*.jsonl` event formats, not a synthetic `skill_invocation` format. +- `~/.codex/logs_2.sqlite` is not a counting source because it includes rendered skill metadata and can confuse available skills with invoked skills. +- Codex skill usage is derived only from `response_item` events with `payload.type === "function_call"` where an `exec_command` directly reads `.../skills//SKILL.md`; `session_meta` and `turn_context` provide session, turn, and cwd context for session identity, turn identity, and relative path resolution. +- Codex import excludes system/developer text, AGENTS.md text, available-skill lists, rendered skill metadata, ordinary text mentions, and search/script output such as `rg`, `grep`, or `python` commands that merely mention `SKILL.md`. +- Codex usage is global across configured Codex artifact roots and is not scoped to Skillpack's startup cwd. +- Codex records are deduplicated by `(turn_id, skill_name)` so segmented reads of the same `SKILL.md` in one turn count as one invocation. +- Codex skill names are normalized from the skill path directory, with plugin-owned Codex skills represented as `plugin-name:skill-name`. +- Codex `function_call_output` exit-code evidence maps successful reads to `used` and failed reads to `failed`. +- Claude Usage Import is intentionally unsupported in the first Codex-first slice and appears as unsupported coverage until a real Claude session adapter is added. +- Global Skills and Project Skills are skill scopes in usage identity, not session-producing runtimes. +- Skill Invocation Records persist minimal facts only: provider, raw skill evidence, best resolved skill identity evidence, session/turn identity, timestamps, Skill Invocation Status, and deterministic Invocation Record ID. +- Prompts, responses, tool arguments, file contents, and full transcripts are never persisted by Skillpack. +- Skill Invocation Status is evidence-based: loaded, used, failed, or unknown. +- Failed invocation records are stored but excluded from default Counted Invocation totals. +- Provider Skill Ranking ranks by exact Counted Invocation count within one Skill Provider. +- Skill Usage Heatmap intensity uses exact Counted Invocation volume across the selected range with one scale across providers. +- Skill Usage does not roll up same-name skills across providers in v1. +- Skill Usage includes Project Skills when session evidence identifies them. +- Skill Usage keeps Historical Skills in aggregates and labels them distinctly. +- Usage Coverage State distinguishes supported zero usage from unsupported or not configured providers. +- Usage Provider Adapters parse only stable, known provider artifact formats. +- Ambiguous or unsupported provider evidence is skipped with Usage Import Diagnostics. +- Usage Import Cursors include an importer version so parser fixes can force a one-time rescan of unchanged artifacts. +- The Skill Usage Log is append-only JSONL partitioned by Session-Producing Provider and month. +- Skill Usage Records are retained until Skill Usage Reset. +- Skill Usage Reset deletes Skillpack's derived Skill Usage Log and Usage Import Cursors only. +- The TUI gets a top-level Skill Usage View reachable with `g`. +- The Skill Usage View combines a provider-by-day heatmap with Provider Skill Ranking for the selected provider and range. +- The heatmap uses fixed-width cells with readable count labels plus optional color or glyph intensity. +- The selected heatmap cell shows exact date, provider, and count. +- The default time range is 7 days. +- Raw invocation history and export are out of scope for v1. + +The decision-rich record shape is: + +```ts +interface SkillInvocationRecord { + schemaVersion: 1; + recordId: string; + provider: string; + sessionId: string; + turnId?: string; + skillName: string; + skillPath?: string; + resolvedPath?: string; + source?: { type: 'skillssh' | 'local'; repo?: string; skillFolderHash?: string }; + identityConfidence: 'confirmed' | 'inferred'; + status: 'loaded' | 'used' | 'failed' | 'unknown'; + startedAt: string; + endedAt?: string | null; +} +``` + +## Testing Decisions + +- The highest-value seam is a core Skill Usage module that imports provider evidence, persists Skill Invocation Records, and returns aggregate Skill Usage for a requested range. +- Tests should exercise public interfaces through real temporary files rather than mocking internal storage functions. +- Provider session artifacts are local-substitutable dependencies: tests should use fixture directories and files that represent supported provider formats. +- Usage Provider Adapter tests should verify observable import results and diagnostics, not internal parser steps. +- JSONL store tests should verify idempotent repeated imports, provider/month partitioning, retention until reset, and range-bounded reads. +- Aggregate tests should use independent literal records to verify Counted Invocation totals, failed exclusion, Historical Skill inclusion, and provider-specific rankings. +- TUI tests can be added where the existing Ink test harness supports them; otherwise the first TUI slice should keep rendering logic factored so core aggregate behavior carries most coverage. +- Do not mock Skillpack's own modules from each other in core tests. +- Do not assert call counts between internal collaborators. +- Do not compute expected rankings or heatmap values using the same algorithm under test. + +## Out of Scope + +- Runtime instrumentation or hooks in Codex, Claude, or any other agent platform. +- Persisting prompts, responses, tool arguments, file contents, or transcripts. +- Raw invocation history browsing. +- Exporting usage aggregates. +- Cross-provider skill rollups. +- Claude Skill Usage Import before a real Claude session artifact adapter exists. +- SQLite or another database dependency. +- Inferring task success from session outcomes. +- Mutating provider-owned session artifacts. +- Deleting provider-owned session history. +- Treating Usage Import Diagnostics as Inventory Issues. +- Adding support for providers beyond Codex in the first implementation wave. + +## Further Notes + +This PRD builds on the provider-native direction: Skillpack remains a management and analysis console, not the canonical owner of provider runtime state. + +Relevant ADRs: ADR-0039 through ADR-0048. diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index c364021..a931f52 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -11,17 +11,24 @@ export interface SourceConfig { enabled: boolean; } +export interface UsageConfig { + importConsent: boolean; + artifactRoots: Record; +} + export interface SkillpackConfig { editor: string; autoCheckUpdates: boolean; projectSkillsDirs: string[]; providers: Record; sources: Record; + usage: UsageConfig; } type PartialSkillpackConfig = Partial> & { providers?: Record>; sources?: Record>; + usage?: Partial; }; export interface ConfigManagerOptions { @@ -42,6 +49,13 @@ export function createDefaultConfig(homeDir = os.homedir()): SkillpackConfig { sources: { skillssh: { enabled: true }, }, + usage: { + importConsent: false, + artifactRoots: { + codex: [path.join(homeDir, '.codex', 'sessions'), path.join(homeDir, '.codex', 'archived_sessions')], + claude: [path.join(homeDir, '.claude', 'projects')], + }, + }, }; } @@ -81,6 +95,12 @@ export class ConfigManager { return this.config; } + async setUsageImportConsent(importConsent: boolean): Promise { + this.config.usage.importConsent = importConsent; + await this.save(); + return this.config; + } + private async autoDetectProviders(): Promise { for (const [id, provider] of Object.entries(this.config.providers)) { let found = false; @@ -113,6 +133,12 @@ function cloneConfig(config: SkillpackConfig): SkillpackConfig { { enabled: source.enabled }, ]), ), + usage: { + importConsent: config.usage.importConsent, + artifactRoots: Object.fromEntries( + Object.entries(config.usage.artifactRoots).map(([id, roots]) => [id, [...roots]]), + ), + }, }; } @@ -138,5 +164,17 @@ function mergeConfig(defaultConfig: SkillpackConfig, userConfig: PartialSkillpac }; } + if (userConfig.usage !== undefined) { + config.usage = { + importConsent: userConfig.usage.importConsent ?? config.usage.importConsent, + artifactRoots: { + ...config.usage.artifactRoots, + ...Object.fromEntries( + Object.entries(userConfig.usage.artifactRoots ?? {}).map(([id, roots]) => [id, [...roots]]), + ), + }, + }; + } + return config; } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index fd0fa29..7d5db50 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -7,3 +7,4 @@ export { DuplicateDetector } from './duplicates.js'; export * from './sources/index.js'; export { SkillManager } from './manager.js'; export { buildSkillInventory, normalizeSkillName } from './models/inventory.js'; +export * from './usage.js'; diff --git a/packages/core/src/manager.ts b/packages/core/src/manager.ts index 66f7803..1cf183d 100644 --- a/packages/core/src/manager.ts +++ b/packages/core/src/manager.ts @@ -11,6 +11,19 @@ import { DuplicateDetector } from './duplicates.js'; import { SkillsLockReader } from './skills-lock.js'; import { parseSkillMd } from './parser.js'; import { buildSkillInventory } from './models/inventory.js'; +import { + type CurrentSkillReference, + SkillUsageManager, + type SkillUsageImportResult, + type SkillUsageOverview, + type SkillUsageOverviewInput, + type SkillUsageProviderConfig, +} from './usage.js'; + +export interface SkillManagerOptions { + usageDataDir?: string; + usageProviders?: SkillUsageProviderConfig[]; +} export class SkillManager { private providers = new Map(); @@ -23,6 +36,14 @@ export class SkillManager { private skillsLock = new SkillsLockReader(); private globalSkillsDir = path.join(os.homedir(), '.agents', 'skills'); private updateAvailability = new Map(); + private skillUsage: SkillUsageManager; + + constructor(options: SkillManagerOptions = {}) { + this.skillUsage = new SkillUsageManager({ + dataDir: options.usageDataDir, + providers: options.usageProviders ?? defaultUsageProviders(), + }); + } registerProvider(provider: ISkillProvider): void { this.providers.set(provider.id, provider); } registerSource(source: IInstallSource): void { this.sources.set(source.id, source); } @@ -152,6 +173,18 @@ export class SkillManager { getSkillsByProvider(providerId: string): Skill[] { return this.skills.filter((s) => s.provider === providerId); } getDuplicates(): DuplicateInfo[] { return this.duplicates; } isDuplicate(skillName: string): boolean { return this.duplicates.some((d) => d.skillName === skillName); } + getSkillUsageOverview(input: SkillUsageOverviewInput): Promise { + return this.skillUsage.getOverview({ + ...input, + currentSkills: input.currentSkills ?? this.getCurrentSkillReferences(), + }); + } + importSkillUsage(): Promise { + return this.skillUsage.importAllProviders(this.getCurrentSkillReferences()); + } + resetSkillUsage(): Promise { + return this.skillUsage.reset(); + } async toggleSkill(skill: Skill): Promise { const provider = this.providers.get(skill.provider); @@ -287,6 +320,16 @@ export class SkillManager { return [...providerDiagnostics, ...projectDiagnostics]; } + + private getCurrentSkillReferences(): CurrentSkillReference[] { + return [...this.skills, ...this.projectSkills].map((skill) => ({ + provider: skill.provider, + name: skill.name, + path: skill.path, + resolvedPath: skill.resolvedPath, + source: skill.source, + })); + } } async function pathExists(targetPath: string): Promise { @@ -309,3 +352,15 @@ function isSkillShManagedGlobalSkill(skill: Pick): function skillKey(skill: Pick): string { return `${skill.provider}\0${skill.path}`; } + +function defaultUsageProviders(): SkillUsageProviderConfig[] { + return [ + { + provider: 'codex', + displayName: 'Codex', + supported: true, + artifactRoots: [path.join(os.homedir(), '.codex', 'sessions'), path.join(os.homedir(), '.codex', 'archived_sessions')], + }, + { provider: 'claude', displayName: 'Claude', supported: false, artifactRoots: [path.join(os.homedir(), '.claude', 'projects')] }, + ]; +} diff --git a/packages/core/src/usage.ts b/packages/core/src/usage.ts new file mode 100644 index 0000000..46be5c9 --- /dev/null +++ b/packages/core/src/usage.ts @@ -0,0 +1,748 @@ +import { createHash } from 'node:crypto'; +import { mkdir, readdir, readFile, realpath, rm, stat, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +const USAGE_IMPORTER_VERSION = 5; + +export type UsageCoverageState = 'unsupported' | 'not-configured' | 'zero' | 'active'; +export type SkillInvocationStatus = 'loaded' | 'used' | 'failed' | 'unknown'; + +export interface SkillUsageProviderConfig { + provider: string; + displayName: string; + supported: boolean; + configured?: boolean; + artifactRoots?: string[]; +} + +export interface SkillUsageOverviewInput { + rangeDays: 7 | 30 | 90; + now?: Date; + currentSkills?: CurrentSkillReference[]; +} + +export interface CurrentSkillReference { + provider: string; + name: string; + path?: string; + resolvedPath?: string; + source?: SkillInvocationSource; +} + +export interface SkillUsageRange { + days: 7 | 30 | 90; + from: string; + to: string; +} + +export interface SkillUsageHeatmapCell { + date: string; + countedInvocations: number; + failedInvocations: number; +} + +export interface ProviderSkillRankingRow { + skillName: string; + countedInvocations: number; + failedInvocations: number; + lastInvokedAt?: string; + historical: boolean; +} + +export interface UsageImportDiagnostic { + provider: string; + message: string; +} + +export interface SkillInvocationSource { + type: 'skillssh' | 'local'; + repo?: string; + skillFolderHash?: string; +} + +export interface SkillInvocationRecord { + schemaVersion: 1; + recordId: string; + provider: string; + sessionId: string; + turnId?: string; + skillName: string; + skillPath?: string; + resolvedPath?: string; + source?: SkillInvocationSource; + identityConfidence: 'confirmed' | 'inferred'; + status: SkillInvocationStatus; + startedAt: string; + endedAt?: string | null; +} + +export interface SkillUsageImportResult { + provider: string; + importedRecords: number; + skippedArtifacts: number; + diagnostics: UsageImportDiagnostic[]; +} + +interface UsageImportCursor { + provider: string; + importerVersion?: number; + stale?: boolean; + artifacts: Record; +} + +interface ParsedInvocationResult { + records: SkillInvocationRecord[]; + ambiguousLineNumbers: number[]; +} + +interface CodexSkillReadCandidate { + callId: string; + lineIndex: number; + sessionId: string; + turnId: string; + startedAt: string; + endedAt?: string; + rawSkillPath: string; + skillPath: string; + statusHint?: SkillInvocationStatus; +} + +export interface ProviderSkillUsageOverview { + provider: string; + displayName: string; + coverageState: UsageCoverageState; + heatmap: SkillUsageHeatmapCell[]; + ranking: ProviderSkillRankingRow[]; + diagnostics: UsageImportDiagnostic[]; +} + +export interface SkillUsageOverview { + range: SkillUsageRange; + providers: ProviderSkillUsageOverview[]; +} + +export class SkillUsageManager { + private providers: SkillUsageProviderConfig[]; + private dataDir: string; + private now?: Date; + private latestDiagnostics = new Map(); + + constructor(options: { providers?: SkillUsageProviderConfig[]; dataDir?: string; now?: Date } = {}) { + this.providers = options.providers ?? []; + this.dataDir = options.dataDir ?? path.join(os.homedir(), '.local', 'share', 'skillpack', 'usage'); + this.now = options.now; + } + + async importProvider(providerId: string, currentSkills?: CurrentSkillReference[]): Promise { + const provider = this.providers.find((candidate) => candidate.provider === providerId); + if (!provider) throw new Error(`Usage provider not found: ${providerId}`); + + const diagnostics: UsageImportDiagnostic[] = []; + let skippedArtifacts = 0; + const records: SkillInvocationRecord[] = []; + const cursor = await this.readCursor(provider.provider); + if (cursor.stale) await rm(path.join(this.dataDir, 'invocations', provider.provider), { recursive: true, force: true }); + const nextCursor: UsageImportCursor = { + provider: provider.provider, + artifacts: { ...cursor.artifacts }, + }; + + for (const root of provider.artifactRoots ?? []) { + const files = await listJsonlFiles(root).catch((err) => { + diagnostics.push({ provider: provider.provider, message: err instanceof Error ? err.message : String(err) }); + return []; + }); + + for (const file of files) { + const metadata = await stat(file).catch((err) => { + diagnostics.push({ provider: provider.provider, message: err instanceof Error ? err.message : String(err) }); + skippedArtifacts += 1; + return null; + }); + if (!metadata) continue; + const cursorEntry = cursor.artifacts[file]; + if (cursorEntry?.size === metadata.size && cursorEntry.mtimeMs === metadata.mtimeMs) continue; + + const content = await readFile(file, 'utf-8').catch((err) => { + diagnostics.push({ provider: provider.provider, message: err instanceof Error ? err.message : String(err) }); + skippedArtifacts += 1; + return ''; + }); + if (!content) continue; + + const lines = content.split(/\r?\n/).filter((line) => line.trim().length > 0); + const result = await parseCodexInvocationLines(provider.provider, file, lines, currentSkills); + records.push(...result.records); + for (const lineNumber of result.ambiguousLineNumbers) { + skippedArtifacts += 1; + diagnostics.push({ + provider: provider.provider, + message: `Skipped ambiguous skill invocation evidence in ${path.basename(file)}:${lineNumber}`, + }); + } + nextCursor.artifacts[file] = { size: metadata.size, mtimeMs: metadata.mtimeMs }; + } + } + + const importedRecords = await this.writeRecords(records); + await this.writeCursor(provider.provider, nextCursor); + this.latestDiagnostics.set(provider.provider, diagnostics); + + return { + provider: provider.provider, + importedRecords, + skippedArtifacts, + diagnostics, + }; + } + + async importAllProviders(currentSkills?: CurrentSkillReference[]): Promise { + const results: SkillUsageImportResult[] = []; + for (const provider of this.providers) { + if (!provider.supported || !provider.artifactRoots?.length) continue; + results.push(await this.importProvider(provider.provider, currentSkills)); + } + return results; + } + + async reset(): Promise { + await rm(path.join(this.dataDir, 'invocations'), { recursive: true, force: true }); + await rm(path.join(this.dataDir, 'cursors'), { recursive: true, force: true }); + this.latestDiagnostics.clear(); + } + + async getOverview(input: SkillUsageOverviewInput): Promise { + const range = buildRange(input.rangeDays, input.now ?? new Date()); + return { + range, + providers: await Promise.all(this.providers.map(async (provider) => { + if (!provider.supported) { + return { + provider: provider.provider, + displayName: provider.displayName, + coverageState: coverageStateFor(provider, 0), + heatmap: [], + ranking: [], + diagnostics: this.latestDiagnostics.get(provider.provider) ?? [], + }; + } + const records = await this.readProviderRecords(provider.provider, range); + const heatmap = buildHeatmap(records, range); + const ranking = buildRanking(records, input.currentSkills); + return { + provider: provider.provider, + displayName: provider.displayName, + coverageState: coverageStateFor(provider, records.length), + heatmap, + ranking, + diagnostics: this.latestDiagnostics.get(provider.provider) ?? [], + }; + })), + }; + } + + private async writeRecords(records: SkillInvocationRecord[]): Promise { + const byPartition = new Map(); + for (const record of records) { + const month = record.startedAt.slice(0, 7); + const partition = path.join(this.dataDir, 'invocations', record.provider, `${month}.jsonl`); + byPartition.set(partition, [...(byPartition.get(partition) ?? []), record]); + } + + let written = 0; + for (const [partition, partitionRecords] of byPartition) { + await mkdir(path.dirname(partition), { recursive: true }); + const existing = await readFile(partition, 'utf-8').catch(() => ''); + const seen = existingRecordIds(existing); + const newRecords = partitionRecords.filter((record) => { + if (seen.has(record.recordId)) return false; + seen.add(record.recordId); + return true; + }); + if (newRecords.length === 0) continue; + written += newRecords.length; + const next = newRecords.map((record) => JSON.stringify(record)).join('\n'); + await writeFile(partition, existing + next + '\n', 'utf-8'); + } + + return written; + } + + private async readProviderRecords(provider: string, range: SkillUsageRange): Promise { + const dir = path.join(this.dataDir, 'invocations', provider); + const files = await listJsonlFiles(dir).catch(() => []); + const records: SkillInvocationRecord[] = []; + + for (const file of files) { + const content = await readFile(file, 'utf-8').catch(() => ''); + for (const line of content.split(/\r?\n/)) { + if (!line.trim()) continue; + try { + const record = JSON.parse(line) as SkillInvocationRecord; + const date = record.startedAt.slice(0, 10); + if (date >= range.from && date <= range.to) records.push(record); + } catch { + // Ignore malformed derived records; provider import diagnostics cover source artifacts. + } + } + } + + return records; + } + + private async readCursor(provider: string): Promise { + const cursorPath = path.join(this.dataDir, 'cursors', `${provider}.json`); + const content = await readFile(cursorPath, 'utf-8').catch(() => ''); + if (!content) return { provider, artifacts: {} }; + try { + const cursor = JSON.parse(content) as UsageImportCursor; + if (cursor.importerVersion !== USAGE_IMPORTER_VERSION) return { provider, stale: true, artifacts: {} }; + return { + provider, + importerVersion: USAGE_IMPORTER_VERSION, + artifacts: cursor.artifacts ?? {}, + }; + } catch { + return { provider, artifacts: {} }; + } + } + + private async writeCursor(provider: string, cursor: UsageImportCursor): Promise { + const cursorPath = path.join(this.dataDir, 'cursors', `${provider}.json`); + await mkdir(path.dirname(cursorPath), { recursive: true }); + await writeFile(cursorPath, JSON.stringify({ + ...cursor, + provider, + importerVersion: USAGE_IMPORTER_VERSION, + }, null, 2) + '\n', 'utf-8'); + } + +} + +function coverageStateFor(provider: SkillUsageProviderConfig, recordCount = 0): UsageCoverageState { + if (!provider.supported) return 'unsupported'; + if (!provider.artifactRoots?.length) return 'not-configured'; + if (provider.configured === false) return 'not-configured'; + if (recordCount > 0) return 'active'; + return 'zero'; +} + +function buildRange(days: 7 | 30 | 90, now: Date): SkillUsageRange { + const end = toUtcDate(now); + const start = new Date(end); + start.setUTCDate(start.getUTCDate() - days + 1); + + return { + days, + from: formatDate(start), + to: formatDate(end), + }; +} + +function toUtcDate(date: Date): Date { + return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())); +} + +function formatDate(date: Date): string { + return date.toISOString().slice(0, 10); +} + +function recordInRange(record: SkillInvocationRecord, range: SkillUsageRange): boolean { + const date = record.startedAt.slice(0, 10); + return date >= range.from && date <= range.to; +} + +function existingRecordIds(content: string): Set { + const ids = new Set(); + for (const line of content.split(/\r?\n/)) { + if (!line.trim()) continue; + try { + const value = JSON.parse(line) as { recordId?: unknown }; + if (typeof value.recordId === 'string') ids.add(value.recordId); + } catch { + // Ignore malformed derived records when deduplicating new writes. + } + } + return ids; +} + +async function listJsonlFiles(root: string): Promise { + const entries = await readdir(root, { withFileTypes: true }).catch((err) => { + if (isNodeError(err) && err.code === 'ENOENT') return []; + throw err; + }); + const files = await Promise.all(entries.map(async (entry) => { + const entryPath = path.join(root, entry.name); + if (entry.isDirectory()) return listJsonlFiles(entryPath); + return entry.isFile() && entry.name.endsWith('.jsonl') ? [entryPath] : []; + })); + return files.flat(); +} + +async function parseCodexInvocationLines( + provider: string, + sourceFile: string, + lines: string[], + currentSkills?: CurrentSkillReference[], +): Promise { + const ambiguousLineNumbers: number[] = []; + const candidates: CodexSkillReadCandidate[] = []; + const outputs = new Map(); + let sessionId = path.basename(sourceFile, '.jsonl'); + let currentTurnId = sessionId; + let cwd: string | undefined; + + for (const [index, line] of lines.entries()) { + let value: unknown; + try { + value = JSON.parse(line); + } catch { + continue; + } + if (!isRecord(value)) continue; + + const payload = isRecord(value.payload) ? value.payload : {}; + if (value.type === 'session_meta') { + sessionId = stringValue(payload.session_id) ?? stringValue(payload.id) ?? sessionId; + currentTurnId = sessionId; + cwd = stringValue(payload.cwd) ?? cwd; + continue; + } + if (value.type === 'event_msg' && stringValue(payload.type) === 'task_started') { + currentTurnId = stringValue(payload.turn_id) ?? currentTurnId; + continue; + } + if (value.type === 'turn_context') { + currentTurnId = stringValue(payload.turn_id) ?? currentTurnId; + cwd = stringValue(payload.cwd) ?? cwd; + continue; + } + if (value.type !== 'response_item') continue; + + const payloadType = stringValue(payload.type); + if (payloadType === 'function_call') { + const callId = stringValue(payload.call_id); + const toolName = stringValue(payload.name); + const startedAt = stringValue(value.timestamp); + if (!callId || toolName !== 'exec_command' || !startedAt) continue; + + const command = parseExecCommand(payload); + if (!command?.cmd) continue; + const skillPaths = extractDirectSkillReadPaths(command.cmd); + if (skillPaths.length === 0) continue; + + const commandCwd = resolveCommandCwd(command.workdir, cwd); + for (const rawSkillPath of skillPaths) { + const skillPath = resolveSkillPath(rawSkillPath, commandCwd); + candidates.push({ + callId, + lineIndex: index, + sessionId, + turnId: currentTurnId, + startedAt, + rawSkillPath, + skillPath, + statusHint: normalizeStatus(stringValue(payload.status)), + }); + } + } else if (payloadType === 'function_call_output') { + const callId = stringValue(payload.call_id); + const output = stringValue(payload.output); + const endedAt = stringValue(value.timestamp); + if (callId && output) outputs.set(callId, { output, endedAt }); + } + } + + const recordsByTurnAndSkill = new Map(); + for (const candidate of candidates) { + const skillIdentity = await readSkillIdentity(candidate.skillPath, candidate.rawSkillPath); + const skillName = skillIdentity.name; + if (!skillName || !candidate.sessionId || !candidate.turnId || !candidate.startedAt) { + ambiguousLineNumbers.push(candidate.lineIndex + 1); + continue; + } + const evidence = statusFromToolOutput(outputs.get(candidate.callId), candidate.statusHint); + const recordKey = `${candidate.turnId}\0${skillName}`; + const existing = recordsByTurnAndSkill.get(recordKey); + if (existing) { + existing.status = mergeInvocationStatus(existing.status, evidence.status); + if (candidate.startedAt < existing.startedAt) existing.startedAt = candidate.startedAt; + if (evidence.endedAt && (!existing.endedAt || evidence.endedAt > existing.endedAt)) existing.endedAt = evidence.endedAt; + if (skillIdentity.confirmed) { + existing.skillPath = candidate.skillPath; + existing.resolvedPath = skillIdentity.resolvedPath; + existing.identityConfidence = 'confirmed'; + } + existing.source ??= findCurrentSkillSource({ + provider, + skillName, + skillPath: candidate.skillPath, + resolvedPath: skillIdentity.resolvedPath, + }, currentSkills); + continue; + } + + const source = findCurrentSkillSource({ + provider, + skillName, + skillPath: candidate.skillPath, + resolvedPath: skillIdentity.resolvedPath, + }, currentSkills); + + recordsByTurnAndSkill.set(recordKey, { + schemaVersion: 1, + recordId: invocationRecordId( + provider, + candidate.sessionId, + candidate.turnId, + skillName, + ), + provider, + sessionId: candidate.sessionId, + turnId: candidate.turnId, + skillName, + skillPath: candidate.skillPath, + resolvedPath: skillIdentity.resolvedPath, + source, + identityConfidence: skillIdentity.confirmed ? 'confirmed' : 'inferred', + status: evidence.status, + startedAt: candidate.startedAt, + endedAt: evidence.endedAt ?? null, + }); + } + + return { records: [...recordsByTurnAndSkill.values()], ambiguousLineNumbers }; +} + +function parseExecCommand(payload: Record): { cmd: string; workdir?: string } | undefined { + const raw = stringValue(payload.arguments) ?? stringValue(payload.input); + if (!raw) return undefined; + let value: unknown; + try { + value = JSON.parse(raw); + } catch { + return undefined; + } + if (!isRecord(value)) return undefined; + const cmd = stringValue(value.cmd); + if (!cmd) return undefined; + return { + cmd, + workdir: stringValue(value.workdir), + }; +} + +function extractDirectSkillReadPaths(command: string): string[] { + const paths: string[] = []; + const segments = command.split(/\s*(?:&&|\|\||;|\n)\s*/); + for (const segment of segments) { + const commandName = basenameOfCommand(firstShellToken(segment)); + if (!isDirectFileReadCommand(commandName)) continue; + paths.push(...extractSkillPaths(segment)); + } + return paths; +} + +function firstShellToken(segment: string): string | undefined { + return segment.trim().match(/^(\S+)/)?.[1]; +} + +function basenameOfCommand(command: string | undefined): string | undefined { + if (!command) return undefined; + return path.basename(command); +} + +function isDirectFileReadCommand(command: string | undefined): boolean { + return command === 'cat' + || command === 'sed' + || command === 'nl' + || command === 'head' + || command === 'tail' + || command === 'bat'; +} + +function extractSkillPaths(segment: string): string[] { + const paths: string[] = []; + const pathPattern = /(?:^|\s|['"])(~?\.?\.?\/?[^'"\s]*\/skills\/[^'"\s]+\/SKILL\.md)(?=$|\s|['"])/g; + for (const match of segment.matchAll(pathPattern)) { + const skillPath = match[1]; + if (!skillPath.includes('<') && !skillPath.includes('>')) paths.push(skillPath); + } + return paths; +} + +function resolveCommandCwd(workdir: string | undefined, cwd: string | undefined): string | undefined { + if (!workdir) return cwd; + if (path.isAbsolute(workdir)) return workdir; + return cwd ? path.resolve(cwd, workdir) : path.resolve(workdir); +} + +function resolveSkillPath(rawSkillPath: string, cwd: string | undefined): string { + if (rawSkillPath === '~') return os.homedir(); + if (rawSkillPath.startsWith('~/')) return path.join(os.homedir(), rawSkillPath.slice(2)); + if (path.isAbsolute(rawSkillPath)) return path.normalize(rawSkillPath); + return path.resolve(cwd ?? process.cwd(), rawSkillPath); +} + +async function readSkillIdentity(skillPath: string, rawSkillPath: string): Promise<{ + name: string; + resolvedPath?: string; + confirmed: boolean; +}> { + const resolvedPath = await realpath(skillPath).catch(() => undefined); + return { + name: normalizeSkillNameFromPath(resolvedPath ?? skillPath), + resolvedPath, + confirmed: resolvedPath !== undefined, + }; +} + +function normalizeSkillNameFromPath(skillPath: string): string { + const normalized = path.normalize(skillPath); + const skillName = path.basename(path.dirname(normalized)); + const parts = normalized.split(path.sep).filter(Boolean); + const cacheIndex = parts.findIndex((part, index) => ( + part === 'cache' && parts[index - 1] === 'plugins' && parts[index - 2] === '.codex' + )); + const pluginName = cacheIndex >= 0 ? parts[cacheIndex + 2] : undefined; + return pluginName ? `${pluginName}:${skillName}` : skillName; +} + +function statusFromToolOutput( + output: { output: string; endedAt?: string } | undefined, + statusHint: SkillInvocationStatus | undefined, +): { status: SkillInvocationStatus; endedAt?: string } { + const exitCode = output?.output.match(/Process exited with code\s+(\d+)/)?.[1] + ?? output?.output.match(/Exit code:\s+(\d+)/)?.[1] + ?? output?.output.match(/Exit status\s+(\d+)/)?.[1]; + if (exitCode !== undefined) return { status: exitCode === '0' ? 'used' : 'failed', endedAt: output?.endedAt }; + if (statusHint && statusHint !== 'unknown') return { status: statusHint, endedAt: output?.endedAt }; + return { status: 'unknown', endedAt: output?.endedAt }; +} + +function mergeInvocationStatus(left: SkillInvocationStatus, right: SkillInvocationStatus): SkillInvocationStatus { + if (left === 'used' || right === 'used') return 'used'; + if (left === 'failed' || right === 'failed') return 'failed'; + if (left === 'loaded' || right === 'loaded') return 'loaded'; + return 'unknown'; +} + +function invocationRecordId( + provider: string, + sessionId: string, + turnId: string, + skillName: string, +): string { + return createHash('sha256') + .update(JSON.stringify({ provider, sessionId, turnId, skillName })) + .digest('hex'); +} + +function buildHeatmap(records: SkillInvocationRecord[], range: SkillUsageRange): SkillUsageHeatmapCell[] { + const byDate = new Map(); + for (const date of datesInRange(range)) { + byDate.set(date, { date, countedInvocations: 0, failedInvocations: 0 }); + } + for (const record of records) { + const date = record.startedAt.slice(0, 10); + const cell = byDate.get(date) ?? { date, countedInvocations: 0, failedInvocations: 0 }; + if (record.status === 'failed') { + cell.failedInvocations += 1; + } else { + cell.countedInvocations += 1; + } + byDate.set(date, cell); + } + return [...byDate.values()].sort((a, b) => a.date.localeCompare(b.date)); +} + +function datesInRange(range: SkillUsageRange): string[] { + const dates: string[] = []; + const current = new Date(`${range.from}T00:00:00.000Z`); + const end = new Date(`${range.to}T00:00:00.000Z`); + while (current <= end) { + dates.push(formatDate(current)); + current.setUTCDate(current.getUTCDate() + 1); + } + return dates; +} + +function buildRanking( + records: SkillInvocationRecord[], + currentSkills: CurrentSkillReference[] | undefined, +): ProviderSkillRankingRow[] { + const rows = new Map(); + for (const record of records) { + const row = rows.get(record.skillName) ?? { + skillName: record.skillName, + countedInvocations: 0, + failedInvocations: 0, + historical: currentSkills !== undefined, + }; + if (record.status === 'failed') { + row.failedInvocations += 1; + } else { + row.countedInvocations += 1; + } + if (!row.lastInvokedAt || record.startedAt > row.lastInvokedAt) row.lastInvokedAt = record.startedAt; + if (!isHistoricalSkill(record, currentSkills)) row.historical = false; + rows.set(record.skillName, row); + } + + return [...rows.values()].sort((a, b) => ( + b.countedInvocations - a.countedInvocations || a.skillName.localeCompare(b.skillName) + )).slice(0, 10); +} + +function isHistoricalSkill( + record: SkillInvocationRecord, + currentSkills: CurrentSkillReference[] | undefined, +): boolean { + if (currentSkills === undefined) return false; + return !currentSkills.some((skill) => matchesCurrentSkill(record, skill)); +} + +function findCurrentSkillSource( + record: Pick, + currentSkills: CurrentSkillReference[] | undefined, +): SkillInvocationSource | undefined { + const skill = currentSkills?.find((candidate) => matchesCurrentSkill(record, candidate)); + return skill?.source; +} + +function matchesCurrentSkill( + record: Pick, + skill: CurrentSkillReference, +): boolean { + if (record.skillPath !== undefined && skill.path !== undefined && matchesSkillMdPath(record.skillPath, skill.path)) return true; + if (record.resolvedPath !== undefined && skill.resolvedPath !== undefined && matchesSkillMdPath(record.resolvedPath, skill.resolvedPath)) return true; + return skill.provider === record.provider && skill.name === record.skillName; +} + +function matchesSkillMdPath(recordPath: string, skillReferencePath: string): boolean { + const normalizedRecordPath = path.normalize(recordPath); + const normalizedSkillPath = path.normalize(skillReferencePath); + return normalizedRecordPath === normalizedSkillPath + || normalizedRecordPath === path.join(normalizedSkillPath, 'SKILL.md'); +} + +function normalizeStatus(value: string | undefined): SkillInvocationStatus { + if (value === 'loaded' || value === 'used' || value === 'failed') return value; + return 'unknown'; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function stringValue(value: unknown): string | undefined { + return typeof value === 'string' ? value : undefined; +} + +function isNodeError(value: unknown): value is NodeJS.ErrnoException { + return value instanceof Error && 'code' in value; +} diff --git a/packages/core/tests/config.test.ts b/packages/core/tests/config.test.ts index 2218f0c..0d1a02f 100644 --- a/packages/core/tests/config.test.ts +++ b/packages/core/tests/config.test.ts @@ -63,5 +63,40 @@ describe('ConfigManager', () => { }); expect(config.providers.claude.enabled).toBe(false); expect(config.projectSkillsDirs).toEqual(['.codex/skills', '.claude/skills', '.agents/skills']); + expect(config.usage.artifactRoots.codex).toEqual([ + path.join(homeDir, '.codex', 'sessions'), + path.join(homeDir, '.codex', 'archived_sessions'), + ]); + expect(config.usage.artifactRoots.claude).toEqual([path.join(homeDir, '.claude', 'projects')]); + }); + + it('merges configured Usage Artifact Roots with defaults', async () => { + const customClaudeUsageRoot = path.join(root, 'claude-history'); + await writeFile(path.join(configDir, 'config.json'), JSON.stringify({ + usage: { + artifactRoots: { + claude: [customClaudeUsageRoot], + }, + }, + }), 'utf-8'); + + const config = await new ConfigManager({ configDir, homeDir: path.join(root, 'home') }).load(); + + expect(config.usage.artifactRoots.claude).toEqual([customClaudeUsageRoot]); + expect(config.usage.artifactRoots.codex[0]).toContain(path.join('.codex', 'sessions')); + }); + + it('persists Usage Import Consent', async () => { + const manager = new ConfigManager(configDir); + let config = await manager.load(); + + expect(config.usage.importConsent).toBe(false); + + config = await manager.setUsageImportConsent(true); + + expect(config.usage.importConsent).toBe(true); + await expect(new ConfigManager(configDir).load()).resolves.toMatchObject({ + usage: { importConsent: true }, + }); }); }); diff --git a/packages/core/tests/usage.test.ts b/packages/core/tests/usage.test.ts new file mode 100644 index 0000000..1a903ea --- /dev/null +++ b/packages/core/tests/usage.test.ts @@ -0,0 +1,476 @@ +import { describe, expect, it } from 'vitest'; +import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { SkillUsageManager } from '../src/usage.js'; + +describe('SkillUsageManager', () => { + it('returns unsupported coverage without reading stale records', async () => { + const root = await mkdtemp(path.join(tmpdir(), 'skillpack-usage-')); + try { + const usageRoot = path.join(root, 'usage'); + await mkdir(path.join(usageRoot, 'invocations', 'codex'), { recursive: true }); + await writeFile(path.join(usageRoot, 'invocations', 'codex', '2026-07.jsonl'), JSON.stringify({ + schemaVersion: 1, + recordId: 'stale', + provider: 'codex', + sessionId: 'session-a', + turnId: 'turn-a', + skillName: 'frontend-testing', + identityConfidence: 'inferred', + status: 'used', + startedAt: '2026-07-07T08:00:00Z', + }) + '\n', 'utf-8'); + const usage = new SkillUsageManager({ + dataDir: usageRoot, + providers: [ + { provider: 'codex', displayName: 'Codex', supported: false }, + ], + }); + + await expect(usage.getOverview({ + rangeDays: 7, + now: new Date('2026-07-08T00:00:00Z'), + })).resolves.toEqual({ + range: { + days: 7, + from: '2026-07-02', + to: '2026-07-08', + }, + providers: [{ + provider: 'codex', + displayName: 'Codex', + coverageState: 'unsupported', + heatmap: [], + ranking: [], + diagnostics: [], + }], + }); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it('imports Codex skill reads from session and archived session JSONL using the stable scan rules', async () => { + const root = await mkdtemp(path.join(tmpdir(), 'skillpack-usage-')); + try { + const sessionsRoot = path.join(root, 'codex-sessions'); + const archivedRoot = path.join(root, 'archived_sessions'); + const usageRoot = path.join(root, 'usage'); + const difyRoot = path.join(root, 'dify'); + await mkdir(path.join(sessionsRoot, '2026', '07', '07'), { recursive: true }); + await mkdir(archivedRoot, { recursive: true }); + await mkdir(path.join(difyRoot, '.agents', 'skills', 'frontend-testing'), { recursive: true }); + await writeFile(path.join(difyRoot, '.agents', 'skills', 'frontend-testing', 'SKILL.md'), '---\nname: ignored-frontmatter-name\n---\n'); + + await writeFile(path.join(sessionsRoot, '2026', '07', '07', 'rollout-session-a.jsonl'), jsonl([ + sessionMeta('session-a', path.join(root, 'other-project'), '2026-07-07T08:00:00Z'), + taskStarted('turn-a', '2026-07-07T08:00:01Z'), + turnContext('turn-a', difyRoot, '2026-07-07T08:00:02Z'), + functionCall('call-a1', "sed -n '1,220p' .agents/skills/frontend-testing/SKILL.md", '2026-07-07T08:01:00Z'), + functionOutput('call-a1', 0, '2026-07-07T08:01:01Z'), + functionCall('call-a2', "sed -n '221,520p' .agents/skills/frontend-testing/SKILL.md", '2026-07-07T08:01:02Z'), + functionOutput('call-a2', 0, '2026-07-07T08:01:03Z'), + functionCall('call-a-rg', "rg 'SKILL.md' /Users/twwu/dify", '2026-07-07T08:02:00Z'), + functionCall('call-a-python', "python3 -c 'print(\"/Users/twwu/dify/.agents/skills/not-invoked/SKILL.md\")'", '2026-07-07T08:02:01Z'), + customToolCall('call-a-custom', "sed -n '1,240p' /Users/twwu/dify/.agents/skills/not-invoked/SKILL.md", '2026-07-07T08:02:02Z'), + taskStarted('turn-b', '2026-07-07T09:00:00Z'), + turnContext('turn-b', difyRoot, '2026-07-07T09:00:01Z'), + functionCall('call-b1', "cat /Users/twwu/.agents/skills/vercel-react-best-practices/SKILL.md", '2026-07-07T09:01:00Z'), + functionOutput('call-b1', 0, '2026-07-07T09:01:01Z'), + taskStarted('turn-c', '2026-07-07T10:00:00Z'), + turnContext('turn-c', path.join(root, 'other-project'), '2026-07-07T10:00:01Z'), + functionCall('call-c1', "cat /Users/twwu/.agents/skills/other-project-only/SKILL.md", '2026-07-07T10:01:00Z'), + ])); + await writeFile(path.join(archivedRoot, 'rollout-session-b.jsonl'), jsonl([ + sessionMeta('session-b', path.join(root, 'other-project'), '2026-07-07T11:00:00Z'), + taskStarted('turn-d', '2026-07-07T11:00:01Z'), + turnContext('turn-d', difyRoot, '2026-07-07T11:00:02Z'), + functionCall('call-d1', "nl -ba /Users/twwu/.codex/skills/.system/openai-docs/SKILL.md", '2026-07-07T11:01:00Z'), + functionOutput('call-d1', 0, '2026-07-07T11:01:01Z'), + functionCall('call-d2', "head /Users/twwu/.codex/plugins/cache/openai-curated-remote/github/0.1.5/skills/github/SKILL.md", '2026-07-07T11:02:00Z'), + functionOutput('call-d2', 0, '2026-07-07T11:02:01Z'), + functionCall('call-d3', "tail /Users/twwu/.codex/plugins/cache/openai-bundled/browser/26.623.141536/skills/control-in-app-browser/SKILL.md", '2026-07-07T11:03:00Z'), + functionOutput('call-d3', 0, '2026-07-07T11:03:01Z'), + taskStarted('turn-e', '2026-06-30T11:00:01Z'), + turnContext('turn-e', difyRoot, '2026-06-30T11:00:02Z'), + functionCall('call-e1', "bat /Users/twwu/.agents/skills/too-old/SKILL.md", '2026-06-30T11:01:00Z'), + functionOutput('call-e1', 0, '2026-06-30T11:01:01Z'), + ])); + const usage = new SkillUsageManager({ + dataDir: usageRoot, + now: new Date('2026-07-08T12:00:00Z'), + providers: [ + { + provider: 'codex', + displayName: 'Codex', + supported: true, + artifactRoots: [sessionsRoot, archivedRoot], + }, + ], + }); + + await expect(usage.importProvider('codex')).resolves.toEqual({ + provider: 'codex', + importedRecords: 7, + skippedArtifacts: 0, + diagnostics: [], + }); + + const sevenDayOverview = await usage.getOverview({ + rangeDays: 7, + now: new Date('2026-07-08T12:00:00Z'), + }); + expect(sevenDayOverview.providers[0].provider).toBe('codex'); + expect(sevenDayOverview.providers[0].coverageState).toBe('active'); + expect(sevenDayOverview.providers[0].heatmap).toHaveLength(7); + expect(sevenDayOverview.providers[0].heatmap.find((cell) => cell.date === '2026-07-07')).toMatchObject({ + countedInvocations: 6, + failedInvocations: 0, + }); + expect(sevenDayOverview.providers[0].ranking).toMatchObject([ + { skillName: 'browser:control-in-app-browser', countedInvocations: 1 }, + { skillName: 'frontend-testing', countedInvocations: 1 }, + { skillName: 'github:github', countedInvocations: 1 }, + { skillName: 'openai-docs', countedInvocations: 1 }, + { skillName: 'other-project-only', countedInvocations: 1 }, + { skillName: 'vercel-react-best-practices', countedInvocations: 1 }, + ]); + + const thirtyDayOverview = await usage.getOverview({ + rangeDays: 30, + now: new Date('2026-07-08T12:00:00Z'), + }); + expect(thirtyDayOverview.providers[0].ranking).toContainEqual(expect.objectContaining({ + skillName: 'too-old', + countedInvocations: 1, + })); + + await expect(usage.importProvider('codex')).resolves.toMatchObject({ importedRecords: 0 }); + const retainedOverview = await usage.getOverview({ + rangeDays: 30, + now: new Date('2026-07-08T12:00:00Z'), + }); + expect(retainedOverview.providers[0].ranking).toContainEqual(expect.objectContaining({ + skillName: 'too-old', + countedInvocations: 1, + })); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it('records failed skill reads but excludes them from counted totals', async () => { + const root = await mkdtemp(path.join(tmpdir(), 'skillpack-usage-')); + try { + const sessionsRoot = path.join(root, 'codex-sessions'); + const difyRoot = path.join(root, 'dify'); + await mkdir(sessionsRoot, { recursive: true }); + await writeFile(path.join(sessionsRoot, 'session.jsonl'), jsonl([ + sessionMeta('session-a', difyRoot, '2026-07-07T08:00:00Z'), + taskStarted('turn-a', '2026-07-07T08:00:01Z'), + turnContext('turn-a', difyRoot, '2026-07-07T08:00:02Z'), + functionCall('call-a1', "sed -n '1,220p' /Users/twwu/.agents/skills/frontend-testing/SKILL.md", '2026-07-07T08:01:00Z'), + functionOutput('call-a1', 1, '2026-07-07T08:01:01Z'), + taskStarted('turn-b', '2026-07-07T09:00:01Z'), + turnContext('turn-b', difyRoot, '2026-07-07T09:00:02Z'), + functionCall('call-b1', "cat /Users/twwu/.agents/skills/frontend-testing/SKILL.md", '2026-07-07T09:01:00Z'), + functionOutput('call-b1', 0, '2026-07-07T09:01:01Z'), + ])); + const usage = new SkillUsageManager({ + dataDir: path.join(root, 'usage'), + now: new Date('2026-07-08T12:00:00Z'), + providers: [ + { provider: 'codex', displayName: 'Codex', supported: true, artifactRoots: [sessionsRoot] }, + ], + }); + + await usage.importProvider('codex'); + + const overview = await usage.getOverview({ + rangeDays: 7, + now: new Date('2026-07-08T12:00:00Z'), + }); + expect(overview.providers[0].heatmap.find((cell) => cell.date === '2026-07-07')).toMatchObject({ + countedInvocations: 1, + failedInvocations: 1, + }); + expect(overview).toMatchObject({ + providers: [{ + ranking: [ + { skillName: 'frontend-testing', countedInvocations: 1, failedInvocations: 1 }, + ], + }], + }); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it('persists source and ended-at evidence when the invoked skill matches current inventory', async () => { + const root = await mkdtemp(path.join(tmpdir(), 'skillpack-usage-')); + try { + const sessionsRoot = path.join(root, 'codex-sessions'); + const usageRoot = path.join(root, 'usage'); + const skillDir = path.join(root, '.agents', 'skills', 'frontend-testing'); + const skillMdPath = path.join(skillDir, 'SKILL.md'); + await mkdir(sessionsRoot, { recursive: true }); + await mkdir(skillDir, { recursive: true }); + await writeFile(skillMdPath, '---\nname: frontend-testing\n---\n'); + await writeFile(path.join(sessionsRoot, 'session.jsonl'), jsonl([ + sessionMeta('session-a', root, '2026-07-07T08:00:00Z'), + taskStarted('turn-a', '2026-07-07T08:00:01Z'), + turnContext('turn-a', root, '2026-07-07T08:00:02Z'), + functionCall('call-a1', `cat ${skillMdPath}`, '2026-07-07T08:01:00Z'), + functionOutput('call-a1', 0, '2026-07-07T08:01:15Z'), + ])); + const currentSkills = [{ + provider: 'global', + name: 'frontend-testing', + path: skillDir, + resolvedPath: skillDir, + source: { type: 'skillssh' as const, repo: 'owner/repo', skillFolderHash: 'abc123' }, + }]; + const usage = new SkillUsageManager({ + dataDir: usageRoot, + now: new Date('2026-07-08T12:00:00Z'), + providers: [ + { provider: 'codex', displayName: 'Codex', supported: true, artifactRoots: [sessionsRoot] }, + ], + }); + + await expect(usage.importProvider('codex', currentSkills)).resolves.toMatchObject({ importedRecords: 1 }); + + const stored = JSON.parse(await readFile(path.join(usageRoot, 'invocations', 'codex', '2026-07.jsonl'), 'utf-8')); + expect(stored).toMatchObject({ + skillName: 'frontend-testing', + source: { type: 'skillssh', repo: 'owner/repo', skillFolderHash: 'abc123' }, + endedAt: '2026-07-07T08:01:15Z', + }); + + const overview = await usage.getOverview({ + rangeDays: 7, + now: new Date('2026-07-08T12:00:00Z'), + currentSkills, + }); + expect(overview.providers[0].ranking[0]).toMatchObject({ + skillName: 'frontend-testing', + historical: false, + }); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it('limits Provider Skill Ranking to the top 10 counted skills', async () => { + const root = await mkdtemp(path.join(tmpdir(), 'skillpack-usage-')); + try { + const sessionsRoot = path.join(root, 'codex-sessions'); + const difyRoot = path.join(root, 'dify'); + await mkdir(sessionsRoot, { recursive: true }); + const events: unknown[] = [sessionMeta('session-a', difyRoot, '2026-07-07T08:00:00Z')]; + for (let index = 0; index < 11; index += 1) { + const skillName = `skill-${String(index).padStart(2, '0')}`; + events.push( + taskStarted(`turn-${index}`, `2026-07-07T08:${String(index).padStart(2, '0')}:00Z`), + turnContext(`turn-${index}`, difyRoot, `2026-07-07T08:${String(index).padStart(2, '0')}:01Z`), + functionCall(`call-${index}`, `cat /Users/twwu/.agents/skills/${skillName}/SKILL.md`, `2026-07-07T08:${String(index).padStart(2, '0')}:02Z`), + functionOutput(`call-${index}`, 0, `2026-07-07T08:${String(index).padStart(2, '0')}:03Z`), + ); + } + await writeFile(path.join(sessionsRoot, 'session.jsonl'), jsonl(events)); + const usage = new SkillUsageManager({ + dataDir: path.join(root, 'usage'), + now: new Date('2026-07-08T12:00:00Z'), + providers: [ + { provider: 'codex', displayName: 'Codex', supported: true, artifactRoots: [sessionsRoot] }, + ], + }); + + await usage.importProvider('codex'); + + const overview = await usage.getOverview({ + rangeDays: 7, + now: new Date('2026-07-08T12:00:00Z'), + }); + expect(overview.providers[0].ranking.map((row) => row.skillName)).toEqual([ + 'skill-00', + 'skill-01', + 'skill-02', + 'skill-03', + 'skill-04', + 'skill-05', + 'skill-06', + 'skill-07', + 'skill-08', + 'skill-09', + ]); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it('imports idempotently and invalidates old cursors when the importer version changes', async () => { + const root = await mkdtemp(path.join(tmpdir(), 'skillpack-usage-')); + try { + const sessionsRoot = path.join(root, 'codex-sessions'); + const usageRoot = path.join(root, 'usage'); + const difyRoot = path.join(root, 'dify'); + const artifact = path.join(sessionsRoot, 'session.jsonl'); + await mkdir(sessionsRoot, { recursive: true }); + await writeFile(artifact, jsonl([ + sessionMeta('session-a', difyRoot, '2026-07-07T08:00:00Z'), + taskStarted('turn-a', '2026-07-07T08:00:01Z'), + turnContext('turn-a', difyRoot, '2026-07-07T08:00:02Z'), + functionCall('call-a1', "sed -n '1,220p' /Users/twwu/.agents/skills/frontend-testing/SKILL.md", '2026-07-07T08:01:00Z'), + functionOutput('call-a1', 0, '2026-07-07T08:01:01Z'), + ])); + const usage = new SkillUsageManager({ + dataDir: usageRoot, + now: new Date('2026-07-08T12:00:00Z'), + providers: [ + { provider: 'codex', displayName: 'Codex', supported: true, artifactRoots: [sessionsRoot] }, + ], + }); + + await expect(usage.importProvider('codex')).resolves.toMatchObject({ importedRecords: 1 }); + await expect(usage.importProvider('codex')).resolves.toMatchObject({ importedRecords: 0 }); + + const metadata = await stat(artifact); + await mkdir(path.join(usageRoot, 'cursors'), { recursive: true }); + await writeFile(path.join(usageRoot, 'cursors', 'codex.json'), JSON.stringify({ + provider: 'codex', + importerVersion: 2, + artifacts: { + [artifact]: { size: metadata.size, mtimeMs: metadata.mtimeMs }, + }, + }) + '\n', 'utf-8'); + await expect(usage.importProvider('codex')).resolves.toMatchObject({ importedRecords: 1 }); + + const stored = await readFile(path.join(usageRoot, 'invocations', 'codex', '2026-07.jsonl'), 'utf-8'); + expect(stored.trim().split('\n')).toHaveLength(1); + await expect(readFile(path.join(usageRoot, 'cursors', 'codex.json'), 'utf-8')).resolves.toContain('"importerVersion": 5'); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it('skips Claude usage imports while the provider is unsupported', async () => { + const root = await mkdtemp(path.join(tmpdir(), 'skillpack-usage-')); + try { + const sessionsRoot = path.join(root, 'codex-sessions'); + const claudeRoot = path.join(root, 'claude-projects'); + const difyRoot = path.join(root, 'dify'); + await mkdir(sessionsRoot, { recursive: true }); + await mkdir(claudeRoot, { recursive: true }); + await writeFile(path.join(sessionsRoot, 'session.jsonl'), jsonl([ + sessionMeta('session-a', difyRoot, '2026-07-07T08:00:00Z'), + taskStarted('turn-a', '2026-07-07T08:00:01Z'), + turnContext('turn-a', difyRoot, '2026-07-07T08:00:02Z'), + functionCall('call-a1', "sed -n '1,220p' /Users/twwu/.agents/skills/frontend-testing/SKILL.md", '2026-07-07T08:01:00Z'), + functionOutput('call-a1', 0, '2026-07-07T08:01:01Z'), + ])); + await writeFile(path.join(claudeRoot, 'session.jsonl'), jsonl([ + { + type: 'skill_invocation', + session_id: 'claude-session', + timestamp: '2026-07-07T09:00:00Z', + status: 'used', + skill: { name: 'frontend-testing' }, + }, + ])); + const usage = new SkillUsageManager({ + dataDir: path.join(root, 'usage'), + now: new Date('2026-07-08T12:00:00Z'), + providers: [ + { provider: 'codex', displayName: 'Codex', supported: true, artifactRoots: [sessionsRoot] }, + { provider: 'claude', displayName: 'Claude', supported: false, artifactRoots: [claudeRoot] }, + ], + }); + + await expect(usage.importAllProviders()).resolves.toEqual([{ + provider: 'codex', + importedRecords: 1, + skippedArtifacts: 0, + diagnostics: [], + }]); + await expect(usage.getOverview({ + rangeDays: 7, + now: new Date('2026-07-08T12:00:00Z'), + })).resolves.toMatchObject({ + providers: [ + { provider: 'codex', ranking: [{ skillName: 'frontend-testing', countedInvocations: 1 }] }, + { provider: 'claude', coverageState: 'unsupported', ranking: [] }, + ], + }); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); +}); + +function jsonl(events: unknown[]): string { + return events.map((event) => JSON.stringify(event)).join('\n') + '\n'; +} + +function sessionMeta(sessionId: string, cwd: string, timestamp: string): Record { + return { + timestamp, + type: 'session_meta', + payload: { session_id: sessionId, cwd }, + }; +} + +function taskStarted(turnId: string, timestamp: string): Record { + return { + timestamp, + type: 'event_msg', + payload: { type: 'task_started', turn_id: turnId }, + }; +} + +function turnContext(turnId: string, cwd: string, timestamp: string): Record { + return { + timestamp, + type: 'turn_context', + payload: { turn_id: turnId, cwd }, + }; +} + +function functionCall(callId: string, cmd: string, timestamp: string): Record { + return { + timestamp, + type: 'response_item', + payload: { + type: 'function_call', + name: 'exec_command', + call_id: callId, + arguments: JSON.stringify({ cmd }), + }, + }; +} + +function functionOutput(callId: string, exitCode: number, timestamp: string): Record { + return { + timestamp, + type: 'response_item', + payload: { + type: 'function_call_output', + call_id: callId, + output: `Chunk ID: abc\nProcess exited with code ${exitCode}\nOutput:\n`, + }, + }; +} + +function customToolCall(callId: string, cmd: string, timestamp: string): Record { + return { + timestamp, + type: 'response_item', + payload: { + type: 'custom_tool_call', + name: 'exec_command', + call_id: callId, + input: JSON.stringify({ cmd }), + }, + }; +} diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index 0eec63d..91a78a4 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -10,6 +10,7 @@ import { InstallView } from './views/install-view.js'; import { ProjectSkillsView } from './views/project-skills-view.js'; import { SettingsView } from './views/settings-view.js'; import { UpdatesView } from './views/updates-view.js'; +import { UsageView } from './views/usage-view.js'; import { TerminalSizeProvider, useTerminalSize } from './hooks/use-terminal-size.js'; import { getTerminalMode, type TerminalSize } from './lib/responsive-layout.js'; import { HelpOverlay } from './components/help-overlay.js'; @@ -25,6 +26,7 @@ function Router() { case 'project': return ; case 'settings': return ; case 'updates': return ; + case 'usage': return ; } } @@ -65,11 +67,12 @@ function TooSmallTerminalView({ rows }: { rows: number }) { export interface AppSurfaceProps { manager: SkillManager | null; config: SkillpackConfig | null; + onUsageImportConsentChange?: (importConsent: boolean) => Promise; error: string | null; terminalSize?: TerminalSize; } -function AppSurfaceContent({ manager, config, error }: Omit) { +function AppSurfaceContent({ manager, config, onUsageImportConsentChange, error }: Omit) { const { columns, rows } = useTerminalSize(); const terminalMode = getTerminalMode({ columns, rows }); @@ -94,7 +97,7 @@ function AppSurfaceContent({ manager, config, error }: Omit + @@ -102,8 +105,15 @@ function AppSurfaceContent({ manager, config, error }: Omit; +export function AppSurface({ manager, config, onUsageImportConsentChange, error, terminalSize }: AppSurfaceProps) { + const content = ( + + ); return terminalSize ? {content} @@ -111,7 +121,7 @@ export function AppSurface({ manager, config, error, terminalSize }: AppSurfaceP } export function App() { - const { manager, config, error } = useSkillManager(); + const { manager, config, error, setUsageImportConsent } = useSkillManager(); - return ; + return ; } diff --git a/packages/tui/src/context/app-context.tsx b/packages/tui/src/context/app-context.tsx index e9de8c4..01eff70 100644 --- a/packages/tui/src/context/app-context.tsx +++ b/packages/tui/src/context/app-context.tsx @@ -1,4 +1,4 @@ -import { createContext, useContext, useState, useCallback, type ReactNode } from 'react'; +import { createContext, useContext, useState, useCallback, useEffect, type ReactNode } from 'react'; import type { SkillManager, Skill, @@ -9,7 +9,7 @@ import type { ScanPathDiagnostic, } from '@skillpack/core'; -export type ViewType = 'list' | 'detail' | 'install' | 'project' | 'settings' | 'updates'; +export type ViewType = 'list' | 'detail' | 'install' | 'project' | 'settings' | 'updates' | 'usage'; interface AppState { manager: SkillManager; @@ -25,6 +25,9 @@ interface AppState { selectedSkill: SkillInventoryInstance | null; searchQuery: string; loading: boolean; + usageImporting: boolean; + usageImportError: string | null; + usageImportRevision: number; } interface AppContextValue extends AppState { @@ -34,6 +37,8 @@ interface AppContextValue extends AppState { setSelectedSkill: (skill: SkillInventoryInstance | null) => void; setSearchQuery: (query: string) => void; setLoading: (loading: boolean) => void; + setUsageImportConsent: (importConsent: boolean) => Promise; + importSkillUsage: () => Promise; refresh: () => Promise; } @@ -48,10 +53,12 @@ export function useAppContext(): AppContextValue { interface AppProviderProps { manager: SkillManager; config: SkillpackConfig; + onUsageImportConsentChange?: (importConsent: boolean) => Promise; children: ReactNode; } -export function AppProvider({ manager, config, children }: AppProviderProps) { +export function AppProvider({ manager, config, onUsageImportConsentChange, children }: AppProviderProps) { + const [currentConfig, setCurrentConfig] = useState(config); const [skills, setSkills] = useState(manager.getAllSkills()); const [inventory, setInventory] = useState(manager.getInventory()); const [projectSkills, setProjectSkills] = useState(manager.getProjectSkills()); @@ -63,10 +70,48 @@ export function AppProvider({ manager, config, children }: AppProviderProps) { const [selectedSkill, setSelectedSkill] = useState(null); const [searchQuery, setSearchQuery] = useState(''); const [loading, setLoading] = useState(false); + const [usageImporting, setUsageImporting] = useState(false); + const [usageImportError, setUsageImportError] = useState(null); + const [usageImportRevision, setUsageImportRevision] = useState(0); + + const setUsageImportConsent = useCallback(async (importConsent: boolean) => { + const nextConfig = onUsageImportConsentChange + ? await onUsageImportConsentChange(importConsent) + : { + ...currentConfig, + usage: { + ...currentConfig.usage, + importConsent, + }, + }; + setCurrentConfig(nextConfig); + }, [currentConfig, onUsageImportConsentChange]); + + const importSkillUsage = useCallback(async () => { + setUsageImporting(true); + setUsageImportError(null); + try { + await manager.importSkillUsage(); + setUsageImportRevision((value) => value + 1); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + setUsageImportError(message); + throw err; + } finally { + setUsageImporting(false); + } + }, [manager]); + + useEffect(() => { + if (!currentConfig.usage.importConsent) return; + importSkillUsage().catch((err) => { + setUsageImportError(err instanceof Error ? err.message : String(err)); + }); + }, [currentConfig.usage.importConsent, importSkillUsage]); const refresh = useCallback(async () => { setLoading(true); - await manager.scanAll(process.cwd(), config.projectSkillsDirs); + await manager.scanAll(process.cwd(), currentConfig.projectSkillsDirs); const newSkills = manager.getAllSkills(); const newInventory = manager.getInventory(); setSkills(newSkills); @@ -89,13 +134,14 @@ export function AppProvider({ manager, config, children }: AppProviderProps) { return nextInstance ?? nextGroup?.instances[0] ?? null; }); setLoading(false); - }, [manager, config]); + }, [manager, currentConfig]); return ( {children} diff --git a/packages/tui/src/hooks/use-skill-manager.ts b/packages/tui/src/hooks/use-skill-manager.ts index 5df61fc..be35a71 100644 --- a/packages/tui/src/hooks/use-skill-manager.ts +++ b/packages/tui/src/hooks/use-skill-manager.ts @@ -10,11 +10,13 @@ export interface SkillManagerResult { manager: SkillManager | null; config: SkillpackConfig | null; error: string | null; + setUsageImportConsent: (importConsent: boolean) => Promise; } export function useSkillManager(): SkillManagerResult { const [manager, setManager] = useState(null); const [config, setConfig] = useState(null); + const [configManager, setConfigManager] = useState(null); const [error, setError] = useState(null); useEffect(() => { @@ -24,7 +26,22 @@ export function useSkillManager(): SkillManagerResult { try { const configManager = new ConfigManager(); const cfg = await configManager.load(); - const mgr = new SkillManager(); + const mgr = new SkillManager({ + usageProviders: [ + { + provider: 'codex', + displayName: 'Codex', + supported: true, + artifactRoots: cfg.usage.artifactRoots.codex ?? [], + }, + { + provider: 'claude', + displayName: 'Claude', + supported: false, + artifactRoots: cfg.usage.artifactRoots.claude ?? [], + }, + ], + }); const providerFactories: Record InstanceType> = { codex: () => new CodexProvider(cfg.providers.codex?.paths), @@ -54,6 +71,7 @@ export function useSkillManager(): SkillManagerResult { if (!cancelled) { setManager(mgr); setConfig(cfg); + setConfigManager(configManager); } } catch (err) { if (!cancelled) setError(err instanceof Error ? err.message : String(err)); @@ -64,5 +82,12 @@ export function useSkillManager(): SkillManagerResult { return () => { cancelled = true; }; }, []); - return { manager, config, error }; + async function setUsageImportConsent(importConsent: boolean): Promise { + if (!configManager) throw new Error('Config manager is not ready'); + const nextConfig = await configManager.setUsageImportConsent(importConsent); + setConfig(nextConfig); + return nextConfig; + } + + return { manager, config, error, setUsageImportConsent }; } diff --git a/packages/tui/src/lib/responsive-layout.ts b/packages/tui/src/lib/responsive-layout.ts index a56f8ce..8fffd52 100644 --- a/packages/tui/src/lib/responsive-layout.ts +++ b/packages/tui/src/lib/responsive-layout.ts @@ -182,6 +182,7 @@ const DETAIL_SECTIONS: DetailSection[] = [ ]; const PROJECT_STATE_WIDTH = 15; const SETTINGS_STATUS_WIDTH = 8; +const SHORTCUT_PREFIX_WIDTH = 3; export function getInventoryLayout(input: InventoryLayoutInput): InventoryLayout { const mode = getTerminalMode(input.size); @@ -228,7 +229,7 @@ export function getVisibleShortcuts( size: TerminalSize, ): Array { const mode = getTerminalMode(size); - const maxWidth = Math.max(0, size.columns - HORIZONTAL_PADDING); + const maxWidth = Math.max(0, size.columns - HORIZONTAL_PADDING - SHORTCUT_PREFIX_WIDTH); const result: Array = []; for (const shortcut of shortcuts) { @@ -236,7 +237,7 @@ export function getVisibleShortcuts( ? (shortcut.compactLabel ?? shortcut.label) : shortcut.label; const next = [...result, { ...shortcut, displayLabel }]; - if (mode === 'compact' && measureShortcutLine(next) > maxWidth) break; + if (measureShortcutLine(next) > maxWidth) break; result.push({ ...shortcut, displayLabel }); } diff --git a/packages/tui/src/lib/shortcuts.ts b/packages/tui/src/lib/shortcuts.ts index 1cdf2ab..b93b635 100644 --- a/packages/tui/src/lib/shortcuts.ts +++ b/packages/tui/src/lib/shortcuts.ts @@ -6,17 +6,28 @@ export interface Shortcut extends ShortcutLayoutItem {} const SHORTCUTS: Record = { list: [ - { key: '↑↓', label: 'navigate', compactLabel: 'nav' }, + { key: '↑↓', label: 'nav' }, { key: 'enter', label: 'detail' }, - { key: '/', label: 'search' }, + { key: '/', label: 'search', compactLabel: 'find' }, { key: '?', label: 'help' }, { key: 'tab', label: 'tabs' }, + { key: 'g', label: 'usage' }, { key: 'p', label: 'project' }, { key: 's', label: 'settings' }, { key: 'i', label: 'install' }, { key: 'u', label: 'updates' }, { key: 'q', label: 'quit' }, ], + usage: [ + { key: 'esc', label: 'back' }, + { key: '?', label: 'help' }, + { key: '↑↓', label: 'provider' }, + { key: '←→', label: 'cell' }, + { key: 'tab', label: 'range' }, + { key: 'r', label: 'rescan' }, + { key: 'x', label: 'reset' }, + { key: 'q', label: 'quit' }, + ], install: [ { key: 'esc', label: 'back' }, { key: '?', label: 'help' }, diff --git a/packages/tui/src/views/list-view.tsx b/packages/tui/src/views/list-view.tsx index bc6a6bc..8090e74 100644 --- a/packages/tui/src/views/list-view.tsx +++ b/packages/tui/src/views/list-view.tsx @@ -94,6 +94,7 @@ export function ListView() { if (input === 's') { setView('settings'); return; } if (input === 'i') { setView('install'); return; } if (input === 'u') { setView('updates'); return; } + if (input === 'g') { setView('usage'); return; } if (key.return && skills[cursor]) { setSelectedGroup(skills[cursor].group); setSelectedSkill(skills[cursor].instance); diff --git a/packages/tui/src/views/settings-view.tsx b/packages/tui/src/views/settings-view.tsx index ac77827..23ef9b2 100644 --- a/packages/tui/src/views/settings-view.tsx +++ b/packages/tui/src/views/settings-view.tsx @@ -49,6 +49,10 @@ export function SettingsView() { enabled: source.enabled, })), [config.sources]); + const usageRootRows = useMemo(() => Object.entries(config.usage.artifactRoots).flatMap(([provider, roots]) => ( + roots.map((root) => ({ provider, root })) + )), [config.usage.artifactRoots]); + const contentRows: SettingsRow[] = useMemo(() => { const result: SettingsRow[] = [ { key: 'scan-heading', element: Scan Roots }, @@ -86,6 +90,26 @@ export function SettingsView() { } } + result.push({ key: 'usage-roots-gap', element: }); + result.push({ key: 'usage-roots-heading', element: Usage Artifact Roots }); + if (usageRootRows.length === 0) { + result.push({ key: 'usage-roots-empty', element: No Usage Artifact Roots configured. }); + } else { + for (const row of usageRootRows) { + result.push({ + key: `usage-root:${row.provider}:${row.root}`, + element: ( + + {fitCell(row.provider, tableColumns.scope)} + {fitCell('usage-root', tableColumns.kind)} + {fitCell('configured', tableColumns.status)} + {fitCell(formatDisplayPath(row.root), tableColumns.path)} + + ), + }); + } + } + result.push({ key: 'provider-gap', element: }); result.push({ key: 'provider-heading', element: Providers }); for (const provider of providerRows) { @@ -116,7 +140,7 @@ export function SettingsView() { } return result; - }, [providerRows, scanPaths, sourceRows, tableColumns]); + }, [providerRows, scanPaths, sourceRows, tableColumns, usageRootRows]); const visibleRows = layout.visibleRows; const visibleContent = contentRows.slice(scrollOffset, scrollOffset + visibleRows); diff --git a/packages/tui/src/views/usage-view.tsx b/packages/tui/src/views/usage-view.tsx new file mode 100644 index 0000000..7e03503 --- /dev/null +++ b/packages/tui/src/views/usage-view.tsx @@ -0,0 +1,262 @@ +import { useEffect, useState } from 'react'; +import { Box, Text, useApp, useInput } from 'ink'; +import { ConfirmDialog } from '../components/confirm-dialog.js'; +import { StatusBar } from '../components/status-bar.js'; +import { useAppContext } from '../context/app-context.js'; +import type { + ProviderSkillUsageOverview, + SkillUsageHeatmapCell, + SkillUsageOverview, + UsageCoverageState, +} from '@skillpack/core'; + +const USAGE_RANGES = [7, 30, 90] as const; +const HEATMAP_COLUMNS = 7; + +export function UsageView() { + const { exit } = useApp(); + const { + manager, + config, + setUsageImportConsent, + setView, + importSkillUsage, + usageImporting, + usageImportError, + usageImportRevision, + } = useAppContext(); + const [error, setError] = useState(null); + const [saving, setSaving] = useState(false); + const [overview, setOverview] = useState(null); + const [rangeIndex, setRangeIndex] = useState(0); + const [selectedProviderIndex, setSelectedProviderIndex] = useState(0); + const [selectedHeatmapIndex, setSelectedHeatmapIndex] = useState(0); + const [refreshNonce, setRefreshNonce] = useState(0); + const [confirmingReset, setConfirmingReset] = useState(false); + const rangeDays = USAGE_RANGES[rangeIndex]; + const hasConsent = config.usage?.importConsent === true; + const selectedProvider = overview?.providers[selectedProviderIndex]; + const selectedHeatmapCell = selectedProvider?.heatmap[selectedHeatmapIndex]; + + useEffect(() => { + if (!hasConsent) { + setOverview(null); + return; + } + + let cancelled = false; + manager.getSkillUsageOverview({ rangeDays }) + .then((nextOverview) => { + if (!cancelled) setOverview(nextOverview); + }) + .catch((err) => { + if (!cancelled) setError(err instanceof Error ? err.message : String(err)); + }); + + return () => { cancelled = true; }; + }, [hasConsent, manager, rangeDays, refreshNonce, usageImportRevision]); + + useEffect(() => { + if (!overview) return; + if (selectedProviderIndex >= overview.providers.length) setSelectedProviderIndex(0); + }, [overview, selectedProviderIndex]); + + useEffect(() => { + if (!selectedProvider) { + setSelectedHeatmapIndex(0); + return; + } + if (selectedHeatmapIndex >= selectedProvider.heatmap.length) setSelectedHeatmapIndex(0); + }, [selectedProvider, selectedHeatmapIndex]); + + useInput((input, key) => { + if (input === 'q') { exit(); return; } + if (key.escape) { setView('list'); } + if (key.tab && hasConsent) { + setRangeIndex((index) => (index + 1) % USAGE_RANGES.length); + setSelectedHeatmapIndex(0); + } + if (key.upArrow && hasConsent && overview && overview.providers.length > 1) { + setSelectedProviderIndex((index) => (index + overview.providers.length - 1) % overview.providers.length); + setSelectedHeatmapIndex(0); + } + if (key.downArrow && hasConsent && overview && overview.providers.length > 1) { + setSelectedProviderIndex((index) => (index + 1) % overview.providers.length); + setSelectedHeatmapIndex(0); + } + if (key.leftArrow && hasConsent && selectedProvider && selectedProvider.heatmap.length > 0) { + setSelectedHeatmapIndex((index) => (index + selectedProvider.heatmap.length - 1) % selectedProvider.heatmap.length); + } + if (key.rightArrow && hasConsent && selectedProvider && selectedProvider.heatmap.length > 0) { + setSelectedHeatmapIndex((index) => (index + 1) % selectedProvider.heatmap.length); + } + if (input === 'r' && hasConsent) { + setOverview(null); + importSkillUsage().catch((err) => { setError(err instanceof Error ? err.message : String(err)); }); + } + if (input === 'x' && hasConsent) { + setConfirmingReset(true); + } + if (input.toLowerCase() === 'y' && !hasConsent && !saving) { + setSaving(true); + setError(null); + setUsageImportConsent(true) + .catch((err) => { setError(err instanceof Error ? err.message : String(err)); }) + .finally(() => { setSaving(false); }); + } + }); + + if (confirmingReset) { + return ( + { + manager.resetSkillUsage() + .then(() => { + setConfirmingReset(false); + setOverview(null); + setRefreshNonce((value) => value + 1); + }) + .catch((err) => { + setError(err instanceof Error ? err.message : String(err)); + setConfirmingReset(false); + }); + }} + onCancel={() => { setConfirmingReset(false); }} + /> + ); + } + + return ( + + + Skill Usage + {rangeDays} days + + + + {hasConsent ? ( + <> + Usage coverage + {overview + ? overview.providers.map((provider) => ( + + )) + : Loading usage...} + {usageImporting && Importing usage...} + {selectedProvider && selectedProvider.heatmap.length > 0 && ( + <> + Heatmap + {chunkHeatmap(selectedProvider.heatmap).map((row, rowIndex) => ( + + {row.map((cell, columnIndex) => { + const index = rowIndex * HEATMAP_COLUMNS + columnIndex; + return ( + + {formatHeatmapCell(cell, index === selectedHeatmapIndex)} + + ); + })} + + ))} + {selectedHeatmapCell && ( + + {selectedHeatmapCell.date}{' '} + {selectedProvider.displayName}{' '} + counted {selectedHeatmapCell.countedInvocations} + {selectedHeatmapCell.failedInvocations > 0 ? ` failed ${selectedHeatmapCell.failedInvocations}` : ''} + + )} + + )} + {selectedProvider && selectedProvider.ranking.length > 0 && ( + <> + Ranking + {selectedProvider.ranking.map((row) => ( + + {row.skillName}{' -> '}{row.countedInvocations} + {row.failedInvocations > 0 ? ` failed ${row.failedInvocations}` : ''} + {row.historical ? ' historical' : ''} + + ))} + + )} + {selectedProvider && selectedProvider.diagnostics.length > 0 && ( + <> + Import diagnostics + {selectedProvider.diagnostics.map((diagnostic) => ( + {diagnostic.message} + ))} + + )} + {usageImportError && {usageImportError}} + {error && {error}} + + ) : ( + <> + Enable usage import? + Skillpack can derive aggregate Skill Usage from provider session artifacts. + No prompts, responses, tool arguments, file contents, or transcripts are stored. + Press y to enable. + {saving && Saving consent...} + {error && {error}} + + )} + + + + + + ); +} + +function ProviderCoverageRow({ provider, selected }: { provider: ProviderSkillUsageOverview; selected: boolean }) { + const badge = coverageBadge(provider.coverageState); + return ( + + {selected ? '> ' : ' '} + {provider.displayName}{' '} + {badge.label} + + ); +} + +function coverageBadge(state: UsageCoverageState): { label: string; color: string } { + switch (state) { + case 'active': return { label: '[active]', color: 'green' }; + case 'zero': return { label: '[zero]', color: 'yellow' }; + case 'not-configured': return { label: '[not configured]', color: 'yellow' }; + case 'unsupported': return { label: '[unsupported]', color: 'red' }; + } +} + +function chunkHeatmap(cells: SkillUsageHeatmapCell[]): SkillUsageHeatmapCell[][] { + const rows: SkillUsageHeatmapCell[][] = []; + for (let index = 0; index < cells.length; index += HEATMAP_COLUMNS) { + rows.push(cells.slice(index, index + HEATMAP_COLUMNS)); + } + return rows; +} + +function formatHeatmapCell(cell: SkillUsageHeatmapCell, selected: boolean): string { + const label = cell.failedInvocations > 0 + ? `!${cappedCount(cell.failedInvocations)}` + : cappedCount(cell.countedInvocations); + const padded = label.padStart(3).slice(-3); + return selected ? `[${padded}] ` : ` ${padded} `; +} + +function cappedCount(value: number): string { + return value > 99 ? '99+' : String(value); +} + +function heatmapColor(cell: SkillUsageHeatmapCell): string { + if (cell.failedInvocations > 0) return 'red'; + if (cell.countedInvocations >= 10) return 'magenta'; + if (cell.countedInvocations > 0) return 'cyan'; + return 'gray'; +} diff --git a/packages/tui/tests/fixtures/inventory.ts b/packages/tui/tests/fixtures/inventory.ts index be7271c..45cc2bf 100644 --- a/packages/tui/tests/fixtures/inventory.ts +++ b/packages/tui/tests/fixtures/inventory.ts @@ -5,8 +5,12 @@ import type { SkillpackConfig, } from '@skillpack/core'; -export function testConfig(overrides: Partial = {}): SkillpackConfig { - return { +type TestConfigOverrides = Partial> & { + usage?: Partial; +}; + +export function testConfig(overrides: TestConfigOverrides = {}): SkillpackConfig { + const config: SkillpackConfig = { editor: 'vi', autoCheckUpdates: false, projectSkillsDirs: ['.codex/skills', '.claude/skills', '.agents/skills'], @@ -18,7 +22,21 @@ export function testConfig(overrides: Partial = {}): SkillpackC sources: { skillssh: { enabled: true }, }, + usage: { + importConsent: false, + artifactRoots: { + codex: [], + claude: [], + }, + }, + }; + return { + ...config, ...overrides, + usage: { + ...config.usage, + ...overrides.usage, + }, }; } diff --git a/packages/tui/tests/helpers/mock-manager.ts b/packages/tui/tests/helpers/mock-manager.ts index c0a1628..701218e 100644 --- a/packages/tui/tests/helpers/mock-manager.ts +++ b/packages/tui/tests/helpers/mock-manager.ts @@ -8,6 +8,7 @@ import type { SkillInventoryInstance, SkillManager, UpdateInfo, + SkillUsageOverview, } from '@skillpack/core'; import { basicInventory, skillsFromInventory } from '../fixtures/inventory.js'; @@ -25,6 +26,9 @@ export type MockSkillManager = SkillManager & { checkUpdates: ReturnType; searchRemote: ReturnType; installFromSource: ReturnType; + importSkillUsage: ReturnType; + resetSkillUsage: ReturnType; + getSkillUsageOverview: ReturnType; }; export interface MockSkillManagerOptions { @@ -36,6 +40,7 @@ export interface MockSkillManagerOptions { remoteResults?: RemoteSkill[]; updateInfo?: UpdateInfo | null; updates?: Array<{ skill: Skill; update: UpdateInfo }>; + usageOverview?: SkillUsageOverview; } export function createMockManager(options: MockSkillManagerOptions = {}): MockSkillManager { @@ -47,6 +52,27 @@ export function createMockManager(options: MockSkillManagerOptions = {}): MockSk const remoteResults = options.remoteResults ?? []; const updates = options.updates ?? []; const updateInfo = options.updateInfo ?? { hasUpdate: false }; + const usageOverview = options.usageOverview ?? { + range: { days: 30, from: '2026-06-08', to: '2026-07-07' }, + providers: [ + { + provider: 'codex', + displayName: 'Codex', + coverageState: 'unsupported', + heatmap: [], + ranking: [], + diagnostics: [], + }, + { + provider: 'claude', + displayName: 'Claude', + coverageState: 'unsupported', + heatmap: [], + ranking: [], + diagnostics: [], + }, + ], + }; return { getAllSkills: vi.fn(() => skills), @@ -62,5 +88,8 @@ export function createMockManager(options: MockSkillManagerOptions = {}): MockSk checkUpdates: vi.fn(async () => updates), searchRemote: vi.fn(async () => remoteResults), installFromSource: vi.fn(async () => {}), + importSkillUsage: vi.fn(async () => []), + resetSkillUsage: vi.fn(async () => {}), + getSkillUsageOverview: vi.fn(async () => usageOverview), } as unknown as MockSkillManager; } diff --git a/packages/tui/tests/helpers/render-tui.tsx b/packages/tui/tests/helpers/render-tui.tsx index da51068..9890da3 100644 --- a/packages/tui/tests/helpers/render-tui.tsx +++ b/packages/tui/tests/helpers/render-tui.tsx @@ -12,6 +12,7 @@ export interface RenderTuiOptions { manager?: MockSkillManager; inventory?: SkillGroup[]; config?: SkillpackConfig; + onUsageImportConsentChange?: (importConsent: boolean) => Promise; terminalSize?: TerminalSize; error?: string | null; } @@ -55,6 +56,7 @@ export function renderTui(options: RenderTuiOptions = {}): RenderTuiResult { manager={manager as SkillManager} config={config} error={options.error ?? null} + onUsageImportConsentChange={options.onUsageImportConsentChange} terminalSize={options.terminalSize ?? { columns: 80, rows: 24 }} />, ); diff --git a/packages/tui/tests/responsive-layout.test.ts b/packages/tui/tests/responsive-layout.test.ts index 2cd5aaf..0773462 100644 --- a/packages/tui/tests/responsive-layout.test.ts +++ b/packages/tui/tests/responsive-layout.test.ts @@ -128,12 +128,19 @@ describe('shortcut layout', () => { { key: 'q', label: 'quit' }, ]; - it('keeps all shortcuts at full width', () => { - const visible = getVisibleShortcuts(shortcuts, { columns: 120, rows: 24 }); + it('keeps all shortcuts when the footer is wide enough', () => { + const visible = getVisibleShortcuts(shortcuts, { columns: 160, rows: 24 }); expect(visible.map((shortcut) => shortcut.key)).toEqual(shortcuts.map((shortcut) => shortcut.key)); }); + it('keeps full-mode shortcuts within the footer width', () => { + const visible = getVisibleShortcuts(shortcuts, { columns: 80, rows: 24 }); + + expect(visible.length).toBeLessThan(shortcuts.length); + expect(measureShortcutLine(visible)).toBeLessThanOrEqual(75); + }); + it('keeps compact shortcuts within the footer width and preserves help', () => { const visible = getVisibleShortcuts(shortcuts, { columns: 60, rows: 18 }); diff --git a/packages/tui/tests/shortcuts.test.ts b/packages/tui/tests/shortcuts.test.ts index 86cf3c3..84d0ee2 100644 --- a/packages/tui/tests/shortcuts.test.ts +++ b/packages/tui/tests/shortcuts.test.ts @@ -23,6 +23,12 @@ describe('getShortcutsForView', () => { expect(shortcuts.map((shortcut) => shortcut.key)).toContain('u'); }); + it('shows the Usage section shortcut in the list', () => { + const shortcuts = getShortcutsForView({ view: 'list', selectedSkill: null, canToggle: false }); + + expect(shortcuts.map((shortcut) => shortcut.key)).toContain('g'); + }); + it('shows Detail update shortcut only for skills.sh-managed Global Skills', () => { const codexShortcuts = getShortcutsForView({ view: 'detail', selectedSkill: skill('codex'), canToggle: false }); const globalShortcuts = getShortcutsForView({ view: 'detail', selectedSkill: skill('global'), canToggle: false }); diff --git a/packages/tui/tests/ui/__snapshots__/app-surface.test.tsx.snap b/packages/tui/tests/ui/__snapshots__/app-surface.test.tsx.snap index e37ee50..0f4265d 100644 --- a/packages/tui/tests/ui/__snapshots__/app-surface.test.tsx.snap +++ b/packages/tui/tests/ui/__snapshots__/app-surface.test.tsx.snap @@ -23,8 +23,8 @@ exports[`Terminal UI Tests > renders a stable inventory frame 1`] = ` - ──↑↓ navigat enter detai / searc ? hel tab tab p projec s setting i u update q quit - install" + + ── ↑↓ nav enter detail / search ? help tab tabs g usage p project" `; exports[`Terminal UI Tests > renders a stable too-small terminal frame 1`] = ` diff --git a/packages/tui/tests/ui/app-surface.test.tsx b/packages/tui/tests/ui/app-surface.test.tsx index b55a6f0..639a263 100644 --- a/packages/tui/tests/ui/app-surface.test.tsx +++ b/packages/tui/tests/ui/app-surface.test.tsx @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it } from 'vitest'; -import { inventoryGroup, inventoryInstance } from '../fixtures/inventory.js'; +import { inventoryGroup, inventoryInstance, testConfig } from '../fixtures/inventory.js'; import { createMockManager } from '../helpers/mock-manager.js'; import { keypress, renderTui, waitForFrame, type RenderTuiResult } from '../helpers/render-tui.js'; @@ -177,4 +177,282 @@ describe.sequential('Terminal UI Tests', () => { app.stdin.write('i'); await waitForFrame(app, (output) => output.includes('Install Skill') && output.includes('Search skills.sh')); }); + + it('shows Usage Artifact Roots in Settings', async () => { + const app = renderApp({ + config: testConfig({ + usage: { + artifactRoots: { + codex: ['/tmp/codex-usage'], + claude: ['/tmp/claude-usage'], + }, + }, + }), + }); + await waitForFrame(app, (output) => output.includes('* Skillpack') && !output.includes('Scanning skills')); + + app.stdin.write('s'); + + await waitForFrame(app, (output) => ( + output.includes('Usage Artifact Roots') + && output.includes('codex') + && output.includes('/tmp/codex-usage') + )); + }); + + it('opens the Usage view from inventory', async () => { + const app = renderApp(); + await waitForFrame(app, (output) => output.includes('* Skillpack') && !output.includes('Scanning skills')); + + app.stdin.write('g'); + + await waitForFrame(app, (output) => ( + output.includes('Skill Usage') && output.includes('Enable usage import?') + )); + + app.stdin.write(keypress.escape); + await waitForFrame(app, (output) => output.includes('* Skillpack') && output.includes('alpha')); + }); + + it('persists Usage Import Consent from the Usage view', async () => { + let persistedConsent: boolean | null = null; + const app = renderApp({ + onUsageImportConsentChange: async (importConsent) => { + persistedConsent = importConsent; + return testConfig({ usage: { importConsent } }); + }, + }); + await waitForFrame(app, (output) => output.includes('* Skillpack') && !output.includes('Scanning skills')); + + app.stdin.write('g'); + await waitForFrame(app, (output) => output.includes('Enable usage import?')); + + app.stdin.write('y'); + + await waitForFrame(app, (output) => output.includes('Usage coverage') && output.includes('Codex [unsupported]')); + expect(persistedConsent).toBe(true); + }); + + it('shows Usage Coverage State from aggregate results', async () => { + const manager = createMockManager({ + usageOverview: { + range: { days: 30, from: '2026-06-08', to: '2026-07-07' }, + providers: [ + { + provider: 'codex', + displayName: 'Codex', + coverageState: 'zero', + heatmap: [], + ranking: [], + diagnostics: [], + }, + { + provider: 'claude', + displayName: 'Claude', + coverageState: 'unsupported', + heatmap: [], + ranking: [], + diagnostics: [], + }, + ], + }, + }); + const app = renderApp({ + manager, + config: testConfig({ usage: { importConsent: true } }), + }); + await waitForFrame(app, (output) => output.includes('* Skillpack') && !output.includes('Scanning skills')); + + app.stdin.write('g'); + + await waitForFrame(app, (output) => output.includes('Codex [zero]') && output.includes('Claude [unsupported]')); + expect(manager.importSkillUsage).toHaveBeenCalledTimes(1); + expect(manager.getSkillUsageOverview).toHaveBeenCalledWith({ rangeDays: 7 }); + }); + + it('shows Skill Usage heatmap counts and Provider Skill Ranking', async () => { + const manager = createMockManager({ + usageOverview: { + range: { days: 30, from: '2026-06-08', to: '2026-07-07' }, + providers: [{ + provider: 'codex', + displayName: 'Codex', + coverageState: 'active', + heatmap: [ + { date: '2026-07-06', countedInvocations: 2, failedInvocations: 0 }, + { date: '2026-07-07', countedInvocations: 1, failedInvocations: 0 }, + ], + ranking: [ + { skillName: 'tdd', countedInvocations: 2, failedInvocations: 0, lastInvokedAt: '2026-07-06T10:02:00Z', historical: false }, + { skillName: 'grilling', countedInvocations: 1, failedInvocations: 0, lastInvokedAt: '2026-07-07T08:00:00Z', historical: false }, + ], + diagnostics: [], + }], + }, + }); + const app = renderApp({ + manager, + config: testConfig({ usage: { importConsent: true } }), + }); + await waitForFrame(app, (output) => output.includes('* Skillpack') && !output.includes('Scanning skills')); + + app.stdin.write('g'); + + await waitForFrame(app, (output) => ( + output.includes('[ 2]') + && output.includes('2026-07-06 Codex counted 2') + && output.includes('tdd -> 2') + && output.includes('grilling -> 1') + )); + + app.stdin.write(keypress.right); + await waitForFrame(app, (output) => output.includes('2026-07-07 Codex counted 1')); + }); + + it('shows failure context, Historical Skills, and Usage Import Diagnostics', async () => { + const manager = createMockManager({ + usageOverview: { + range: { days: 30, from: '2026-06-08', to: '2026-07-07' }, + providers: [{ + provider: 'codex', + displayName: 'Codex', + coverageState: 'active', + heatmap: [ + { date: '2026-07-06', countedInvocations: 1, failedInvocations: 1 }, + ], + ranking: [ + { skillName: 'deleted-skill', countedInvocations: 1, failedInvocations: 1, lastInvokedAt: '2026-07-06T10:02:00Z', historical: true }, + ], + diagnostics: [ + { provider: 'codex', message: 'Skipped ambiguous skill invocation evidence in session.jsonl:1' }, + ], + }], + }, + }); + const app = renderApp({ + manager, + config: testConfig({ usage: { importConsent: true } }), + }); + await waitForFrame(app, (output) => output.includes('* Skillpack') && !output.includes('Scanning skills')); + + app.stdin.write('g'); + + await waitForFrame(app, (output) => ( + output.includes('[ !1]') + && output.includes('2026-07-06 Codex counted 1 failed 1') + && output.includes('deleted-skill -> 1 failed 1 historical') + && output.includes('Skipped ambiguous skill invocation evidence') + )); + }); + + it('switches the selected Skill Usage provider', async () => { + const manager = createMockManager({ + usageOverview: { + range: { days: 7, from: '2026-07-01', to: '2026-07-07' }, + providers: [ + { + provider: 'codex', + displayName: 'Codex', + coverageState: 'zero', + heatmap: [], + ranking: [], + diagnostics: [], + }, + { + provider: 'claude', + displayName: 'Claude', + coverageState: 'active', + heatmap: [ + { date: '2026-07-07', countedInvocations: 3, failedInvocations: 0 }, + ], + ranking: [ + { skillName: 'claude-skill', countedInvocations: 3, failedInvocations: 0, lastInvokedAt: '2026-07-07T10:00:00Z', historical: false }, + ], + diagnostics: [], + }, + ], + }, + }); + const app = renderApp({ + manager, + config: testConfig({ usage: { importConsent: true } }), + }); + await waitForFrame(app, (output) => output.includes('* Skillpack') && !output.includes('Scanning skills')); + + app.stdin.write('g'); + await waitForFrame(app, (output) => output.includes('> Codex [zero]') && output.includes(' Claude [active]')); + + app.stdin.write(keypress.down); + + await waitForFrame(app, (output) => ( + output.includes(' Codex [zero]') + && output.includes('> Claude [active]') + && output.includes('claude-skill -> 3') + )); + }); + + it('cycles Skill Usage ranges from 7 to 30 days', async () => { + const manager = createMockManager(); + manager.getSkillUsageOverview.mockImplementation(async ({ rangeDays }) => ({ + range: { days: rangeDays, from: '2026-01-01', to: '2026-01-30' }, + providers: [{ + provider: 'codex', + displayName: 'Codex', + coverageState: 'zero', + heatmap: [], + ranking: [], + diagnostics: [], + }], + })); + const app = renderApp({ + manager, + config: testConfig({ usage: { importConsent: true } }), + }); + await waitForFrame(app, (output) => output.includes('* Skillpack') && !output.includes('Scanning skills')); + + app.stdin.write('g'); + await waitForFrame(app, (output) => output.includes('Skill Usage') && output.includes('7 days')); + + app.stdin.write(keypress.tab); + + await waitForFrame(app, (output) => output.includes('Skill Usage') && output.includes('30 days')); + expect(manager.getSkillUsageOverview).toHaveBeenCalledWith({ rangeDays: 30 }); + }); + + it('manually rescans Skill Usage for the selected range', async () => { + const manager = createMockManager(); + const app = renderApp({ + manager, + config: testConfig({ usage: { importConsent: true } }), + }); + await waitForFrame(app, (output) => output.includes('* Skillpack') && !output.includes('Scanning skills')); + + app.stdin.write('g'); + await waitForFrame(app, (output) => output.includes('Usage coverage')); + + app.stdin.write('r'); + await waitForFrame(app, () => manager.importSkillUsage.mock.calls.length === 2); + + expect(manager.getSkillUsageOverview).toHaveBeenLastCalledWith({ rangeDays: 7 }); + }); + + it('confirms Skill Usage Reset before deleting derived usage data', async () => { + const manager = createMockManager(); + const app = renderApp({ + manager, + config: testConfig({ usage: { importConsent: true } }), + }); + await waitForFrame(app, (output) => output.includes('* Skillpack') && !output.includes('Scanning skills')); + + app.stdin.write('g'); + await waitForFrame(app, (output) => output.includes('Usage coverage')); + + app.stdin.write('x'); + await waitForFrame(app, (output) => output.includes('Reset Skill Usage data?')); + + app.stdin.write('y'); + await waitForFrame(app, () => manager.resetSkillUsage.mock.calls.length === 1); + + expect(manager.getSkillUsageOverview).toHaveBeenLastCalledWith({ rangeDays: 7 }); + }); }); From f7fc43ed50cbd1df267b0eeae001832325e5695e Mon Sep 17 00:00:00 2001 From: WTW0313 Date: Wed, 8 Jul 2026 17:54:25 +0800 Subject: [PATCH 2/4] feat: redesign skill usage view --- CONTEXT.md | 8 +- docs/prds/skill-usage-tracing.md | 36 +- packages/core/src/usage.ts | 98 +++- packages/core/tests/usage.test.ts | 94 +++- packages/tui/src/lib/shortcuts.ts | 8 +- packages/tui/src/views/usage-view.tsx | 577 +++++++++++++++++---- packages/tui/tests/helpers/mock-manager.ts | 2 + packages/tui/tests/helpers/render-tui.tsx | 5 + packages/tui/tests/ui/app-surface.test.tsx | 189 ++++++- 9 files changed, 850 insertions(+), 167 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index bbd029e..984f048 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -92,12 +92,16 @@ _Avoid_: Skill Inventory, Skill Availability, inventory status, session history, A Skill Usage aggregate that ranks skills within a Skill Provider by exact Counted Invocation count, with failed invocation count and recency as supporting context. _Avoid_: Global leaderboard, inventory order +**Selected-Day Usage Detail**: +The Skill Usage detail for one selected heatmap day, scoped to one Session-Producing Provider and grouped by skill with exact Counted Invocation and failure context for that day. +_Avoid_: Session transcript, raw invocation history, provider-wide total + **Skill Usage Heatmap**: -A Skill Usage aggregate that shows exact Counted Invocation volume over time. The overview heatmap compares Skill Providers by day using one scale across the selected range, while provider drilldowns can compare skills by day within one provider. +A Skill Usage aggregate that shows Counted Invocation volume over time using colored block intensity, with exact counts exposed through Selected-Day Usage Detail. The overview heatmap compares Skill Providers by day using one scale across the selected range, while provider drilldowns can compare skills by day within one provider. _Avoid_: Inventory status grid, provider availability matrix **Skill Usage View**: -The top-level TUI workflow for Skill Usage, combining a provider-by-day heatmap with Provider Skill Ranking for the selected Skill Provider and time range. +The top-level TUI workflow for Skill Usage, combining provider selection, a provider/day heatmap, Selected-Day Usage Detail, and Provider Skill Ranking for the selected Skill Provider and time range. _Avoid_: Skill Inventory tab, update view, session browser **Skill Usage Import**: diff --git a/docs/prds/skill-usage-tracing.md b/docs/prds/skill-usage-tracing.md index 3a81a44..6cf99d8 100644 --- a/docs/prds/skill-usage-tracing.md +++ b/docs/prds/skill-usage-tracing.md @@ -25,8 +25,8 @@ Skill Usage is separate from Skill Inventory. Inventory answers what is currentl 7. As a Skillpack user, I want Skillpack to persist only minimal invocation facts, so that prompts, responses, tool arguments, file contents, and transcripts are not copied. 8. As a Skillpack user, I want failed invocations recorded but excluded from default rankings, so that broken skills do not look popular. 9. As a Skillpack user, I want failure counts shown as separate context, so that I can notice repeated failed usage without confusing it with normal activity. -10. As a Skillpack user, I want heatmap cells to show exact counts or capped labels, so that usage remains understandable without color. -11. As a Skillpack user, I want the selected heatmap cell to show its exact provider, date, and count, so that compact cells are still inspectable. +10. As a Skillpack user, I want heatmap cells to use clear colored activity blocks, so that usage patterns remain quickly scannable. +11. As a Skillpack user, I want the selected heatmap cell detail to show its exact provider, date, and count, so that compact cells remain inspectable without permanent count labels. 12. As a Skillpack user, I want the default usage window to be 7 days, so that the first view reflects recent runtime behavior. 13. As a Skillpack user, I want the Provider Skill Ranking capped to the top 10 skills, so that the output stays focused and scannable. 14. As a Skillpack user, I want Project Skills included when they are invoked, so that repository-owned skills are not omitted from usage analysis. @@ -81,9 +81,35 @@ Skill Usage is separate from Skill Inventory. Inventory answers what is currentl - Skill Usage Records are retained until Skill Usage Reset. - Skill Usage Reset deletes Skillpack's derived Skill Usage Log and Usage Import Cursors only. - The TUI gets a top-level Skill Usage View reachable with `g`. -- The Skill Usage View combines a provider-by-day heatmap with Provider Skill Ranking for the selected provider and range. -- The heatmap uses fixed-width cells with readable count labels plus optional color or glyph intensity. -- The selected heatmap cell shows exact date, provider, and count. +- The Skill Usage View uses provider tabs to switch the selected Session-Producing Provider. +- Unsupported and not-configured provider tabs remain selectable, but they show an explanatory empty state instead of fabricated heatmap or table data. +- The Skill Usage View combines a GitHub-style provider/day heatmap, selected-day usage detail, and an always-present Provider Skill Ranking table. +- The Usage view uses `tab` and `shift+tab` to switch providers, `1`/`2`/`3` to select the 7/30/90 day ranges, up/down arrows to move the selected heatmap day by one day, and left/right arrows to move by one week. +- Heatmap navigation does not wrap at range boundaries. +- When week navigation targets a date that is not present in the heatmap, Skillpack selects the nearest available date in the target week instead of leaving selection unchanged. +- Switching provider or range selects the latest day in the selected range. +- The latest day remains selected by default even when it has zero invocations; Skillpack does not auto-jump to the most recent active day. +- When the Usage view scrolls, page up/page down move the viewport and home/end jump to the top or bottom. +- The Usage view does not introduce a focus mode in v1; keyboard navigation always controls provider/range/day selection unless using page-scroll keys. +- Provider tabs appear at the top of the Usage view, followed by a range segmented control for 7d, 30d, and 90d. +- Usage view keyboard shortcuts are surfaced in the StatusBar rather than repeated inside the main content. +- The bottom Provider Skill Ranking table is not row-selectable and does not support alternate sorting in v1. +- The bottom Provider Skill Ranking table sorts by Counted Invocation count descending, failed invocation count descending, last-used timestamp descending, then skill name ascending. +- The bottom Provider Skill Ranking table columns are Skill, Counted, Failed, Last Used, and Status. +- The heatmap uses fixed-width colored block cells to show relative Counted Invocation volume across the selected provider and range; exact counts are shown in Selected-Day Usage Detail instead of permanent cell labels. +- Heatmap color intensity is normalized within the selected provider and selected range, using that view's maximum single-day Counted Invocation count. +- Failed invocations do not contribute to heatmap color intensity; they are shown in Selected-Day Usage Detail and Provider Skill Ranking context. +- The heatmap shows month labels and does not show weekday labels. +- The heatmap uses a GitHub-style calendar layout with weeks as columns and days aligned by weekday rows. +- The heatmap uses a high-contrast colored palette for activity levels; zero-count cells should be visually quiet but not rendered as a grayscale intensity ramp. +- The selected heatmap cell shows exact date, provider, Counted Invocation count, failed invocation count, and per-skill aggregate usage for that day. +- Selected-Day Usage Detail is grouped by skill and does not expose session-level or turn-level raw invocation history. +- Selected-Day Usage Detail appears to the right of the heatmap on wide terminals and below the heatmap on narrower terminals. +- Selected-Day Usage Detail columns are Skill, Counted, Failed, and Status, where Status distinguishes current and historical skills. +- Selected-Day Usage Detail should show all skills used on the selected day whenever possible. +- When Selected-Day Usage Detail or the bottom Provider Skill Ranking table cannot fit in the terminal, the Usage view becomes scrollable instead of truncating those aggregates. +- The bottom Provider Skill Ranking table is scoped to the selected provider and selected range; Skill Usage does not show a cross-provider total table in v1. +- The bottom Provider Skill Ranking table remains capped to the top 10 skills even when the Usage view is scrollable. - The default time range is 7 days. - Raw invocation history and export are out of scope for v1. diff --git a/packages/core/src/usage.ts b/packages/core/src/usage.ts index 46be5c9..32aaac3 100644 --- a/packages/core/src/usage.ts +++ b/packages/core/src/usage.ts @@ -50,6 +50,19 @@ export interface ProviderSkillRankingRow { historical: boolean; } +export interface ProviderSkillDailyUsage { + date: string; + rows: ProviderSkillUsageRow[]; +} + +export interface ProviderSkillUsageRow { + skillName: string; + countedInvocations: number; + failedInvocations: number; + lastInvokedAt?: string; + historical: boolean; +} + export interface UsageImportDiagnostic { provider: string; message: string; @@ -116,6 +129,7 @@ export interface ProviderSkillUsageOverview { displayName: string; coverageState: UsageCoverageState; heatmap: SkillUsageHeatmapCell[]; + dailySkillUsage: ProviderSkillDailyUsage[]; ranking: ProviderSkillRankingRow[]; diagnostics: UsageImportDiagnostic[]; } @@ -226,18 +240,21 @@ export class SkillUsageManager { displayName: provider.displayName, coverageState: coverageStateFor(provider, 0), heatmap: [], + dailySkillUsage: [], ranking: [], diagnostics: this.latestDiagnostics.get(provider.provider) ?? [], }; } const records = await this.readProviderRecords(provider.provider, range); const heatmap = buildHeatmap(records, range); + const dailySkillUsage = buildDailySkillUsage(records, range, input.currentSkills); const ranking = buildRanking(records, input.currentSkills); return { provider: provider.provider, displayName: provider.displayName, coverageState: coverageStateFor(provider, records.length), heatmap, + dailySkillUsage, ranking, diagnostics: this.latestDiagnostics.get(provider.provider) ?? [], }; @@ -675,27 +692,74 @@ function buildRanking( records: SkillInvocationRecord[], currentSkills: CurrentSkillReference[] | undefined, ): ProviderSkillRankingRow[] { - const rows = new Map(); + const rows = new Map(); for (const record of records) { - const row = rows.get(record.skillName) ?? { - skillName: record.skillName, - countedInvocations: 0, - failedInvocations: 0, - historical: currentSkills !== undefined, - }; - if (record.status === 'failed') { - row.failedInvocations += 1; - } else { - row.countedInvocations += 1; - } - if (!row.lastInvokedAt || record.startedAt > row.lastInvokedAt) row.lastInvokedAt = record.startedAt; - if (!isHistoricalSkill(record, currentSkills)) row.historical = false; + const row = rows.get(record.skillName) ?? createUsageRow(record.skillName, currentSkills); + addRecordToUsageRow(row, record, currentSkills); rows.set(record.skillName, row); } - return [...rows.values()].sort((a, b) => ( - b.countedInvocations - a.countedInvocations || a.skillName.localeCompare(b.skillName) - )).slice(0, 10); + return [...rows.values()].sort(compareUsageRows).slice(0, 10); +} + +function buildDailySkillUsage( + records: SkillInvocationRecord[], + range: SkillUsageRange, + currentSkills: CurrentSkillReference[] | undefined, +): ProviderSkillDailyUsage[] { + const byDate = new Map>(); + for (const date of datesInRange(range)) byDate.set(date, new Map()); + + for (const record of records) { + const date = record.startedAt.slice(0, 10); + const rows = byDate.get(date) ?? new Map(); + const row = rows.get(record.skillName) ?? createUsageRow(record.skillName, currentSkills); + addRecordToUsageRow(row, record, currentSkills); + rows.set(record.skillName, row); + byDate.set(date, rows); + } + + return [...byDate.entries()].map(([date, rows]) => ({ + date, + rows: [...rows.values()].sort(compareUsageRows), + })).sort((a, b) => a.date.localeCompare(b.date)); +} + +function createUsageRow(skillName: string, currentSkills: CurrentSkillReference[] | undefined): ProviderSkillUsageRow { + return { + skillName, + countedInvocations: 0, + failedInvocations: 0, + historical: currentSkills !== undefined, + }; +} + +function addRecordToUsageRow( + row: ProviderSkillUsageRow, + record: SkillInvocationRecord, + currentSkills: CurrentSkillReference[] | undefined, +): void { + if (record.status === 'failed') { + row.failedInvocations += 1; + } else { + row.countedInvocations += 1; + } + if (!row.lastInvokedAt || record.startedAt > row.lastInvokedAt) row.lastInvokedAt = record.startedAt; + if (!isHistoricalSkill(record, currentSkills)) row.historical = false; +} + +function compareUsageRows(a: ProviderSkillUsageRow, b: ProviderSkillUsageRow): number { + return b.countedInvocations - a.countedInvocations + || b.failedInvocations - a.failedInvocations + || compareOptionalTimestampDesc(a.lastInvokedAt, b.lastInvokedAt) + || a.skillName.localeCompare(b.skillName); +} + +function compareOptionalTimestampDesc(left: string | undefined, right: string | undefined): number { + if (left === right) return 0; + if (left === undefined) return 1; + if (right === undefined) return -1; + return right.localeCompare(left); } function isHistoricalSkill( diff --git a/packages/core/tests/usage.test.ts b/packages/core/tests/usage.test.ts index 1a903ea..de3011c 100644 --- a/packages/core/tests/usage.test.ts +++ b/packages/core/tests/usage.test.ts @@ -42,6 +42,7 @@ describe('SkillUsageManager', () => { displayName: 'Codex', coverageState: 'unsupported', heatmap: [], + dailySkillUsage: [], ranking: [], diagnostics: [], }], @@ -130,11 +131,11 @@ describe('SkillUsageManager', () => { }); expect(sevenDayOverview.providers[0].ranking).toMatchObject([ { skillName: 'browser:control-in-app-browser', countedInvocations: 1 }, - { skillName: 'frontend-testing', countedInvocations: 1 }, { skillName: 'github:github', countedInvocations: 1 }, { skillName: 'openai-docs', countedInvocations: 1 }, { skillName: 'other-project-only', countedInvocations: 1 }, { skillName: 'vercel-react-best-practices', countedInvocations: 1 }, + { skillName: 'frontend-testing', countedInvocations: 1 }, ]); const thirtyDayOverview = await usage.getOverview({ @@ -207,6 +208,79 @@ describe('SkillUsageManager', () => { } }); + it('returns per-day skill usage details and fixed ranking order', async () => { + const root = await mkdtemp(path.join(tmpdir(), 'skillpack-usage-')); + try { + const sessionsRoot = path.join(root, 'codex-sessions'); + const usageRoot = path.join(root, 'usage'); + const projectRoot = path.join(root, 'project'); + await mkdir(sessionsRoot, { recursive: true }); + await writeFile(path.join(sessionsRoot, 'session.jsonl'), jsonl([ + sessionMeta('session-a', projectRoot, '2026-07-07T08:00:00Z'), + taskStarted('turn-beta-1', '2026-07-07T08:00:01Z'), + turnContext('turn-beta-1', projectRoot, '2026-07-07T08:00:02Z'), + functionCall('call-beta-1', 'cat /Users/twwu/.agents/skills/skill-beta/SKILL.md', '2026-07-07T08:01:00Z'), + functionOutput('call-beta-1', 0, '2026-07-07T08:01:01Z'), + taskStarted('turn-alpha-success', '2026-07-07T09:00:01Z'), + turnContext('turn-alpha-success', projectRoot, '2026-07-07T09:00:02Z'), + functionCall('call-alpha-success', 'cat /Users/twwu/.agents/skills/skill-alpha/SKILL.md', '2026-07-07T09:01:00Z'), + functionOutput('call-alpha-success', 0, '2026-07-07T09:01:01Z'), + taskStarted('turn-zeta', '2026-07-07T10:00:01Z'), + turnContext('turn-zeta', projectRoot, '2026-07-07T10:00:02Z'), + functionCall('call-zeta', 'cat /Users/twwu/.agents/skills/skill-zeta/SKILL.md', '2026-07-07T10:01:00Z'), + functionOutput('call-zeta', 0, '2026-07-07T10:01:01Z'), + taskStarted('turn-alpha-failed', '2026-07-07T11:00:01Z'), + turnContext('turn-alpha-failed', projectRoot, '2026-07-07T11:00:02Z'), + functionCall('call-alpha-failed', 'cat /Users/twwu/.agents/skills/skill-alpha/SKILL.md', '2026-07-07T11:01:00Z'), + functionOutput('call-alpha-failed', 1, '2026-07-07T11:01:01Z'), + taskStarted('turn-beta-2', '2026-07-07T12:00:01Z'), + turnContext('turn-beta-2', projectRoot, '2026-07-07T12:00:02Z'), + functionCall('call-beta-2', 'cat /Users/twwu/.agents/skills/skill-beta/SKILL.md', '2026-07-07T12:01:00Z'), + functionOutput('call-beta-2', 0, '2026-07-07T12:01:01Z'), + ])); + const currentSkills = [{ + provider: 'global', + name: 'skill-beta', + path: '/Users/twwu/.agents/skills/skill-beta', + resolvedPath: '/Users/twwu/.agents/skills/skill-beta', + }]; + const usage = new SkillUsageManager({ + dataDir: usageRoot, + now: new Date('2026-07-08T12:00:00Z'), + providers: [ + { provider: 'codex', displayName: 'Codex', supported: true, artifactRoots: [sessionsRoot] }, + ], + }); + + await usage.importProvider('codex', currentSkills); + + const overview = await usage.getOverview({ + rangeDays: 7, + now: new Date('2026-07-08T12:00:00Z'), + currentSkills, + }); + const provider = overview.providers[0]; + + expect(provider.ranking.map((row) => ({ + skillName: row.skillName, + countedInvocations: row.countedInvocations, + failedInvocations: row.failedInvocations, + historical: row.historical, + }))).toEqual([ + { skillName: 'skill-beta', countedInvocations: 2, failedInvocations: 0, historical: false }, + { skillName: 'skill-alpha', countedInvocations: 1, failedInvocations: 1, historical: true }, + { skillName: 'skill-zeta', countedInvocations: 1, failedInvocations: 0, historical: true }, + ]); + expect(provider.dailySkillUsage.find((day) => day.date === '2026-07-07')?.rows).toEqual([ + { skillName: 'skill-beta', countedInvocations: 2, failedInvocations: 0, lastInvokedAt: '2026-07-07T12:01:00Z', historical: false }, + { skillName: 'skill-alpha', countedInvocations: 1, failedInvocations: 1, lastInvokedAt: '2026-07-07T11:01:00Z', historical: true }, + { skillName: 'skill-zeta', countedInvocations: 1, failedInvocations: 0, lastInvokedAt: '2026-07-07T10:01:00Z', historical: true }, + ]); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + it('persists source and ended-at evidence when the invoked skill matches current inventory', async () => { const root = await mkdtemp(path.join(tmpdir(), 'skillpack-usage-')); try { @@ -294,16 +368,16 @@ describe('SkillUsageManager', () => { now: new Date('2026-07-08T12:00:00Z'), }); expect(overview.providers[0].ranking.map((row) => row.skillName)).toEqual([ - 'skill-00', - 'skill-01', - 'skill-02', - 'skill-03', - 'skill-04', - 'skill-05', - 'skill-06', - 'skill-07', - 'skill-08', + 'skill-10', 'skill-09', + 'skill-08', + 'skill-07', + 'skill-06', + 'skill-05', + 'skill-04', + 'skill-03', + 'skill-02', + 'skill-01', ]); } finally { await rm(root, { recursive: true, force: true }); diff --git a/packages/tui/src/lib/shortcuts.ts b/packages/tui/src/lib/shortcuts.ts index b93b635..906fcaa 100644 --- a/packages/tui/src/lib/shortcuts.ts +++ b/packages/tui/src/lib/shortcuts.ts @@ -21,9 +21,11 @@ const SHORTCUTS: Record = { usage: [ { key: 'esc', label: 'back' }, { key: '?', label: 'help' }, - { key: '↑↓', label: 'provider' }, - { key: '←→', label: 'cell' }, - { key: 'tab', label: 'range' }, + { key: 'tab', label: 'provider' }, + { key: '1/2/3', label: 'range' }, + { key: '↑↓', label: 'day' }, + { key: '←→', label: 'week' }, + { key: 'pg', label: 'scroll' }, { key: 'r', label: 'rescan' }, { key: 'x', label: 'reset' }, { key: 'q', label: 'quit' }, diff --git a/packages/tui/src/views/usage-view.tsx b/packages/tui/src/views/usage-view.tsx index 7e03503..262a4d9 100644 --- a/packages/tui/src/views/usage-view.tsx +++ b/packages/tui/src/views/usage-view.tsx @@ -1,20 +1,36 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState, type ReactNode } from 'react'; import { Box, Text, useApp, useInput } from 'ink'; import { ConfirmDialog } from '../components/confirm-dialog.js'; import { StatusBar } from '../components/status-bar.js'; import { useAppContext } from '../context/app-context.js'; +import { useTerminalSize } from '../hooks/use-terminal-size.js'; +import { fitCell, getBoundedContentLayout } from '../lib/responsive-layout.js'; import type { ProviderSkillUsageOverview, + ProviderSkillUsageRow, SkillUsageHeatmapCell, SkillUsageOverview, UsageCoverageState, } from '@skillpack/core'; const USAGE_RANGES = [7, 30, 90] as const; -const HEATMAP_COLUMNS = 7; +const DAY_MS = 24 * 60 * 60 * 1000; +const WEEK_MS = 7 * DAY_MS; +const HEATMAP_CELL_WIDTH = 3; + +interface UsageContentRow { + key: string; + element: ReactNode; +} + +interface CalendarWeek { + monthLabel?: string; + days: Array; +} export function UsageView() { const { exit } = useApp(); + const { columns, rows } = useTerminalSize(); const { manager, config, @@ -30,13 +46,21 @@ export function UsageView() { const [overview, setOverview] = useState(null); const [rangeIndex, setRangeIndex] = useState(0); const [selectedProviderIndex, setSelectedProviderIndex] = useState(0); - const [selectedHeatmapIndex, setSelectedHeatmapIndex] = useState(0); + const [selectedDate, setSelectedDate] = useState(null); + const [scrollOffset, setScrollOffset] = useState(0); const [refreshNonce, setRefreshNonce] = useState(0); const [confirmingReset, setConfirmingReset] = useState(false); const rangeDays = USAGE_RANGES[rangeIndex]; const hasConsent = config.usage?.importConsent === true; const selectedProvider = overview?.providers[selectedProviderIndex]; - const selectedHeatmapCell = selectedProvider?.heatmap[selectedHeatmapIndex]; + const effectiveSelectedDate = selectedDate ?? overview?.range.to ?? null; + const selectedHeatmapCell = selectedProvider?.heatmap.find((cell) => cell.date === effectiveSelectedDate); + const selectedDayUsage = selectedProvider?.dailySkillUsage.find((day) => day.date === effectiveSelectedDate); + const layout = getBoundedContentLayout({ + size: { columns, rows }, + fullChromeLines: 3, + compactChromeLines: 3, + }); useEffect(() => { if (!hasConsent) { @@ -62,33 +86,101 @@ export function UsageView() { }, [overview, selectedProviderIndex]); useEffect(() => { - if (!selectedProvider) { - setSelectedHeatmapIndex(0); - return; - } - if (selectedHeatmapIndex >= selectedProvider.heatmap.length) setSelectedHeatmapIndex(0); - }, [selectedProvider, selectedHeatmapIndex]); + setSelectedDate(null); + setScrollOffset(0); + }, [selectedProviderIndex, rangeIndex]); + + const contentRows = useMemo(() => { + if (!hasConsent) return []; + if (!overview) return [{ key: 'loading', element: Loading usage... }]; + return buildUsageContentRows({ + overview, + rangeDays, + selectedProviderIndex, + selectedProvider, + selectedDate: effectiveSelectedDate, + selectedHeatmapCell, + selectedDayRows: selectedDayUsage?.rows ?? [], + usageImporting, + usageImportError, + error, + columns, + }); + }, [ + columns, + error, + hasConsent, + overview, + rangeDays, + effectiveSelectedDate, + selectedDayUsage, + selectedHeatmapCell, + selectedProvider, + selectedProviderIndex, + usageImportError, + usageImporting, + ]); + + const visibleRows = layout.visibleRows; + const maxScrollOffset = Math.max(0, contentRows.length - visibleRows); + const visibleContent = contentRows.slice(scrollOffset, scrollOffset + visibleRows); + const showScroll = hasConsent && contentRows.length > visibleRows; useInput((input, key) => { + if (confirmingReset) return; if (input === 'q') { exit(); return; } - if (key.escape) { setView('list'); } - if (key.tab && hasConsent) { - setRangeIndex((index) => (index + 1) % USAGE_RANGES.length); - setSelectedHeatmapIndex(0); - } - if (key.upArrow && hasConsent && overview && overview.providers.length > 1) { + if (key.escape) { setView('list'); return; } + if (key.shiftTab && hasConsent && overview && overview.providers.length > 1) { setSelectedProviderIndex((index) => (index + overview.providers.length - 1) % overview.providers.length); - setSelectedHeatmapIndex(0); + return; } - if (key.downArrow && hasConsent && overview && overview.providers.length > 1) { + if (key.tab && hasConsent && overview && overview.providers.length > 1) { setSelectedProviderIndex((index) => (index + 1) % overview.providers.length); - setSelectedHeatmapIndex(0); + return; } - if (key.leftArrow && hasConsent && selectedProvider && selectedProvider.heatmap.length > 0) { - setSelectedHeatmapIndex((index) => (index + selectedProvider.heatmap.length - 1) % selectedProvider.heatmap.length); + if (input === '1' && hasConsent) { + setRangeIndex(0); + return; + } + if (input === '2' && hasConsent) { + setRangeIndex(1); + return; + } + if (input === '3' && hasConsent) { + setRangeIndex(2); + return; + } + if (key.leftArrow && hasConsent) { + setSelectedDate((date) => moveDateWithinRange(date, overview, -WEEK_MS, selectedProvider?.heatmap, true)); + return; } - if (key.rightArrow && hasConsent && selectedProvider && selectedProvider.heatmap.length > 0) { - setSelectedHeatmapIndex((index) => (index + 1) % selectedProvider.heatmap.length); + if (key.rightArrow && hasConsent) { + setSelectedDate((date) => moveDateWithinRange(date, overview, WEEK_MS, selectedProvider?.heatmap, true)); + return; + } + if (key.upArrow && hasConsent) { + setSelectedDate((date) => moveDateWithinRange(date, overview, -DAY_MS, selectedProvider?.heatmap)); + return; + } + if (key.downArrow && hasConsent) { + setSelectedDate((date) => moveDateWithinRange(date, overview, DAY_MS, selectedProvider?.heatmap)); + return; + } + if (key.pageDown && hasConsent) { + setScrollOffset((offset) => Math.min(offset + visibleRows, maxScrollOffset)); + return; + } + if (key.pageUp && hasConsent) { + setScrollOffset((offset) => Math.max(0, offset - visibleRows)); + return; + } + if (key.home && hasConsent) { + setScrollOffset(0); + return; + } + if (key.end && hasConsent) { + setScrollOffset(maxScrollOffset); + return; } if (input === 'r' && hasConsent) { setOverview(null); @@ -132,69 +224,15 @@ export function UsageView() { Skill Usage {rangeDays} days + {showScroll && ( + {scrollOffset + 1}-{Math.min(scrollOffset + visibleRows, contentRows.length)} of {contentRows.length} + )} - + {hasConsent ? ( <> - Usage coverage - {overview - ? overview.providers.map((provider) => ( - - )) - : Loading usage...} - {usageImporting && Importing usage...} - {selectedProvider && selectedProvider.heatmap.length > 0 && ( - <> - Heatmap - {chunkHeatmap(selectedProvider.heatmap).map((row, rowIndex) => ( - - {row.map((cell, columnIndex) => { - const index = rowIndex * HEATMAP_COLUMNS + columnIndex; - return ( - - {formatHeatmapCell(cell, index === selectedHeatmapIndex)} - - ); - })} - - ))} - {selectedHeatmapCell && ( - - {selectedHeatmapCell.date}{' '} - {selectedProvider.displayName}{' '} - counted {selectedHeatmapCell.countedInvocations} - {selectedHeatmapCell.failedInvocations > 0 ? ` failed ${selectedHeatmapCell.failedInvocations}` : ''} - - )} - - )} - {selectedProvider && selectedProvider.ranking.length > 0 && ( - <> - Ranking - {selectedProvider.ranking.map((row) => ( - - {row.skillName}{' -> '}{row.countedInvocations} - {row.failedInvocations > 0 ? ` failed ${row.failedInvocations}` : ''} - {row.historical ? ' historical' : ''} - - ))} - - )} - {selectedProvider && selectedProvider.diagnostics.length > 0 && ( - <> - Import diagnostics - {selectedProvider.diagnostics.map((diagnostic) => ( - {diagnostic.message} - ))} - - )} - {usageImportError && {usageImportError}} - {error && {error}} + {visibleContent.map((row) => {row.element})} ) : ( <> @@ -214,17 +252,6 @@ export function UsageView() { ); } -function ProviderCoverageRow({ provider, selected }: { provider: ProviderSkillUsageOverview; selected: boolean }) { - const badge = coverageBadge(provider.coverageState); - return ( - - {selected ? '> ' : ' '} - {provider.displayName}{' '} - {badge.label} - - ); -} - function coverageBadge(state: UsageCoverageState): { label: string; color: string } { switch (state) { case 'active': return { label: '[active]', color: 'green' }; @@ -234,29 +261,361 @@ function coverageBadge(state: UsageCoverageState): { label: string; color: strin } } -function chunkHeatmap(cells: SkillUsageHeatmapCell[]): SkillUsageHeatmapCell[][] { - const rows: SkillUsageHeatmapCell[][] = []; - for (let index = 0; index < cells.length; index += HEATMAP_COLUMNS) { - rows.push(cells.slice(index, index + HEATMAP_COLUMNS)); +function buildUsageContentRows(input: { + overview: SkillUsageOverview; + rangeDays: number; + selectedProviderIndex: number; + selectedProvider: ProviderSkillUsageOverview | undefined; + selectedDate: string | null; + selectedHeatmapCell: SkillUsageHeatmapCell | undefined; + selectedDayRows: ProviderSkillUsageRow[]; + usageImporting: boolean; + usageImportError: string | null; + error: string | null; + columns: number; +}): UsageContentRow[] { + const rows: UsageContentRow[] = []; + rows.push({ + key: 'providers', + element: ( + + Providers + {input.overview.providers.map((provider, index) => renderProviderTab(provider, index === input.selectedProviderIndex))} + + ), + }); + rows.push({ + key: 'range', + element: ( + + Range + {USAGE_RANGES.map((range) => ( + {range === input.rangeDays ? `[${range}d] ` : `${range}d `} + ))} + + ), + }); + if (input.usageImporting) rows.push({ key: 'importing', element: Importing usage... }); + rows.push({ key: 'gap:top', element: }); + + if (!input.selectedProvider) { + rows.push({ key: 'empty:no-provider', element: No usage provider selected. }); + return rows; + } + + const provider = input.selectedProvider; + if (provider.coverageState === 'unsupported' || provider.coverageState === 'not-configured') { + rows.push({ key: 'empty:state', element: {emptyStateForProvider(provider)} }); + rows.push({ key: 'empty:records', element: No usage records for this provider and range. }); + appendErrors(rows, input); + return rows; } + + const sideBySide = shouldRenderSideBySide(input.columns, provider.heatmap); + const heatmapRows = [ + { key: 'heatmap:heading', element: Usage heatmap }, + ...renderCalendarHeatmap(provider.heatmap, input.selectedDate), + ]; + if (sideBySide) { + const heatmapWidth = heatmapDisplayWidth(provider.heatmap); + const detailColumns = Math.max(30, input.columns - heatmapWidth - 6); + const detailRows = renderSelectedDayDetail(provider, input.selectedHeatmapCell, input.selectedDayRows, detailColumns); + rows.push(...combineSideBySideRows(heatmapRows, detailRows, heatmapWidth, detailColumns)); + } else { + rows.push(...heatmapRows); + rows.push({ key: 'gap:detail', element: }); + rows.push(...renderSelectedDayDetail(provider, input.selectedHeatmapCell, input.selectedDayRows, input.columns)); + } + rows.push({ key: 'gap:ranking', element: }); + rows.push(...renderRankingTable(provider, input.columns)); + if (provider.diagnostics.length > 0) { + rows.push({ key: 'gap:diagnostics', element: }); + rows.push({ key: 'diagnostics:heading', element: Import diagnostics }); + for (const diagnostic of provider.diagnostics) { + rows.push({ key: `diagnostic:${diagnostic.message}`, element: {diagnostic.message} }); + } + } + appendErrors(rows, input); return rows; } +function renderProviderTab(provider: ProviderSkillUsageOverview, selected: boolean): ReactNode { + const suffix = provider.coverageState === 'active' ? '' : ` ${coverageBadge(provider.coverageState).label.replace(/^\[|\]$/g, '')}`; + const label = `${provider.displayName}${suffix}`; + return ( + + {selected ? `[${label}] ` : `${label} `} + + ); +} + +function emptyStateForProvider(provider: ProviderSkillUsageOverview): string { + if (provider.coverageState === 'unsupported') return `Usage import is not supported for ${provider.displayName} yet.`; + if (provider.coverageState === 'not-configured') return `${provider.displayName} Usage Artifact Roots are not configured.`; + return `No usage records for ${provider.displayName}.`; +} + +function renderCalendarHeatmap(cells: SkillUsageHeatmapCell[], selectedDate: string | null): UsageContentRow[] { + const weeks = calendarWeeks(cells); + const maxCount = Math.max(0, ...cells.map((cell) => cell.countedInvocations)); + const rows: UsageContentRow[] = []; + rows.push({ + key: 'heatmap:months', + element: ( + + {' '.repeat(10)} + {weeks.map((week, index) => ( + {fitCell(week.monthLabel ?? '', HEATMAP_CELL_WIDTH)} + ))} + + ), + }); + for (let day = 0; day < 7; day += 1) { + rows.push({ + key: `heatmap:day:${day}`, + element: ( + + {' '.repeat(10)} + {weeks.map((week, weekIndex) => { + const cell = week.days[day]; + return ( + + {cell ? formatHeatmapCell(cell, cell.date === selectedDate) : ' '.repeat(HEATMAP_CELL_WIDTH)} + + ); + })} + + ), + }); + } + return rows; +} + +function shouldRenderSideBySide(columns: number, cells: SkillUsageHeatmapCell[]): boolean { + return cells.length > 0 && columns >= Math.max(96, heatmapDisplayWidth(cells) + 36); +} + +function heatmapDisplayWidth(cells: SkillUsageHeatmapCell[]): number { + return Math.max(20, 10 + calendarWeeks(cells).length * HEATMAP_CELL_WIDTH); +} + +function combineSideBySideRows( + leftRows: UsageContentRow[], + rightRows: UsageContentRow[], + leftWidth: number, + rightWidth: number, +): UsageContentRow[] { + const combined: UsageContentRow[] = []; + const rowCount = Math.max(leftRows.length, rightRows.length); + for (let index = 0; index < rowCount; index += 1) { + const left = leftRows[index]?.element ?? ; + const right = rightRows[index]?.element ?? ; + combined.push({ + key: `wide:${leftRows[index]?.key ?? 'empty'}:${rightRows[index]?.key ?? 'empty'}:${index}`, + element: ( + + {left} + {right} + + ), + }); + } + return combined; +} + +function calendarWeeks(cells: SkillUsageHeatmapCell[]): CalendarWeek[] { + if (cells.length === 0) return []; + const byDate = new Map(cells.map((cell) => [cell.date, cell])); + const first = parseDate(cells[0].date); + const last = parseDate(cells[cells.length - 1].date); + const cursor = new Date(first); + cursor.setUTCDate(cursor.getUTCDate() - cursor.getUTCDay()); + const weeks: CalendarWeek[] = []; + + while (cursor <= last) { + const days: Array = []; + let monthLabel: string | undefined; + for (let day = 0; day < 7; day += 1) { + const date = formatDate(cursor); + const cell = byDate.get(date) ?? null; + days.push(cell); + if (cell && (weeks.length === 0 || new Date(`${date}T00:00:00.000Z`).getUTCDate() <= 7)) { + monthLabel ??= formatMonth(date); + } + cursor.setUTCDate(cursor.getUTCDate() + 1); + } + weeks.push({ monthLabel, days }); + } + + return weeks; +} + +function renderSelectedDayDetail( + provider: ProviderSkillUsageOverview, + cell: SkillUsageHeatmapCell | undefined, + dayRows: ProviderSkillUsageRow[], + columns: number, +): UsageContentRow[] { + const date = cell?.date ?? 'No date selected'; + const counted = cell?.countedInvocations ?? 0; + const failed = cell?.failedInvocations ?? 0; + const result: UsageContentRow[] = [ + { key: 'detail:heading', element: Selected day }, + { key: 'detail:summary', element: {date} · {provider.displayName} }, + { key: 'detail:counts', element: Counted {counted} · Failed {failed} }, + ]; + + if (dayRows.length === 0) { + result.push({ key: 'detail:empty', element: No skill usage on this day. }); + return result; + } + + const widths = usageTableWidths(columns, false); + result.push({ + key: 'detail:header', + element: ( + + {fitCell('Skill', widths.skill)} {fitCell('Counted', widths.counted)} {fitCell('Failed', widths.failed)} {fitCell('Status', widths.status)} + + ), + }); + for (const row of dayRows) { + result.push({ + key: `detail:${row.skillName}`, + element: ( + + {fitCell(row.skillName, widths.skill)} {fitCell(String(row.countedInvocations), widths.counted)} {fitCell(formatFailed(row.failedInvocations), widths.failed)} {fitCell(formatStatus(row), widths.status)} + + ), + }); + } + return result; +} + +function renderRankingTable(provider: ProviderSkillUsageOverview, columns: number): UsageContentRow[] { + const result: UsageContentRow[] = [ + { key: 'ranking:heading', element: Provider Skill Ranking }, + ]; + if (provider.ranking.length === 0) { + result.push({ key: 'ranking:empty', element: No usage records for this provider and range. }); + return result; + } + + const widths = usageTableWidths(columns, true); + result.push({ + key: 'ranking:header', + element: ( + + {fitCell('Skill', widths.skill)} {fitCell('Counted', widths.counted)} {fitCell('Failed', widths.failed)} {fitCell('Last Used', widths.lastUsed)} {fitCell('Status', widths.status)} + + ), + }); + for (const row of provider.ranking) { + result.push({ + key: `ranking:${row.skillName}`, + element: ( + + {fitCell(row.skillName, widths.skill)} {fitCell(String(row.countedInvocations), widths.counted)} {fitCell(formatFailed(row.failedInvocations), widths.failed)} {fitCell(formatLastUsed(row.lastInvokedAt), widths.lastUsed)} {fitCell(formatStatus(row), widths.status)} + + ), + }); + } + return result; +} + +function usageTableWidths(columns: number, withLastUsed: boolean): { skill: number; counted: number; failed: number; lastUsed: number; status: number } { + const contentWidth = Math.max(40, columns - 2); + const counted = 7; + const failed = 6; + const status = 10; + const lastUsed = withLastUsed ? 10 : 0; + const gaps = withLastUsed ? 4 : 3; + const skill = Math.max(16, contentWidth - counted - failed - status - lastUsed - gaps); + return { skill, counted, failed, lastUsed, status }; +} + +function appendErrors(rows: UsageContentRow[], input: { usageImportError: string | null; error: string | null }) { + if (input.usageImportError) rows.push({ key: 'error:import', element: {input.usageImportError} }); + if (input.error) rows.push({ key: 'error:view', element: {input.error} }); +} + function formatHeatmapCell(cell: SkillUsageHeatmapCell, selected: boolean): string { - const label = cell.failedInvocations > 0 - ? `!${cappedCount(cell.failedInvocations)}` - : cappedCount(cell.countedInvocations); - const padded = label.padStart(3).slice(-3); - return selected ? `[${padded}] ` : ` ${padded} `; + const glyph = cell.countedInvocations === 0 ? '□' : '■'; + return selected ? `[${glyph}]` : ` ${glyph} `; +} + +function heatmapColor(cell: SkillUsageHeatmapCell, maxCount: number): string | undefined { + if (cell.countedInvocations === 0) return undefined; + const ratio = maxCount === 0 ? 0 : cell.countedInvocations / maxCount; + if (ratio >= 0.75) return 'redBright'; + if (ratio >= 0.5) return 'magenta'; + if (ratio >= 0.25) return 'cyan'; + return 'blue'; +} + +function moveDateWithinRange( + date: string | null, + overview: SkillUsageOverview | null, + deltaMs: number, + cells: SkillUsageHeatmapCell[] | undefined = [], + snapToNearestTargetWeekDate = false, +): string | null { + if (!overview) return date; + const current = parseDate(date ?? overview.range.to); + const next = new Date(current.getTime() + deltaMs); + const nextDate = formatDate(next); + const selectableDates = (cells ?? []).map((cell) => cell.date).sort(); + if (selectableDates.includes(nextDate)) return nextDate; + if (snapToNearestTargetWeekDate) { + const nearestDate = nearestDateInTargetWeek(next, selectableDates); + if (nearestDate) return nearestDate; + } + if (nextDate < overview.range.from || nextDate > overview.range.to) return date ?? overview.range.to; + return nextDate; +} + +function nearestDateInTargetWeek(target: Date, selectableDates: string[]): string | null { + const targetWeekStart = startOfUtcWeek(target); + const candidates = selectableDates.filter((date) => startOfUtcWeek(parseDate(date)).getTime() === targetWeekStart.getTime()); + if (candidates.length === 0) return null; + const targetTime = target.getTime(); + return candidates.sort((left, right) => ( + Math.abs(parseDate(left).getTime() - targetTime) - Math.abs(parseDate(right).getTime() - targetTime) + || left.localeCompare(right) + ))[0]; +} + +function startOfUtcWeek(date: Date): Date { + const start = new Date(date); + start.setUTCHours(0, 0, 0, 0); + start.setUTCDate(start.getUTCDate() - start.getUTCDay()); + return start; +} + +function parseDate(date: string): Date { + return new Date(`${date}T00:00:00.000Z`); +} + +function formatDate(date: Date): string { + return date.toISOString().slice(0, 10); +} + +function formatMonth(date: string): string { + return parseDate(date).toLocaleString('en-US', { month: 'short', timeZone: 'UTC' }); +} + +function formatFailed(value: number): string { + return value === 0 ? '-' : String(value); } -function cappedCount(value: number): string { - return value > 99 ? '99+' : String(value); +function formatLastUsed(value: string | undefined): string { + return value ? value.slice(0, 10) : '-'; } -function heatmapColor(cell: SkillUsageHeatmapCell): string { - if (cell.failedInvocations > 0) return 'red'; - if (cell.countedInvocations >= 10) return 'magenta'; - if (cell.countedInvocations > 0) return 'cyan'; - return 'gray'; +function formatStatus(row: Pick): string { + return row.historical ? 'historical' : 'current'; } diff --git a/packages/tui/tests/helpers/mock-manager.ts b/packages/tui/tests/helpers/mock-manager.ts index 701218e..d3a7888 100644 --- a/packages/tui/tests/helpers/mock-manager.ts +++ b/packages/tui/tests/helpers/mock-manager.ts @@ -60,6 +60,7 @@ export function createMockManager(options: MockSkillManagerOptions = {}): MockSk displayName: 'Codex', coverageState: 'unsupported', heatmap: [], + dailySkillUsage: [], ranking: [], diagnostics: [], }, @@ -68,6 +69,7 @@ export function createMockManager(options: MockSkillManagerOptions = {}): MockSk displayName: 'Claude', coverageState: 'unsupported', heatmap: [], + dailySkillUsage: [], ranking: [], diagnostics: [], }, diff --git a/packages/tui/tests/helpers/render-tui.tsx b/packages/tui/tests/helpers/render-tui.tsx index 9890da3..8c829dc 100644 --- a/packages/tui/tests/helpers/render-tui.tsx +++ b/packages/tui/tests/helpers/render-tui.tsx @@ -32,6 +32,11 @@ export const keypress = { left: '\x1B[D', right: '\x1B[C', tab: '\t', + shiftTab: '\x1B[Z', + pageDown: '\x1B[6~', + pageUp: '\x1B[5~', + home: '\x1B[H', + end: '\x1B[F', }; export function stripAnsi(value: string): string { diff --git a/packages/tui/tests/ui/app-surface.test.tsx b/packages/tui/tests/ui/app-surface.test.tsx index 639a263..2ffc635 100644 --- a/packages/tui/tests/ui/app-surface.test.tsx +++ b/packages/tui/tests/ui/app-surface.test.tsx @@ -229,7 +229,7 @@ describe.sequential('Terminal UI Tests', () => { app.stdin.write('y'); - await waitForFrame(app, (output) => output.includes('Usage coverage') && output.includes('Codex [unsupported]')); + await waitForFrame(app, (output) => output.includes('Providers') && output.includes('[Codex unsupported]')); expect(persistedConsent).toBe(true); }); @@ -243,6 +243,7 @@ describe.sequential('Terminal UI Tests', () => { displayName: 'Codex', coverageState: 'zero', heatmap: [], + dailySkillUsage: [], ranking: [], diagnostics: [], }, @@ -251,6 +252,7 @@ describe.sequential('Terminal UI Tests', () => { displayName: 'Claude', coverageState: 'unsupported', heatmap: [], + dailySkillUsage: [], ranking: [], diagnostics: [], }, @@ -260,16 +262,119 @@ describe.sequential('Terminal UI Tests', () => { const app = renderApp({ manager, config: testConfig({ usage: { importConsent: true } }), + terminalSize: { columns: 120, rows: 40 }, }); await waitForFrame(app, (output) => output.includes('* Skillpack') && !output.includes('Scanning skills')); app.stdin.write('g'); - await waitForFrame(app, (output) => output.includes('Codex [zero]') && output.includes('Claude [unsupported]')); + await waitForFrame(app, (output) => output.includes('[Codex zero]') && output.includes('Claude unsupported')); expect(manager.importSkillUsage).toHaveBeenCalledTimes(1); expect(manager.getSkillUsageOverview).toHaveBeenCalledWith({ rangeDays: 7 }); }); + it('renders the redesigned Skill Usage layout and keyboard model', async () => { + const manager = createMockManager(); + manager.getSkillUsageOverview.mockImplementation(async ({ rangeDays }) => ({ + range: { days: rangeDays, from: rangeDays === 30 ? '2026-06-09' : '2026-07-02', to: '2026-07-08' }, + providers: [ + { + provider: 'codex', + displayName: 'Codex', + coverageState: 'active', + heatmap: [ + { date: '2026-07-02', countedInvocations: 0, failedInvocations: 0 }, + { date: '2026-07-03', countedInvocations: 1, failedInvocations: 0 }, + { date: '2026-07-04', countedInvocations: 0, failedInvocations: 0 }, + { date: '2026-07-05', countedInvocations: 4, failedInvocations: 0 }, + { date: '2026-07-06', countedInvocations: 2, failedInvocations: 0 }, + { date: '2026-07-07', countedInvocations: 1, failedInvocations: 0 }, + { date: '2026-07-08', countedInvocations: 3, failedInvocations: 1 }, + ], + dailySkillUsage: [ + { + date: '2026-07-07', + rows: [ + { skillName: 'openai-docs', countedInvocations: 1, failedInvocations: 0, lastInvokedAt: '2026-07-07T08:00:00Z', historical: false }, + ], + }, + { + date: '2026-07-08', + rows: [ + { skillName: 'frontend-testing', countedInvocations: 2, failedInvocations: 1, lastInvokedAt: '2026-07-08T11:00:00Z', historical: false }, + { skillName: 'github:github', countedInvocations: 1, failedInvocations: 0, lastInvokedAt: '2026-07-08T10:00:00Z', historical: false }, + ], + }, + ], + ranking: [ + { skillName: 'frontend-testing', countedInvocations: 5, failedInvocations: 1, lastInvokedAt: '2026-07-08T11:00:00Z', historical: false }, + { skillName: 'github:github', countedInvocations: 2, failedInvocations: 0, lastInvokedAt: '2026-07-08T10:00:00Z', historical: false }, + ], + diagnostics: [], + }, + { + provider: 'claude', + displayName: 'Claude', + coverageState: 'unsupported', + heatmap: [], + dailySkillUsage: [], + ranking: [], + diagnostics: [], + }, + ], + })); + const app = renderApp({ + manager, + config: testConfig({ usage: { importConsent: true } }), + terminalSize: { columns: 120, rows: 40 }, + }); + await waitForFrame(app, (output) => output.includes('* Skillpack') && !output.includes('Scanning skills')); + + app.stdin.write('g'); + + await waitForFrame(app, (output) => ( + output.includes('Providers') + && output.includes('[Codex]') + && output.includes('Claude unsupported') + && output.includes('Range') + && output.includes('[7d]') + && output.includes('Usage heatmap') + && output.includes('Jul') + && output.includes('[■]') + && !output.includes('[3]') + && output.includes('2026-07-08 · Codex') + && output.includes('Counted 3 · Failed 1') + && output.includes('Selected day') + && output.includes('frontend-testing') + && output.includes('github:github') + && output.includes('Provider Skill Ranking') + && output.includes('Last Used') + )); + + app.stdin.write(keypress.up); + await waitForFrame(app, (output) => output.includes('2026-07-07 · Codex') && output.includes('openai-docs')); + + app.stdin.write(keypress.down); + await waitForFrame(app, (output) => output.includes('2026-07-08 · Codex') && output.includes('frontend-testing')); + + app.stdin.write(keypress.left); + await waitForFrame(app, (output) => output.includes('2026-07-02 · Codex') && output.includes('No skill usage on this day.')); + + app.stdin.write(keypress.right); + await waitForFrame(app, (output) => output.includes('2026-07-08 · Codex') && output.includes('frontend-testing')); + + app.stdin.write('2'); + await waitForFrame(app, (output) => output.includes('[30d]')); + expect(manager.getSkillUsageOverview).toHaveBeenLastCalledWith({ rangeDays: 30 }); + + app.stdin.write(keypress.tab); + await waitForFrame(app, (output) => ( + output.includes('[Claude unsupported]') + && output.includes('Usage import is not supported for Claude yet.') + && output.includes('No usage records for this provider and range.') + )); + }); + it('shows Skill Usage heatmap counts and Provider Skill Ranking', async () => { const manager = createMockManager({ usageOverview: { @@ -282,6 +387,20 @@ describe.sequential('Terminal UI Tests', () => { { date: '2026-07-06', countedInvocations: 2, failedInvocations: 0 }, { date: '2026-07-07', countedInvocations: 1, failedInvocations: 0 }, ], + dailySkillUsage: [ + { + date: '2026-07-06', + rows: [ + { skillName: 'tdd', countedInvocations: 2, failedInvocations: 0, lastInvokedAt: '2026-07-06T10:02:00Z', historical: false }, + ], + }, + { + date: '2026-07-07', + rows: [ + { skillName: 'grilling', countedInvocations: 1, failedInvocations: 0, lastInvokedAt: '2026-07-07T08:00:00Z', historical: false }, + ], + }, + ], ranking: [ { skillName: 'tdd', countedInvocations: 2, failedInvocations: 0, lastInvokedAt: '2026-07-06T10:02:00Z', historical: false }, { skillName: 'grilling', countedInvocations: 1, failedInvocations: 0, lastInvokedAt: '2026-07-07T08:00:00Z', historical: false }, @@ -293,26 +412,30 @@ describe.sequential('Terminal UI Tests', () => { const app = renderApp({ manager, config: testConfig({ usage: { importConsent: true } }), + terminalSize: { columns: 120, rows: 40 }, }); await waitForFrame(app, (output) => output.includes('* Skillpack') && !output.includes('Scanning skills')); app.stdin.write('g'); await waitForFrame(app, (output) => ( - output.includes('[ 2]') - && output.includes('2026-07-06 Codex counted 2') - && output.includes('tdd -> 2') - && output.includes('grilling -> 1') + output.includes('Usage heatmap') + && output.includes('2026-07-07 · Codex') + && output.includes('Counted 1 · Failed 0') + && output.includes('Provider Skill Ranking') + && output.includes('Last Used') + && output.includes('tdd') + && output.includes('grilling') )); - app.stdin.write(keypress.right); - await waitForFrame(app, (output) => output.includes('2026-07-07 Codex counted 1')); + app.stdin.write(keypress.up); + await waitForFrame(app, (output) => output.includes('2026-07-06 · Codex') && output.includes('Counted 2 · Failed 0')); }); it('shows failure context, Historical Skills, and Usage Import Diagnostics', async () => { const manager = createMockManager({ usageOverview: { - range: { days: 30, from: '2026-06-08', to: '2026-07-07' }, + range: { days: 7, from: '2026-06-30', to: '2026-07-06' }, providers: [{ provider: 'codex', displayName: 'Codex', @@ -320,6 +443,14 @@ describe.sequential('Terminal UI Tests', () => { heatmap: [ { date: '2026-07-06', countedInvocations: 1, failedInvocations: 1 }, ], + dailySkillUsage: [ + { + date: '2026-07-06', + rows: [ + { skillName: 'deleted-skill', countedInvocations: 1, failedInvocations: 1, lastInvokedAt: '2026-07-06T10:02:00Z', historical: true }, + ], + }, + ], ranking: [ { skillName: 'deleted-skill', countedInvocations: 1, failedInvocations: 1, lastInvokedAt: '2026-07-06T10:02:00Z', historical: true }, ], @@ -332,15 +463,17 @@ describe.sequential('Terminal UI Tests', () => { const app = renderApp({ manager, config: testConfig({ usage: { importConsent: true } }), + terminalSize: { columns: 120, rows: 40 }, }); await waitForFrame(app, (output) => output.includes('* Skillpack') && !output.includes('Scanning skills')); app.stdin.write('g'); await waitForFrame(app, (output) => ( - output.includes('[ !1]') - && output.includes('2026-07-06 Codex counted 1 failed 1') - && output.includes('deleted-skill -> 1 failed 1 historical') + output.includes('2026-07-06 · Codex') + && output.includes('Counted 1 · Failed 1') + && output.includes('deleted-skill') + && output.includes('historical') && output.includes('Skipped ambiguous skill invocation evidence') )); }); @@ -355,6 +488,7 @@ describe.sequential('Terminal UI Tests', () => { displayName: 'Codex', coverageState: 'zero', heatmap: [], + dailySkillUsage: [], ranking: [], diagnostics: [], }, @@ -365,6 +499,14 @@ describe.sequential('Terminal UI Tests', () => { heatmap: [ { date: '2026-07-07', countedInvocations: 3, failedInvocations: 0 }, ], + dailySkillUsage: [ + { + date: '2026-07-07', + rows: [ + { skillName: 'claude-skill', countedInvocations: 3, failedInvocations: 0, lastInvokedAt: '2026-07-07T10:00:00Z', historical: false }, + ], + }, + ], ranking: [ { skillName: 'claude-skill', countedInvocations: 3, failedInvocations: 0, lastInvokedAt: '2026-07-07T10:00:00Z', historical: false }, ], @@ -380,14 +522,14 @@ describe.sequential('Terminal UI Tests', () => { await waitForFrame(app, (output) => output.includes('* Skillpack') && !output.includes('Scanning skills')); app.stdin.write('g'); - await waitForFrame(app, (output) => output.includes('> Codex [zero]') && output.includes(' Claude [active]')); + await waitForFrame(app, (output) => output.includes('[Codex zero]') && output.includes('Claude')); - app.stdin.write(keypress.down); + app.stdin.write(keypress.tab); await waitForFrame(app, (output) => ( - output.includes(' Codex [zero]') - && output.includes('> Claude [active]') - && output.includes('claude-skill -> 3') + output.includes('Codex zero') + && output.includes('[Claude]') + && output.includes('claude-skill') )); }); @@ -400,6 +542,7 @@ describe.sequential('Terminal UI Tests', () => { displayName: 'Codex', coverageState: 'zero', heatmap: [], + dailySkillUsage: [], ranking: [], diagnostics: [], }], @@ -413,9 +556,9 @@ describe.sequential('Terminal UI Tests', () => { app.stdin.write('g'); await waitForFrame(app, (output) => output.includes('Skill Usage') && output.includes('7 days')); - app.stdin.write(keypress.tab); + app.stdin.write('2'); - await waitForFrame(app, (output) => output.includes('Skill Usage') && output.includes('30 days')); + await waitForFrame(app, (output) => output.includes('Skill Usage') && output.includes('30 days') && output.includes('[30d]')); expect(manager.getSkillUsageOverview).toHaveBeenCalledWith({ rangeDays: 30 }); }); @@ -428,7 +571,7 @@ describe.sequential('Terminal UI Tests', () => { await waitForFrame(app, (output) => output.includes('* Skillpack') && !output.includes('Scanning skills')); app.stdin.write('g'); - await waitForFrame(app, (output) => output.includes('Usage coverage')); + await waitForFrame(app, (output) => output.includes('Providers')); app.stdin.write('r'); await waitForFrame(app, () => manager.importSkillUsage.mock.calls.length === 2); @@ -445,11 +588,15 @@ describe.sequential('Terminal UI Tests', () => { await waitForFrame(app, (output) => output.includes('* Skillpack') && !output.includes('Scanning skills')); app.stdin.write('g'); - await waitForFrame(app, (output) => output.includes('Usage coverage')); + await waitForFrame(app, (output) => output.includes('Providers')); app.stdin.write('x'); await waitForFrame(app, (output) => output.includes('Reset Skill Usage data?')); + app.stdin.write('q'); + await waitForFrame(app, (output) => output.includes('Reset Skill Usage data?')); + expect(manager.resetSkillUsage).not.toHaveBeenCalled(); + app.stdin.write('y'); await waitForFrame(app, () => manager.resetSkillUsage.mock.calls.length === 1); From 3563cac68f46ab511e4bf21570f5d6c77c087c49 Mon Sep 17 00:00:00 2001 From: WTW0313 Date: Thu, 9 Jul 2026 09:59:16 +0800 Subject: [PATCH 3/4] Show source paths in skill usage --- CONTEXT.md | 12 ++-- ...p-historical-skills-in-usage-aggregates.md | 1 + ...049-use-source-path-in-skill-usage-rows.md | 5 ++ docs/prds/skill-usage-tracing.md | 22 +++--- packages/core/src/manager.ts | 5 +- packages/core/src/usage.ts | 61 ++++++++--------- packages/core/tests/usage.test.ts | 67 ++++++++++++++++--- packages/tui/src/views/usage-view.tsx | 21 +++--- packages/tui/tests/ui/app-surface.test.tsx | 31 ++++----- 9 files changed, 138 insertions(+), 87 deletions(-) create mode 100644 docs/adr/0049-use-source-path-in-skill-usage-rows.md diff --git a/CONTEXT.md b/CONTEXT.md index 984f048..e082062 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -76,24 +76,24 @@ _Avoid_: Skill Identity, duplicate confidence The evidence-based runtime state for a Skill Invocation, indicating whether provider session artifacts say the skill was loaded, used, failed, or unknown. _Avoid_: Skill Availability, Inventory Issue, inventory status +**Skill Source Path**: +The `SKILL.md` path recorded from provider session evidence for an invoked skill, falling back to a resolved path only when the evidence path is unavailable. +_Avoid_: Historical status, Scan Root, inventory path + **Counted Invocation**: A Skill Invocation Record included in default Skill Usage aggregates. Failed invocations are excluded from default intensity and ranking counts but can be shown as separate failure context. _Avoid_: Successful invocation, completed task -**Historical Skill**: -A skill referenced by a Skill Invocation Record that is not currently discovered in Skill Inventory or Project Skills. -_Avoid_: Broken skill, Inventory Issue - **Skill Usage**: User-facing aggregates derived from Skill Invocations, used to understand which skills are being used across Skill Providers, including Project Skills when provider session evidence identifies them. _Avoid_: Skill Inventory, Skill Availability, inventory status, session history, cross-provider skill rollup **Provider Skill Ranking**: -A Skill Usage aggregate that ranks skills within a Skill Provider by exact Counted Invocation count, with failed invocation count and recency as supporting context. +A Skill Usage aggregate that ranks invoked skill source paths within a Skill Provider by exact Counted Invocation count, with failed invocation count and recency as supporting context. _Avoid_: Global leaderboard, inventory order **Selected-Day Usage Detail**: -The Skill Usage detail for one selected heatmap day, scoped to one Session-Producing Provider and grouped by skill with exact Counted Invocation and failure context for that day. +The Skill Usage detail for one selected heatmap day, scoped to one Session-Producing Provider and grouped by skill source path with exact Counted Invocation and failure context for that day. _Avoid_: Session transcript, raw invocation history, provider-wide total **Skill Usage Heatmap**: diff --git a/docs/adr/0048-keep-historical-skills-in-usage-aggregates.md b/docs/adr/0048-keep-historical-skills-in-usage-aggregates.md index e4e64fe..0298027 100644 --- a/docs/adr/0048-keep-historical-skills-in-usage-aggregates.md +++ b/docs/adr/0048-keep-historical-skills-in-usage-aggregates.md @@ -1,3 +1,4 @@ # Keep Historical Skills in usage aggregates +status: superseded by ADR-0049 Skillpack will keep Historical Skills in Skill Usage aggregates when Skill Invocation Records reference skills that are no longer currently discovered. Historical Skills should appear in Usage views with clear labeling, but they should not appear in the controllable Skill Inventory unless a current Provider Instance or Project Skill is discovered. diff --git a/docs/adr/0049-use-source-path-in-skill-usage-rows.md b/docs/adr/0049-use-source-path-in-skill-usage-rows.md new file mode 100644 index 0000000..66408ce --- /dev/null +++ b/docs/adr/0049-use-source-path-in-skill-usage-rows.md @@ -0,0 +1,5 @@ +# Use source path in Skill Usage rows + +Skillpack will group Skill Usage table rows by skill name and Skill Source Path, and will show Source Path instead of current/historical status. Skill Usage should report what provider session evidence says was invoked, not whether the invoked skill is currently discovered by Skill Inventory or Project Skills scanning. + +This supersedes ADR-0048. Current/historical labeling was too dependent on configured scan roots: a valid Project Skill invoked from a repository outside the current scan set could be labeled historical even though the invocation evidence contained the exact skill path. Source Path keeps deleted, moved, unscanned, and project-local skills visible without turning Usage into an Inventory status view. diff --git a/docs/prds/skill-usage-tracing.md b/docs/prds/skill-usage-tracing.md index 6cf99d8..4b2bf53 100644 --- a/docs/prds/skill-usage-tracing.md +++ b/docs/prds/skill-usage-tracing.md @@ -2,7 +2,7 @@ ## Problem Statement -Users can see which skills exist and which Skill Providers can load them, but they cannot see which skills are actually invoked during agent sessions. This makes it hard to understand whether a skill is useful, whether a Skill Provider is actively using skills, or whether stale and historical skills still explain recent agent behavior. +Users can see which skills exist and which Skill Providers can load them, but they cannot see which skills are actually invoked during agent sessions. This makes it hard to understand whether a skill is useful, whether a Skill Provider is actively using skills, or which source paths explain recent agent behavior. Users need a privacy-conscious Skill Usage view that derives aggregate usage from provider-owned session artifacts without turning Skillpack into a session transcript browser. @@ -12,7 +12,7 @@ Add Skill Usage tracing to Skillpack. Skillpack will read supported provider session artifacts after explicit Usage Import Consent, derive minimal Skill Invocation Records, persist them in a local append-only Skill Usage Log, and show aggregate Skill Usage in a dedicated TUI view. The default output is a 7-day Provider Skill Ranking capped to the top 10 skills, with failed reads shown separately from counted invocations. -Skill Usage is separate from Skill Inventory. Inventory answers what is currently discovered and manageable. Usage answers what was invoked over time, including Project Skills and Historical Skills when provider session evidence identifies them. +Skill Usage is separate from Skill Inventory. Inventory answers what is currently discovered and manageable. Usage answers what was invoked over time, including Project Skills and their Skill Source Paths when provider session evidence identifies them. ## User Stories @@ -30,8 +30,8 @@ Skill Usage is separate from Skill Inventory. Inventory answers what is currentl 12. As a Skillpack user, I want the default usage window to be 7 days, so that the first view reflects recent runtime behavior. 13. As a Skillpack user, I want the Provider Skill Ranking capped to the top 10 skills, so that the output stays focused and scannable. 14. As a Skillpack user, I want Project Skills included when they are invoked, so that repository-owned skills are not omitted from usage analysis. -15. As a Skillpack user, I want Historical Skills kept in usage aggregates, so that deleted or moved skills do not disappear from historical totals. -16. As a Skillpack user, I want Historical Skills clearly labeled, so that I do not confuse them with current controllable Provider Instances. +15. As a Skillpack user, I want usage rows to show the Skill Source Path, so that unscanned Project Skills and moved skills remain understandable without relying on Inventory status. +16. As a Skillpack user, I want same-named skills from different source paths separated in usage aggregates, so that project-local skills are not collapsed into misleading totals. 17. As a Skillpack user, I want unsupported providers shown differently from zero-usage providers, so that missing coverage is not mistaken for inactivity. 18. As a Skillpack user, I want Usage Import Diagnostics when artifacts are skipped, so that I can understand why usage may be incomplete. 19. As a Skillpack user, I want Skillpack to skip ambiguous provider evidence, so that false invocation records are not created. @@ -59,7 +59,7 @@ Skill Usage is separate from Skill Inventory. Inventory answers what is currentl - Codex skill usage is derived only from `response_item` events with `payload.type === "function_call"` where an `exec_command` directly reads `.../skills//SKILL.md`; `session_meta` and `turn_context` provide session, turn, and cwd context for session identity, turn identity, and relative path resolution. - Codex import excludes system/developer text, AGENTS.md text, available-skill lists, rendered skill metadata, ordinary text mentions, and search/script output such as `rg`, `grep`, or `python` commands that merely mention `SKILL.md`. - Codex usage is global across configured Codex artifact roots and is not scoped to Skillpack's startup cwd. -- Codex records are deduplicated by `(turn_id, skill_name)` so segmented reads of the same `SKILL.md` in one turn count as one invocation. +- Codex records are deduplicated by `(turn_id, skill_name, source_path)` so segmented reads of the same `SKILL.md` in one turn count as one invocation while same-named skills from different paths stay separate. - Codex skill names are normalized from the skill path directory, with plugin-owned Codex skills represented as `plugin-name:skill-name`. - Codex `function_call_output` exit-code evidence maps successful reads to `used` and failed reads to `failed`. - Claude Usage Import is intentionally unsupported in the first Codex-first slice and appears as unsupported coverage until a real Claude session adapter is added. @@ -68,11 +68,11 @@ Skill Usage is separate from Skill Inventory. Inventory answers what is currentl - Prompts, responses, tool arguments, file contents, and full transcripts are never persisted by Skillpack. - Skill Invocation Status is evidence-based: loaded, used, failed, or unknown. - Failed invocation records are stored but excluded from default Counted Invocation totals. -- Provider Skill Ranking ranks by exact Counted Invocation count within one Skill Provider. +- Provider Skill Ranking ranks by exact Counted Invocation count within one Skill Provider and keeps same-named skills from different Skill Source Paths as separate rows. - Skill Usage Heatmap intensity uses exact Counted Invocation volume across the selected range with one scale across providers. - Skill Usage does not roll up same-name skills across providers in v1. - Skill Usage includes Project Skills when session evidence identifies them. -- Skill Usage keeps Historical Skills in aggregates and labels them distinctly. +- Skill Usage rows show Skill Source Path instead of current/historical status, because Usage should not depend on current scan-root coverage. - Usage Coverage State distinguishes supported zero usage from unsupported or not configured providers. - Usage Provider Adapters parse only stable, known provider artifact formats. - Ambiguous or unsupported provider evidence is skipped with Usage Import Diagnostics. @@ -95,7 +95,7 @@ Skill Usage is separate from Skill Inventory. Inventory answers what is currentl - Usage view keyboard shortcuts are surfaced in the StatusBar rather than repeated inside the main content. - The bottom Provider Skill Ranking table is not row-selectable and does not support alternate sorting in v1. - The bottom Provider Skill Ranking table sorts by Counted Invocation count descending, failed invocation count descending, last-used timestamp descending, then skill name ascending. -- The bottom Provider Skill Ranking table columns are Skill, Counted, Failed, Last Used, and Status. +- The bottom Provider Skill Ranking table columns are Skill, Counted, Failed, Last Used, and Source Path. - The heatmap uses fixed-width colored block cells to show relative Counted Invocation volume across the selected provider and range; exact counts are shown in Selected-Day Usage Detail instead of permanent cell labels. - Heatmap color intensity is normalized within the selected provider and selected range, using that view's maximum single-day Counted Invocation count. - Failed invocations do not contribute to heatmap color intensity; they are shown in Selected-Day Usage Detail and Provider Skill Ranking context. @@ -103,9 +103,9 @@ Skill Usage is separate from Skill Inventory. Inventory answers what is currentl - The heatmap uses a GitHub-style calendar layout with weeks as columns and days aligned by weekday rows. - The heatmap uses a high-contrast colored palette for activity levels; zero-count cells should be visually quiet but not rendered as a grayscale intensity ramp. - The selected heatmap cell shows exact date, provider, Counted Invocation count, failed invocation count, and per-skill aggregate usage for that day. -- Selected-Day Usage Detail is grouped by skill and does not expose session-level or turn-level raw invocation history. +- Selected-Day Usage Detail is grouped by skill source path and does not expose session-level or turn-level raw invocation history. - Selected-Day Usage Detail appears to the right of the heatmap on wide terminals and below the heatmap on narrower terminals. -- Selected-Day Usage Detail columns are Skill, Counted, Failed, and Status, where Status distinguishes current and historical skills. +- Selected-Day Usage Detail columns are Skill, Counted, Failed, and Source Path. - Selected-Day Usage Detail should show all skills used on the selected day whenever possible. - When Selected-Day Usage Detail or the bottom Provider Skill Ranking table cannot fit in the terminal, the Usage view becomes scrollable instead of truncating those aggregates. - The bottom Provider Skill Ranking table is scoped to the selected provider and selected range; Skill Usage does not show a cross-provider total table in v1. @@ -140,7 +140,7 @@ interface SkillInvocationRecord { - Provider session artifacts are local-substitutable dependencies: tests should use fixture directories and files that represent supported provider formats. - Usage Provider Adapter tests should verify observable import results and diagnostics, not internal parser steps. - JSONL store tests should verify idempotent repeated imports, provider/month partitioning, retention until reset, and range-bounded reads. -- Aggregate tests should use independent literal records to verify Counted Invocation totals, failed exclusion, Historical Skill inclusion, and provider-specific rankings. +- Aggregate tests should use independent literal records to verify Counted Invocation totals, failed exclusion, source-path separation, and provider-specific rankings. - TUI tests can be added where the existing Ink test harness supports them; otherwise the first TUI slice should keep rendering logic factored so core aggregate behavior carries most coverage. - Do not mock Skillpack's own modules from each other in core tests. - Do not assert call counts between internal collaborators. diff --git a/packages/core/src/manager.ts b/packages/core/src/manager.ts index 1cf183d..91f4eaf 100644 --- a/packages/core/src/manager.ts +++ b/packages/core/src/manager.ts @@ -174,10 +174,7 @@ export class SkillManager { getDuplicates(): DuplicateInfo[] { return this.duplicates; } isDuplicate(skillName: string): boolean { return this.duplicates.some((d) => d.skillName === skillName); } getSkillUsageOverview(input: SkillUsageOverviewInput): Promise { - return this.skillUsage.getOverview({ - ...input, - currentSkills: input.currentSkills ?? this.getCurrentSkillReferences(), - }); + return this.skillUsage.getOverview(input); } importSkillUsage(): Promise { return this.skillUsage.importAllProviders(this.getCurrentSkillReferences()); diff --git a/packages/core/src/usage.ts b/packages/core/src/usage.ts index 32aaac3..900fa5e 100644 --- a/packages/core/src/usage.ts +++ b/packages/core/src/usage.ts @@ -3,7 +3,7 @@ import { mkdir, readdir, readFile, realpath, rm, stat, writeFile } from 'node:fs import os from 'node:os'; import path from 'node:path'; -const USAGE_IMPORTER_VERSION = 5; +const USAGE_IMPORTER_VERSION = 6; export type UsageCoverageState = 'unsupported' | 'not-configured' | 'zero' | 'active'; export type SkillInvocationStatus = 'loaded' | 'used' | 'failed' | 'unknown'; @@ -19,7 +19,6 @@ export interface SkillUsageProviderConfig { export interface SkillUsageOverviewInput { rangeDays: 7 | 30 | 90; now?: Date; - currentSkills?: CurrentSkillReference[]; } export interface CurrentSkillReference { @@ -44,10 +43,10 @@ export interface SkillUsageHeatmapCell { export interface ProviderSkillRankingRow { skillName: string; + sourcePath?: string; countedInvocations: number; failedInvocations: number; lastInvokedAt?: string; - historical: boolean; } export interface ProviderSkillDailyUsage { @@ -57,10 +56,10 @@ export interface ProviderSkillDailyUsage { export interface ProviderSkillUsageRow { skillName: string; + sourcePath?: string; countedInvocations: number; failedInvocations: number; lastInvokedAt?: string; - historical: boolean; } export interface UsageImportDiagnostic { @@ -247,8 +246,8 @@ export class SkillUsageManager { } const records = await this.readProviderRecords(provider.provider, range); const heatmap = buildHeatmap(records, range); - const dailySkillUsage = buildDailySkillUsage(records, range, input.currentSkills); - const ranking = buildRanking(records, input.currentSkills); + const dailySkillUsage = buildDailySkillUsage(records, range); + const ranking = buildRanking(records); return { provider: provider.provider, displayName: provider.displayName, @@ -483,7 +482,11 @@ async function parseCodexInvocationLines( continue; } const evidence = statusFromToolOutput(outputs.get(candidate.callId), candidate.statusHint); - const recordKey = `${candidate.turnId}\0${skillName}`; + const sourcePath = sourcePathForRecord({ + skillPath: candidate.skillPath, + resolvedPath: skillIdentity.resolvedPath, + }); + const recordKey = `${candidate.turnId}\0${skillName}\0${sourcePath ?? ''}`; const existing = recordsByTurnAndSkill.get(recordKey); if (existing) { existing.status = mergeInvocationStatus(existing.status, evidence.status); @@ -517,6 +520,7 @@ async function parseCodexInvocationLines( candidate.sessionId, candidate.turnId, skillName, + sourcePath, ), provider, sessionId: candidate.sessionId, @@ -653,9 +657,10 @@ function invocationRecordId( sessionId: string, turnId: string, skillName: string, + sourcePath: string | undefined, ): string { return createHash('sha256') - .update(JSON.stringify({ provider, sessionId, turnId, skillName })) + .update(JSON.stringify({ provider, sessionId, turnId, skillName, sourcePath })) .digest('hex'); } @@ -688,15 +693,13 @@ function datesInRange(range: SkillUsageRange): string[] { return dates; } -function buildRanking( - records: SkillInvocationRecord[], - currentSkills: CurrentSkillReference[] | undefined, -): ProviderSkillRankingRow[] { +function buildRanking(records: SkillInvocationRecord[]): ProviderSkillRankingRow[] { const rows = new Map(); for (const record of records) { - const row = rows.get(record.skillName) ?? createUsageRow(record.skillName, currentSkills); - addRecordToUsageRow(row, record, currentSkills); - rows.set(record.skillName, row); + const rowKey = usageRowKey(record); + const row = rows.get(rowKey) ?? createUsageRow(record); + addRecordToUsageRow(row, record); + rows.set(rowKey, row); } return [...rows.values()].sort(compareUsageRows).slice(0, 10); @@ -705,7 +708,6 @@ function buildRanking( function buildDailySkillUsage( records: SkillInvocationRecord[], range: SkillUsageRange, - currentSkills: CurrentSkillReference[] | undefined, ): ProviderSkillDailyUsage[] { const byDate = new Map>(); for (const date of datesInRange(range)) byDate.set(date, new Map()); @@ -713,9 +715,10 @@ function buildDailySkillUsage( for (const record of records) { const date = record.startedAt.slice(0, 10); const rows = byDate.get(date) ?? new Map(); - const row = rows.get(record.skillName) ?? createUsageRow(record.skillName, currentSkills); - addRecordToUsageRow(row, record, currentSkills); - rows.set(record.skillName, row); + const rowKey = usageRowKey(record); + const row = rows.get(rowKey) ?? createUsageRow(record); + addRecordToUsageRow(row, record); + rows.set(rowKey, row); byDate.set(date, rows); } @@ -725,19 +728,22 @@ function buildDailySkillUsage( })).sort((a, b) => a.date.localeCompare(b.date)); } -function createUsageRow(skillName: string, currentSkills: CurrentSkillReference[] | undefined): ProviderSkillUsageRow { +function usageRowKey(record: SkillInvocationRecord): string { + return `${record.skillName}\0${sourcePathForRecord(record) ?? ''}`; +} + +function createUsageRow(record: SkillInvocationRecord): ProviderSkillUsageRow { return { - skillName, + skillName: record.skillName, + sourcePath: sourcePathForRecord(record), countedInvocations: 0, failedInvocations: 0, - historical: currentSkills !== undefined, }; } function addRecordToUsageRow( row: ProviderSkillUsageRow, record: SkillInvocationRecord, - currentSkills: CurrentSkillReference[] | undefined, ): void { if (record.status === 'failed') { row.failedInvocations += 1; @@ -745,7 +751,6 @@ function addRecordToUsageRow( row.countedInvocations += 1; } if (!row.lastInvokedAt || record.startedAt > row.lastInvokedAt) row.lastInvokedAt = record.startedAt; - if (!isHistoricalSkill(record, currentSkills)) row.historical = false; } function compareUsageRows(a: ProviderSkillUsageRow, b: ProviderSkillUsageRow): number { @@ -762,12 +767,8 @@ function compareOptionalTimestampDesc(left: string | undefined, right: string | return right.localeCompare(left); } -function isHistoricalSkill( - record: SkillInvocationRecord, - currentSkills: CurrentSkillReference[] | undefined, -): boolean { - if (currentSkills === undefined) return false; - return !currentSkills.some((skill) => matchesCurrentSkill(record, skill)); +function sourcePathForRecord(record: Pick): string | undefined { + return record.skillPath ?? record.resolvedPath; } function findCurrentSkillSource( diff --git a/packages/core/tests/usage.test.ts b/packages/core/tests/usage.test.ts index de3011c..4ce45c1 100644 --- a/packages/core/tests/usage.test.ts +++ b/packages/core/tests/usage.test.ts @@ -257,24 +257,23 @@ describe('SkillUsageManager', () => { const overview = await usage.getOverview({ rangeDays: 7, now: new Date('2026-07-08T12:00:00Z'), - currentSkills, }); const provider = overview.providers[0]; expect(provider.ranking.map((row) => ({ skillName: row.skillName, + sourcePath: row.sourcePath, countedInvocations: row.countedInvocations, failedInvocations: row.failedInvocations, - historical: row.historical, }))).toEqual([ - { skillName: 'skill-beta', countedInvocations: 2, failedInvocations: 0, historical: false }, - { skillName: 'skill-alpha', countedInvocations: 1, failedInvocations: 1, historical: true }, - { skillName: 'skill-zeta', countedInvocations: 1, failedInvocations: 0, historical: true }, + { skillName: 'skill-beta', sourcePath: '/Users/twwu/.agents/skills/skill-beta/SKILL.md', countedInvocations: 2, failedInvocations: 0 }, + { skillName: 'skill-alpha', sourcePath: '/Users/twwu/.agents/skills/skill-alpha/SKILL.md', countedInvocations: 1, failedInvocations: 1 }, + { skillName: 'skill-zeta', sourcePath: '/Users/twwu/.agents/skills/skill-zeta/SKILL.md', countedInvocations: 1, failedInvocations: 0 }, ]); expect(provider.dailySkillUsage.find((day) => day.date === '2026-07-07')?.rows).toEqual([ - { skillName: 'skill-beta', countedInvocations: 2, failedInvocations: 0, lastInvokedAt: '2026-07-07T12:01:00Z', historical: false }, - { skillName: 'skill-alpha', countedInvocations: 1, failedInvocations: 1, lastInvokedAt: '2026-07-07T11:01:00Z', historical: true }, - { skillName: 'skill-zeta', countedInvocations: 1, failedInvocations: 0, lastInvokedAt: '2026-07-07T10:01:00Z', historical: true }, + { skillName: 'skill-beta', sourcePath: '/Users/twwu/.agents/skills/skill-beta/SKILL.md', countedInvocations: 2, failedInvocations: 0, lastInvokedAt: '2026-07-07T12:01:00Z' }, + { skillName: 'skill-alpha', sourcePath: '/Users/twwu/.agents/skills/skill-alpha/SKILL.md', countedInvocations: 1, failedInvocations: 1, lastInvokedAt: '2026-07-07T11:01:00Z' }, + { skillName: 'skill-zeta', sourcePath: '/Users/twwu/.agents/skills/skill-zeta/SKILL.md', countedInvocations: 1, failedInvocations: 0, lastInvokedAt: '2026-07-07T10:01:00Z' }, ]); } finally { await rm(root, { recursive: true, force: true }); @@ -325,17 +324,63 @@ describe('SkillUsageManager', () => { const overview = await usage.getOverview({ rangeDays: 7, now: new Date('2026-07-08T12:00:00Z'), - currentSkills, }); expect(overview.providers[0].ranking[0]).toMatchObject({ skillName: 'frontend-testing', - historical: false, + sourcePath: skillMdPath, }); } finally { await rm(root, { recursive: true, force: true }); } }); + it('keeps same-named skills separate when their source paths differ', async () => { + const root = await mkdtemp(path.join(tmpdir(), 'skillpack-usage-')); + try { + const sessionsRoot = path.join(root, 'codex-sessions'); + const projectA = path.join(root, 'project-a'); + const projectB = path.join(root, 'project-b'); + const skillA = path.join(projectA, '.agents', 'skills', 'shared-name', 'SKILL.md'); + const skillB = path.join(projectB, '.agents', 'skills', 'shared-name', 'SKILL.md'); + await mkdir(sessionsRoot, { recursive: true }); + await writeFile(path.join(sessionsRoot, 'session.jsonl'), jsonl([ + sessionMeta('session-a', root, '2026-07-07T08:00:00Z'), + taskStarted('turn-a', '2026-07-07T08:00:01Z'), + turnContext('turn-a', projectA, '2026-07-07T08:00:02Z'), + functionCall('call-a1', `cat ${skillA}`, '2026-07-07T08:01:00Z'), + functionOutput('call-a1', 0, '2026-07-07T08:01:01Z'), + taskStarted('turn-b', '2026-07-07T09:00:01Z'), + turnContext('turn-b', projectB, '2026-07-07T09:00:02Z'), + functionCall('call-b1', `cat ${skillB}`, '2026-07-07T09:01:00Z'), + functionOutput('call-b1', 0, '2026-07-07T09:01:01Z'), + ])); + const usage = new SkillUsageManager({ + dataDir: path.join(root, 'usage'), + now: new Date('2026-07-08T12:00:00Z'), + providers: [ + { provider: 'codex', displayName: 'Codex', supported: true, artifactRoots: [sessionsRoot] }, + ], + }); + + await usage.importProvider('codex'); + + const overview = await usage.getOverview({ + rangeDays: 7, + now: new Date('2026-07-08T12:00:00Z'), + }); + expect(overview.providers[0].ranking.map((row) => ({ + skillName: row.skillName, + sourcePath: row.sourcePath, + countedInvocations: row.countedInvocations, + }))).toEqual([ + { skillName: 'shared-name', sourcePath: skillB, countedInvocations: 1 }, + { skillName: 'shared-name', sourcePath: skillA, countedInvocations: 1 }, + ]); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + it('limits Provider Skill Ranking to the top 10 counted skills', async () => { const root = await mkdtemp(path.join(tmpdir(), 'skillpack-usage-')); try { @@ -423,7 +468,7 @@ describe('SkillUsageManager', () => { const stored = await readFile(path.join(usageRoot, 'invocations', 'codex', '2026-07.jsonl'), 'utf-8'); expect(stored.trim().split('\n')).toHaveLength(1); - await expect(readFile(path.join(usageRoot, 'cursors', 'codex.json'), 'utf-8')).resolves.toContain('"importerVersion": 5'); + await expect(readFile(path.join(usageRoot, 'cursors', 'codex.json'), 'utf-8')).resolves.toContain('"importerVersion": 6'); } finally { await rm(root, { recursive: true, force: true }); } diff --git a/packages/tui/src/views/usage-view.tsx b/packages/tui/src/views/usage-view.tsx index 262a4d9..7d6cdd9 100644 --- a/packages/tui/src/views/usage-view.tsx +++ b/packages/tui/src/views/usage-view.tsx @@ -479,7 +479,7 @@ function renderSelectedDayDetail( key: 'detail:header', element: ( - {fitCell('Skill', widths.skill)} {fitCell('Counted', widths.counted)} {fitCell('Failed', widths.failed)} {fitCell('Status', widths.status)} + {fitCell('Skill', widths.skill)} {fitCell('Counted', widths.counted)} {fitCell('Failed', widths.failed)} {fitCell('Source Path', widths.sourcePath)} ), }); @@ -488,7 +488,7 @@ function renderSelectedDayDetail( key: `detail:${row.skillName}`, element: ( - {fitCell(row.skillName, widths.skill)} {fitCell(String(row.countedInvocations), widths.counted)} {fitCell(formatFailed(row.failedInvocations), widths.failed)} {fitCell(formatStatus(row), widths.status)} + {fitCell(row.skillName, widths.skill)} {fitCell(String(row.countedInvocations), widths.counted)} {fitCell(formatFailed(row.failedInvocations), widths.failed)} {fitCell(formatSourcePath(row.sourcePath), widths.sourcePath)} ), }); @@ -510,7 +510,7 @@ function renderRankingTable(provider: ProviderSkillUsageOverview, columns: numbe key: 'ranking:header', element: ( - {fitCell('Skill', widths.skill)} {fitCell('Counted', widths.counted)} {fitCell('Failed', widths.failed)} {fitCell('Last Used', widths.lastUsed)} {fitCell('Status', widths.status)} + {fitCell('Skill', widths.skill)} {fitCell('Counted', widths.counted)} {fitCell('Failed', widths.failed)} {fitCell('Last Used', widths.lastUsed)} {fitCell('Source Path', widths.sourcePath)} ), }); @@ -519,7 +519,7 @@ function renderRankingTable(provider: ProviderSkillUsageOverview, columns: numbe key: `ranking:${row.skillName}`, element: ( - {fitCell(row.skillName, widths.skill)} {fitCell(String(row.countedInvocations), widths.counted)} {fitCell(formatFailed(row.failedInvocations), widths.failed)} {fitCell(formatLastUsed(row.lastInvokedAt), widths.lastUsed)} {fitCell(formatStatus(row), widths.status)} + {fitCell(row.skillName, widths.skill)} {fitCell(String(row.countedInvocations), widths.counted)} {fitCell(formatFailed(row.failedInvocations), widths.failed)} {fitCell(formatLastUsed(row.lastInvokedAt), widths.lastUsed)} {fitCell(formatSourcePath(row.sourcePath), widths.sourcePath)} ), }); @@ -527,15 +527,16 @@ function renderRankingTable(provider: ProviderSkillUsageOverview, columns: numbe return result; } -function usageTableWidths(columns: number, withLastUsed: boolean): { skill: number; counted: number; failed: number; lastUsed: number; status: number } { +function usageTableWidths(columns: number, withLastUsed: boolean): { skill: number; counted: number; failed: number; lastUsed: number; sourcePath: number } { const contentWidth = Math.max(40, columns - 2); const counted = 7; const failed = 6; - const status = 10; const lastUsed = withLastUsed ? 10 : 0; const gaps = withLastUsed ? 4 : 3; - const skill = Math.max(16, contentWidth - counted - failed - status - lastUsed - gaps); - return { skill, counted, failed, lastUsed, status }; + const fixed = counted + failed + lastUsed + gaps; + const skill = Math.max(16, Math.min(32, Math.floor((contentWidth - fixed) * 0.4))); + const sourcePath = Math.max(12, contentWidth - skill - fixed); + return { skill, counted, failed, lastUsed, sourcePath }; } function appendErrors(rows: UsageContentRow[], input: { usageImportError: string | null; error: string | null }) { @@ -616,6 +617,6 @@ function formatLastUsed(value: string | undefined): string { return value ? value.slice(0, 10) : '-'; } -function formatStatus(row: Pick): string { - return row.historical ? 'historical' : 'current'; +function formatSourcePath(value: string | undefined): string { + return value ?? '-'; } diff --git a/packages/tui/tests/ui/app-surface.test.tsx b/packages/tui/tests/ui/app-surface.test.tsx index 2ffc635..fa38b24 100644 --- a/packages/tui/tests/ui/app-surface.test.tsx +++ b/packages/tui/tests/ui/app-surface.test.tsx @@ -295,20 +295,20 @@ describe.sequential('Terminal UI Tests', () => { { date: '2026-07-07', rows: [ - { skillName: 'openai-docs', countedInvocations: 1, failedInvocations: 0, lastInvokedAt: '2026-07-07T08:00:00Z', historical: false }, + { skillName: 'openai-docs', sourcePath: '/Users/twwu/.codex/skills/.system/openai-docs/SKILL.md', countedInvocations: 1, failedInvocations: 0, lastInvokedAt: '2026-07-07T08:00:00Z' }, ], }, { date: '2026-07-08', rows: [ - { skillName: 'frontend-testing', countedInvocations: 2, failedInvocations: 1, lastInvokedAt: '2026-07-08T11:00:00Z', historical: false }, - { skillName: 'github:github', countedInvocations: 1, failedInvocations: 0, lastInvokedAt: '2026-07-08T10:00:00Z', historical: false }, + { skillName: 'frontend-testing', sourcePath: '/Users/twwu/project/.agents/skills/frontend-testing/SKILL.md', countedInvocations: 2, failedInvocations: 1, lastInvokedAt: '2026-07-08T11:00:00Z' }, + { skillName: 'github:github', sourcePath: '/Users/twwu/.codex/plugins/cache/openai-curated/github/skills/github/SKILL.md', countedInvocations: 1, failedInvocations: 0, lastInvokedAt: '2026-07-08T10:00:00Z' }, ], }, ], ranking: [ - { skillName: 'frontend-testing', countedInvocations: 5, failedInvocations: 1, lastInvokedAt: '2026-07-08T11:00:00Z', historical: false }, - { skillName: 'github:github', countedInvocations: 2, failedInvocations: 0, lastInvokedAt: '2026-07-08T10:00:00Z', historical: false }, + { skillName: 'frontend-testing', sourcePath: '/Users/twwu/project/.agents/skills/frontend-testing/SKILL.md', countedInvocations: 5, failedInvocations: 1, lastInvokedAt: '2026-07-08T11:00:00Z' }, + { skillName: 'github:github', sourcePath: '/Users/twwu/.codex/plugins/cache/openai-curated/github/skills/github/SKILL.md', countedInvocations: 2, failedInvocations: 0, lastInvokedAt: '2026-07-08T10:00:00Z' }, ], diagnostics: [], }, @@ -391,19 +391,19 @@ describe.sequential('Terminal UI Tests', () => { { date: '2026-07-06', rows: [ - { skillName: 'tdd', countedInvocations: 2, failedInvocations: 0, lastInvokedAt: '2026-07-06T10:02:00Z', historical: false }, + { skillName: 'tdd', sourcePath: '/Users/twwu/project/.agents/skills/tdd/SKILL.md', countedInvocations: 2, failedInvocations: 0, lastInvokedAt: '2026-07-06T10:02:00Z' }, ], }, { date: '2026-07-07', rows: [ - { skillName: 'grilling', countedInvocations: 1, failedInvocations: 0, lastInvokedAt: '2026-07-07T08:00:00Z', historical: false }, + { skillName: 'grilling', sourcePath: '/Users/twwu/project/.agents/skills/grilling/SKILL.md', countedInvocations: 1, failedInvocations: 0, lastInvokedAt: '2026-07-07T08:00:00Z' }, ], }, ], ranking: [ - { skillName: 'tdd', countedInvocations: 2, failedInvocations: 0, lastInvokedAt: '2026-07-06T10:02:00Z', historical: false }, - { skillName: 'grilling', countedInvocations: 1, failedInvocations: 0, lastInvokedAt: '2026-07-07T08:00:00Z', historical: false }, + { skillName: 'tdd', sourcePath: '/Users/twwu/project/.agents/skills/tdd/SKILL.md', countedInvocations: 2, failedInvocations: 0, lastInvokedAt: '2026-07-06T10:02:00Z' }, + { skillName: 'grilling', sourcePath: '/Users/twwu/project/.agents/skills/grilling/SKILL.md', countedInvocations: 1, failedInvocations: 0, lastInvokedAt: '2026-07-07T08:00:00Z' }, ], diagnostics: [], }], @@ -432,7 +432,7 @@ describe.sequential('Terminal UI Tests', () => { await waitForFrame(app, (output) => output.includes('2026-07-06 · Codex') && output.includes('Counted 2 · Failed 0')); }); - it('shows failure context, Historical Skills, and Usage Import Diagnostics', async () => { + it('shows failure context, source paths, and Usage Import Diagnostics', async () => { const manager = createMockManager({ usageOverview: { range: { days: 7, from: '2026-06-30', to: '2026-07-06' }, @@ -447,12 +447,12 @@ describe.sequential('Terminal UI Tests', () => { { date: '2026-07-06', rows: [ - { skillName: 'deleted-skill', countedInvocations: 1, failedInvocations: 1, lastInvokedAt: '2026-07-06T10:02:00Z', historical: true }, + { skillName: 'deleted-skill', sourcePath: '/Users/twwu/old-project/.agents/skills/deleted-skill/SKILL.md', countedInvocations: 1, failedInvocations: 1, lastInvokedAt: '2026-07-06T10:02:00Z' }, ], }, ], ranking: [ - { skillName: 'deleted-skill', countedInvocations: 1, failedInvocations: 1, lastInvokedAt: '2026-07-06T10:02:00Z', historical: true }, + { skillName: 'deleted-skill', sourcePath: '/Users/twwu/old-project/.agents/skills/deleted-skill/SKILL.md', countedInvocations: 1, failedInvocations: 1, lastInvokedAt: '2026-07-06T10:02:00Z' }, ], diagnostics: [ { provider: 'codex', message: 'Skipped ambiguous skill invocation evidence in session.jsonl:1' }, @@ -473,7 +473,8 @@ describe.sequential('Terminal UI Tests', () => { output.includes('2026-07-06 · Codex') && output.includes('Counted 1 · Failed 1') && output.includes('deleted-skill') - && output.includes('historical') + && output.includes('Source Path') + && output.includes('old-project') && output.includes('Skipped ambiguous skill invocation evidence') )); }); @@ -503,12 +504,12 @@ describe.sequential('Terminal UI Tests', () => { { date: '2026-07-07', rows: [ - { skillName: 'claude-skill', countedInvocations: 3, failedInvocations: 0, lastInvokedAt: '2026-07-07T10:00:00Z', historical: false }, + { skillName: 'claude-skill', sourcePath: '/Users/twwu/.claude/skills/claude-skill/SKILL.md', countedInvocations: 3, failedInvocations: 0, lastInvokedAt: '2026-07-07T10:00:00Z' }, ], }, ], ranking: [ - { skillName: 'claude-skill', countedInvocations: 3, failedInvocations: 0, lastInvokedAt: '2026-07-07T10:00:00Z', historical: false }, + { skillName: 'claude-skill', sourcePath: '/Users/twwu/.claude/skills/claude-skill/SKILL.md', countedInvocations: 3, failedInvocations: 0, lastInvokedAt: '2026-07-07T10:00:00Z' }, ], diagnostics: [], }, From 1205c656d8369b633c7e02d56671a90bf8b2d987 Mon Sep 17 00:00:00 2001 From: WTW0313 Date: Thu, 9 Jul 2026 13:44:07 +0800 Subject: [PATCH 4/4] fix: tighten inventory related providers --- packages/core/src/models/inventory.ts | 5 +++- packages/core/tests/inventory.test.ts | 29 ++++++++++++++++++++++ packages/tui/src/components/status-bar.tsx | 10 +++++--- packages/tui/src/views/settings-view.tsx | 15 +++++++++-- packages/tui/src/views/usage-view.tsx | 2 +- packages/tui/tests/ui/app-surface.test.tsx | 10 ++++++++ 6 files changed, 64 insertions(+), 7 deletions(-) diff --git a/packages/core/src/models/inventory.ts b/packages/core/src/models/inventory.ts index 12c2946..15c9e89 100644 --- a/packages/core/src/models/inventory.ts +++ b/packages/core/src/models/inventory.ts @@ -69,8 +69,11 @@ export function normalizeSkillName(name: string): string { function strongIdentityFor(skill: Skill): { key: string; reason: string; confirmsSingleInstance: boolean } | null { const realPath = skill.resolvedPath ?? skill.path; if (skill.source?.type === 'skillssh' && (skill.source.skillFolderHash || skill.source.repo)) { + const sourceKey = skill.source.skillFolderHash + ? `hash:${skill.source.skillFolderHash}` + : `repo:${skill.source.repo}:skill:${normalizeSkillName(skill.name)}`; return { - key: `skillssh:${skill.source.skillFolderHash ?? skill.source.repo}`, + key: `skillssh:${sourceKey}`, reason: 'skills.sh provenance', confirmsSingleInstance: true, }; diff --git a/packages/core/tests/inventory.test.ts b/packages/core/tests/inventory.test.ts index f7ce5a3..e520de3 100644 --- a/packages/core/tests/inventory.test.ts +++ b/packages/core/tests/inventory.test.ts @@ -177,6 +177,35 @@ describe('Skill Inventory', () => { expect(inventory[0].instances[0].actions).toEqual(['update', 'remove']); }); + it('does not group different skills from the same skills.sh repo when folder hashes are unavailable', () => { + const inventory = buildSkillInventory([ + { + name: 'lark-doc', + description: '', + provider: 'global', + path: path.join(root, 'global', 'lark-doc'), + enabled: true, + scope: 'global', + metadata: {}, + source: { type: 'skillssh', repo: 'owner/lark-skills' }, + }, + { + name: 'lark-task', + description: '', + provider: 'global', + path: path.join(root, 'global', 'lark-task'), + enabled: true, + scope: 'global', + metadata: {}, + source: { type: 'skillssh', repo: 'owner/lark-skills' }, + }, + ]); + + expect(inventory).toHaveLength(2); + expect(inventory.map((group) => group.name).sort()).toEqual(['lark-doc', 'lark-task']); + expect(inventory.flatMap((group) => group.notices)).toEqual([]); + }); + it('does not expose toggle actions when the provider has no Disable Strategy', () => { const inventory = buildSkillInventory([{ name: 'plugin-skill', diff --git a/packages/tui/src/components/status-bar.tsx b/packages/tui/src/components/status-bar.tsx index 0d9aada..d6c1adb 100644 --- a/packages/tui/src/components/status-bar.tsx +++ b/packages/tui/src/components/status-bar.tsx @@ -2,13 +2,17 @@ import { Box, Text } from 'ink'; import { useAppContext } from '../context/app-context.js'; import { useTerminalSize } from '../hooks/use-terminal-size.js'; import { getVisibleShortcuts } from '../lib/responsive-layout.js'; -import { getShortcutsForView } from '../lib/shortcuts.js'; +import { getShortcutsForView, type Shortcut } from '../lib/shortcuts.js'; -export function StatusBar() { +interface StatusBarProps { + shortcuts?: Shortcut[]; +} + +export function StatusBar({ shortcuts: shortcutsOverride }: StatusBarProps = {}) { const { view, selectedSkill } = useAppContext(); const { columns, rows } = useTerminalSize(); const canToggle = selectedSkill?.actions.some((action) => action === 'enable' || action === 'disable') ?? false; - const shortcuts = getShortcutsForView({ view, selectedSkill, canToggle }); + const shortcuts = shortcutsOverride ?? getShortcutsForView({ view, selectedSkill, canToggle }); const visibleShortcuts = getVisibleShortcuts(shortcuts, { columns, rows }); return ( diff --git a/packages/tui/src/views/settings-view.tsx b/packages/tui/src/views/settings-view.tsx index 23ef9b2..579d6f0 100644 --- a/packages/tui/src/views/settings-view.tsx +++ b/packages/tui/src/views/settings-view.tsx @@ -1,10 +1,11 @@ import { Box, Text, useApp, useInput } from 'ink'; -import { useMemo, useState, type ReactNode } from 'react'; +import { useEffect, useMemo, useState, type ReactNode } from 'react'; import { useAppContext } from '../context/app-context.js'; import { StatusBar } from '../components/status-bar.js'; import { formatDisplayPath } from '../lib/format-path.js'; import { useTerminalSize } from '../hooks/use-terminal-size.js'; import { fitCell, getBoundedContentLayout, getSettingsColumns } from '../lib/responsive-layout.js'; +import type { Shortcut } from '../lib/shortcuts.js'; interface SettingsRow { key: string; @@ -145,6 +146,16 @@ export function SettingsView() { const visibleRows = layout.visibleRows; const visibleContent = contentRows.slice(scrollOffset, scrollOffset + visibleRows); const showScroll = contentRows.length > visibleRows; + const settingsShortcuts: Shortcut[] = useMemo(() => [ + { key: 'esc', label: 'back' }, + { key: '?', label: 'help' }, + ...(showScroll ? [{ key: '↑↓', label: 'scroll' }] : []), + { key: 'q', label: 'quit' }, + ], [showScroll]); + + useEffect(() => { + setScrollOffset((offset) => Math.min(offset, Math.max(0, contentRows.length - visibleRows))); + }, [contentRows.length, visibleRows]); return ( @@ -164,7 +175,7 @@ export function SettingsView() { - + ); } diff --git a/packages/tui/src/views/usage-view.tsx b/packages/tui/src/views/usage-view.tsx index 7d6cdd9..d770dc5 100644 --- a/packages/tui/src/views/usage-view.tsx +++ b/packages/tui/src/views/usage-view.tsx @@ -130,7 +130,7 @@ export function UsageView() { if (confirmingReset) return; if (input === 'q') { exit(); return; } if (key.escape) { setView('list'); return; } - if (key.shiftTab && hasConsent && overview && overview.providers.length > 1) { + if (key.tab && key.shift && hasConsent && overview && overview.providers.length > 1) { setSelectedProviderIndex((index) => (index + overview.providers.length - 1) % overview.providers.length); return; } diff --git a/packages/tui/tests/ui/app-surface.test.tsx b/packages/tui/tests/ui/app-surface.test.tsx index fa38b24..fa67313 100644 --- a/packages/tui/tests/ui/app-surface.test.tsx +++ b/packages/tui/tests/ui/app-surface.test.tsx @@ -200,6 +200,16 @@ describe.sequential('Terminal UI Tests', () => { )); }); + it('does not show Settings scroll shortcut when all rows fit', async () => { + const app = renderApp(); + await waitForFrame(app, (output) => output.includes('* Skillpack') && !output.includes('Scanning skills')); + + app.stdin.write('s'); + + const frame = await waitForFrame(app, (output) => output.includes('Settings') && output.includes('read-only')); + expect(frame).not.toContain('↑↓ scroll'); + }); + it('opens the Usage view from inventory', async () => { const app = renderApp(); await waitForFrame(app, (output) => output.includes('* Skillpack') && !output.includes('Scanning skills'));