@groeponline/pi-wishcraft 1.3.0 → 1.4.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/CHANGELOG.md +8 -0
- package/ROADMAP.md +22 -127
- package/docs/design/vnext-release-plan.md +3 -1
- package/package.json +1 -1
- package/src/config/parse.ts +12 -6
- package/src/config/settings-registry.ts +118 -0
- package/src/config/structural-presets.ts +6 -1
- package/src/core/frontmatter.ts +5 -2
- package/src/extension/contrib/appearance.ts +91 -0
- package/src/extension/contrib/registry.ts +42 -0
- package/src/extension/contrib/types.ts +20 -0
- package/src/extension/core/state.ts +5 -4
- package/src/extension/hooks/hooks-config.ts +6 -1
- package/src/extension/hooks/index.ts +11 -5
- package/src/extension/session/read-hints.ts +6 -2
- package/src/extension/settings/wishcraft-config-items.ts +14 -110
- package/src/extension/settings/wishcraft-config.ts +19 -17
- package/src/extension/skills/inline-invocation.ts +35 -11
- package/src/extension/skills/skill-registry.ts +41 -2
- package/src/extension/ui/deck/component.ts +27 -7
- package/src/extension/ui/deck/render.ts +4 -4
- package/src/extension/ui/deck/routes.ts +17 -2
- package/src/extension/ui/deck/types.ts +2 -0
- package/src/motion/catalog.ts +14 -11
- package/src/motion/policy.ts +3 -2
- package/src/signal/render.ts +49 -6
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
14
|
+
import { SETTING_DEFAULTS } from "../../config/settings-registry.ts";
|
|
14
15
|
import type { RuntimeState } from "../core/types.ts";
|
|
15
16
|
import {
|
|
16
17
|
getSettingsPath,
|
|
@@ -44,7 +45,7 @@ let hooksSettings: WishcraftHooksSettings = {};
|
|
|
44
45
|
let hooksEnabled = false;
|
|
45
46
|
let policyRules: PolicyRule[] = [];
|
|
46
47
|
let policyEnabled = false;
|
|
47
|
-
let repairsEnabled =
|
|
48
|
+
let repairsEnabled: boolean = SETTING_DEFAULTS["wishcraft.repairsEnabled"];
|
|
48
49
|
let pendingSessionContext: string | null = null;
|
|
49
50
|
|
|
50
51
|
function refreshSettings(cwd: string): void {
|
|
@@ -59,10 +60,15 @@ function refreshSettings(cwd: string): void {
|
|
|
59
60
|
const policy = parsePolicySettings(globalWishcraft);
|
|
60
61
|
policyRules = policy.rules;
|
|
61
62
|
policyEnabled = policy.enabled;
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
(merged as Record<string, unknown>).repairsEnabled
|
|
63
|
+
if (!merged || typeof merged !== "object" || Array.isArray(merged)) {
|
|
64
|
+
repairsEnabled = SETTING_DEFAULTS["wishcraft.repairsEnabled"];
|
|
65
|
+
} else {
|
|
66
|
+
const configured = (merged as Record<string, unknown>).repairsEnabled;
|
|
67
|
+
repairsEnabled =
|
|
68
|
+
typeof configured === "boolean"
|
|
69
|
+
? configured
|
|
70
|
+
: SETTING_DEFAULTS["wishcraft.repairsEnabled"];
|
|
71
|
+
}
|
|
66
72
|
}
|
|
67
73
|
|
|
68
74
|
function hasAnyHook(hooks: WishcraftHooksSettings): boolean {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { SETTING_DEFAULTS } from "../../config/settings-registry.ts";
|
|
1
2
|
import { readSettings } from "../settings/settings-io.ts";
|
|
2
3
|
|
|
3
4
|
export interface ReadToolInput {
|
|
@@ -43,9 +44,12 @@ export function readHintsEnabled(wishcraftSettings: unknown): boolean {
|
|
|
43
44
|
typeof wishcraftSettings !== "object" ||
|
|
44
45
|
Array.isArray(wishcraftSettings)
|
|
45
46
|
) {
|
|
46
|
-
return
|
|
47
|
+
return SETTING_DEFAULTS["wishcraft.readHints"];
|
|
47
48
|
}
|
|
48
|
-
|
|
49
|
+
const configured = (wishcraftSettings as Record<string, unknown>).readHints;
|
|
50
|
+
return typeof configured === "boolean"
|
|
51
|
+
? configured
|
|
52
|
+
: SETTING_DEFAULTS["wishcraft.readHints"];
|
|
49
53
|
}
|
|
50
54
|
|
|
51
55
|
/** True when core read output already carries an offset/range continuation line. */
|
|
@@ -1,119 +1,23 @@
|
|
|
1
|
-
/**
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
/** Data-driven groups for `/wishcraft settings`, derived from the canonical registry. */
|
|
2
|
+
import {
|
|
3
|
+
SETTINGS_REGISTRY,
|
|
4
|
+
SETTING_GROUPS,
|
|
5
|
+
type SettingDefinition,
|
|
6
|
+
type SettingValue,
|
|
7
|
+
} from "../../config/settings-registry.ts";
|
|
5
8
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
export type ConfigValue = boolean | string | number | null;
|
|
10
|
-
|
|
11
|
-
export interface ConfigItem {
|
|
12
|
-
/** Label in the list. */
|
|
13
|
-
label: string;
|
|
14
|
-
/** Path inside settings ("powerline.placement", "wishcraft.hooksEnabled", ...). */
|
|
15
|
-
path: string;
|
|
16
|
-
/** Edit kind. */
|
|
17
|
-
kind: "toggle" | "select" | "text" | "number";
|
|
18
|
-
/** For select: the choices. */
|
|
19
|
-
choices?: string[];
|
|
20
|
-
/** Hint under the group. */
|
|
21
|
-
hint?: string;
|
|
22
|
-
/** Detail explanation (optional). */
|
|
23
|
-
description?: string;
|
|
24
|
-
/** Effective value when the setting is absent (toggles only; defaults to false). */
|
|
25
|
-
default?: boolean;
|
|
26
|
-
}
|
|
9
|
+
export type ConfigValue = SettingValue | null;
|
|
10
|
+
export type ConfigItem = SettingDefinition;
|
|
27
11
|
|
|
28
12
|
export interface ConfigGroup {
|
|
29
13
|
title: string;
|
|
30
14
|
items: ConfigItem[];
|
|
31
15
|
}
|
|
32
16
|
|
|
33
|
-
|
|
34
|
-
"powerline",
|
|
35
|
-
"powerline-thin",
|
|
36
|
-
"slash",
|
|
37
|
-
"pipe",
|
|
38
|
-
"block",
|
|
39
|
-
"none",
|
|
40
|
-
"ascii",
|
|
41
|
-
"dot",
|
|
42
|
-
"chevron",
|
|
43
|
-
"star",
|
|
44
|
-
];
|
|
45
|
-
|
|
46
|
-
/** Build groups from current settings (values shown live). */
|
|
17
|
+
/** Build the operator groups from the canonical registry. */
|
|
47
18
|
export function buildConfigGroups(_settings: Record<string, unknown>): ConfigGroup[] {
|
|
48
|
-
return
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
{ label: "Preset", path: "powerline.preset", kind: "select", choices: ["default", "minimal", "compact", "full", "nerd", "ascii", "chef"] },
|
|
53
|
-
{
|
|
54
|
-
label: "Structural base",
|
|
55
|
-
path: "powerline.appearance.base",
|
|
56
|
-
kind: "select",
|
|
57
|
-
choices: [...STRUCTURAL_PRESET_NAMES],
|
|
58
|
-
hint: "colors and motion for Signal; layout preset stays separate",
|
|
59
|
-
},
|
|
60
|
-
{
|
|
61
|
-
label: "Motion level",
|
|
62
|
-
path: "powerline.motionLevel",
|
|
63
|
-
kind: "select",
|
|
64
|
-
choices: [...MOTION_LEVELS],
|
|
65
|
-
hint: "full · reduced · functional · off",
|
|
66
|
-
},
|
|
67
|
-
{ label: "Separator", path: "powerline.separator", kind: "select", choices: SEPARATORS },
|
|
68
|
-
{ label: "Placement", path: "powerline.placement", kind: "select", choices: ["above", "below"] },
|
|
69
|
-
{ label: "Path mode", path: "powerline.segmentOptions.path.mode", kind: "select", choices: ["basename", "abbreviated", "full"] },
|
|
70
|
-
{ label: "Path max length", path: "powerline.segmentOptions.path.maxLength", kind: "number", hint: "0 = unlimited" },
|
|
71
|
-
{ label: "Time format", path: "powerline.segmentOptions.time.format", kind: "select", choices: ["12h", "24h"] },
|
|
72
|
-
{ label: "Time seconds", path: "powerline.segmentOptions.time.showSeconds", kind: "toggle" },
|
|
73
|
-
{ label: "Git host icons", path: "powerline.segmentOptions.git.hostIcon", kind: "toggle" },
|
|
74
|
-
{ label: "Git ahead/behind", path: "powerline.segmentOptions.git.showAheadBehind", kind: "toggle" },
|
|
75
|
-
{ label: "Git latest commit", path: "powerline.segmentOptions.git.showCommit", kind: "toggle" },
|
|
76
|
-
{ label: "Context format", path: "powerline.segmentOptions.context.format", kind: "select", choices: ["full", "percent"] },
|
|
77
|
-
{ label: "Cache-read format", path: "powerline.segmentOptions.cache_read.format", kind: "select", choices: ["tokens", "percent", "both"] },
|
|
78
|
-
{ label: "Cost display", path: "powerline.segmentOptions.cost.subscriptionDisplay", kind: "select", choices: ["subscription", "reported-cost", "both"] },
|
|
79
|
-
{ label: "Currency", path: "powerline.segmentOptions.cost.currency", kind: "text" },
|
|
80
|
-
{ label: "Ports include UDP", path: "powerline.segmentOptions.openPorts.includeUdp", kind: "toggle" },
|
|
81
|
-
{ label: "TPS window (ms)", path: "powerline.segmentOptions.tps.windowMs", kind: "number", hint: "default 1000" },
|
|
82
|
-
{ label: "TPS mode", path: "powerline.segmentOptions.tps.mode", kind: "select", choices: ["both", "out", "in"] },
|
|
83
|
-
{ label: "TPS label", path: "powerline.segmentLabels.tps", kind: "text", hint: "empty = no label" },
|
|
84
|
-
],
|
|
85
|
-
},
|
|
86
|
-
{
|
|
87
|
-
title: "Welcome & vibes",
|
|
88
|
-
items: [
|
|
89
|
-
{ label: "Welcome overlay", path: "powerline.welcome", kind: "toggle", hint: "on = overlay at startup, off = no welcome" },
|
|
90
|
-
{ label: "Animate wishcraft lantern", path: "wishcraft.welcome.animateLantern", kind: "toggle", hint: "flicker on the lantern" },
|
|
91
|
-
],
|
|
92
|
-
},
|
|
93
|
-
{
|
|
94
|
-
title: "Skills",
|
|
95
|
-
items: [
|
|
96
|
-
{ label: "Inline expand /command and $skill", path: "wishcraft.inlineSkills", kind: "toggle", hint: "needs a restart to take effect" },
|
|
97
|
-
{ label: "Read hints", path: "wishcraft.readHints", kind: "toggle", default: true, hint: "off = no continuation hint after partial reads" },
|
|
98
|
-
],
|
|
99
|
-
},
|
|
100
|
-
{
|
|
101
|
-
title: "Hooks & repairs (harness)",
|
|
102
|
-
items: [
|
|
103
|
-
{ label: "Hooks enabled", path: "wishcraft.hooksEnabled", kind: "toggle", hint: "preToolUse / postToolUse / sessionStart command hooks" },
|
|
104
|
-
{ label: "Tool-input repairs", path: "wishcraft.repairsEnabled", kind: "toggle", hint: "null-for-optional, auto-link, json-array, path aliases" },
|
|
105
|
-
{ label: "Daily token budget", path: "wishcraft.tokenBudget.daily", kind: "number", hint: "colours the cost segment; never blocks. 0 = off" },
|
|
106
|
-
],
|
|
107
|
-
},
|
|
108
|
-
{
|
|
109
|
-
title: "Shortcuts",
|
|
110
|
-
items: [
|
|
111
|
-
{ label: "Menu", path: "powerlineShortcuts.menu", kind: "text", hint: "e.g. alt+p" },
|
|
112
|
-
{ label: "Info", path: "powerlineShortcuts.info", kind: "text" },
|
|
113
|
-
{ label: "Stash", path: "powerlineShortcuts.stashHistory", kind: "text" },
|
|
114
|
-
{ label: "Idea", path: "powerlineShortcuts.ideaCapture", kind: "text" },
|
|
115
|
-
{ label: "Queue", path: "powerlineShortcuts.queueOpen", kind: "text" },
|
|
116
|
-
],
|
|
117
|
-
},
|
|
118
|
-
];
|
|
19
|
+
return SETTING_GROUPS.map((group) => ({
|
|
20
|
+
title: group.title,
|
|
21
|
+
items: SETTINGS_REGISTRY.filter((item) => item.group === group.id),
|
|
22
|
+
}));
|
|
119
23
|
}
|
|
@@ -75,7 +75,9 @@ export function assignNestedConfigValue(
|
|
|
75
75
|
return true;
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
-
/** Nested write (new object per level) + persist to settings.json.
|
|
78
|
+
/** Nested write (new object per level) + persist to settings.json.
|
|
79
|
+
* An empty string for a text field means "clear" — the key is removed from settings
|
|
80
|
+
* so the code falls through to the default. */
|
|
79
81
|
export function writeConfigPath(
|
|
80
82
|
cwd: string,
|
|
81
83
|
path: string,
|
|
@@ -112,20 +114,16 @@ function isPrintable(data: string): boolean {
|
|
|
112
114
|
|
|
113
115
|
/** Value shown for a config item given its stored value. */
|
|
114
116
|
export function displayValue(item: ConfigItem, value: ConfigValue): string {
|
|
115
|
-
|
|
116
|
-
if (
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
if (value === null || value === undefined || value === "") return item.kind === "toggle" ? "off" : "—";
|
|
120
|
-
if (item.kind === "toggle") return value ? "on" : "off";
|
|
121
|
-
return String(value);
|
|
117
|
+
const effective = value ?? item.defaultValue ?? null;
|
|
118
|
+
if (effective === null || effective === "") return item.kind === "toggle" ? "off" : "—";
|
|
119
|
+
if (item.kind === "toggle") return effective === true ? "on" : "off";
|
|
120
|
+
return String(effective);
|
|
122
121
|
}
|
|
123
122
|
|
|
124
|
-
/** Next stored boolean after toggling `item` from its current
|
|
123
|
+
/** Next stored boolean after toggling `item` from its current effective value. */
|
|
125
124
|
export function nextToggleValue(item: ConfigItem, value: ConfigValue): boolean {
|
|
126
|
-
const effective =
|
|
127
|
-
|
|
128
|
-
return !effective;
|
|
125
|
+
const effective = value ?? item.defaultValue ?? false;
|
|
126
|
+
return effective !== true;
|
|
129
127
|
}
|
|
130
128
|
|
|
131
129
|
function coerce(item: ConfigItem, current: ConfigValue, next: string): ConfigValue {
|
|
@@ -190,9 +188,11 @@ export async function showWishcraftConfig(rt: RuntimeState, ctx: any): Promise<v
|
|
|
190
188
|
};
|
|
191
189
|
|
|
192
190
|
const cycleSelect = (item: ConfigItem, forward: boolean) => {
|
|
191
|
+
if (item.kind !== "select") return;
|
|
193
192
|
const cur = readConfigPath(settings, item.path);
|
|
194
|
-
const list = item.choices
|
|
195
|
-
const
|
|
193
|
+
const list = item.choices;
|
|
194
|
+
const effective = cur ?? item.defaultValue ?? list[0];
|
|
195
|
+
const idx = list.indexOf(String(effective));
|
|
196
196
|
const next = list[(idx + (forward ? 1 : list.length - 1) + list.length) % list.length]!;
|
|
197
197
|
const ok = writeConfigPath(cwd, item.path, next);
|
|
198
198
|
settings = readSettings(cwd);
|
|
@@ -208,14 +208,15 @@ export async function showWishcraftConfig(rt: RuntimeState, ctx: any): Promise<v
|
|
|
208
208
|
|
|
209
209
|
const toggle = (item: ConfigItem) => {
|
|
210
210
|
const cur = readConfigPath(settings, item.path);
|
|
211
|
-
const
|
|
211
|
+
const next = nextToggleValue(item, cur);
|
|
212
|
+
const ok = writeConfigPath(cwd, item.path, next);
|
|
212
213
|
settings = readSettings(cwd);
|
|
213
214
|
groups = buildConfigGroups(settings);
|
|
214
215
|
if (item.path.startsWith("powerline")) {
|
|
215
216
|
reloadPowerlineFromSettings(rt, settings);
|
|
216
217
|
}
|
|
217
218
|
ctx.ui.notify(
|
|
218
|
-
ok ? `${item.label}: ${
|
|
219
|
+
ok ? `${item.label}: ${next ? "on" : "off"} (saved)` : `${item.label} not saved`,
|
|
219
220
|
ok ? "info" : "warning",
|
|
220
221
|
);
|
|
221
222
|
};
|
|
@@ -282,7 +283,8 @@ export async function showWishcraftConfig(rt: RuntimeState, ctx: any): Promise<v
|
|
|
282
283
|
editing = false;
|
|
283
284
|
editBuffer = "";
|
|
284
285
|
} else if (matchesKey(data, "enter")) {
|
|
285
|
-
if (
|
|
286
|
+
// Save even if empty — allows clearing a field (e.g. remove a shortcut binding)
|
|
287
|
+
applyEdit(editBuffer.trim());
|
|
286
288
|
editing = false;
|
|
287
289
|
editBuffer = "";
|
|
288
290
|
} else if (matchesKey(data, "backspace")) {
|
|
@@ -17,6 +17,7 @@ import { logDiscoveryError } from "../../welcome/discover.ts";
|
|
|
17
17
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
18
18
|
import type { RuntimeState } from "../core/types.ts";
|
|
19
19
|
import {
|
|
20
|
+
getAvailableCommands,
|
|
20
21
|
getAvailableSkills,
|
|
21
22
|
invalidateSkillCache,
|
|
22
23
|
recordSkillUsage,
|
|
@@ -59,32 +60,51 @@ function isExcluded(index: number, ranges: Array<[number, number]>): boolean {
|
|
|
59
60
|
}
|
|
60
61
|
|
|
61
62
|
/**
|
|
62
|
-
* Expandeer
|
|
63
|
+
* Expandeer inline triggers — /command (trigger-based) en $skill (name-based)
|
|
64
|
+
* via gescheiden lookups.
|
|
65
|
+
*
|
|
66
|
+
* /command → expandeert ALLEEN als de skill `trigger: /command` heeft
|
|
67
|
+
* $skill → expandeert op basis van `name:` in frontmatter
|
|
68
|
+
*
|
|
69
|
+
* Skills zonder `trigger:` reageren niet op /-prefix, en skills met
|
|
70
|
+
* `trigger:` reageren op de exacte trigger-naam (zonder /-prefix).
|
|
63
71
|
*/
|
|
64
72
|
export function expandInlineTriggers(text: string): string {
|
|
65
73
|
const availableSkills = getAvailableSkills();
|
|
74
|
+
const availableCommands = getAvailableCommands();
|
|
66
75
|
|
|
67
|
-
// Pattern for both /command and $skill
|
|
68
|
-
const TRIGGER_REGEX = /(^|[\s(])(\/|\$)([a-zA-Z0-9_-]+)/g;
|
|
69
76
|
const excluded = findExcludedRanges(text);
|
|
70
77
|
|
|
78
|
+
// ── Match /command (trigger-based) ──
|
|
79
|
+
const SLASH_REGEX = /(^|[\s(])\/([a-zA-Z0-9_-]+)/g;
|
|
71
80
|
const matches: Array<{
|
|
72
81
|
start: number;
|
|
73
82
|
end: number;
|
|
74
83
|
full: string;
|
|
75
84
|
name: string;
|
|
76
85
|
}> = [];
|
|
77
|
-
let match: RegExpExecArray | null;
|
|
78
86
|
|
|
79
|
-
|
|
80
|
-
|
|
87
|
+
let match: RegExpExecArray | null;
|
|
88
|
+
while ((match = SLASH_REGEX.exec(text)) !== null) {
|
|
81
89
|
if (isExcluded(match.index, excluded)) continue;
|
|
90
|
+
const name = match[2]!;
|
|
91
|
+
// Alleen expanden als er een skill met matching trigger is
|
|
92
|
+
if (!availableCommands.has(name)) continue;
|
|
93
|
+
matches.push({
|
|
94
|
+
start: match.index + match[1]!.length,
|
|
95
|
+
end: match.index + match[0].length,
|
|
96
|
+
full: match[0],
|
|
97
|
+
name,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
82
100
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
101
|
+
// ── Match $skill (name-based) ──
|
|
102
|
+
const DOLLAR_REGEX = /(^|[\s(])\$([a-zA-Z0-9_-]+)/g;
|
|
103
|
+
while ((match = DOLLAR_REGEX.exec(text)) !== null) {
|
|
104
|
+
if (isExcluded(match.index, excluded)) continue;
|
|
105
|
+
const name = match[2]!;
|
|
106
|
+
// Alleen expanden als de skill-naam bekend is
|
|
86
107
|
if (!availableSkills.has(name)) continue;
|
|
87
|
-
|
|
88
108
|
matches.push({
|
|
89
109
|
start: match.index + match[1]!.length,
|
|
90
110
|
end: match.index + match[0].length,
|
|
@@ -107,6 +127,10 @@ export function expandInlineTriggers(text: string): string {
|
|
|
107
127
|
}
|
|
108
128
|
}
|
|
109
129
|
|
|
130
|
+
// /command lookups use command map, $skill lookups use skill map
|
|
131
|
+
const lookupMap = (m: { full: string }) =>
|
|
132
|
+
m.full.startsWith("/") ? availableCommands : availableSkills;
|
|
133
|
+
|
|
110
134
|
// Rebuild the text with expansions
|
|
111
135
|
let result = "";
|
|
112
136
|
let cursor = 0;
|
|
@@ -114,7 +138,7 @@ export function expandInlineTriggers(text: string): string {
|
|
|
114
138
|
for (const m of deduped) {
|
|
115
139
|
result += text.slice(cursor, m.start);
|
|
116
140
|
|
|
117
|
-
const filePath =
|
|
141
|
+
const filePath = lookupMap(m).get(m.name)!;
|
|
118
142
|
try {
|
|
119
143
|
const rawContent = readFileSync(filePath, "utf8");
|
|
120
144
|
const cleanContent = stripFrontmatter(rawContent);
|
|
@@ -37,6 +37,8 @@ export interface SkillEntry {
|
|
|
37
37
|
mtimeMs: number;
|
|
38
38
|
/** All frontmatter keys (raw, including unknown). */
|
|
39
39
|
frontmatterKeys: string[];
|
|
40
|
+
/** Frontmatter trigger value (e.g. /test, /showcase). */
|
|
41
|
+
trigger: string | null;
|
|
40
42
|
/** Diagnostic message (core diagnostics, or empty description). */
|
|
41
43
|
warning?: string;
|
|
42
44
|
}
|
|
@@ -82,6 +84,7 @@ function scanLooseExtraFiles(
|
|
|
82
84
|
lineCount: content.split("\n").length,
|
|
83
85
|
mtimeMs,
|
|
84
86
|
frontmatterKeys: parseFrontmatterKeys(content),
|
|
87
|
+
trigger: null,
|
|
85
88
|
warning: "no description — the model will not see this skill in the prompt",
|
|
86
89
|
});
|
|
87
90
|
knownPaths.add(filePath);
|
|
@@ -95,6 +98,7 @@ const CACHE_TTL_MS = 30_000;
|
|
|
95
98
|
let cachedAt = 0;
|
|
96
99
|
let cachedEntries: SkillEntry[] | null = null;
|
|
97
100
|
let cachedPathMap: Map<string, string> | null = null;
|
|
101
|
+
let cachedTriggerMap: Map<string, string> | null = null;
|
|
98
102
|
let cachedCwd: string | null = null;
|
|
99
103
|
let onCacheInvalidated: (() => void) | null = null;
|
|
100
104
|
|
|
@@ -110,6 +114,7 @@ export function invalidateSkillCache(): void {
|
|
|
110
114
|
cachedAt = 0;
|
|
111
115
|
cachedEntries = null;
|
|
112
116
|
cachedPathMap = null;
|
|
117
|
+
cachedTriggerMap = null;
|
|
113
118
|
cachedCwd = null;
|
|
114
119
|
onCacheInvalidated?.();
|
|
115
120
|
}
|
|
@@ -237,6 +242,7 @@ function buildRejectedSkillEntries(
|
|
|
237
242
|
lineCount,
|
|
238
243
|
mtimeMs,
|
|
239
244
|
frontmatterKeys: parseFrontmatterKeys(content),
|
|
245
|
+
trigger: null,
|
|
240
246
|
warning: message,
|
|
241
247
|
});
|
|
242
248
|
knownPaths.add(filePath);
|
|
@@ -283,6 +289,7 @@ export function loadSkillCatalog(cwd: string = process.cwd()): SkillEntry[] {
|
|
|
283
289
|
if (!warning && s.filePath && diagByPath.has(s.filePath)) {
|
|
284
290
|
warning = diagByPath.get(s.filePath);
|
|
285
291
|
}
|
|
292
|
+
const fm = content ? parseSkillFrontmatter(content) : { name: null, description: null, trigger: null };
|
|
286
293
|
return {
|
|
287
294
|
name: s.name,
|
|
288
295
|
description: s.description,
|
|
@@ -295,6 +302,7 @@ export function loadSkillCatalog(cwd: string = process.cwd()): SkillEntry[] {
|
|
|
295
302
|
lineCount,
|
|
296
303
|
mtimeMs,
|
|
297
304
|
frontmatterKeys: parseFrontmatterKeys(content),
|
|
305
|
+
trigger: fm.trigger ?? null,
|
|
298
306
|
warning,
|
|
299
307
|
};
|
|
300
308
|
});
|
|
@@ -305,6 +313,25 @@ export function loadSkillCatalog(cwd: string = process.cwd()): SkillEntry[] {
|
|
|
305
313
|
...entries.map((e) => e.filePath),
|
|
306
314
|
...loose.map((e) => e.filePath),
|
|
307
315
|
]);
|
|
316
|
+
|
|
317
|
+
// ── Name dedup: prefer project > global > prompts > extra ──
|
|
318
|
+
// When the same skill name appears in multiple roots (e.g. both
|
|
319
|
+
// ~/.pi/agent/skills/test/ and ./.pi/skills/test/), keep only the
|
|
320
|
+
// highest-priority entry so the catalog never shows doubles.
|
|
321
|
+
const NAME_PRIORITY: Record<SkillCategory, number> = {
|
|
322
|
+
project: 0,
|
|
323
|
+
global: 1,
|
|
324
|
+
prompts: 2,
|
|
325
|
+
extra: 3,
|
|
326
|
+
};
|
|
327
|
+
const seen = new Map<string, { prio: number; entry: SkillEntry }>();
|
|
328
|
+
for (const e of entries) {
|
|
329
|
+
const prio = NAME_PRIORITY[e.category] ?? 4;
|
|
330
|
+
const cur = seen.get(e.name);
|
|
331
|
+
if (!cur || prio < cur.prio) seen.set(e.name, { prio, entry: e });
|
|
332
|
+
}
|
|
333
|
+
const deduped = [...seen.values()].map((v) => v.entry);
|
|
334
|
+
|
|
308
335
|
const rejected = buildRejectedSkillEntries(
|
|
309
336
|
result.diagnostics,
|
|
310
337
|
catalogPaths,
|
|
@@ -314,20 +341,32 @@ export function loadSkillCatalog(cwd: string = process.cwd()): SkillEntry[] {
|
|
|
314
341
|
cachedAt = now;
|
|
315
342
|
cachedCwd = cwd;
|
|
316
343
|
cachedEntries = [
|
|
317
|
-
...
|
|
344
|
+
...deduped.filter((e) => !looseNames.has(e.name)),
|
|
318
345
|
...loose,
|
|
319
346
|
...rejected,
|
|
320
347
|
].sort((a, b) => a.name.localeCompare(b.name));
|
|
321
348
|
cachedPathMap = new Map(cachedEntries.map((e) => [e.name, e.filePath] as const));
|
|
349
|
+
cachedTriggerMap = new Map(
|
|
350
|
+
cachedEntries
|
|
351
|
+
.filter((e) => e.trigger !== null)
|
|
352
|
+
.map((e) => [e.trigger!, e.filePath] as const),
|
|
353
|
+
);
|
|
322
354
|
return cachedEntries;
|
|
323
355
|
}
|
|
324
356
|
|
|
325
|
-
/** Compat: name → file path (for inline-invocation). */
|
|
357
|
+
/** Compat: name → file path (for inline-invocation $skill). */
|
|
326
358
|
export function getAvailableSkills(): Map<string, string> {
|
|
327
359
|
loadSkillCatalog();
|
|
328
360
|
return cachedPathMap ?? new Map();
|
|
329
361
|
}
|
|
330
362
|
|
|
363
|
+
/** Compat: trigger → file path (for inline-invocation /command).
|
|
364
|
+
* Only skills with a `trigger: /command` frontmatter field are returned. */
|
|
365
|
+
export function getAvailableCommands(): Map<string, string> {
|
|
366
|
+
loadSkillCatalog();
|
|
367
|
+
return cachedTriggerMap ?? new Map();
|
|
368
|
+
}
|
|
369
|
+
|
|
331
370
|
// ---------------------------------------------------------------------------
|
|
332
371
|
// Usage-ledger (~/.pi/agent/skill-usage.json)
|
|
333
372
|
// ---------------------------------------------------------------------------
|
|
@@ -59,6 +59,7 @@ export function createDeckNavState(
|
|
|
59
59
|
assignEvent: "streaming",
|
|
60
60
|
skillCreate: false,
|
|
61
61
|
skillCreateName: "",
|
|
62
|
+
navMode: false,
|
|
62
63
|
};
|
|
63
64
|
}
|
|
64
65
|
|
|
@@ -155,7 +156,7 @@ export function createDeckComponent(
|
|
|
155
156
|
|
|
156
157
|
if (state.pendingJump === "g" && data.length === 1 && isOverlayPrintable(data)) {
|
|
157
158
|
const route = deckRouteByJump(data);
|
|
158
|
-
state = { ...state, pendingJump: null };
|
|
159
|
+
state = { ...state, pendingJump: null, navMode: false };
|
|
159
160
|
if (route) setRoute(route);
|
|
160
161
|
return;
|
|
161
162
|
}
|
|
@@ -167,8 +168,27 @@ export function createDeckComponent(
|
|
|
167
168
|
return;
|
|
168
169
|
}
|
|
169
170
|
|
|
171
|
+
// ←/tab returns focus to the NAVIGATION column in one press; from there
|
|
172
|
+
// ↑↓ moves the nav selection and any other key drops back into the list.
|
|
173
|
+
if (
|
|
174
|
+
!state.composerOpen &&
|
|
175
|
+
!state.skillCreate &&
|
|
176
|
+
(matchesKey(data, "left") || matchesKey(data, "tab"))
|
|
177
|
+
) {
|
|
178
|
+
state = { ...state, navMode: true };
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
if (state.navMode) {
|
|
182
|
+
if (matchesKey(data, "up") || matchesKey(data, "down")) {
|
|
183
|
+
// fall through to the nav handlers at the bottom
|
|
184
|
+
} else {
|
|
185
|
+
state = { ...state, navMode: false };
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
170
190
|
if (state.route === "appearance") {
|
|
171
|
-
if (handleList(data, "selectedAppearance", STRUCTURAL_PRESET_NAMES.length)) return;
|
|
191
|
+
if (!state.navMode && handleList(data, "selectedAppearance", STRUCTURAL_PRESET_NAMES.length)) return;
|
|
172
192
|
if (matchesKey(data, "enter")) {
|
|
173
193
|
const name = STRUCTURAL_PRESET_NAMES[state.selectedAppearance];
|
|
174
194
|
if (name) {
|
|
@@ -182,7 +202,7 @@ export function createDeckComponent(
|
|
|
182
202
|
|
|
183
203
|
if (state.route === "motion") {
|
|
184
204
|
const count = filterMotions(state.searchQuery).length;
|
|
185
|
-
if (handleList(data, "selectedMotion", count)) return;
|
|
205
|
+
if (!state.navMode && handleList(data, "selectedMotion", count)) return;
|
|
186
206
|
if (data === "t") {
|
|
187
207
|
state = { ...state, assignEvent: cycleAssignEvent(state.assignEvent) };
|
|
188
208
|
return;
|
|
@@ -248,7 +268,7 @@ export function createDeckComponent(
|
|
|
248
268
|
}
|
|
249
269
|
const snapshot = liveSnapshot();
|
|
250
270
|
const rows = filterSkillRows(snapshot.skills, state.searchQuery);
|
|
251
|
-
if (handleList(data, "selectedSkill", rows.length)) return;
|
|
271
|
+
if (!state.navMode && handleList(data, "selectedSkill", rows.length)) return;
|
|
252
272
|
if (matchesKey(data, "enter")) {
|
|
253
273
|
const selected = rows[state.selectedSkill];
|
|
254
274
|
if (!selected) return;
|
|
@@ -266,7 +286,7 @@ export function createDeckComponent(
|
|
|
266
286
|
}
|
|
267
287
|
|
|
268
288
|
if (state.route === "ideas") {
|
|
269
|
-
if (handleList(data, "selectedIdea", liveSnapshot().ideas.length)) return;
|
|
289
|
+
if (!state.navMode && handleList(data, "selectedIdea", liveSnapshot().ideas.length)) return;
|
|
270
290
|
}
|
|
271
291
|
|
|
272
292
|
if (matchesKey(data, "up")) {
|
|
@@ -311,7 +331,7 @@ export function createDeckComponent(
|
|
|
311
331
|
if (state.route !== "motion" && state.route !== "skills") {
|
|
312
332
|
const matches = filterDeckRoutes(next);
|
|
313
333
|
if (matches.length === 1) {
|
|
314
|
-
state = { ...state, searchOpen: false, searchQuery: "" };
|
|
334
|
+
state = { ...state, searchOpen: false, searchQuery: "", navMode: false };
|
|
315
335
|
setRoute(matches[0]!);
|
|
316
336
|
}
|
|
317
337
|
}
|
|
@@ -324,7 +344,7 @@ export function createDeckComponent(
|
|
|
324
344
|
}
|
|
325
345
|
const matches = filterDeckRoutes(state.searchQuery);
|
|
326
346
|
if (matches[0]) {
|
|
327
|
-
state = { ...state, searchOpen: false, searchQuery: "" };
|
|
347
|
+
state = { ...state, searchOpen: false, searchQuery: "", navMode: false };
|
|
328
348
|
setRoute(matches[0]);
|
|
329
349
|
}
|
|
330
350
|
}
|
|
@@ -31,13 +31,13 @@ export function deckFooter(state: DeckNavState): string {
|
|
|
31
31
|
if (state.skillCreate) return "type a name · enter create · esc cancel";
|
|
32
32
|
switch (state.route) {
|
|
33
33
|
case "appearance":
|
|
34
|
-
return "↑↓ select base · enter apply · / Search · g h Home · Esc Close";
|
|
34
|
+
return "↑↓ select base · enter apply · ←/tab nav · / Search · g h Home · Esc Close";
|
|
35
35
|
case "motion":
|
|
36
|
-
return "↑↓ motion · t event · e composer · enter apply · Esc Close";
|
|
36
|
+
return "↑↓ motion · t event · e composer · enter apply · ←/tab nav · Esc Close";
|
|
37
37
|
case "skills":
|
|
38
|
-
return "↑↓ skill · enter insert · n new · / filter · Esc Close";
|
|
38
|
+
return "↑↓ skill · enter insert · n new · / filter · ←/tab nav · Esc Close";
|
|
39
39
|
case "ideas":
|
|
40
|
-
return "↑↓ idea · / Search · g h Home · Esc Close";
|
|
40
|
+
return "↑↓ idea · / Search · ←/tab nav · g h Home · Esc Close";
|
|
41
41
|
default:
|
|
42
42
|
return "/ Search g h Home g s Signal g i Ideas ? Help Esc Close";
|
|
43
43
|
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { DeckRoute, DeckRouteDef } from "./types.ts";
|
|
2
2
|
import { DECK_ROUTES } from "./types.ts";
|
|
3
|
+
import { getContributedDeckRoutes } from "../../contrib/registry.ts";
|
|
3
4
|
|
|
4
|
-
export const
|
|
5
|
+
export const BUILTIN_DECK_ROUTE_DEFS: readonly DeckRouteDef[] = [
|
|
5
6
|
{ id: "home", label: "Home", jumpKey: "h", description: "Session overview and next intent" },
|
|
6
7
|
{ id: "signal", label: "Signal", jumpKey: "s", description: "Lane layout and live activity" },
|
|
7
8
|
{ id: "skills", label: "Skills", jumpKey: "k", description: "Catalog and health diagnostics" },
|
|
@@ -15,8 +16,22 @@ export const DECK_ROUTE_DEFS: readonly DeckRouteDef[] = [
|
|
|
15
16
|
{ id: "diagnostics", label: "Diagnostics", jumpKey: "d", description: "Environment and capability checks" },
|
|
16
17
|
];
|
|
17
18
|
|
|
19
|
+
export const DECK_ROUTE_DEFS: readonly DeckRouteDef[] = BUILTIN_DECK_ROUTE_DEFS;
|
|
20
|
+
|
|
21
|
+
export function getAllDeckRouteDefs(): readonly DeckRouteDef[] {
|
|
22
|
+
const contributed = getContributedDeckRoutes().map((r) => ({
|
|
23
|
+
id: r.id as DeckRoute,
|
|
24
|
+
label: r.label,
|
|
25
|
+
jumpKey: r.jumpKey ?? "",
|
|
26
|
+
description: r.description ?? "",
|
|
27
|
+
}));
|
|
28
|
+
// ponytail: no dedup beyond registry — built-ins win, contributed appended
|
|
29
|
+
return [...BUILTIN_DECK_ROUTE_DEFS, ...contributed];
|
|
30
|
+
}
|
|
31
|
+
|
|
18
32
|
export function isDeckRoute(value: string): value is DeckRoute {
|
|
19
|
-
|
|
33
|
+
if ((DECK_ROUTES as readonly string[]).includes(value)) return true;
|
|
34
|
+
return getContributedDeckRoutes().some((r) => r.id === value);
|
|
20
35
|
}
|
|
21
36
|
|
|
22
37
|
export function parseDeckRouteArg(args: string | undefined): DeckRoute {
|