@groeponline/pi-wishcraft 1.4.11 → 1.4.13

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.
@@ -8,7 +8,7 @@
8
8
  */
9
9
 
10
10
  import { CADENCE_MS, CHANNEL_MATRIX, PREVIEW_INTERVAL_MS } from "./catalog.ts";
11
- import type { MotionChannel, MotionEvent, MotionLevel, MotionPolicy, MotionToggles } from "./types.ts";
11
+ import type { MotionChannel, MotionDef, MotionEvent, MotionLevel, MotionPolicy, MotionToggles } from "./types.ts";
12
12
 
13
13
  const CHANNEL_TOGGLE: Record<MotionChannel, keyof MotionToggles> = {
14
14
  workingGlyph: "state",
@@ -53,6 +53,33 @@ export function effectiveLevel(policy: MotionPolicy): MotionLevel {
53
53
  return policy.level;
54
54
  }
55
55
 
56
+ /**
57
+ * Channels a motion is allowed to drive under the current policy.
58
+ *
59
+ * Same a11y filter as `allowedChannels`, but the source is the **motion's own
60
+ * declared channels** instead of the event table. This is what makes an
61
+ * explicit motion choice (preset signature or `appearance.motion` override)
62
+ * actually runnable: the event no longer vetoes a motion it wasn't paired
63
+ * with by the default matrix — the motion decides, the policy still filters.
64
+ */
65
+ export function channelsForMotion(
66
+ motion: MotionDef,
67
+ policy: MotionPolicy,
68
+ ): MotionChannel[] {
69
+ if (policy.screenReader || policy.level === "off") return [];
70
+
71
+ const effective = effectiveLevel(policy);
72
+ return motion.channels.filter((channel) => {
73
+ if (effective === "functional") {
74
+ return channel === "workingGlyph" || channel === "panelIndicator";
75
+ }
76
+ if (effective === "reduced") {
77
+ if (channel === "ambient" || channel === "signal") return false;
78
+ }
79
+ return policy.toggles[CHANNEL_TOGGLE[channel]];
80
+ });
81
+ }
82
+
56
83
  export function cadenceFor(
57
84
  channel: MotionChannel,
58
85
  policy: MotionPolicy,
@@ -6,7 +6,8 @@
6
6
  * rail, streaming = travelling head + trail, compacting = inward heads.
7
7
  */
8
8
 
9
- import { sweepPosition, trailGlyph } from "../motion/index.ts";
9
+ import { defaultMotionFor, getMotion } from "../motion/catalog.ts";
10
+ import { frameAt, framesOf, sweepPosition, trailGlyph } from "../motion/frames.ts";
10
11
  import type { SignalRuntime } from "../signal/controller.ts";
11
12
  import type { SignalSpec } from "../config/types.ts";
12
13
  import { ansi, colorEnabled, getFgAnsiCode } from "../theme/colors.ts";
@@ -16,6 +17,7 @@ export function renderActivity(
16
17
  runtime: SignalRuntime,
17
18
  spec: SignalSpec,
18
19
  ascii = false,
20
+ width = 80,
19
21
  ): string {
20
22
  const label = runtime.activity || "ready";
21
23
  const open = spec.caps.leftOpen ?? "";
@@ -23,28 +25,70 @@ export function renderActivity(
23
25
  const dim = getFgAnsiCode("sep");
24
26
  const hot = getFgAnsiCode("accent");
25
27
  const reset = colorEnabled() ? ansi.reset : "";
26
- // One glyph family, directional comet: light `─` track, fixed solid
27
- // head, and a short box-drawing trail ONLY behind the head. Idle is a
28
- // calm flat rail (no cycling glyphs — the old shade-block cloud read
29
- // muddy across three unrelated glyph families).
30
- const RAIL_WIDTH = 12;
28
+ // Adaptive rail width: ~20% of terminal, clamped [16, 40]. Wider
29
+ // terminals get a longer sweep so the motion reads at a glance.
30
+ const RAIL_WIDTH = Math.max(16, Math.min(40, Math.round(width * 0.2)));
31
31
  const track = ascii ? "-" : "─";
32
32
  const head = ascii ? "o" : "●";
33
33
  const railColor = runtime.active ? hot : dim;
34
+ const def = getMotion(runtime.motionId);
35
+ // The head glyph is the chosen motion's own frame — so ember-relay
36
+ // sweeps a ◇→◈→◆ sequence and hex-relay carries #-density, instead of
37
+ // every motion wearing the same generic comet. The trail reuses the
38
+ // motion's own past frames too, so each motion leaves its own wake.
39
+ // ASCII terminals fall back to the clean box-drawing comet — motion
40
+ // frames are a color-font feature.
41
+ const headGlyph = (tick: number, distance: number) => {
42
+ if (def && !ascii) return frameAt(def, Math.max(0, tick - distance), false);
43
+ return distance === 0 ? head : trailGlyph(distance, ascii);
44
+ };
45
+ const trailDepth = def?.generator?.trail ?? 4;
46
+ // Per-cell color gradient: hot head fading to dim through the palette.
47
+ // Distance 0 = accent, then model → path → sep so the wake cools off.
48
+ const cellColor = (distance: number): string => {
49
+ if (!colorEnabled()) return "";
50
+ if (distance <= 0) return hot;
51
+ if (distance <= 1) return getFgAnsiCode("model");
52
+ if (distance <= 2) return getFgAnsiCode("path");
53
+ return dim;
54
+ };
34
55
  let railBlock: string;
35
56
  if (!runtime.active) {
36
- railBlock = track.repeat(RAIL_WIDTH);
57
+ // Idle: a frozen breathing wave of the ambient motion (wisp) frames.
58
+ // No animation consumer at idle, but a sine-sampled wave reads as
59
+ // "resting, not dead" — calmer than a full sweep, warmer than a flat track.
60
+ const ambient = getMotion(defaultMotionFor("idle"));
61
+ const ambientFrames = ambient ? framesOf(ambient) : null;
62
+ if (ambientFrames && colorEnabled()) {
63
+ const phase = Date.now() % 4000 / 4000;
64
+ const built: string[] = [];
65
+ for (let i = 0; i < RAIL_WIDTH; i++) {
66
+ const wave = Math.sin((i / RAIL_WIDTH) * Math.PI * 2 + phase * Math.PI * 2);
67
+ const frameIdx = Math.floor(((wave + 1) / 2) * ambientFrames.length) % ambientFrames.length;
68
+ const color = wave > 0.3 ? hot : dim;
69
+ built.push(`${color}${ambientFrames[frameIdx]}${reset}`);
70
+ }
71
+ railBlock = built.join("");
72
+ } else {
73
+ railBlock = track.repeat(RAIL_WIDTH);
74
+ }
37
75
  } else if (runtime.activity === "compacting") {
38
- // Compact state: two heads travel inward and compress a heavy core —
39
- // visually distinct from the sweep so compaction reads at a glance.
40
- railBlock = renderCompactRail(runtime.tick, RAIL_WIDTH, ascii);
76
+ railBlock = renderCompactRail(runtime.tick, RAIL_WIDTH, ascii, headGlyph, cellColor);
41
77
  } else {
42
78
  const pos = sweepPosition(runtime.tick, RAIL_WIDTH, true);
43
79
  const built: string[] = [];
44
80
  for (let i = 0; i < RAIL_WIDTH; i++) {
45
- if (i === pos) built.push(head);
46
- else if (i < pos) built.push(trailGlyph(Math.min(pos - i, 4), ascii));
47
- else built.push(track);
81
+ if (i === pos) built.push(`${cellColor(0)}${headGlyph(runtime.tick, 0)}${reset}`);
82
+ else if (i < pos) {
83
+ const distance = pos - i;
84
+ if (distance <= trailDepth) {
85
+ built.push(`${cellColor(distance)}${headGlyph(runtime.tick, distance)}${reset}`);
86
+ } else {
87
+ built.push(track);
88
+ }
89
+ } else {
90
+ built.push(track);
91
+ }
48
92
  }
49
93
  railBlock = built.join("");
50
94
  }
@@ -79,6 +123,8 @@ function renderCompactRail(
79
123
  tick: number,
80
124
  width: number,
81
125
  ascii: boolean,
126
+ headGlyph: (tick: number, i: number) => string,
127
+ cellColor: (distance: number) => string,
82
128
  ): string {
83
129
  const half = Math.floor(width / 2);
84
130
  // Heads oscillate from the edges toward center and back.
@@ -87,13 +133,21 @@ function renderCompactRail(
87
133
  const inward = phase < span ? phase : span * 2 - phase;
88
134
  const leftPos = inward;
89
135
  const rightPos = width - 1 - inward;
90
- const head = ascii ? "*" : "●";
91
136
  const core = ascii ? "=" : "━";
92
137
  const track = ascii ? "-" : "─";
138
+ const reset = colorEnabled() ? ansi.reset : "";
139
+ // Color the core by distance from the nearest head — hottest at the
140
+ // heads, cooling toward center, so the compression reads thermally.
93
141
  let built = "";
94
142
  for (let i = 0; i < width; i++) {
95
- if (i === leftPos || i === rightPos) built += head;
96
- else built += i > leftPos && i < rightPos ? core : track;
143
+ if (i === leftPos || i === rightPos) {
144
+ built += `${cellColor(0)}${headGlyph(tick, 0)}${reset}`;
145
+ } else if (i > leftPos && i < rightPos) {
146
+ const nearestHead = Math.min(Math.abs(i - leftPos), Math.abs(i - rightPos));
147
+ built += `${cellColor(nearestHead)}${core}${reset}`;
148
+ } else {
149
+ built += track;
150
+ }
97
151
  }
98
152
  return built;
99
153
  }
@@ -97,7 +97,7 @@ export function renderStatusLineV2(
97
97
 
98
98
  merged.leftSegments.forEach((id, i) => pushSegment(id, 10_000 - i));
99
99
 
100
- const rail = renderActivity(runtime, options.signal, options.ascii);
100
+ const rail = renderActivity(runtime, options.signal, options.ascii, width);
101
101
  segments.push({ id: "signal", text: rail, priority: 5_000 });
102
102
  primary.push("signal");
103
103
 
@@ -6,7 +6,7 @@
6
6
  */
7
7
 
8
8
  import {
9
- allowedChannels,
9
+ channelsForMotion,
10
10
  defaultMotionFor,
11
11
  getMotion,
12
12
  type MotionEvent,
@@ -61,17 +61,17 @@ export function setSignalEvent(
61
61
  runtime.tick = 0;
62
62
  runtime.startedAt = Date.now();
63
63
  runtime.activity = options.activity ?? activityForEvent(event);
64
- runtime.active = event !== "idle";
65
-
66
- if (
67
- event === "idle" ||
68
- !allowedChannels(event, policy).includes("signal")
69
- ) {
70
- runtime.active = false;
71
- return;
72
- }
73
64
 
74
65
  const def = getMotion(runtime.motionId);
66
+ // The chosen motion's own channel declaration decides whether the rail
67
+ // runs — not the event table. `CHANNEL_MATRIX` still picks the default
68
+ // motion per event, but an explicit choice (preset signature or an
69
+ // `appearance.motion` override) is never vetoed by the matrix afterwards.
70
+ // The a11y policy (screen-reader, full→reduced, functional, toggles)
71
+ // filters both paths identically via `channelsForMotion`.
72
+ runtime.active =
73
+ def !== undefined && channelsForMotion(def, policy).includes("signal");
74
+ if (!runtime.active) return;
75
75
  // Wrap subscribe so a throw doesn't leave runtime.active=true with
76
76
  // release=null (a leaked state that would survive stopSignal).
77
77
  let release: (() => void) | null = null;
@@ -1,19 +1,24 @@
1
- /**
2
- * Fullscreen studio component (U5, KTD3). Non-overlay `ctx.ui.custom()`
3
- * the editor-replacing variant, unlike the Deck's centered overlay. Panes are
4
- * placeholders here; U6-U10 fill list/detail/actions/advice content.
5
- */
1
+ /** Fullscreen Skill Studio workbench. The component owns presentation and input;
2
+ * the existing skills modules remain the only discovery and mutation backend. */
6
3
 
7
4
  import { matchesKey } from "@earendil-works/pi-tui";
8
5
  import type { Theme } from "@earendil-works/pi-coding-agent";
6
+ import {
7
+ buildListRows,
8
+ filterListRows,
9
+ } from "./list.ts";
10
+ import { resolveReferences, type ResolvedReference } from "./inspect.ts";
11
+ import { readSkillBody, type SkillEntry, type SkillUsage } from "../extension/skills/skill-registry.ts";
12
+ import type { AdvicePane } from "./advice-pane.ts";
13
+ import type { AdviseMode } from "./advise/prompts.ts";
9
14
  import {
10
15
  createStudioState,
11
16
  handleStudioKey,
12
17
  STUDIO_PANES,
13
18
  } from "./state.ts";
14
- import type { StudioKeyEvent, StudioState } from "./types.ts";
19
+ import type { StudioKeyEvent, StudioPaneId, StudioState } from "./types.ts";
15
20
 
16
- const PANE_LABELS: Record<string, string> = {
21
+ const PANE_LABELS: Record<StudioPaneId, string> = {
17
22
  list: "Skills",
18
23
  detail: "Detail",
19
24
  actions: "Actions",
@@ -25,11 +30,31 @@ const HELP_LINES: readonly string[] = [
25
30
  "",
26
31
  " j / k or arrows Move selection",
27
32
  " / Filter skills",
28
- " Tab Cycle pane focus",
33
+ " Tab or 1-4 Focus a pane",
34
+ " Enter Open the selected skill",
35
+ " n Create a skill",
36
+ " e Edit the selected skill",
37
+ " d Run doctor",
38
+ " a Focus AI advice",
39
+ " r Run advice · i insert answer",
29
40
  " ? Toggle this help",
30
41
  " q / Esc Exit studio",
31
42
  ];
32
43
 
44
+ export interface StudioComponentOptions {
45
+ entries?: readonly SkillEntry[];
46
+ usage?: ReadonlyMap<string, SkillUsage>;
47
+ advicePane?: AdvicePane;
48
+ adviceMode?: AdviseMode;
49
+ onRefresh?: () => readonly SkillEntry[];
50
+ onCreate?: () => Promise<void> | void;
51
+ onEdit?: (entry: SkillEntry) => Promise<void> | void;
52
+ onDoctor?: () => Promise<void> | void;
53
+ onAdvice?: (entry: SkillEntry, mode: AdviseMode, pane: AdvicePane) => Promise<void> | void;
54
+ onInsert?: (pane: AdvicePane) => Promise<void> | void;
55
+ onError?: (error: unknown) => void;
56
+ }
57
+
33
58
  export function mapRawInput(data: string): StudioKeyEvent {
34
59
  if (matchesKey(data, "escape")) return { key: "escape" };
35
60
  if (matchesKey(data, "return")) return { key: "return" };
@@ -45,36 +70,132 @@ export function mapRawInput(data: string): StudioKeyEvent {
45
70
  return { key: "other" };
46
71
  }
47
72
 
48
- export function renderStudioFrame(theme: Theme, width: number, state: StudioState): string[] {
49
- if (state.mode === "help") {
50
- const lines = [theme.fg("accent", HELP_LINES[0] ?? ""), ""];
51
- for (const line of HELP_LINES.slice(2)) {
52
- lines.push(theme.fg("muted", line));
53
- }
54
- lines.push("", theme.fg("dim", "Press q, Esc, or Enter to close help"));
73
+ function fit(text: string, width: number): string {
74
+ if (width <= 0) return "";
75
+ return text.length <= width ? text : `${text.slice(0, Math.max(0, width - 1))}…`;
76
+ }
77
+
78
+ function safeReadReferences(entry: SkillEntry): ResolvedReference[] {
79
+ return resolveReferences(readSkillBody(entry.filePath), entry.baseDir);
80
+ }
81
+
82
+ function selectedEntry(
83
+ entries: readonly SkillEntry[],
84
+ query: string,
85
+ selectedIndex: number,
86
+ ): SkillEntry | null {
87
+ const rows = filterListRows(buildListRows(entries), query);
88
+ const row = rows[selectedIndex] ?? rows[0];
89
+ return entries.find((entry) => entry.filePath === row?.filePath) ?? null;
90
+ }
91
+
92
+ function renderHelp(theme: Theme): string[] {
93
+ return HELP_LINES.map((line, index) => {
94
+ if (index === 0) return theme.fg("accent", line);
95
+ return theme.fg(index === HELP_LINES.length - 1 ? "dim" : "muted", line);
96
+ });
97
+ }
98
+
99
+ function renderList(
100
+ theme: Theme,
101
+ width: number,
102
+ entries: readonly SkillEntry[],
103
+ state: StudioState,
104
+ ): string[] {
105
+ const rows = filterListRows(buildListRows(entries), state.filterQuery);
106
+ const visible = rows.slice(Math.max(0, state.selectedIndex - 5), state.selectedIndex + 7);
107
+ const lines = [theme.fg("accent", `SKILLS · ${rows.length}/${entries.length}`)];
108
+ if (state.filterQuery) lines.push(theme.fg("muted", `filter: ${state.filterQuery}`));
109
+ if (rows.length === 0) {
110
+ lines.push(theme.fg("warning", "No skills match this filter"));
55
111
  return lines;
56
112
  }
113
+ for (const row of visible) {
114
+ const absolute = rows.indexOf(row);
115
+ const marker = absolute === state.selectedIndex ? "›" : " ";
116
+ const warning = row.warning ? theme.fg("warning", " ⚠") : "";
117
+ lines.push(
118
+ `${theme.fg(absolute === state.selectedIndex ? "accent" : "text", `${marker} ${fit(row.name, Math.max(8, width - 18))}`)} ${theme.fg("dim", `[${row.badge}]`)}${warning}`,
119
+ );
120
+ if (absolute === state.selectedIndex && row.description) {
121
+ lines.push(theme.fg("muted", ` ${fit(row.description, Math.max(8, width - 4))}`));
122
+ }
123
+ }
124
+ return lines;
125
+ }
57
126
 
58
- const focusMark = (pane: string): string =>
59
- state.focus === pane ? theme.fg("accent", `[${PANE_LABELS[pane] ?? pane}]`) : theme.fg("dim", ` ${PANE_LABELS[pane] ?? pane} `);
127
+ function renderDetail(theme: Theme, width: number, entry: SkillEntry | null, usage: ReadonlyMap<string, SkillUsage>): string[] {
128
+ if (!entry) return [theme.fg("accent", "DETAIL"), theme.fg("dim", "Select a skill from the list")];
129
+ const body = readSkillBody(entry.filePath);
130
+ const refs = safeReadReferences(entry);
131
+ const use = usage.get(entry.name);
132
+ const health = entry.warning ? theme.fg("warning", "warn") : theme.fg("success", "ok");
133
+ const override = entry.category === "project" ? "project override" : entry.category === "global" ? "global default" : "standalone";
134
+ const lines = [
135
+ theme.fg("accent", `DETAIL · ${fit(entry.name, Math.max(8, width - 10))}`),
136
+ theme.fg("muted", fit(entry.description || "No description", width)),
137
+ theme.fg("dim", `health: ${health} · ${override} · used: ${use?.count ?? 0}×`),
138
+ theme.fg("dim", `file: ${fit(entry.filePath, Math.max(8, width - 6))}`),
139
+ theme.fg("dim", `frontmatter: ${entry.frontmatterKeys.join(", ") || "none"}`),
140
+ theme.fg("dim", `model invocation: ${entry.disableModelInvocation ? "off" : "on"}`),
141
+ theme.fg("accent", `REFERENCES · ${refs.length}`),
142
+ ];
143
+ if (refs.length === 0) lines.push(theme.fg("dim", " none detected"));
144
+ for (const ref of refs.slice(0, 5)) {
145
+ lines.push(theme.fg(ref.exists ? "muted" : "warning", ` ${ref.exists ? "✓" : "✗"} ${fit(ref.href, Math.max(8, width - 6))}`));
146
+ }
147
+ return lines;
148
+ }
149
+
150
+ function adviceModeLabel(mode: AdviseMode): string {
151
+ return mode === "explain" ? "explain" : mode === "integrate" ? "integrate" : mode === "examples" ? "examples" : "improve";
152
+ }
60
153
 
61
- const header = STUDIO_PANES.map((pane) => focusMark(pane)).join(" ");
62
- const filterLine = state.mode === "filter"
63
- ? theme.fg("accent", `filter: ${state.filterQuery}_`)
64
- : state.filterQuery
65
- ? theme.fg("muted", `filter: ${state.filterQuery}`)
66
- : theme.fg("dim", "press / to filter, ? for help");
154
+ function renderAdvice(theme: Theme, width: number, entry: SkillEntry | null, pane: AdvicePane, mode: AdviseMode): string[] {
155
+ const lines = [
156
+ theme.fg("accent", `AI ADVICE · ${adviceModeLabel(mode)}`),
157
+ entry ? theme.fg("muted", `skill: ${entry.name}`) : theme.fg("dim", "Select a skill first"),
158
+ theme.fg("dim", "r run · e explain · g integrate · x examples · m improve · i insert"),
159
+ ];
160
+ if (pane.state === "running") lines.push(theme.fg("accent", "Thinking…"));
161
+ if (pane.state === "unavailable") lines.push(theme.fg("warning", `Advice unavailable: ${pane.error ?? "unknown error"}`));
162
+ const text = pane.text.trim();
163
+ if (text) {
164
+ lines.push(theme.fg("text", fit(text.replace(/\n/g, " "), Math.max(8, width - 2))));
165
+ } else if (pane.state === "idle") {
166
+ lines.push(theme.fg("dim", "Advice uses the selected skill and its local references."));
167
+ }
168
+ return lines;
169
+ }
67
170
 
68
- const lines: string[] = [
171
+ export function renderStudioFrame(
172
+ theme: Theme,
173
+ width: number,
174
+ state: StudioState,
175
+ options: Pick<StudioComponentOptions, "entries" | "usage" | "advicePane" | "adviceMode"> = {},
176
+ ): string[] {
177
+ if (state.mode === "help") return renderHelp(theme);
178
+ const entries = options.entries ?? [];
179
+ const usage = options.usage ?? new Map<string, SkillUsage>();
180
+ const pane = options.advicePane ?? { state: "idle", text: "", error: null } as AdvicePane;
181
+ const entry = selectedEntry(entries, state.filterQuery, state.selectedIndex);
182
+ const mode: AdviseMode = options.adviceMode ?? "explain";
183
+ const innerWidth = Math.max(20, width - 2);
184
+ const lines = [
69
185
  theme.fg("accent", "Skill Studio"),
70
- header,
71
- filterLine,
72
- theme.fg("dim", "─".repeat(Math.max(1, Math.min(width - 1, 60)))),
73
- theme.fg("muted", `selected: ${state.selectedIndex}`),
74
- theme.fg("dim", "panes populate in upcoming units (browse, actions, advice)"),
75
- theme.fg("dim", "─".repeat(Math.max(1, Math.min(width - 1, 60)))),
76
- theme.fg("dim", "q/Esc exit · / filter · Tab focus · ? help"),
186
+ STUDIO_PANES.map((paneId) => state.focus === paneId ? theme.fg("accent", `[${PANE_LABELS[paneId]}]`) : theme.fg("dim", ` ${PANE_LABELS[paneId]} `)).join(" "),
187
+ theme.fg("dim", "─".repeat(Math.max(1, Math.min(innerWidth, 72)))),
77
188
  ];
189
+ lines.push(...renderList(theme, innerWidth, entries, state));
190
+ lines.push(theme.fg("dim", "─".repeat(Math.max(1, Math.min(innerWidth, 72)))));
191
+ lines.push(...renderDetail(theme, innerWidth, entry, usage));
192
+ lines.push(theme.fg("dim", "─".repeat(Math.max(1, Math.min(innerWidth, 72)))));
193
+ lines.push(theme.fg("accent", "ACTIONS"));
194
+ lines.push(theme.fg("muted", "n create · e edit · d doctor · Enter detail"));
195
+ lines.push(theme.fg("dim", "─".repeat(Math.max(1, Math.min(innerWidth, 72)))));
196
+ lines.push(...renderAdvice(theme, innerWidth, entry, pane, mode));
197
+ lines.push(theme.fg("dim", "─".repeat(Math.max(1, Math.min(innerWidth, 72)))));
198
+ lines.push(theme.fg("dim", "q/Esc exit · / filter · Tab focus · ? help"));
78
199
  return lines;
79
200
  }
80
201
 
@@ -82,22 +203,77 @@ export function createStudioComponent(
82
203
  theme: Theme,
83
204
  done: (value: string | null) => void,
84
205
  onStateChange?: (state: StudioState) => void,
206
+ options: StudioComponentOptions = {},
85
207
  ) {
86
208
  let state = createStudioState();
209
+ let entries = [...(options.entries ?? [])];
210
+ let adviceMode: AdviseMode = "explain";
211
+ let finished = false;
212
+ const pane = options.advicePane ?? {
213
+ state: "idle",
214
+ text: "",
215
+ error: null,
216
+ run: async () => {},
217
+ reset: () => {},
218
+ } as AdvicePane;
219
+
220
+ const refresh = () => {
221
+ const next = options.onRefresh?.();
222
+ if (next) entries = [...next];
223
+ };
224
+ const current = () => selectedEntry(entries, state.filterQuery, state.selectedIndex);
225
+ const notifyChange = (next: StudioState) => {
226
+ state = next;
227
+ onStateChange?.(state);
228
+ };
229
+ const invoke = (work: () => Promise<void> | void) => {
230
+ void Promise.resolve(work()).then(() => {
231
+ refresh();
232
+ }).catch((error: unknown) => options.onError?.(error));
233
+ };
234
+ const focus = (paneId: StudioPaneId) => notifyChange({ ...state, focus: paneId });
235
+ const runAdvice = () => {
236
+ const entry = current();
237
+ if (!entry || !options.onAdvice) return;
238
+ invoke(() => options.onAdvice!(entry, adviceMode, pane));
239
+ };
87
240
 
88
241
  return {
89
242
  focused: true,
90
243
  invalidate() {},
91
244
  render(width: number) {
92
- return renderStudioFrame(theme, width, state);
245
+ return renderStudioFrame(theme, width, state, { entries, usage: options.usage, advicePane: pane, adviceMode });
93
246
  },
94
247
  handleInput(data: string) {
95
- const next = handleStudioKey(state, mapRawInput(data));
96
- if (next !== state) {
97
- state = next;
98
- onStateChange?.(state);
248
+ if (state.mode === "normal") {
249
+ if (data >= "1" && data <= "4") {
250
+ focus(STUDIO_PANES[Number(data) - 1] ?? "list");
251
+ return;
252
+ }
253
+ if (data === "\r" || matchesKey(data, "return")) {
254
+ if (state.focus === "list") focus("detail");
255
+ return;
256
+ }
257
+ if (state.focus === "advice") {
258
+ if (data === "e") adviceMode = "explain";
259
+ else if (data === "g") adviceMode = "integrate";
260
+ else if (data === "x") adviceMode = "examples";
261
+ else if (data === "m") adviceMode = "improve";
262
+ else if (data === "r") runAdvice();
263
+ else if (data === "i" && options.onInsert) invoke(() => options.onInsert!(pane));
264
+ } else if (state.focus === "actions" || state.focus === "detail" || state.focus === "list") {
265
+ if (data === "n" && options.onCreate) invoke(() => options.onCreate!());
266
+ else if (data === "e" && current() && options.onEdit) invoke(() => options.onEdit!(current()!));
267
+ else if (data === "d" && options.onDoctor) invoke(() => options.onDoctor!());
268
+ else if (data === "a") focus("advice");
269
+ else if (data === "i" && pane.state === "ok" && options.onInsert) invoke(() => options.onInsert!(pane));
270
+ else if (state.focus === "detail" && data === "r") runAdvice();
271
+ }
99
272
  }
100
- if (state.exitRequested) {
273
+ const next = handleStudioKey(state, mapRawInput(data));
274
+ if (next !== state) notifyChange(next);
275
+ if (state.exitRequested && !finished) {
276
+ finished = true;
101
277
  done(null);
102
278
  }
103
279
  },
@@ -1,11 +1,21 @@
1
- /** DeepWiki disk cache (U9). TTL + stale fallback, no LRU (ponytail: add when
2
- * the cache directory count actually grows). */
1
+ /** DeepWiki disk cache (U9). TTL + stale fallback + bounded LRU eviction. */
3
2
 
4
- import { mkdirSync, readFileSync, writeFileSync, existsSync } from "node:fs";
3
+ import {
4
+ existsSync,
5
+ mkdirSync,
6
+ readdirSync,
7
+ readFileSync,
8
+ statSync,
9
+ unlinkSync,
10
+ utimesSync,
11
+ writeFileSync,
12
+ } from "node:fs";
5
13
  import { join } from "node:path";
6
14
  import { getAgentPath } from "../../paths/agent-dirs.ts";
7
15
  import type { RepoRef } from "./extract.ts";
8
16
 
17
+ const DEFAULT_MAX_ENTRIES = 64;
18
+
9
19
  export function resolveCacheDir(override?: string): string {
10
20
  return override ?? getAgentPath("wishcraft-cache", "deepwiki");
11
21
  }
@@ -21,32 +31,96 @@ export interface CacheReadResult<T> {
21
31
  entry: CacheFile<T> | null;
22
32
  }
23
33
 
34
+ export interface CacheOptions {
35
+ ttlMs: number;
36
+ now?: number;
37
+ maxEntries?: number;
38
+ }
39
+
24
40
  function fileFor(dir: string, repo: RepoRef): string {
25
41
  return join(dir, repo.owner, `${repo.repo}.json`);
26
42
  }
27
43
 
44
+ function cacheFiles(dir: string): string[] {
45
+ const files: string[] = [];
46
+ let owners: string[];
47
+ try {
48
+ owners = readdirSync(dir);
49
+ } catch {
50
+ return files;
51
+ }
52
+ for (const owner of owners) {
53
+ const ownerDir = join(dir, owner);
54
+ let entries: string[];
55
+ try {
56
+ entries = readdirSync(ownerDir);
57
+ } catch {
58
+ continue;
59
+ }
60
+ for (const entry of entries) {
61
+ if (entry.endsWith(".json")) files.push(join(ownerDir, entry));
62
+ }
63
+ }
64
+ return files;
65
+ }
66
+
67
+ /** Remove least-recently-used entries until the cache is within its cap. */
68
+ export function evictCacheEntries(dir: string, maxEntries = DEFAULT_MAX_ENTRIES): void {
69
+ const cap = Math.max(1, Math.floor(maxEntries));
70
+ const files = cacheFiles(dir);
71
+ if (files.length <= cap) return;
72
+ const ordered = files
73
+ .map((file) => {
74
+ try {
75
+ return { file, atimeMs: statSync(file).atimeMs, mtimeMs: statSync(file).mtimeMs };
76
+ } catch {
77
+ return { file, atimeMs: 0, mtimeMs: 0 };
78
+ }
79
+ })
80
+ .sort((a, b) => (a.atimeMs - b.atimeMs) || (a.mtimeMs - b.mtimeMs));
81
+ for (const item of ordered.slice(0, files.length - cap)) {
82
+ try {
83
+ unlinkSync(item.file);
84
+ } catch {
85
+ // A concurrent cleanup must not break advice or cache reads.
86
+ }
87
+ }
88
+ }
89
+
90
+ function touch(file: string, now: number): void {
91
+ try {
92
+ const date = new Date(now);
93
+ utimesSync(file, date, date);
94
+ } catch {
95
+ // Cache recency is best-effort metadata.
96
+ }
97
+ }
98
+
28
99
  export async function writeCacheEntry<T>(
29
100
  dir: string,
30
101
  repo: RepoRef,
31
102
  data: T,
32
- options: { ttlMs: number; now?: number },
103
+ options: CacheOptions,
33
104
  ): Promise<void> {
34
105
  const now = options.now ?? Date.now();
35
106
  const file = fileFor(dir, repo);
36
107
  mkdirSync(join(dir, repo.owner), { recursive: true });
37
108
  writeFileSync(file, JSON.stringify({ savedAt: now, data }), "utf8");
109
+ touch(file, now);
110
+ evictCacheEntries(dir, options.maxEntries);
38
111
  }
39
112
 
40
113
  export async function readCacheEntry<T>(
41
114
  dir: string,
42
115
  repo: RepoRef,
43
- options: { ttlMs: number; now?: number },
116
+ options: CacheOptions,
44
117
  ): Promise<CacheReadResult<T>> {
45
118
  const now = options.now ?? Date.now();
46
119
  const file = fileFor(dir, repo);
47
120
  if (!existsSync(file)) return { status: "miss", stale: false, entry: null };
48
121
  try {
49
122
  const raw = JSON.parse(readFileSync(file, "utf8")) as CacheFile<T>;
123
+ touch(file, now);
50
124
  const fresh = now - raw.savedAt <= options.ttlMs;
51
125
  return { status: fresh ? "hit" : "miss", stale: !fresh, entry: raw };
52
126
  } catch {
@@ -57,7 +131,7 @@ export async function readCacheEntry<T>(
57
131
  export async function withCache<T>(
58
132
  dir: string,
59
133
  repo: RepoRef,
60
- options: { ttlMs: number; now?: number },
134
+ options: CacheOptions,
61
135
  networkFetch: () => Promise<T>,
62
136
  ): Promise<CacheReadResult<T>> {
63
137
  const fresh = await readCacheEntry<T>(dir, repo, options);