@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.
- package/README.md +34 -25
- package/package.json +3 -3
- package/src/chrome.test.ts +53 -0
- package/src/chrome.ts +2 -0
- package/src/config.test.ts +29 -0
- package/src/config.ts +28 -0
- package/src/editor/index.test.ts +619 -0
- package/src/editor/index.ts +134 -2
- package/src/editor/spin.test.ts +702 -0
- package/src/editor/spin.ts +471 -0
- package/src/index.test.ts +362 -0
- package/src/index.ts +91 -5
|
@@ -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
|
-
//
|
|
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,
|