@pi-archimedes/core 2.5.0 → 2.6.2

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.
@@ -0,0 +1,362 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+ import type { Theme } from "@earendil-works/pi-coding-agent";
4
+
5
+ // ── Mock surface ────────────────────────────────────────────────────────
6
+ // Only seam that MUST be mocked: loadCoreConfig (drives the spin flag).
7
+ // settings-io is mocked the same way as in config.test.ts (its module scope
8
+ // calls getAgentDir()); the editor and the rest of index.ts' imports are
9
+ // verified side-effect-free at import time, so they stay real — the real
10
+ // HephaestusEditor is what makes the setInterval/onSpinInterval assertions
11
+ // meaningful.
12
+ vi.mock("./settings-io.js", () => ({
13
+ loadConfig: vi.fn(),
14
+ saveConfig: vi.fn(),
15
+ }));
16
+ vi.mock("./config.js", async (importOriginal) => {
17
+ const actual = await importOriginal<typeof import("./config.js")>();
18
+ return {
19
+ ...actual,
20
+ loadCoreConfig: vi.fn(),
21
+ };
22
+ });
23
+ vi.mock("./bus.js", () => ({
24
+ initBus: vi.fn(),
25
+ }));
26
+ vi.mock("./startup/capture.js", async (importOriginal) => {
27
+ const actual = await importOriginal<typeof import("./startup/capture.js")>();
28
+ return {
29
+ ...actual,
30
+ patchConsoleLog: vi.fn(),
31
+ };
32
+ });
33
+ vi.mock("./thinking/patch.js", () => ({
34
+ patchThinkingRenderer: vi.fn(),
35
+ }));
36
+
37
+ const { registerCore, getCoreSettingsItems } = await import("./index.js");
38
+ const { loadCoreConfig, DEFAULT_CORE_CONFIG } = await import("./config.js");
39
+
40
+ // ── Sparse spy handles (real casts strip the spy typing) ───────────────
41
+
42
+ interface UiSpies {
43
+ setHeader: ReturnType<typeof vi.fn>;
44
+ setEditorComponent: ReturnType<typeof vi.fn>;
45
+ setWorkingVisible: ReturnType<typeof vi.fn>;
46
+ }
47
+ type TimerSpy = {
48
+ mock: {
49
+ calls: unknown[][];
50
+ results: ReadonlyArray<{ value: unknown }>;
51
+ invocationCallOrder: number[];
52
+ };
53
+ mockRestore(): void;
54
+ };
55
+
56
+ function makeCtx(): { ctx: ExtensionContext; ui: UiSpies } {
57
+ const ui: UiSpies = {
58
+ setHeader: vi.fn(),
59
+ setEditorComponent: vi.fn(),
60
+ setWorkingVisible: vi.fn(),
61
+ };
62
+ const ctx = {
63
+ ui: { ...ui, theme: {} as Theme },
64
+ isIdle: vi.fn(() => true),
65
+ shutdown: vi.fn(),
66
+ } as unknown as ExtensionContext;
67
+ return { ctx, ui };
68
+ }
69
+
70
+ /** Build an editor via the most recently registered factory. */
71
+ function buildEditor(ui: UiSpies): unknown {
72
+ const factory = ui.setEditorComponent.mock.calls.at(-1)![0] as (
73
+ tui: unknown,
74
+ theme: unknown,
75
+ keybindings: unknown,
76
+ ) => unknown;
77
+ // `borderColor` set (like the custom editor mock in editor/index.test.ts) so
78
+ // the real pi-coding-agent `renderTopBorder` doesn't trip on an unset
79
+ // base-class field when these tests render through the real editor.
80
+ const editor = factory(
81
+ { terminal: { rows: 24 }, requestRender: () => {} },
82
+ { borderColor: (s: string) => s },
83
+ {},
84
+ );
85
+ constructed.push(editor);
86
+ return editor;
87
+ }
88
+
89
+ function registerAndCapture(): {
90
+ start: (ctx: ExtensionContext) => void;
91
+ shutdown: (ctx: ExtensionContext) => void;
92
+ } {
93
+ const onSpy = vi.fn();
94
+ registerCore({ on: onSpy } as unknown as ExtensionAPI);
95
+ // Top-level registrations are exactly: session_shutdown + session_start
96
+ // (message_end is registered inside the session_start handler).
97
+ const calls = onSpy.mock.calls as Array<
98
+ [string, (event: unknown, ctx: ExtensionContext) => void]
99
+ >;
100
+ const startCall = calls.find((c) => c[0] === "session_start");
101
+ const shutdownCall = calls.find((c) => c[0] === "session_shutdown");
102
+ expect(startCall).toBeTruthy();
103
+ expect(shutdownCall).toBeTruthy();
104
+ const startFn = startCall! ? startCall[1] : undefined;
105
+ const shutdownFn = shutdownCall ? shutdownCall[1] : undefined;
106
+ return {
107
+ start: (ctx) => startFn!(null, ctx),
108
+ shutdown: (ctx) => shutdownFn!(null, ctx),
109
+ };
110
+ }
111
+
112
+ let start: (ctx: ExtensionContext) => void;
113
+ let shutdown: (ctx: ExtensionContext) => void;
114
+ let ctx: ExtensionContext;
115
+ let ui: UiSpies;
116
+ let si: TimerSpy;
117
+ let cl: TimerSpy;
118
+ let constructed: unknown[];
119
+
120
+ /** Count of timers registered with delay 32 ms (the spinner cadence at the default config — the default `pendulum` native 12 ms × the normal multiplier, clamped at the 32 ms tick floor). */
121
+ function spinTimerCount(): number {
122
+ return si.mock.calls.filter((c) => c[1] === 32).length;
123
+ }
124
+
125
+ beforeEach(() => {
126
+ const handlers = registerAndCapture();
127
+ start = handlers.start;
128
+ shutdown = handlers.shutdown;
129
+ const made = makeCtx();
130
+ ctx = made.ctx;
131
+ ui = made.ui;
132
+ si = vi.spyOn(globalThis, "setInterval") as unknown as TimerSpy;
133
+ cl = vi.spyOn(globalThis, "clearInterval") as unknown as TimerSpy;
134
+ constructed = [];
135
+ });
136
+
137
+ afterEach(() => {
138
+ // Reap any live spinner timers so the test process never hangs.
139
+ for (const e of constructed.splice(0)) {
140
+ (e as { dispose?: () => void }).dispose?.();
141
+ }
142
+ si.mockRestore();
143
+ cl.mockRestore();
144
+ });
145
+
146
+ // ── 1. Default (on) ────────────────────────────────────────────────────
147
+
148
+ describe("editorSpinBorder = true (default)", () => {
149
+ it("session_start hides the Working line; the editor drives a 32ms timer (the default pendulum) stored onSpinInterval; shutdown restores and reaps", () => {
150
+ vi.mocked(loadCoreConfig).mockReturnValue(DEFAULT_CORE_CONFIG);
151
+
152
+ start(ctx);
153
+ expect(ui.setWorkingVisible.mock.calls.map((c: unknown[]) => c[0])).toEqual(
154
+ [false],
155
+ );
156
+
157
+ buildEditor(ui); // real ctor: setInterval(fn, 32) + onSpinInterval(token)
158
+ expect(spinTimerCount()).toBe(1);
159
+ const setCall = si.mock.calls.at(-1)!;
160
+ expect(typeof setCall[0]).toBe("function");
161
+ expect(setCall[1]).toBe(32);
162
+
163
+ // onSpinInterval stored the handle: shutdown() clears it.
164
+ shutdown(ctx);
165
+ const visCalls = ui.setWorkingVisible.mock.calls.map((c: unknown[]) => c[0]);
166
+ expect(visCalls.at(-1)).toBe(true);
167
+ expect(cl.mock.calls.length).toBeGreaterThan(0);
168
+ const token = si.mock.results[si.mock.calls.length - 1]!.value;
169
+ expect(cl.mock.calls.some((c) => c[0] === token)).toBe(true);
170
+ });
171
+
172
+ it("a second shutdown is a no-op (no restore, no clear)", () => {
173
+ vi.mocked(loadCoreConfig).mockReturnValue(DEFAULT_CORE_CONFIG);
174
+ start(ctx);
175
+ buildEditor(ui);
176
+ shutdown(ctx);
177
+ const visAfter = ui.setWorkingVisible.mock.calls.length;
178
+ const clearAfter = cl.mock.calls.length;
179
+ shutdown(ctx); // spinFlag already reset, handle already cleared
180
+ expect(ui.setWorkingVisible.mock.calls.length).toBe(visAfter);
181
+ expect(cl.mock.calls.length).toBe(clearAfter);
182
+ });
183
+
184
+ it("editorSpinSpeed = \"fast\": the editor's interval period is 48 ms (the typing 80 ms native × 0.6)", () => {
185
+ vi.mocked(loadCoreConfig).mockReturnValue({
186
+ ...DEFAULT_CORE_CONFIG,
187
+ editorSpinSpeed: "fast",
188
+ editorSpinStyle: "typing",
189
+ });
190
+
191
+ start(ctx);
192
+ buildEditor(ui);
193
+ const setCall = si.mock.calls.at(-1)!;
194
+ expect(setCall[1]).toBe(48);
195
+ });
196
+
197
+ it("editorSpinSpeed = \"slow\": the editor's interval period is 120 ms (the typing 80 ms native × 1.5)", () => {
198
+ vi.mocked(loadCoreConfig).mockReturnValue({
199
+ ...DEFAULT_CORE_CONFIG,
200
+ editorSpinSpeed: "slow",
201
+ editorSpinStyle: "typing",
202
+ });
203
+
204
+ start(ctx);
205
+ buildEditor(ui);
206
+ const setCall = si.mock.calls.at(-1)!;
207
+ expect(setCall[1]).toBe(120);
208
+ });
209
+
210
+ it("editorSpinStyle = \"rain\" → the factory pass-through: the editor's interval period is 40 × 1 (normal) = 40 (rain's native tempo) and the border shows rain's frames (ported — batch 4)", () => {
211
+ vi.mocked(loadCoreConfig).mockReturnValue({
212
+ ...DEFAULT_CORE_CONFIG,
213
+ editorSpinStyle: "rain",
214
+ });
215
+ vi.mocked(ctx.isIdle).mockReturnValue(false); // busy
216
+
217
+ start(ctx);
218
+ buildEditor(ui);
219
+ const setCall = si.mock.calls.at(-1)!;
220
+ expect(setCall[1]).toBe(40); // 40 × 1 — not 80 (typing), so the style reached the editor
221
+
222
+ const editor = buildEditor(ui) as unknown as { tickSpin(): void; render(w: number): string[] };
223
+ // hold-0 port styles run the full source loop: a never-ticked box is at step 0, so the border shows rain's real step-0 frame (the seeded drops — NOT the typing ⠁; NOT a blank beat)
224
+ const plain = (l: string) => l.replace(/\x1b\[[0-9;]*m/g, "");
225
+ expect(plain(editor.render(60)[1]!)).toContain("⠈⠠⠠ Working");
226
+ expect(plain(editor.render(60)[1]!)).not.toContain("⠁ Working"); // not the typing fallback
227
+ });
228
+
229
+ it("editorSpinLabel = \"Thinking\" → the factory-built editor's busy border carries ` Thinking`, not ` Working`", () => {
230
+ vi.mocked(loadCoreConfig).mockReturnValue({
231
+ ...DEFAULT_CORE_CONFIG,
232
+ editorSpinLabel: "Thinking",
233
+ });
234
+
235
+ start(ctx);
236
+ vi.mocked(ctx.isIdle).mockReturnValue(false); // busy
237
+ const editor = buildEditor(ui) as { render(w: number): string[] };
238
+ const plain = (l: string) => l.replace(/\x1b\[[0-9;]*m/g, "");
239
+ const row = plain(editor.render(60)[1]!);
240
+ expect(row).toContain(" Thinking");
241
+ expect(row).not.toContain("Working");
242
+ });
243
+
244
+ it("editorSpinLabel = \"\" → the factory-built editor's busy border shows the window only (no label, dash-compensated)", () => {
245
+ vi.mocked(loadCoreConfig).mockReturnValue({
246
+ ...DEFAULT_CORE_CONFIG,
247
+ editorSpinLabel: "",
248
+ });
249
+
250
+ start(ctx);
251
+ vi.mocked(ctx.isIdle).mockReturnValue(false); // busy
252
+ const editor = buildEditor(ui) as { render(w: number): string[] };
253
+ const plain = (l: string) => l.replace(/\x1b\[[0-9;]*m/g, "");
254
+ const row = plain(editor.render(60)[1]!);
255
+ // Corner + leading space + the 4-cell window (frame-agnostic here — the default style is now pendulum and its seeded step-0 frame fills the window, not typing's clear beat; window frames are covered in editor/index.test.ts) + the full (51) trailing run.
256
+ expect(row).toMatch(/\s[^\s─]{4}─{51}/);
257
+ expect(row).not.toContain("Working");
258
+ });
259
+ });
260
+
261
+ // ── 2b. Corrupt (hand-edited) config robustness ────────────────────────────
262
+
263
+ describe("getCoreSettingsItems with hand-edited (corrupt) values", () => {
264
+ it("an empty-string editorSpinSpeed (corrupt) projects to \"Normal\" without throwing", () => {
265
+ const items = getCoreSettingsItems({
266
+ ...DEFAULT_CORE_CONFIG,
267
+ editorSpinSpeed: "" as never,
268
+ });
269
+ const item = items.find((i) => i.id === "editorSpinSpeed");
270
+ expect(item).toBeTruthy();
271
+ expect(item!.currentValue).toBe("Normal");
272
+ });
273
+
274
+ it("a trailing-dash editorSpinStyle (empty split word) projects without throwing", () => {
275
+ const items = getCoreSettingsItems({
276
+ ...DEFAULT_CORE_CONFIG,
277
+ editorSpinStyle: "wave-" as never,
278
+ });
279
+ const item = items.find((i) => i.id === "editorSpinStyle");
280
+ expect(item).toBeTruthy();
281
+ expect(item!.currentValue).toBe("Wave");
282
+ });
283
+
284
+ it("a number editorSpinSpeed (corrupt — non-string) projects to \"Normal\" without throwing", () => {
285
+ const items = getCoreSettingsItems({
286
+ ...DEFAULT_CORE_CONFIG,
287
+ editorSpinSpeed: 2 as never,
288
+ });
289
+ const item = items.find((i) => i.id === "editorSpinSpeed");
290
+ expect(item).toBeTruthy();
291
+ expect(item!.currentValue).toBe("Normal");
292
+ });
293
+
294
+ it("a null editorSpinStyle (corrupt — non-string) projects to \"Typing\" without throwing", () => {
295
+ const items = getCoreSettingsItems({
296
+ ...DEFAULT_CORE_CONFIG,
297
+ editorSpinStyle: null as never,
298
+ });
299
+ const item = items.find((i) => i.id === "editorSpinStyle");
300
+ expect(item).toBeTruthy();
301
+ expect(item!.currentValue).toBe("Typing");
302
+ });
303
+ });
304
+
305
+ // ── 2. Off ──────────────────────────────────────────────────────────────
306
+
307
+ describe("editorSpinBorder = false", () => {
308
+ it("no timer; setWorkingVisible(true) (default); shutdown is inert; re-start re-applies the flag", () => {
309
+ vi.mocked(loadCoreConfig).mockReturnValue({
310
+ ...DEFAULT_CORE_CONFIG,
311
+ editorSpinBorder: false,
312
+ });
313
+
314
+ start(ctx);
315
+ expect(ui.setWorkingVisible.mock.calls.at(-1)?.[0]).toBe(true);
316
+
317
+ buildEditor(ui);
318
+ expect(spinTimerCount()).toBe(0);
319
+
320
+ const visBeforeShutdown = ui.setWorkingVisible.mock.calls.length;
321
+ const clearBeforeShutdown = cl.mock.calls.length;
322
+ shutdown(ctx); // nothing to restore or reap
323
+ expect(ui.setWorkingVisible.mock.calls.length).toBe(visBeforeShutdown);
324
+ expect(cl.mock.calls.length).toBe(clearBeforeShutdown);
325
+
326
+ // Idempotent restore: the flag is re-stored on the next session_start.
327
+ start(ctx);
328
+ expect(ui.setWorkingVisible.mock.calls.at(-1)?.[0]).toBe(true);
329
+ });
330
+ });
331
+
332
+ // ── 3. Orphaned-timer reaping (/reload rebind) ─────────────────────────
333
+
334
+ describe("orphaned timer reaping", () => {
335
+ it("a re-invoked session_start (the /reload rebind) reaps the previous editor's timer before the new editor constructs", () => {
336
+ vi.mocked(loadCoreConfig).mockReturnValue(DEFAULT_CORE_CONFIG);
337
+
338
+ // (1) First session_start
339
+ start(ctx);
340
+ buildEditor(ui); // real ctor starts the timer → onSpinInterval stores it
341
+ const tokenA = si.mock.results[si.mock.calls.length - 1]!.value;
342
+ expect(spinTimerCount()).toBe(1);
343
+
344
+ // (3) /reload rebind — session_start runs BEFORE factory B is built,
345
+ // so merely calling factory A again would NOT trigger the reap.
346
+ const reconnectsBefore = ui.setEditorComponent.mock.calls.length;
347
+ start(ctx);
348
+ expect(ui.setEditorComponent.mock.calls.length).toBe(reconnectsBefore + 1);
349
+ expect(cl.mock.calls.at(-1)?.[0]).toBe(tokenA);
350
+ const clearOrderA =
351
+ cl.mock.invocationCallOrder[cl.mock.calls.length - 1]!;
352
+
353
+ // (4) Build factory B's editor (new constructor, new timer)
354
+ buildEditor(ui);
355
+ expect(spinTimerCount()).toBe(2);
356
+ const setOrderB =
357
+ si.mock.invocationCallOrder[si.mock.calls.length - 1]!;
358
+
359
+ // (5) The orphan was reaped BEFORE the new editor's setInterval
360
+ expect(clearOrderA).toBeLessThan(setOrderB);
361
+ });
362
+ });
package/src/index.ts CHANGED
@@ -51,6 +51,61 @@ export function getCoreSettingsItems(config: CoreConfig): SettingItem[] {
51
51
  currentValue: config.animationStyle,
52
52
  values: [...ANIMATION_STYLES],
53
53
  },
54
+ {
55
+ id: "editorSpinBorder",
56
+ label: "Editor Spin Border",
57
+ description: "Type across the editor's top border while the agent is working (hides the “Working” line)",
58
+ currentValue: config.editorSpinBorder ? "On" : "Off",
59
+ values: ["On", "Off"],
60
+ },
61
+ {
62
+ id: "editorSpinSpeed",
63
+ label: "Spin Speed",
64
+ description: "Border spinner speed (slow / normal / fast — the × 1.5 / × 1 / × 0.6 of the style's native tempo)",
65
+ currentValue: (() => {
66
+ // Hand-edited (corrupt) values — non-strings (null/number/boolean) or an
67
+ // empty string — fall back to `normal` (the `typeof` guard keeps the
68
+ // settings panel from TypeError-ing on a non-string setting; the
69
+ // falsy check keeps an empty string from projecting as a `NaN` label).
70
+ const s =
71
+ typeof config.editorSpinSpeed === "string" && config.editorSpinSpeed
72
+ ? config.editorSpinSpeed
73
+ : "normal";
74
+ return s[0]!.toUpperCase() + s.slice(1);
75
+ })(),
76
+ values: ["Slow", "Normal", "Fast"],
77
+ },
78
+ {
79
+ id: "editorSpinStyle",
80
+ label: "Spin Style",
81
+ description: "Which animation the editor border runs while working",
82
+ currentValue:
83
+ typeof config.editorSpinStyle === "string"
84
+ ? config.editorSpinStyle
85
+ .split("-")
86
+ .filter(Boolean)
87
+ .map((w) => w[0]!.toUpperCase() + w.slice(1))
88
+ .join(" ")
89
+ : "Typing",
90
+ values: [
91
+ "Typing",
92
+ "Wave Rows",
93
+ "Columns",
94
+ "Pulse",
95
+ "Marquee",
96
+ "Pendulum",
97
+ "Rain",
98
+ "Cascade",
99
+ "Diagonal Swipe",
100
+ "Sparkle",
101
+ ],
102
+ },
103
+ {
104
+ id: "editorSpinLabel",
105
+ label: "Spinner Label",
106
+ description: "Label typed after the spin window (empty hides it)",
107
+ currentValue: config.editorSpinLabel,
108
+ },
54
109
  ];
55
110
  }
56
111
 
@@ -61,12 +116,31 @@ let coreRef: ListingRef | undefined;
61
116
  let coreCtx: ExtensionContext | undefined;
62
117
  let coreTui: TUI | undefined;
63
118
 
119
+ // Spin-prompt lifecycle state: the editor self-drives its timer and reports it
120
+ // back via onSpinInterval so the session handlers can reap it (no circular import
121
+ // — editor/index.ts must never import this module).
122
+ let spinFlag = false;
123
+ let spinInterval: ReturnType<typeof setInterval> | undefined;
124
+ function clearSpinInterval(): void {
125
+ if (spinInterval) {
126
+ clearInterval(spinInterval);
127
+ spinInterval = undefined;
128
+ }
129
+ }
130
+
64
131
  export function registerCore(pi: ExtensionAPI): void {
65
132
  // Patch console.log for model scope capture
66
133
  patchConsoleLog();
67
134
 
68
135
  // session_shutdown handler (top-level to prevent accumulation on /reload)
69
136
  pi.on("session_shutdown", (_event, _ctx) => {
137
+ // Restore the Working line if we hid it, and reap the editor's spinner
138
+ // timer. Runs FIRST: enabled-only restore of the Working line +
139
+ // unconditional spinner-timer reap (idempotent when spin is off).
140
+ if (spinFlag) { _ctx.ui.setWorkingVisible(true); }
141
+ clearSpinInterval();
142
+ spinFlag = false;
143
+
70
144
  // Mark listing as settled
71
145
  if (coreRef) { coreRef.settled = true; }
72
146
  const g: Record<string | symbol, unknown> = globalThis as unknown as typeof global & Record<string | symbol, unknown>;
@@ -136,6 +210,10 @@ export function registerCore(pi: ExtensionAPI): void {
136
210
  // Save context for shutdown cleanup
137
211
  coreCtx = ctx;
138
212
 
213
+ // Spin-prompt flag (drives the editor self-timer + Working-line visibility)
214
+ const config = loadCoreConfig();
215
+ spinFlag = config.editorSpinBorder;
216
+
139
217
  // Set animated header
140
218
  coreRef = {
141
219
  sections: [],
@@ -159,20 +237,28 @@ export function registerCore(pi: ExtensionAPI): void {
159
237
  };
160
238
  ctx.ui.setHeader(headerFactory);
161
239
 
162
- // Set editor component
240
+ // Set editor component (reap any orphaned timer from a previous editor /
241
+ // /reload rebind first — pi does not dispose the old editor's timer)
242
+ clearSpinInterval();
243
+ // Unconditional: OFF idempotently recovers a carried-over hidden state
244
+ // (pi's resetExtensionUI() resets workingVisible = true before session_start
245
+ // re-applies it, so this is always safe)
246
+ ctx.ui.setWorkingVisible(!spinFlag);
163
247
  ctx.ui.setEditorComponent((tui: TUI, editorTheme: EditorTheme, keybindings: KeybindingsManager) => {
164
248
  const theme = ctx.ui.theme;
165
249
  return new HephaestusEditor(tui, editorTheme, keybindings, {
166
250
  getTheme: () => theme,
167
251
  isIdle: () => ctx.isIdle(),
168
252
  shutdown: () => ctx.shutdown(),
253
+ spin: spinFlag,
254
+ spinSpeed: config.editorSpinSpeed,
255
+ spinStyle: config.editorSpinStyle,
256
+ spinLabel: config.editorSpinLabel,
257
+ onSpinInterval: (i) => { spinInterval = i; },
169
258
  });
170
259
  });
171
260
 
172
- // Load config for thinking transformation + label overrides
173
- const config = loadCoreConfig();
174
-
175
- // Patch thinking renderer
261
+ // Patch thinking renderer (config was hoisted above the editor factory)
176
262
  patchThinkingRenderer(() => ctx.ui.theme, {
177
263
  labelText: config.labelText,
178
264
  labelColor: config.labelColor,