@pi-archimedes/core 2.7.3 → 2.9.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.
- package/README.md +6 -27
- package/package.json +8 -6
- package/src/bridge/channel.test.ts +52 -0
- package/src/bridge/channel.ts +322 -0
- package/src/bridge/events.ts +197 -0
- package/src/bridge/index.test.ts +722 -0
- package/src/bridge/index.ts +166 -0
- package/src/bridge/self-usage.test.ts +146 -0
- package/src/bridge/self-usage.ts +37 -0
- package/src/bus.ts +8 -1
- package/src/config.test.ts +8 -103
- package/src/config.ts +4 -66
- package/src/index.test.ts +19 -350
- package/src/index.ts +12 -269
- package/src/tool-render.ts +1 -1
- package/src/editor/index.test.ts +0 -820
- package/src/editor/index.ts +0 -379
- package/src/editor/spin-quips.test.ts +0 -106
- package/src/editor/spin-quips.ts +0 -44
- package/src/editor/spin.test.ts +0 -702
- package/src/editor/spin.ts +0 -471
- package/src/startup/capture.ts +0 -47
- package/src/startup/index.ts +0 -330
- package/src/startup/logo.test.ts +0 -260
- package/src/startup/logo.ts +0 -85
- package/src/startup/sections.test.ts +0 -314
- package/src/startup/sections.ts +0 -284
- package/src/startup/version.test.ts +0 -98
- package/src/startup/version.ts +0 -26
- package/src/thinking/patch.test.ts +0 -389
- package/src/thinking/patch.ts +0 -288
- package/src/thinking/theme.test.ts +0 -216
- package/src/thinking/theme.ts +0 -156
- package/src/thinking/transform.test.ts +0 -89
- package/src/thinking/transform.ts +0 -21
- package/src/thinking/unindent.test.ts +0 -74
- package/src/thinking/unindent.ts +0 -74
package/src/index.test.ts
CHANGED
|
@@ -1,362 +1,31 @@
|
|
|
1
|
-
import { describe, it, expect, vi, beforeEach
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
2
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
-
import
|
|
3
|
+
import { registerCore } from "./index.js";
|
|
4
|
+
import { initBus } from "./bus.js";
|
|
5
|
+
import { registerBridge } from "./bridge/index.js";
|
|
6
|
+
import { registerSelfUsage } from "./bridge/self-usage.js";
|
|
4
7
|
|
|
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
8
|
vi.mock("./bus.js", () => ({
|
|
24
9
|
initBus: vi.fn(),
|
|
25
10
|
}));
|
|
26
|
-
vi.mock("./
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
};
|
|
32
|
-
});
|
|
33
|
-
vi.mock("./thinking/patch.js", () => ({
|
|
34
|
-
patchThinkingRenderer: vi.fn(),
|
|
11
|
+
vi.mock("./bridge/index.js", () => ({
|
|
12
|
+
registerBridge: vi.fn(),
|
|
13
|
+
}));
|
|
14
|
+
vi.mock("./bridge/self-usage.js", () => ({
|
|
15
|
+
registerSelfUsage: vi.fn(),
|
|
35
16
|
}));
|
|
36
17
|
|
|
37
|
-
|
|
38
|
-
|
|
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");
|
|
18
|
+
describe("registerCore", () => {
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
vi.clearAllMocks();
|
|
302
21
|
});
|
|
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
22
|
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
23
|
+
it("registers bus, bridge, and self-usage", () => {
|
|
24
|
+
const pi = {} as ExtensionAPI;
|
|
25
|
+
registerCore(pi);
|
|
26
|
+
expect(initBus).toHaveBeenCalled();
|
|
27
|
+
expect(registerBridge).toHaveBeenCalledWith(pi);
|
|
28
|
+
expect(registerSelfUsage).toHaveBeenCalledWith(pi);
|
|
329
29
|
});
|
|
330
30
|
});
|
|
331
31
|
|
|
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
|
@@ -1,277 +1,20 @@
|
|
|
1
|
-
import type { ExtensionAPI
|
|
2
|
-
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
3
|
-
import { type TUI, type EditorTheme, type Component, type SettingItem } from "@earendil-works/pi-tui";
|
|
4
|
-
|
|
5
|
-
import { HephaestusEditor } from "./editor/index.js";
|
|
6
|
-
|
|
7
|
-
import { renderHeader, patchStartupListing, type ListingRef } from "./startup/index.js";
|
|
8
|
-
import { patchConsoleLog, unpatchConsoleLog } from "./startup/capture.js";
|
|
9
|
-
import { patchThinkingRenderer } from "./thinking/patch.js";
|
|
10
|
-
import { transformThinkingContent } from "./thinking/transform.js";
|
|
11
|
-
import { loadCoreConfig, saveCoreConfig, DEFAULT_CORE_CONFIG, ANIMATION_STYLES, type CoreConfig } from "./config.js";
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
12
2
|
import { initBus } from "./bus.js";
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
export { unpatchConsoleLog } from "./startup/capture.js";
|
|
16
|
-
|
|
17
|
-
// ── Settings items ────────────────────────────────────────────────────────
|
|
18
|
-
|
|
19
|
-
export function getCoreSettingsItems(config: CoreConfig): SettingItem[] {
|
|
20
|
-
return [
|
|
21
|
-
{
|
|
22
|
-
id: "mutedTheme",
|
|
23
|
-
label: "Muted Theme",
|
|
24
|
-
description: "Use muted colors for thinking blocks",
|
|
25
|
-
currentValue: config.mutedTheme ? "On" : "Off",
|
|
26
|
-
values: ["On", "Off"],
|
|
27
|
-
},
|
|
28
|
-
{
|
|
29
|
-
id: "codeUnindent",
|
|
30
|
-
label: "Code Unindent",
|
|
31
|
-
description: "Remove 2-space indent from code blocks",
|
|
32
|
-
currentValue: config.codeUnindent ? "On" : "Off",
|
|
33
|
-
values: ["On", "Off"],
|
|
34
|
-
},
|
|
35
|
-
{
|
|
36
|
-
id: "labelText",
|
|
37
|
-
label: "Label Text",
|
|
38
|
-
description: "Text shown before thinking blocks",
|
|
39
|
-
currentValue: config.labelText,
|
|
40
|
-
},
|
|
41
|
-
{
|
|
42
|
-
id: "labelColor",
|
|
43
|
-
label: "Label Color",
|
|
44
|
-
description: "RGB color for thinking label (e.g. 255,215,0)",
|
|
45
|
-
currentValue: config.labelColor,
|
|
46
|
-
},
|
|
47
|
-
{
|
|
48
|
-
id: "animationStyle",
|
|
49
|
-
label: "Logo Animation",
|
|
50
|
-
description: "Splashscreen logo reveal style",
|
|
51
|
-
currentValue: config.animationStyle,
|
|
52
|
-
values: [...ANIMATION_STYLES],
|
|
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
|
-
},
|
|
109
|
-
];
|
|
110
|
-
}
|
|
3
|
+
import { registerBridge } from "./bridge/index.js";
|
|
4
|
+
import { registerSelfUsage } from "./bridge/self-usage.js";
|
|
111
5
|
|
|
112
6
|
// ── Core registration ─────────────────────────────────────────────────────
|
|
113
7
|
|
|
114
|
-
// Module-level state for session lifecycle (shared between session_start and session_shutdown)
|
|
115
|
-
let coreRef: ListingRef | undefined;
|
|
116
|
-
let coreCtx: ExtensionContext | undefined;
|
|
117
|
-
let coreTui: TUI | undefined;
|
|
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
|
-
|
|
131
8
|
export function registerCore(pi: ExtensionAPI): void {
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
//
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
clearSpinInterval();
|
|
142
|
-
spinFlag = false;
|
|
143
|
-
|
|
144
|
-
// Mark listing as settled
|
|
145
|
-
if (coreRef) { coreRef.settled = true; }
|
|
146
|
-
const g: Record<string | symbol, unknown> = globalThis as unknown as typeof global & Record<string | symbol, unknown>;
|
|
147
|
-
const listingRef = g["listingRef"] as ListingRef | undefined;
|
|
148
|
-
if (listingRef) { listingRef.settled = true; }
|
|
149
|
-
|
|
150
|
-
// Restore patched addChild to prevent closure accumulation across reloads
|
|
151
|
-
const PATCHED_LISTING = Symbol.for("splashscreen:listingPatched");
|
|
152
|
-
const ORIG_ADD_CHILD = Symbol.for("splashscreen:origAddChild");
|
|
153
|
-
if (coreTui) {
|
|
154
|
-
try {
|
|
155
|
-
// Direct TUI children (old Pi: chatContainer was a direct child)
|
|
156
|
-
for (const child of coreTui.children) {
|
|
157
|
-
const cc = child as any;
|
|
158
|
-
if (cc[PATCHED_LISTING] && cc[ORIG_ADD_CHILD]) {
|
|
159
|
-
cc.addChild = cc[ORIG_ADD_CHILD];
|
|
160
|
-
cc[PATCHED_LISTING] = false;
|
|
161
|
-
cc[ORIG_ADD_CHILD] = undefined;
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
// Nested children (Pi 0.84+: loadedResourcesContainer is inside documentContainer)
|
|
165
|
-
for (const topChild of coreTui.children) {
|
|
166
|
-
const tc = topChild as any;
|
|
167
|
-
if (!tc.children) continue;
|
|
168
|
-
for (const child of tc.children) {
|
|
169
|
-
const cc = child as any;
|
|
170
|
-
if (cc[PATCHED_LISTING] && cc[ORIG_ADD_CHILD]) {
|
|
171
|
-
cc.addChild = cc[ORIG_ADD_CHILD];
|
|
172
|
-
cc[PATCHED_LISTING] = false;
|
|
173
|
-
cc[ORIG_ADD_CHILD] = undefined;
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
// Clear animation interval and debounce timer for prompt cleanup
|
|
179
|
-
const ANIM_INTERVAL = Symbol.for("splashscreen:animInterval");
|
|
180
|
-
const DEBOUNCE_TIMER = Symbol.for("splashscreen:debounceTimer");
|
|
181
|
-
for (const child of coreTui.children) {
|
|
182
|
-
const cc = child as any;
|
|
183
|
-
if (cc[ANIM_INTERVAL]) clearInterval(cc[ANIM_INTERVAL]);
|
|
184
|
-
if (cc[DEBOUNCE_TIMER]) clearTimeout(cc[DEBOUNCE_TIMER]);
|
|
185
|
-
}
|
|
186
|
-
// Nested children (Pi 0.84+: loadedResourcesContainer is inside documentContainer)
|
|
187
|
-
for (const topChild of coreTui.children) {
|
|
188
|
-
const tc = topChild as any;
|
|
189
|
-
if (!tc.children) continue;
|
|
190
|
-
for (const child of tc.children) {
|
|
191
|
-
const cc = child as any;
|
|
192
|
-
if (cc[ANIM_INTERVAL]) clearInterval(cc[ANIM_INTERVAL]);
|
|
193
|
-
if (cc[DEBOUNCE_TIMER]) clearTimeout(cc[DEBOUNCE_TIMER]);
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
} catch {
|
|
197
|
-
/* TUI structure may have changed — ignore */
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
// Clear editor component override
|
|
202
|
-
if (coreCtx) { coreCtx.ui.setEditorComponent(undefined); }
|
|
203
|
-
});
|
|
204
|
-
|
|
205
|
-
// session_start handler
|
|
206
|
-
pi.on("session_start", (_event, ctx: ExtensionContext) => {
|
|
207
|
-
// Initialize bus (flushes queued events)
|
|
208
|
-
initBus();
|
|
209
|
-
|
|
210
|
-
// Save context for shutdown cleanup
|
|
211
|
-
coreCtx = ctx;
|
|
212
|
-
|
|
213
|
-
// Spin-prompt flag (drives the editor self-timer + Working-line visibility)
|
|
214
|
-
const config = loadCoreConfig();
|
|
215
|
-
spinFlag = config.editorSpinBorder;
|
|
216
|
-
|
|
217
|
-
// Set animated header
|
|
218
|
-
coreRef = {
|
|
219
|
-
sections: [],
|
|
220
|
-
frame: 0,
|
|
221
|
-
revealed: false,
|
|
222
|
-
revealedAt: 0,
|
|
223
|
-
scaffoldAt: 0,
|
|
224
|
-
settled: false,
|
|
225
|
-
};
|
|
226
|
-
const ref = coreRef;
|
|
227
|
-
const headerFactory = (tui: TUI, theme: Theme): Component & { dispose?(): void } => {
|
|
228
|
-
coreTui = tui; // Capture for shutdown cleanup
|
|
229
|
-
const comp: Component & { dispose?(): void } = {
|
|
230
|
-
invalidate(): void { /* no-op */ },
|
|
231
|
-
render(width: number): string[] {
|
|
232
|
-
return renderHeader(theme, ref, width, tui.terminal.rows - 3);
|
|
233
|
-
},
|
|
234
|
-
};
|
|
235
|
-
patchStartupListing(tui, theme, ref);
|
|
236
|
-
return comp;
|
|
237
|
-
};
|
|
238
|
-
ctx.ui.setHeader(headerFactory);
|
|
239
|
-
|
|
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);
|
|
247
|
-
ctx.ui.setEditorComponent((tui: TUI, editorTheme: EditorTheme, keybindings: KeybindingsManager) => {
|
|
248
|
-
const theme = ctx.ui.theme;
|
|
249
|
-
return new HephaestusEditor(tui, editorTheme, keybindings, {
|
|
250
|
-
getTheme: () => theme,
|
|
251
|
-
isIdle: () => ctx.isIdle(),
|
|
252
|
-
shutdown: () => ctx.shutdown(),
|
|
253
|
-
spin: spinFlag,
|
|
254
|
-
spinSpeed: config.editorSpinSpeed,
|
|
255
|
-
spinStyle: config.editorSpinStyle,
|
|
256
|
-
spinLabel: config.editorSpinLabel,
|
|
257
|
-
onSpinInterval: (i) => { spinInterval = i; },
|
|
258
|
-
});
|
|
259
|
-
});
|
|
260
|
-
|
|
261
|
-
// Patch thinking renderer (config was hoisted above the editor factory)
|
|
262
|
-
patchThinkingRenderer(() => ctx.ui.theme, {
|
|
263
|
-
labelText: config.labelText,
|
|
264
|
-
labelColor: config.labelColor,
|
|
265
|
-
});
|
|
266
|
-
|
|
267
|
-
// Register events
|
|
268
|
-
pi.on("message_end", (event, _ctx) => {
|
|
269
|
-
// Transform thinking content (unindent code blocks if enabled)
|
|
270
|
-
if (config.codeUnindent) {
|
|
271
|
-
transformThinkingContent(event.message as any);
|
|
272
|
-
}
|
|
273
|
-
});
|
|
274
|
-
});
|
|
9
|
+
initBus();
|
|
10
|
+
registerBridge(pi);
|
|
11
|
+
|
|
12
|
+
// Self-usage (per-turn COST_UPDATE, source "main") — top-level like the
|
|
13
|
+
// bridge (never inside a session handler, so it doesn't accumulate on
|
|
14
|
+
// /reload). Unconditional: NOT gated on bridge mode (design decision 3 —
|
|
15
|
+
// the TUI footer's CostAccumulator sums it; in bridge mode the existing
|
|
16
|
+
// events.ts forwarder carries it to the desktop).
|
|
17
|
+
registerSelfUsage(pi);
|
|
275
18
|
}
|
|
276
19
|
|
|
277
20
|
// ── Default export (for standalone pi.extensions loading) ─────────────────
|
package/src/tool-render.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Shared tool-row rendering helpers.
|
|
3
3
|
*
|
|
4
|
-
* Archimedes tools (
|
|
4
|
+
* Archimedes tools (todo, …) render a consistent two-part row:
|
|
5
5
|
*
|
|
6
6
|
* line 1 (header): <toolName> (blue bold) + <action> (orange accent)
|
|
7
7
|
* result line: <glyph> <label> — glyph reflects run status:
|