@groeponline/pi-wishcraft 1.11.0 → 1.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +3 -1
- package/CHANGELOG.md +13 -12
- package/build-stamp.json +1 -0
- package/docs/commands.md +3 -2
- package/docs/index.md +1 -0
- package/docs/release.md +16 -0
- package/docs/segments.md +2 -1
- package/package.json +1 -1
- package/src/config/presets.ts +3 -0
- package/src/config/types.ts +1 -0
- package/src/extension/commands/commands.ts +5 -0
- package/src/extension/commands/powerline-completions.ts +4 -0
- package/src/extension/commands/powerline-doctor.ts +10 -0
- package/src/extension/skills/workbench-catalog.ts +60 -0
- package/src/extension/skills/workbench.ts +337 -0
- package/src/extension/ui/deck/appearance-search.ts +227 -0
- package/src/extension/ui/deck/component.ts +53 -5
- package/src/extension/ui/deck/deck-actions.ts +78 -0
- package/src/extension/ui/deck/render.ts +3 -2
- package/src/extension/ui/deck/route-bodies.ts +22 -0
- package/src/extension/ui/deck/types.ts +4 -0
- package/src/motion/accessibility.ts +37 -2
- package/src/motion/catalog-recovered.ts +321 -0
- package/src/motion/catalog.ts +6 -1
- package/src/motion/gallery.ts +7 -0
- package/src/product-identity.ts +86 -0
- package/src/render/motion-candidates.ts +98 -4
- package/src/render/motion-rail.ts +73 -8
- package/src/segments/registry.ts +2 -0
- package/src/segments/usage.ts +40 -5
- package/src/signal/controller.ts +44 -1
package/AGENTS.md
CHANGED
|
@@ -115,9 +115,11 @@ scripts/github-release.sh
|
|
|
115
115
|
|
|
116
116
|
CI workflows (`.github/workflows/`):
|
|
117
117
|
- `test.yml` — Node 24, typecheck, test, circular, verify:package, npm audit
|
|
118
|
-
- `release.yml` — reuses test.yml, prepares release-candidate branch
|
|
118
|
+
- `release.yml` — reuses test.yml, prepares release-candidate branch, publishes on tag, attaches `npm pack` + `SHA256SUMS`
|
|
119
119
|
- `promote-release-candidate.yml` — promotes verified SHA to main, tags, dispatches publish
|
|
120
120
|
|
|
121
|
+
Tag `vX.Y.Z` must equal `package.json` and sit on `origin/main`. Identity: `src/product-identity.ts --version` / `/signal version`. See [`docs/release.md`](docs/release.md).
|
|
122
|
+
|
|
121
123
|
Release behavior:
|
|
122
124
|
- **Every main merge with `[Unreleased]` CHANGELOG notes cuts a release automatically** (verify → candidate → promote → tag → npm). Notes-less merges skip candidate creation entirely — no more empty versions.
|
|
123
125
|
- Notes live under `## [Unreleased]` in CHANGELOG.md; the release commit rolls them into the new version heading.
|
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [1.13.0] - 2026-09-25
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
- `budget` segment (daily token budget): renders `budget NN%` with a fill bar when `wishcraft.tokenBudget.daily.limit` is set; wired into the `chef`, `full`, and `nerd` presets.
|
|
9
|
+
- Resting animation has a real clock: while idle at full motion, the signal rail leases the scheduler's `ambient` channel and breathes from that tick. `reduced`/`off` motion still renders a stable dim marker with no scheduler consumer.
|
|
10
|
+
|
|
11
|
+
## [1.12.0] - 2026-09-25
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
- Recovered the unmatched vNext gallery motions (ember-trail, lantern-glow, fat-band, and the rest that were not already in the catalog) and the extreme-motion rail candidates (braille wave, boids, chevrons, shimmer, seismograph, fat-band). Assign motion `fat-band` to paint the topographic streaming rail; the default sweep stays a single status row.
|
|
15
|
+
- Deck skills: `w` opens the multi-step new-skill wizard (name, description, template, triggers) and writes `SKILL.md`. `n` remains the one-field create.
|
|
16
|
+
- Appearance route search (`/`) filters presets, palettes, motions, and motion levels. Enter persists a structural base, a streaming motion, or a motion level.
|
|
17
|
+
|
|
5
18
|
## [1.11.0] - 2026-09-20
|
|
6
19
|
|
|
7
20
|
### Fixed
|
|
@@ -31,18 +44,6 @@
|
|
|
31
44
|
- Bash forward-mode privacy notice (issue #71): the first keystroke forwarded to a running command raises an info notice that typed input may echo into the transcript; documented in `docs/bash-mode.md`.
|
|
32
45
|
- Managed PTY suite is now script-gated (issue #73): PTY-core tests skip when `script(1)` is missing instead of silently passing in degraded pipe mode, the basic run asserts a real PTY transport, and the explicit pipe-mode tests still cover the fallback.
|
|
33
46
|
|
|
34
|
-
### Changed
|
|
35
|
-
- Signal rail is now a compact single-line state surface. `ready` is stable and costs no animation frames; thinking, streaming, tools, and compaction use a bounded directional trail without expanding the footer to three rows.
|
|
36
|
-
- Chef preset now includes session age, generated-token throughput, and cache-read information when present. The port counter names its protocol (`tcp` or `tcp+udp`) instead of displaying an ambiguous bare count.
|
|
37
|
-
|
|
38
|
-
### Fixed
|
|
39
|
-
- Bash paste-while-running (issue #72): a bracketed paste performed while a command runs is stripped of its markers and forwarded to the child stdin — including split delivery across input chunks — instead of silently queuing in the editor buffer behind the command.
|
|
40
|
-
- PTY/transcript memory bounds (from jan `fix/runtime-pty-bounds`): a single unterminated output line is capped at a 64 KiB UTF-8 tail with a one-time notice, the partial-escape tail is bounded, and the active transcript command trims head lines/bytes within limits instead of growing unbounded.
|
|
41
|
-
- Segment-options spread hygiene (CodeFactor #86–89): `normalizeSegmentOptions` assigns fields imperatively instead of spreading conditional empty objects.
|
|
42
|
-
- Motion frames that contain multiple terminal columns no longer expand a rail cell and shift the footer layout while they animate.
|
|
43
|
-
- Idle rail no longer samples `Date.now()` without a corresponding repaint clock, eliminating stale or apparently random idle frames.
|
|
44
|
-
- TPS now reports `--` until a live two-sample rate exists, rather than falsely presenting idle telemetry as `0`; the existing `both`/`out`/`in` setting is validated and honored.
|
|
45
|
-
|
|
46
47
|
## [1.4.16] - 2026-09-05
|
|
47
48
|
|
|
48
49
|
### Fixed
|
package/build-stamp.json
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"source_sha":"394402d958799308f24ffc284a240b878588a931"}
|
package/docs/commands.md
CHANGED
|
@@ -86,7 +86,7 @@ You can also set it in the agent settings file (`~/.pi/agent/settings.json` by d
|
|
|
86
86
|
| `full` | Everything: hostname, model, path (abbreviated), full git incl commit + ↑/↓, totals, context total, elapsed + clock |
|
|
87
87
|
| `nerd` | Maximum detail for Nerd Font users: qualified model, full tokens + cache, commit, totals, seconds clock |
|
|
88
88
|
| `ascii` | Safe for any terminal: branch + short commit + ↑/↓, tokens, cost, context% (no Nerd glyphs) |
|
|
89
|
-
| `chef` | Fork default: muted colors, slash separators,
|
|
89
|
+
| `chef` | Fork default: muted colors, slash separators, breathing state rail, session age, TPS in/out, protocol-labelled open ports, generated tokens, cache, budget, subagent cost |
|
|
90
90
|
|
|
91
91
|
**Environment:** `POWERLINE_NERD_FONTS=1` to force Nerd Fonts, `=0` for ASCII.
|
|
92
92
|
|
|
@@ -99,7 +99,8 @@ Pi core renders the footer as static text, so live click is not possible; action
|
|
|
99
99
|
- `/tps`: overlay of the live 1s window (same ring as the segment). `/tps <value>` sets `POWERLINE_TPS`
|
|
100
100
|
- `/usage`: session / today / week overlay from `~/.pi/agent/wishcraft-usage.json`
|
|
101
101
|
- `/open-ports`: list listening ports and pick one
|
|
102
|
-
- `/powerline doctor`: diagnostics overlay — settings file validity, unknown presets, Nerd Font detection, git polling, bash-mode status,
|
|
102
|
+
- `/powerline doctor`: diagnostics overlay — settings file validity, unknown presets, Nerd Font detection, git polling, bash-mode status, queue file health, and `package.identity`
|
|
103
|
+
- `/powerline version`: `{ version, source_sha }` from `package.json` (SHA only when release-stamped). See [release.md](./release.md).
|
|
103
104
|
- `/powerline export`: export the current preset + effective layout + labels as a JSON snippet (Enter copies it to the clipboard)
|
|
104
105
|
- `alt+p`: **Wishcraft Deck** — operator overlay (Home, Signal, Skills, Ideas, Guardrails, Appearance, …). `g` then a jump key (`h` home, `s` signal, `a` appearance). Escape closes. `/signal menu` still opens Navigate / Configure / Status.
|
|
105
106
|
- `alt+i`: **powerline info**: full open-ports list
|
package/docs/index.md
CHANGED
|
@@ -11,6 +11,7 @@ This package is a Pi.dev extension, not a standalone website. The README is the
|
|
|
11
11
|
- [Skill manager](./skill-manager.md) — browsing, inserting, `/skills doctor`, and `/skills new` templates.
|
|
12
12
|
- [Working vibes](./working-vibes.md) — themed loading messages, modes, and configuration.
|
|
13
13
|
- [Segments & theming](./segments.md) — segment reference, separators, thinking/path/git options, and theme overrides.
|
|
14
|
+
- [Release route](./release.md) — tag == package version, GitHub Release artifacts, and identity.
|
|
14
15
|
|
|
15
16
|
## Design System & vNext Specifications
|
|
16
17
|
|
package/docs/release.md
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# Release route
|
|
2
|
+
|
|
3
|
+
Declared version source: `package.json` `version`. Product tag `vX.Y.Z` must equal that value at the tagged commit, and the commit must be an ancestor of `origin/main`.
|
|
4
|
+
|
|
5
|
+
Existing flow is unchanged: main pushes with `[Unreleased]` notes still prepare a candidate; promotion still tags protected main and dispatches publish. `scripts/npm-publish.sh` remains the npm publish target.
|
|
6
|
+
|
|
7
|
+
This route adds GitHub Release evidence on top of that:
|
|
8
|
+
|
|
9
|
+
1. `.github/workflows/release.yml` re-checks tag == `package.json` and ancestry on `origin/main`.
|
|
10
|
+
2. It writes `build-stamp.json` / `PI_WISHCRAFT_BUILD_SHA` from the tag commit only (never a fake such as `0.0.0`).
|
|
11
|
+
3. It runs `npm pack`, writes `SHA256SUMS`, and attaches those assets to the GitHub Release.
|
|
12
|
+
4. Visible identity: `node --experimental-strip-types src/product-identity.ts --version` prints `{ version, source_sha }`. `/signal version` and `/signal doctor` (`package.identity`) show the same values. Missing stamp/env is `null`, never invented.
|
|
13
|
+
|
|
14
|
+
`workflow_dispatch` input `tag` backfills the same checks at an existing tag (for example `v1.10.0`) and uploads artifacts. It does not publish to npm. Do not run the backfill from a contract PR.
|
|
15
|
+
|
|
16
|
+
This document does not create a tag or GitHub Release.
|
package/docs/segments.md
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
- `tps`: live tokens/sec over a rolling 1-second window (EMA-free, no spikes), reporting **output and input** rates separately (`⇡out ⇣in`) so you can see generation speed and incoming prompt tokens at a glance. It shows `--` until two live samples exist, rather than pretending an idle session runs at zero. Set `segmentOptions.tps.mode` to `both`, `out`, or `in`; override with `POWERLINE_TPS` when needed.
|
|
8
8
|
- `open_ports`: count of unique **TCP** listening ports (`ss` → `netstat` → `/proc/net` fallback, dedupes IPv4/IPv6) rendered as `21 tcp`. Set `segmentOptions.openPorts.includeUdp: true` to include noisy UDP (mDNS/DHCP/ephemeral), rendered as `tcp+udp`.
|
|
9
|
+
- `budget`: daily token-budget usage (`budget 62% ▓▓▓▓░░░░`) when `wishcraft.tokenBudget.daily.limit` is configured. Warns through the same threshold colors as the cost segment.
|
|
9
10
|
|
|
10
11
|
### Open-port process owners
|
|
11
12
|
|
|
@@ -101,7 +102,7 @@ The `minimal` and `compact` presets keep these off to stay lean; set `showCommit
|
|
|
101
102
|
|
|
102
103
|
## Segments
|
|
103
104
|
|
|
104
|
-
`model` · `shell_mode` · `path` · `git` · `subagents` · `queue` · `token_in` · `token_out` · `token_total` · `cost` · `context_pct` · `context_total` · `time_spent` · `time` · `session` · `hostname` · `cache_read` · `cache_write` · `thinking` · `tps` · `open_ports` · `extension_statuses`
|
|
105
|
+
`model` · `shell_mode` · `path` · `git` · `subagents` · `queue` · `token_in` · `token_out` · `token_total` · `cost` · `budget` · `context_pct` · `context_total` · `time_spent` · `time` · `session` · `hostname` · `cache_read` · `cache_write` · `thinking` · `tps` · `open_ports` · `extension_statuses`
|
|
105
106
|
|
|
106
107
|
## Separators
|
|
107
108
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@groeponline/pi-wishcraft",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.13.0",
|
|
4
4
|
"description": "Operator cockpit for Pi: live powerline status, searchable skills, idea queue, sticky Bash, hooks, policy controls, and session UX.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"files": [
|
package/src/config/presets.ts
CHANGED
|
@@ -251,6 +251,7 @@ export const PRESETS: Record<StatusLinePreset, PresetDef> = {
|
|
|
251
251
|
"cost",
|
|
252
252
|
"context_pct",
|
|
253
253
|
"context_total",
|
|
254
|
+
"budget",
|
|
254
255
|
"time_spent",
|
|
255
256
|
"time",
|
|
256
257
|
"extension_statuses",
|
|
@@ -299,6 +300,7 @@ export const PRESETS: Record<StatusLinePreset, PresetDef> = {
|
|
|
299
300
|
"cost",
|
|
300
301
|
"context_pct",
|
|
301
302
|
"context_total",
|
|
303
|
+
"budget",
|
|
302
304
|
"time_spent",
|
|
303
305
|
"time",
|
|
304
306
|
"extension_statuses",
|
|
@@ -369,6 +371,7 @@ export const PRESETS: Record<StatusLinePreset, PresetDef> = {
|
|
|
369
371
|
"subagents",
|
|
370
372
|
"cost",
|
|
371
373
|
"context_pct",
|
|
374
|
+
"budget",
|
|
372
375
|
"time",
|
|
373
376
|
],
|
|
374
377
|
separator: "slash",
|
package/src/config/types.ts
CHANGED
|
@@ -35,6 +35,7 @@ import { config, normalizePreset } from "../core/state.ts";
|
|
|
35
35
|
import type { RuntimeState } from "../core/types.ts";
|
|
36
36
|
import { getPowerlineArgumentCompletions } from "./powerline-completions.ts";
|
|
37
37
|
import { settleSignal } from "../../signal/integration.ts";
|
|
38
|
+
import { formatProductIdentity, readProductIdentity } from "../../product-identity.ts";
|
|
38
39
|
|
|
39
40
|
export function registerCommands(pi: ExtensionAPI, rt: RuntimeState): void {
|
|
40
41
|
registerCdCommand(pi, () => rt.currentCtx?.cwd ?? process.cwd());
|
|
@@ -110,6 +111,10 @@ export function registerCommands(pi: ExtensionAPI, rt: RuntimeState): void {
|
|
|
110
111
|
await runPowerlineDoctor(rt, ctx);
|
|
111
112
|
return;
|
|
112
113
|
}
|
|
114
|
+
if (normalizedArgs === "version") {
|
|
115
|
+
ctx.ui.notify(formatProductIdentity(readProductIdentity()).trimEnd(), "info");
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
113
118
|
if (normalizedArgs === "export") {
|
|
114
119
|
await runPowerlineExport(ctx);
|
|
115
120
|
return;
|
|
@@ -11,6 +11,10 @@ export const POWERLINE_SUBCOMMANDS: ReadonlyArray<{
|
|
|
11
11
|
value: "doctor",
|
|
12
12
|
description: "Run environment and settings diagnostics",
|
|
13
13
|
},
|
|
14
|
+
{
|
|
15
|
+
value: "version",
|
|
16
|
+
description: "Print package version and stamped source SHA",
|
|
17
|
+
},
|
|
14
18
|
{
|
|
15
19
|
value: "export",
|
|
16
20
|
description: "Export the current status-line configuration",
|
|
@@ -13,6 +13,7 @@ import { hasNerdFonts } from "../../theme/icons.ts";
|
|
|
13
13
|
import { config } from "../core/state.ts";
|
|
14
14
|
import type { RuntimeState } from "../core/types.ts";
|
|
15
15
|
import { showSelectOverlay } from "../ui/menu-views.ts";
|
|
16
|
+
import { readProductIdentity } from "../../product-identity.ts";
|
|
16
17
|
|
|
17
18
|
export type DoctorStatus = "ok" | "warn" | "fail";
|
|
18
19
|
|
|
@@ -51,6 +52,15 @@ export function buildDoctorReport(
|
|
|
51
52
|
const cwd = ctx.cwd ?? process.cwd();
|
|
52
53
|
const checks: DoctorCheck[] = [];
|
|
53
54
|
|
|
55
|
+
const identity = readProductIdentity();
|
|
56
|
+
checks.push({
|
|
57
|
+
status: "ok",
|
|
58
|
+
name: "package.identity",
|
|
59
|
+
detail: identity.source_sha
|
|
60
|
+
? `${identity.version} source_sha=${identity.source_sha}`
|
|
61
|
+
: `${identity.version} source_sha=null`,
|
|
62
|
+
});
|
|
63
|
+
|
|
54
64
|
// ── Settings ─────────────────────────────────────────────────────────────
|
|
55
65
|
const globalPath = getSettingsPath();
|
|
56
66
|
const projectPath = getProjectSettingsPath(cwd);
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Map the skill catalog onto workbench rows (preview, health, sparkline).
|
|
3
|
+
* The usage ledger stores a count, not a histogram, so the series is that count.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
collectSkillDoctorInputs,
|
|
8
|
+
diagnoseSkills,
|
|
9
|
+
type SkillDoctorRow,
|
|
10
|
+
} from "./skill-doctor.ts";
|
|
11
|
+
import {
|
|
12
|
+
readSkillBody,
|
|
13
|
+
type SkillEntry,
|
|
14
|
+
type SkillUsage,
|
|
15
|
+
} from "./skill-registry.ts";
|
|
16
|
+
import { parseSkillTriggers, type WorkbenchSkill } from "./workbench.ts";
|
|
17
|
+
|
|
18
|
+
function usageSeriesOf(usage: SkillUsage | undefined): number[] {
|
|
19
|
+
if (!usage || usage.count <= 0) return [];
|
|
20
|
+
return [usage.count];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function workbenchSkillFromEntry(
|
|
24
|
+
entry: SkillEntry,
|
|
25
|
+
usage: ReadonlyMap<string, SkillUsage>,
|
|
26
|
+
doctorByName?: ReadonlyMap<string, SkillDoctorRow>,
|
|
27
|
+
body = readSkillBody(entry.filePath),
|
|
28
|
+
): WorkbenchSkill {
|
|
29
|
+
const used = usage.get(entry.name);
|
|
30
|
+
const row = doctorByName?.get(entry.name);
|
|
31
|
+
return {
|
|
32
|
+
name: entry.name,
|
|
33
|
+
description: entry.description,
|
|
34
|
+
category: entry.category,
|
|
35
|
+
warning: entry.warning,
|
|
36
|
+
usageCount: used?.count ?? 0,
|
|
37
|
+
usageSeries: usageSeriesOf(used),
|
|
38
|
+
health: row?.status ?? (entry.warning ? "warn" : "ok"),
|
|
39
|
+
bodyPreview: body,
|
|
40
|
+
triggers: parseSkillTriggers(entry.name, body),
|
|
41
|
+
filePath: entry.filePath,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function loadWorkbenchSkills(cwd: string = process.cwd()): WorkbenchSkill[] {
|
|
46
|
+
const inputs = collectSkillDoctorInputs(cwd);
|
|
47
|
+
const rank: Record<SkillDoctorRow["status"], number> = { fail: 0, warn: 1, ok: 2 };
|
|
48
|
+
const doctorByName = new Map<string, SkillDoctorRow>();
|
|
49
|
+
for (const row of diagnoseSkills(inputs.entries, inputs.usage, inputs.contents)) {
|
|
50
|
+
const prev = doctorByName.get(row.skill);
|
|
51
|
+
if (!prev || rank[row.status] < rank[prev.status]) doctorByName.set(row.skill, row);
|
|
52
|
+
}
|
|
53
|
+
return inputs.entries.map((entry) =>
|
|
54
|
+
workbenchSkillFromEntry(entry, inputs.usage, doctorByName, inputs.contents.get(entry.filePath)),
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function doctorRowsBySkill(rows: readonly SkillDoctorRow[]): Map<string, SkillDoctorRow> {
|
|
59
|
+
return new Map(rows.map((row) => [row.skill, row]));
|
|
60
|
+
}
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Skill workbench: split-pane list + metadata + health + sparkline + preview,
|
|
3
|
+
* plus an inline new-skill wizard and workflow visualization.
|
|
4
|
+
* Recovered from the closed vNext operator draft and wired to the Deck.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
10
|
+
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
11
|
+
import { getAgentPath } from "../../paths/agent-dirs.ts";
|
|
12
|
+
import { invalidateSkillCache } from "./skill-registry.ts";
|
|
13
|
+
import {
|
|
14
|
+
SKILL_TEMPLATE_IDS,
|
|
15
|
+
SkillNameError,
|
|
16
|
+
isSkillTemplateId,
|
|
17
|
+
renderSkillTemplate,
|
|
18
|
+
sanitizeSkillName,
|
|
19
|
+
type SkillTemplateId,
|
|
20
|
+
} from "./skill-templates.ts";
|
|
21
|
+
import type { SkillDoctorStatus } from "./skill-doctor.ts";
|
|
22
|
+
|
|
23
|
+
export interface WorkbenchSkill {
|
|
24
|
+
name: string;
|
|
25
|
+
description: string;
|
|
26
|
+
category: string;
|
|
27
|
+
warning?: string;
|
|
28
|
+
usageCount: number;
|
|
29
|
+
usageSeries?: number[];
|
|
30
|
+
health?: SkillDoctorStatus;
|
|
31
|
+
bodyPreview?: string;
|
|
32
|
+
triggers?: string[];
|
|
33
|
+
filePath?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const WIZARD_STEPS = [
|
|
37
|
+
"name",
|
|
38
|
+
"description",
|
|
39
|
+
"template",
|
|
40
|
+
"triggers",
|
|
41
|
+
"confirm",
|
|
42
|
+
] as const;
|
|
43
|
+
|
|
44
|
+
export type WizardStep = (typeof WIZARD_STEPS)[number];
|
|
45
|
+
|
|
46
|
+
export interface SkillWizardState {
|
|
47
|
+
step: WizardStep;
|
|
48
|
+
name: string;
|
|
49
|
+
description: string;
|
|
50
|
+
template: SkillTemplateId;
|
|
51
|
+
triggers: string;
|
|
52
|
+
error: string | null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export type WizardInputResult =
|
|
56
|
+
| { type: "update"; wizard: SkillWizardState }
|
|
57
|
+
| { type: "close" }
|
|
58
|
+
| { type: "save"; wizard: SkillWizardState };
|
|
59
|
+
|
|
60
|
+
export function createSkillWizard(): SkillWizardState {
|
|
61
|
+
return {
|
|
62
|
+
step: "name",
|
|
63
|
+
name: "",
|
|
64
|
+
description: "",
|
|
65
|
+
template: "standard",
|
|
66
|
+
triggers: "",
|
|
67
|
+
error: null,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function wizardStepIndex(step: WizardStep): number {
|
|
72
|
+
return WIZARD_STEPS.indexOf(step);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function applyWizardInput(state: SkillWizardState, data: string): SkillWizardState {
|
|
76
|
+
if (data === "escape" || data === "\x1b") return state;
|
|
77
|
+
if (data === "backspace" || data === "\x7f" || data === "\b") {
|
|
78
|
+
return editWizardField(state, (value) => value.slice(0, -1));
|
|
79
|
+
}
|
|
80
|
+
if (data === "tab" || data === "enter" || data === "\r" || data === "\n") {
|
|
81
|
+
return advanceWizard(state);
|
|
82
|
+
}
|
|
83
|
+
if (data.length === 1 && data >= " " && data <= "~") {
|
|
84
|
+
return editWizardField(state, (value) => value + data);
|
|
85
|
+
}
|
|
86
|
+
return state;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function handleWizardKey(wizard: SkillWizardState, data: string): WizardInputResult {
|
|
90
|
+
if (data === "escape" || data === "\x1b") {
|
|
91
|
+
if (wizard.step === "name") return { type: "close" };
|
|
92
|
+
return { type: "update", wizard: retreatWizard(wizard) };
|
|
93
|
+
}
|
|
94
|
+
if ((data === "[" || data === "]") && wizard.step === "template") {
|
|
95
|
+
return {
|
|
96
|
+
type: "update",
|
|
97
|
+
wizard: cycleWizardTemplate(wizard, data === "]" ? 1 : -1),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
if ((data === "enter" || data === "\r" || data === "\n") && wizardIsComplete(wizard)) {
|
|
101
|
+
return { type: "save", wizard };
|
|
102
|
+
}
|
|
103
|
+
return { type: "update", wizard: applyWizardInput(wizard, data) };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function editWizardField(
|
|
107
|
+
state: SkillWizardState,
|
|
108
|
+
edit: (value: string) => string,
|
|
109
|
+
): SkillWizardState {
|
|
110
|
+
switch (state.step) {
|
|
111
|
+
case "name":
|
|
112
|
+
return { ...state, name: edit(state.name), error: null };
|
|
113
|
+
case "description":
|
|
114
|
+
return { ...state, description: edit(state.description), error: null };
|
|
115
|
+
case "triggers":
|
|
116
|
+
return { ...state, triggers: edit(state.triggers), error: null };
|
|
117
|
+
case "template": {
|
|
118
|
+
const next = edit(state.template).trim();
|
|
119
|
+
return {
|
|
120
|
+
...state,
|
|
121
|
+
template: isSkillTemplateId(next) ? next : state.template,
|
|
122
|
+
error: null,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
case "confirm":
|
|
126
|
+
return state;
|
|
127
|
+
default: {
|
|
128
|
+
const exhaustive: never = state.step;
|
|
129
|
+
return exhaustive;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function cycleWizardTemplate(state: SkillWizardState, direction = 1): SkillWizardState {
|
|
135
|
+
const index = SKILL_TEMPLATE_IDS.indexOf(state.template);
|
|
136
|
+
const next =
|
|
137
|
+
SKILL_TEMPLATE_IDS[(index + direction + SKILL_TEMPLATE_IDS.length) % SKILL_TEMPLATE_IDS.length]!;
|
|
138
|
+
return { ...state, template: next, error: null };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function advanceWizard(state: SkillWizardState): SkillWizardState {
|
|
142
|
+
if (state.step === "name") {
|
|
143
|
+
try {
|
|
144
|
+
const name = sanitizeSkillName(state.name);
|
|
145
|
+
return { ...state, name, error: null, step: "description" };
|
|
146
|
+
} catch (error) {
|
|
147
|
+
return {
|
|
148
|
+
...state,
|
|
149
|
+
error: error instanceof Error ? error.message : "Invalid skill name",
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
if (state.step === "description") {
|
|
154
|
+
if (!state.description.trim()) {
|
|
155
|
+
return { ...state, error: "Description is required" };
|
|
156
|
+
}
|
|
157
|
+
return { ...state, step: "template", error: null };
|
|
158
|
+
}
|
|
159
|
+
if (state.step === "template") return { ...state, step: "triggers", error: null };
|
|
160
|
+
if (state.step === "triggers") return { ...state, step: "confirm", error: null };
|
|
161
|
+
return state;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function retreatWizard(state: SkillWizardState): SkillWizardState {
|
|
165
|
+
const index = wizardStepIndex(state.step);
|
|
166
|
+
if (index <= 0) return state;
|
|
167
|
+
return { ...state, step: WIZARD_STEPS[index - 1]!, error: null };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function wizardIsComplete(state: SkillWizardState): boolean {
|
|
171
|
+
return state.step === "confirm" && !state.error && Boolean(state.name);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function parseSkillTriggers(name: string, body: string): string[] {
|
|
175
|
+
const listed: string[] = [];
|
|
176
|
+
const heading = body.match(/## Triggers\s*\n+([\s\S]*?)(?=\n## |\s*$)/i);
|
|
177
|
+
if (heading?.[1]) {
|
|
178
|
+
for (const line of heading[1].split("\n")) {
|
|
179
|
+
const item = line.replace(/^\s*[-*]\s+/, "").trim();
|
|
180
|
+
if (item) listed.push(item);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return listed.length > 0 ? listed : [name, `$${name}`];
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function composeWizardSkill(state: SkillWizardState): string {
|
|
187
|
+
const name = sanitizeSkillName(state.name);
|
|
188
|
+
const body = renderSkillTemplate(state.template, name);
|
|
189
|
+
const triggers = state.triggers
|
|
190
|
+
.split(",")
|
|
191
|
+
.map((item) => item.trim())
|
|
192
|
+
.filter(Boolean);
|
|
193
|
+
const triggerBlock =
|
|
194
|
+
triggers.length > 0
|
|
195
|
+
? `\n## Triggers\n\n${triggers.map((item) => `- ${item}`).join("\n")}\n`
|
|
196
|
+
: "";
|
|
197
|
+
if (!state.description.trim()) return `${body}${triggerBlock}`;
|
|
198
|
+
return (
|
|
199
|
+
body.replace(/description: .*/, `description: ${state.description.trim()}`) + triggerBlock
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function writeWizardSkill(
|
|
204
|
+
state: SkillWizardState,
|
|
205
|
+
skillsRoot: string = getAgentPath("skills"),
|
|
206
|
+
): { filePath: string } {
|
|
207
|
+
const name = sanitizeSkillName(state.name);
|
|
208
|
+
const dir = join(skillsRoot, name);
|
|
209
|
+
const filePath = join(dir, "SKILL.md");
|
|
210
|
+
if (existsSync(filePath)) {
|
|
211
|
+
throw new SkillNameError(`Skill already exists: ${name}`);
|
|
212
|
+
}
|
|
213
|
+
mkdirSync(dir, { recursive: true });
|
|
214
|
+
writeFileSync(filePath, composeWizardSkill({ ...state, name }), "utf8");
|
|
215
|
+
invalidateSkillCache();
|
|
216
|
+
return { filePath };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export function renderUsageSparkline(counts: number[], width = 12): string {
|
|
220
|
+
const blocks = [" ", "▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"];
|
|
221
|
+
if (width <= 0) return "";
|
|
222
|
+
if (counts.length === 0) return "·".repeat(width);
|
|
223
|
+
const series =
|
|
224
|
+
counts.length >= width
|
|
225
|
+
? counts.slice(counts.length - width)
|
|
226
|
+
: [...Array(width - counts.length).fill(0), ...counts];
|
|
227
|
+
const max = Math.max(1, ...series);
|
|
228
|
+
return series
|
|
229
|
+
.map((value) => {
|
|
230
|
+
const index = Math.round((value / max) * (blocks.length - 1));
|
|
231
|
+
return blocks[index] ?? " ";
|
|
232
|
+
})
|
|
233
|
+
.join("");
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export function renderSkillWorkflow(skill: WorkbenchSkill): string[] {
|
|
237
|
+
const health = skill.health ?? (skill.warning ? "warn" : "ok");
|
|
238
|
+
const triggers = skill.triggers?.length ? skill.triggers.join(", ") : "name / $skill";
|
|
239
|
+
return [
|
|
240
|
+
`discover(${skill.category}) → health(${health}) → usage(${skill.usageCount}x) → insert`,
|
|
241
|
+
`triggers: ${triggers}`,
|
|
242
|
+
];
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export function renderSkillWizard(theme: Theme, width: number, state: SkillWizardState): string[] {
|
|
246
|
+
const title = theme.fg(
|
|
247
|
+
"accent",
|
|
248
|
+
`NEW SKILL WIZARD · step ${wizardStepIndex(state.step) + 1}/${WIZARD_STEPS.length}`,
|
|
249
|
+
);
|
|
250
|
+
const lines = [
|
|
251
|
+
title,
|
|
252
|
+
`Name: ${state.name || "…"}`,
|
|
253
|
+
`Description: ${state.description || "…"}`,
|
|
254
|
+
`Template: ${state.template}`,
|
|
255
|
+
`Triggers: ${state.triggers || "(none)"}`,
|
|
256
|
+
"",
|
|
257
|
+
`Editing: ${state.step}`,
|
|
258
|
+
];
|
|
259
|
+
if (state.error) lines.push(theme.fg("warning", state.error));
|
|
260
|
+
if (state.step === "confirm") {
|
|
261
|
+
lines.push("Enter saves · esc cancels");
|
|
262
|
+
lines.push(...composeWizardSkill(state).split("\n").slice(0, 6));
|
|
263
|
+
} else {
|
|
264
|
+
lines.push("type · enter next · [ ] template · backspace edit");
|
|
265
|
+
}
|
|
266
|
+
return lines.map((line) => truncateToWidth(line, width, "…", true));
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export function renderSkillWorkbench(
|
|
270
|
+
theme: Theme,
|
|
271
|
+
width: number,
|
|
272
|
+
skills: readonly WorkbenchSkill[],
|
|
273
|
+
selected: number,
|
|
274
|
+
wizard: SkillWizardState | null,
|
|
275
|
+
): string[] {
|
|
276
|
+
if (wizard) return renderSkillWizard(theme, width, wizard);
|
|
277
|
+
if (skills.length === 0) {
|
|
278
|
+
return [
|
|
279
|
+
theme.fg("warning", "No skills installed"),
|
|
280
|
+
"Press n or ctrl+n to open the new-skill wizard",
|
|
281
|
+
].map((line) => truncateToWidth(line, width, "…", true));
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const index = Math.max(0, Math.min(selected, skills.length - 1));
|
|
285
|
+
const skill = skills[index]!;
|
|
286
|
+
const leftW = Math.max(12, Math.floor(width * 0.32));
|
|
287
|
+
const midW = Math.max(16, Math.floor(width * 0.36));
|
|
288
|
+
const rightW = Math.max(12, width - leftW - midW - 2);
|
|
289
|
+
const windowSize = 8;
|
|
290
|
+
const start = Math.max(
|
|
291
|
+
0,
|
|
292
|
+
Math.min(index - Math.floor(windowSize / 2), Math.max(0, skills.length - windowSize)),
|
|
293
|
+
);
|
|
294
|
+
const visible = skills.slice(start, start + windowSize);
|
|
295
|
+
|
|
296
|
+
const list = [
|
|
297
|
+
theme.fg(
|
|
298
|
+
"accent",
|
|
299
|
+
skills.length > windowSize ? `SKILLS ${index + 1}/${skills.length}` : "SKILLS",
|
|
300
|
+
),
|
|
301
|
+
...visible.map((entry, i) => {
|
|
302
|
+
const actual = start + i;
|
|
303
|
+
const marker = actual === index ? "→" : " ";
|
|
304
|
+
const warn = entry.warning ? " !" : "";
|
|
305
|
+
return `${marker} ${entry.name}${warn}`;
|
|
306
|
+
}),
|
|
307
|
+
];
|
|
308
|
+
const meta = [
|
|
309
|
+
theme.fg("accent", "METADATA"),
|
|
310
|
+
skill.name,
|
|
311
|
+
skill.description || "(no description)",
|
|
312
|
+
`health: ${skill.health ?? (skill.warning ? "warn" : "ok")}`,
|
|
313
|
+
`usage: ${skill.usageCount}x ${renderUsageSparkline(skill.usageSeries ?? [skill.usageCount])}`,
|
|
314
|
+
...renderSkillWorkflow(skill),
|
|
315
|
+
];
|
|
316
|
+
const preview = [
|
|
317
|
+
theme.fg("accent", "PREVIEW"),
|
|
318
|
+
...(skill.bodyPreview || "(empty body)").split("\n").slice(0, 6),
|
|
319
|
+
];
|
|
320
|
+
|
|
321
|
+
const rows = Math.max(list.length, meta.length, preview.length);
|
|
322
|
+
const lines: string[] = [];
|
|
323
|
+
for (let i = 0; i < rows; i++) {
|
|
324
|
+
const left = padPlain(list[i] ?? "", leftW);
|
|
325
|
+
const mid = padPlain(meta[i] ?? "", midW);
|
|
326
|
+
const right = padPlain(preview[i] ?? "", rightW);
|
|
327
|
+
lines.push(truncateToWidth(`${left} ${mid} ${right}`, width, "…", true));
|
|
328
|
+
}
|
|
329
|
+
lines.push(truncateToWidth("n new skill · ↑↓ select · enter insert", width, "…", true));
|
|
330
|
+
return lines;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function padPlain(text: string, width: number): string {
|
|
334
|
+
const plain = text.replace(/\x1b\[[0-9;]*m/g, "");
|
|
335
|
+
if (plain.length >= width) return truncateToWidth(text, width, "…", true);
|
|
336
|
+
return text + " ".repeat(width - plain.length);
|
|
337
|
+
}
|