@groeponline/pi-wishcraft 1.3.1 → 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.
@@ -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 = true;
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
- repairsEnabled =
63
- !merged ||
64
- typeof merged !== "object" ||
65
- (merged as Record<string, unknown>).repairsEnabled !== false;
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 true;
47
+ return SETTING_DEFAULTS["wishcraft.readHints"];
47
48
  }
48
- return (wishcraftSettings as Record<string, unknown>).readHints !== false;
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
- * Declarative groups for `/wishcraft settings`.
3
- * Kept beside the overlay so the TUI file stays under the size budget.
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
- import { STRUCTURAL_PRESET_NAMES } from "../../config/types.ts";
7
- import { MOTION_LEVELS } from "../../motion/accessibility.ts";
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
- const SEPARATORS = [
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
- title: "Status bar",
51
- items: [
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
- // An unset toggle renders as its declared default (read hints default on).
116
- if (item.kind === "toggle" && (value === null || value === undefined)) {
117
- return (item.default ?? false) ? "on" : "off";
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 `value`. */
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
- item.kind === "toggle" && value === null ? (item.default ?? false) : value === true;
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 idx = list.indexOf(String(cur ?? list[0]));
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 ok = writeConfigPath(cwd, item.path, nextToggleValue(item, cur));
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}: ${!(cur === true) ? "on" : "off"} (saved)` : `${item.label} not saved`,
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 (editBuffer.trim() !== "") applyEdit(editBuffer.trim());
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 alle inline triggers (/command en $skill) in de tekst
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
- while ((match = TRIGGER_REGEX.exec(text)) !== null) {
80
- // Skip als binnen code block
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
- const name = match[3]!;
84
-
85
- // Alleen bekende skills expanden
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 = availableSkills.get(m.name)!;
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
- ...entries.filter((e) => !looseNames.has(e.name)),
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
  // ---------------------------------------------------------------------------
@@ -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 DECK_ROUTE_DEFS: readonly DeckRouteDef[] = [
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
- return (DECK_ROUTES as readonly string[]).includes(value);
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 {
@@ -20,7 +20,8 @@ const CORE_MOTIONS: readonly MotionDef[] = [
20
20
  colorRole: "motionHot",
21
21
  fallbackGlyph: "*",
22
22
  loop: "while-active",
23
- generator: { geometry: "ember", trail: 3, direction: "forward", intervalMs: 90, ease: "pulse" },
23
+ // ponytail: vivid — longer trail + faster pulse, travels the full Signal rail
24
+ generator: { geometry: "ember", trail: 6, direction: "forward", intervalMs: 62, ease: "pulse" },
24
25
  description: "Lanternwake signature. A warm ember travels the connected segments, only while work happens.",
25
26
  },
26
27
  {
@@ -29,10 +30,11 @@ const CORE_MOTIONS: readonly MotionDef[] = [
29
30
  category: "wishcraft",
30
31
  kind: "frames",
31
32
  channels: ["workingGlyph", "ambient"],
32
- colorRole: "motionDim",
33
+ colorRole: "motionHot",
33
34
  fallbackGlyph: "o",
34
35
  loop: "ambient",
35
- frames: ["◌", "○", "◈", "○"],
36
+ // ponytail: vivid ambient — higher contrast, tighter loop so idle actually breathes
37
+ frames: ["◌", "◎", "◈", "⬡", "◈", "◎"],
36
38
  description: "Soft phase drift for minimal presets.",
37
39
  },
38
40
  {
@@ -56,7 +58,7 @@ const CORE_MOTIONS: readonly MotionDef[] = [
56
58
  colorRole: "motionHot",
57
59
  fallbackGlyph: "#",
58
60
  loop: "while-active",
59
- generator: { geometry: "heat", trail: 4, direction: "forward", intervalMs: 80, ease: "linear" },
61
+ generator: { geometry: "heat", trail: 7, direction: "forward", intervalMs: 55, ease: "linear" },
60
62
  description: "Heat propagates block by block toward the active tool.",
61
63
  },
62
64
  {
@@ -125,10 +127,10 @@ const CORE_MOTIONS: readonly MotionDef[] = [
125
127
  category: "matrix",
126
128
  kind: "generator",
127
129
  channels: ["workingGlyph", "signal", "ambient"],
128
- colorRole: "secondary",
130
+ colorRole: "motionHot",
129
131
  fallbackGlyph: "O",
130
132
  loop: "ambient",
131
- generator: { geometry: "orbit", radius: 2, trail: 3, intervalMs: 280, ease: "breathe" },
133
+ generator: { geometry: "orbit", radius: 3, trail: 5, intervalMs: 150, ease: "breathe" },
132
134
  description: "Orbit and phase motion, slow enough for night work.",
133
135
  },
134
136
  {
@@ -276,13 +278,14 @@ export const CHANNEL_MATRIX: Record<MotionEvent, readonly MotionChannel[]> = {
276
278
  };
277
279
 
278
280
  /** Documented cadence bands per channel. */
281
+ // ponytail: tightened for vivid motion — signal/glyph sweep at ~60-80ms, ambient breathes at ~180ms
279
282
  export const CADENCE_MS: Record<MotionChannel, { min: number; max: number }> = {
280
- workingGlyph: { min: 80, max: 120 },
281
- signal: { min: 80, max: 120 },
283
+ workingGlyph: { min: 55, max: 85 },
284
+ signal: { min: 55, max: 85 },
282
285
  deckTransient: { min: 50, max: 100 },
283
- panelIndicator: { min: 120, max: 250 },
284
- borderEmphasis: { min: 200, max: 400 },
285
- ambient: { min: 250, max: 750 },
286
+ panelIndicator: { min: 100, max: 200 },
287
+ borderEmphasis: { min: 180, max: 350 },
288
+ ambient: { min: 180, max: 400 },
286
289
  };
287
290
 
288
291
  /** Gallery previews are allowed to run hotter than production channels. */
@@ -80,9 +80,10 @@ export function targetFps(
80
80
  ? allowed.filter((channel) => consumers.includes(channel))
81
81
  : allowed;
82
82
  if (live.length === 0) return 0;
83
- if (live.length === 1 && live[0] === "ambient") return 3;
83
+ // ponytail: vivid idle breathes visibly; active sweeps at 16 FPS
84
+ if (live.length === 1 && live[0] === "ambient") return 8;
84
85
  if (effectiveLevel(policy) === "reduced") return 3;
85
- return 10;
86
+ return 16;
86
87
  }
87
88
 
88
89
  /** Color transitions are suppressed without color; glyph motion may stay. */