@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.
@@ -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
  // ---------------------------------------------------------------------------
@@ -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 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 {
@@ -94,4 +94,6 @@ export interface DeckNavState {
94
94
  assignEvent: import("../../../motion/types.ts").MotionEvent;
95
95
  skillCreate: boolean;
96
96
  skillCreateName: string;
97
+ /** True when ↑↓ moves the NAVIGATION column instead of the route list. */
98
+ navMode: boolean;
97
99
  }