@pi-archimedes/core 2.8.0 → 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/src/index.test.ts CHANGED
@@ -1,366 +1,31 @@
1
- import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
2
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
- import type { Theme } from "@earendil-works/pi-coding-agent";
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("./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(),
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
- 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: registerBridge (added right after patchConsoleLog)
96
- // registers session_start + agent_start + agent_settled FIRST, then
97
- // registerCore registers session_shutdown + its own session_start. The core's
98
- // session_start handler (the one that drives the editor/spin logic) is the
99
- // LAST session_start registered — find it by position, not .find (which would
100
- // return the bridge's no-op handler).
101
- const calls = onSpy.mock.calls as Array<
102
- [string, (event: unknown, ctx: ExtensionContext) => void]
103
- >;
104
- const startCall = calls.filter((c) => c[0] === "session_start").at(-1);
105
- const shutdownCall = calls.find((c) => c[0] === "session_shutdown");
106
- expect(startCall).toBeTruthy();
107
- expect(shutdownCall).toBeTruthy();
108
- const startFn = startCall! ? startCall[1] : undefined;
109
- const shutdownFn = shutdownCall ? shutdownCall[1] : undefined;
110
- return {
111
- start: (ctx) => startFn!(null, ctx),
112
- shutdown: (ctx) => shutdownFn!(null, ctx),
113
- };
114
- }
115
-
116
- let start: (ctx: ExtensionContext) => void;
117
- let shutdown: (ctx: ExtensionContext) => void;
118
- let ctx: ExtensionContext;
119
- let ui: UiSpies;
120
- let si: TimerSpy;
121
- let cl: TimerSpy;
122
- let constructed: unknown[];
123
-
124
- /** 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). */
125
- function spinTimerCount(): number {
126
- return si.mock.calls.filter((c) => c[1] === 32).length;
127
- }
128
-
129
- beforeEach(() => {
130
- const handlers = registerAndCapture();
131
- start = handlers.start;
132
- shutdown = handlers.shutdown;
133
- const made = makeCtx();
134
- ctx = made.ctx;
135
- ui = made.ui;
136
- si = vi.spyOn(globalThis, "setInterval") as unknown as TimerSpy;
137
- cl = vi.spyOn(globalThis, "clearInterval") as unknown as TimerSpy;
138
- constructed = [];
139
- });
140
-
141
- afterEach(() => {
142
- // Reap any live spinner timers so the test process never hangs.
143
- for (const e of constructed.splice(0)) {
144
- (e as { dispose?: () => void }).dispose?.();
145
- }
146
- si.mockRestore();
147
- cl.mockRestore();
148
- });
149
-
150
- // ── 1. Default (on) ────────────────────────────────────────────────────
151
-
152
- describe("editorSpinBorder = true (default)", () => {
153
- it("session_start hides the Working line; the editor drives a 32ms timer (the default pendulum) stored onSpinInterval; shutdown restores and reaps", () => {
154
- vi.mocked(loadCoreConfig).mockReturnValue(DEFAULT_CORE_CONFIG);
155
-
156
- start(ctx);
157
- expect(ui.setWorkingVisible.mock.calls.map((c: unknown[]) => c[0])).toEqual(
158
- [false],
159
- );
160
-
161
- buildEditor(ui); // real ctor: setInterval(fn, 32) + onSpinInterval(token)
162
- expect(spinTimerCount()).toBe(1);
163
- const setCall = si.mock.calls.at(-1)!;
164
- expect(typeof setCall[0]).toBe("function");
165
- expect(setCall[1]).toBe(32);
166
-
167
- // onSpinInterval stored the handle: shutdown() clears it.
168
- shutdown(ctx);
169
- const visCalls = ui.setWorkingVisible.mock.calls.map((c: unknown[]) => c[0]);
170
- expect(visCalls.at(-1)).toBe(true);
171
- expect(cl.mock.calls.length).toBeGreaterThan(0);
172
- const token = si.mock.results[si.mock.calls.length - 1]!.value;
173
- expect(cl.mock.calls.some((c) => c[0] === token)).toBe(true);
174
- });
175
-
176
- it("a second shutdown is a no-op (no restore, no clear)", () => {
177
- vi.mocked(loadCoreConfig).mockReturnValue(DEFAULT_CORE_CONFIG);
178
- start(ctx);
179
- buildEditor(ui);
180
- shutdown(ctx);
181
- const visAfter = ui.setWorkingVisible.mock.calls.length;
182
- const clearAfter = cl.mock.calls.length;
183
- shutdown(ctx); // spinFlag already reset, handle already cleared
184
- expect(ui.setWorkingVisible.mock.calls.length).toBe(visAfter);
185
- expect(cl.mock.calls.length).toBe(clearAfter);
186
- });
187
-
188
- it("editorSpinSpeed = \"fast\": the editor's interval period is 48 ms (the typing 80 ms native × 0.6)", () => {
189
- vi.mocked(loadCoreConfig).mockReturnValue({
190
- ...DEFAULT_CORE_CONFIG,
191
- editorSpinSpeed: "fast",
192
- editorSpinStyle: "typing",
193
- });
194
-
195
- start(ctx);
196
- buildEditor(ui);
197
- const setCall = si.mock.calls.at(-1)!;
198
- expect(setCall[1]).toBe(48);
199
- });
200
-
201
- it("editorSpinSpeed = \"slow\": the editor's interval period is 120 ms (the typing 80 ms native × 1.5)", () => {
202
- vi.mocked(loadCoreConfig).mockReturnValue({
203
- ...DEFAULT_CORE_CONFIG,
204
- editorSpinSpeed: "slow",
205
- editorSpinStyle: "typing",
206
- });
207
-
208
- start(ctx);
209
- buildEditor(ui);
210
- const setCall = si.mock.calls.at(-1)!;
211
- expect(setCall[1]).toBe(120);
212
- });
213
-
214
- 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)", () => {
215
- vi.mocked(loadCoreConfig).mockReturnValue({
216
- ...DEFAULT_CORE_CONFIG,
217
- editorSpinStyle: "rain",
218
- });
219
- vi.mocked(ctx.isIdle).mockReturnValue(false); // busy
220
-
221
- start(ctx);
222
- buildEditor(ui);
223
- const setCall = si.mock.calls.at(-1)!;
224
- expect(setCall[1]).toBe(40); // 40 × 1 — not 80 (typing), so the style reached the editor
225
-
226
- const editor = buildEditor(ui) as unknown as { tickSpin(): void; render(w: number): string[] };
227
- // 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)
228
- const plain = (l: string) => l.replace(/\x1b\[[0-9;]*m/g, "");
229
- expect(plain(editor.render(60)[1]!)).toContain("⠈⠠⠠ Working");
230
- expect(plain(editor.render(60)[1]!)).not.toContain("⠁ Working"); // not the typing fallback
231
- });
232
-
233
- it("editorSpinLabel = \"Thinking\" → the factory-built editor's busy border carries ` Thinking`, not ` Working`", () => {
234
- vi.mocked(loadCoreConfig).mockReturnValue({
235
- ...DEFAULT_CORE_CONFIG,
236
- editorSpinLabel: "Thinking",
237
- });
238
-
239
- start(ctx);
240
- vi.mocked(ctx.isIdle).mockReturnValue(false); // busy
241
- const editor = buildEditor(ui) as { render(w: number): string[] };
242
- const plain = (l: string) => l.replace(/\x1b\[[0-9;]*m/g, "");
243
- const row = plain(editor.render(60)[1]!);
244
- expect(row).toContain(" Thinking");
245
- expect(row).not.toContain("Working");
246
- });
247
-
248
- it("editorSpinLabel = \"\" → the factory-built editor's busy border shows the window only (no label, dash-compensated)", () => {
249
- vi.mocked(loadCoreConfig).mockReturnValue({
250
- ...DEFAULT_CORE_CONFIG,
251
- editorSpinLabel: "",
252
- });
253
-
254
- start(ctx);
255
- vi.mocked(ctx.isIdle).mockReturnValue(false); // busy
256
- const editor = buildEditor(ui) as { render(w: number): string[] };
257
- const plain = (l: string) => l.replace(/\x1b\[[0-9;]*m/g, "");
258
- const row = plain(editor.render(60)[1]!);
259
- // 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.
260
- expect(row).toMatch(/\s[^\s─]{4}─{51}/);
261
- expect(row).not.toContain("Working");
262
- });
263
- });
264
-
265
- // ── 2b. Corrupt (hand-edited) config robustness ────────────────────────────
266
-
267
- describe("getCoreSettingsItems with hand-edited (corrupt) values", () => {
268
- it("an empty-string editorSpinSpeed (corrupt) projects to \"Normal\" without throwing", () => {
269
- const items = getCoreSettingsItems({
270
- ...DEFAULT_CORE_CONFIG,
271
- editorSpinSpeed: "" as never,
272
- });
273
- const item = items.find((i) => i.id === "editorSpinSpeed");
274
- expect(item).toBeTruthy();
275
- expect(item!.currentValue).toBe("Normal");
276
- });
277
-
278
- it("a trailing-dash editorSpinStyle (empty split word) projects without throwing", () => {
279
- const items = getCoreSettingsItems({
280
- ...DEFAULT_CORE_CONFIG,
281
- editorSpinStyle: "wave-" as never,
282
- });
283
- const item = items.find((i) => i.id === "editorSpinStyle");
284
- expect(item).toBeTruthy();
285
- expect(item!.currentValue).toBe("Wave");
286
- });
287
-
288
- it("a number editorSpinSpeed (corrupt — non-string) projects to \"Normal\" without throwing", () => {
289
- const items = getCoreSettingsItems({
290
- ...DEFAULT_CORE_CONFIG,
291
- editorSpinSpeed: 2 as never,
292
- });
293
- const item = items.find((i) => i.id === "editorSpinSpeed");
294
- expect(item).toBeTruthy();
295
- expect(item!.currentValue).toBe("Normal");
296
- });
297
-
298
- it("a null editorSpinStyle (corrupt — non-string) projects to \"Typing\" without throwing", () => {
299
- const items = getCoreSettingsItems({
300
- ...DEFAULT_CORE_CONFIG,
301
- editorSpinStyle: null as never,
302
- });
303
- const item = items.find((i) => i.id === "editorSpinStyle");
304
- expect(item).toBeTruthy();
305
- expect(item!.currentValue).toBe("Typing");
18
+ describe("registerCore", () => {
19
+ beforeEach(() => {
20
+ vi.clearAllMocks();
306
21
  });
307
- });
308
-
309
- // ── 2. Off ──────────────────────────────────────────────────────────────
310
-
311
- describe("editorSpinBorder = false", () => {
312
- it("no timer; setWorkingVisible(true) (default); shutdown is inert; re-start re-applies the flag", () => {
313
- vi.mocked(loadCoreConfig).mockReturnValue({
314
- ...DEFAULT_CORE_CONFIG,
315
- editorSpinBorder: false,
316
- });
317
-
318
- start(ctx);
319
- expect(ui.setWorkingVisible.mock.calls.at(-1)?.[0]).toBe(true);
320
-
321
- buildEditor(ui);
322
- expect(spinTimerCount()).toBe(0);
323
-
324
- const visBeforeShutdown = ui.setWorkingVisible.mock.calls.length;
325
- const clearBeforeShutdown = cl.mock.calls.length;
326
- shutdown(ctx); // nothing to restore or reap
327
- expect(ui.setWorkingVisible.mock.calls.length).toBe(visBeforeShutdown);
328
- expect(cl.mock.calls.length).toBe(clearBeforeShutdown);
329
22
 
330
- // Idempotent restore: the flag is re-stored on the next session_start.
331
- start(ctx);
332
- expect(ui.setWorkingVisible.mock.calls.at(-1)?.[0]).toBe(true);
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);
333
29
  });
334
30
  });
335
31
 
336
- // ── 3. Orphaned-timer reaping (/reload rebind) ─────────────────────────
337
-
338
- describe("orphaned timer reaping", () => {
339
- it("a re-invoked session_start (the /reload rebind) reaps the previous editor's timer before the new editor constructs", () => {
340
- vi.mocked(loadCoreConfig).mockReturnValue(DEFAULT_CORE_CONFIG);
341
-
342
- // (1) First session_start
343
- start(ctx);
344
- buildEditor(ui); // real ctor starts the timer → onSpinInterval stores it
345
- const tokenA = si.mock.results[si.mock.calls.length - 1]!.value;
346
- expect(spinTimerCount()).toBe(1);
347
-
348
- // (3) /reload rebind — session_start runs BEFORE factory B is built,
349
- // so merely calling factory A again would NOT trigger the reap.
350
- const reconnectsBefore = ui.setEditorComponent.mock.calls.length;
351
- start(ctx);
352
- expect(ui.setEditorComponent.mock.calls.length).toBe(reconnectsBefore + 1);
353
- expect(cl.mock.calls.at(-1)?.[0]).toBe(tokenA);
354
- const clearOrderA =
355
- cl.mock.invocationCallOrder[cl.mock.calls.length - 1]!;
356
-
357
- // (4) Build factory B's editor (new constructor, new timer)
358
- buildEditor(ui);
359
- expect(spinTimerCount()).toBe(2);
360
- const setOrderB =
361
- si.mock.invocationCallOrder[si.mock.calls.length - 1]!;
362
-
363
- // (5) The orphan was reaped BEFORE the new editor's setInterval
364
- expect(clearOrderA).toBeLessThan(setOrderB);
365
- });
366
- });
package/src/index.ts CHANGED
@@ -1,283 +1,20 @@
1
- import type { ExtensionAPI, ExtensionContext, ExtensionCommandContext, KeybindingsManager } from "@earendil-works/pi-coding-agent";
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
3
  import { registerBridge } from "./bridge/index.js";
14
-
15
- // Re-export for session lifecycle management
16
- export { unpatchConsoleLog } from "./startup/capture.js";
17
-
18
- // ── Settings items ────────────────────────────────────────────────────────
19
-
20
- export function getCoreSettingsItems(config: CoreConfig): SettingItem[] {
21
- return [
22
- {
23
- id: "mutedTheme",
24
- label: "Muted Theme",
25
- description: "Use muted colors for thinking blocks",
26
- currentValue: config.mutedTheme ? "On" : "Off",
27
- values: ["On", "Off"],
28
- },
29
- {
30
- id: "codeUnindent",
31
- label: "Code Unindent",
32
- description: "Remove 2-space indent from code blocks",
33
- currentValue: config.codeUnindent ? "On" : "Off",
34
- values: ["On", "Off"],
35
- },
36
- {
37
- id: "labelText",
38
- label: "Label Text",
39
- description: "Text shown before thinking blocks",
40
- currentValue: config.labelText,
41
- },
42
- {
43
- id: "labelColor",
44
- label: "Label Color",
45
- description: "RGB color for thinking label (e.g. 255,215,0)",
46
- currentValue: config.labelColor,
47
- },
48
- {
49
- id: "animationStyle",
50
- label: "Logo Animation",
51
- description: "Splashscreen logo reveal style",
52
- currentValue: config.animationStyle,
53
- values: [...ANIMATION_STYLES],
54
- },
55
- {
56
- id: "editorSpinBorder",
57
- label: "Editor Spin Border",
58
- description: "Type across the editor's top border while the agent is working (hides the “Working” line)",
59
- currentValue: config.editorSpinBorder ? "On" : "Off",
60
- values: ["On", "Off"],
61
- },
62
- {
63
- id: "editorSpinSpeed",
64
- label: "Spin Speed",
65
- description: "Border spinner speed (slow / normal / fast — the × 1.5 / × 1 / × 0.6 of the style's native tempo)",
66
- currentValue: (() => {
67
- // Hand-edited (corrupt) values — non-strings (null/number/boolean) or an
68
- // empty string — fall back to `normal` (the `typeof` guard keeps the
69
- // settings panel from TypeError-ing on a non-string setting; the
70
- // falsy check keeps an empty string from projecting as a `NaN` label).
71
- const s =
72
- typeof config.editorSpinSpeed === "string" && config.editorSpinSpeed
73
- ? config.editorSpinSpeed
74
- : "normal";
75
- return s[0]!.toUpperCase() + s.slice(1);
76
- })(),
77
- values: ["Slow", "Normal", "Fast"],
78
- },
79
- {
80
- id: "editorSpinStyle",
81
- label: "Spin Style",
82
- description: "Which animation the editor border runs while working",
83
- currentValue:
84
- typeof config.editorSpinStyle === "string"
85
- ? config.editorSpinStyle
86
- .split("-")
87
- .filter(Boolean)
88
- .map((w) => w[0]!.toUpperCase() + w.slice(1))
89
- .join(" ")
90
- : "Typing",
91
- values: [
92
- "Typing",
93
- "Wave Rows",
94
- "Columns",
95
- "Pulse",
96
- "Marquee",
97
- "Pendulum",
98
- "Rain",
99
- "Cascade",
100
- "Diagonal Swipe",
101
- "Sparkle",
102
- ],
103
- },
104
- {
105
- id: "editorSpinLabel",
106
- label: "Spinner Label",
107
- description: "Label typed after the spin window (empty hides it)",
108
- currentValue: config.editorSpinLabel,
109
- },
110
- ];
111
- }
4
+ import { registerSelfUsage } from "./bridge/self-usage.js";
112
5
 
113
6
  // ── Core registration ─────────────────────────────────────────────────────
114
7
 
115
- // Module-level state for session lifecycle (shared between session_start and session_shutdown)
116
- let coreRef: ListingRef | undefined;
117
- let coreCtx: ExtensionContext | undefined;
118
- let coreTui: TUI | undefined;
119
-
120
- // Spin-prompt lifecycle state: the editor self-drives its timer and reports it
121
- // back via onSpinInterval so the session handlers can reap it (no circular import
122
- // — editor/index.ts must never import this module).
123
- let spinFlag = false;
124
- let spinInterval: ReturnType<typeof setInterval> | undefined;
125
- function clearSpinInterval(): void {
126
- if (spinInterval) {
127
- clearInterval(spinInterval);
128
- spinInterval = undefined;
129
- }
130
- }
131
-
132
8
  export function registerCore(pi: ExtensionAPI): void {
133
- // Patch console.log for model scope capture
134
- patchConsoleLog();
135
-
136
- // Bridge (env-gated local channel to the Client) — top-level, like
137
- // patchConsoleLog (never inside a session handler, so it doesn't accumulate
138
- // on /reload).
9
+ initBus();
139
10
  registerBridge(pi);
140
11
 
141
- // session_shutdown handler (top-level to prevent accumulation on /reload)
142
- pi.on("session_shutdown", (_event, _ctx) => {
143
- // Restore the Working line if we hid it, and reap the editor's spinner
144
- // timer. Runs FIRST: enabled-only restore of the Working line +
145
- // unconditional spinner-timer reap (idempotent when spin is off).
146
- if (spinFlag) { _ctx.ui.setWorkingVisible(true); }
147
- clearSpinInterval();
148
- spinFlag = false;
149
-
150
- // Mark listing as settled
151
- if (coreRef) { coreRef.settled = true; }
152
- const g: Record<string | symbol, unknown> = globalThis as unknown as typeof global & Record<string | symbol, unknown>;
153
- const listingRef = g["listingRef"] as ListingRef | undefined;
154
- if (listingRef) { listingRef.settled = true; }
155
-
156
- // Restore patched addChild to prevent closure accumulation across reloads
157
- const PATCHED_LISTING = Symbol.for("splashscreen:listingPatched");
158
- const ORIG_ADD_CHILD = Symbol.for("splashscreen:origAddChild");
159
- if (coreTui) {
160
- try {
161
- // Direct TUI children (old Pi: chatContainer was a direct child)
162
- for (const child of coreTui.children) {
163
- const cc = child as any;
164
- if (cc[PATCHED_LISTING] && cc[ORIG_ADD_CHILD]) {
165
- cc.addChild = cc[ORIG_ADD_CHILD];
166
- cc[PATCHED_LISTING] = false;
167
- cc[ORIG_ADD_CHILD] = undefined;
168
- }
169
- }
170
- // Nested children (Pi 0.84+: loadedResourcesContainer is inside documentContainer)
171
- for (const topChild of coreTui.children) {
172
- const tc = topChild as any;
173
- if (!tc.children) continue;
174
- for (const child of tc.children) {
175
- const cc = child as any;
176
- if (cc[PATCHED_LISTING] && cc[ORIG_ADD_CHILD]) {
177
- cc.addChild = cc[ORIG_ADD_CHILD];
178
- cc[PATCHED_LISTING] = false;
179
- cc[ORIG_ADD_CHILD] = undefined;
180
- }
181
- }
182
- }
183
-
184
- // Clear animation interval and debounce timer for prompt cleanup
185
- const ANIM_INTERVAL = Symbol.for("splashscreen:animInterval");
186
- const DEBOUNCE_TIMER = Symbol.for("splashscreen:debounceTimer");
187
- for (const child of coreTui.children) {
188
- const cc = child as any;
189
- if (cc[ANIM_INTERVAL]) clearInterval(cc[ANIM_INTERVAL]);
190
- if (cc[DEBOUNCE_TIMER]) clearTimeout(cc[DEBOUNCE_TIMER]);
191
- }
192
- // Nested children (Pi 0.84+: loadedResourcesContainer is inside documentContainer)
193
- for (const topChild of coreTui.children) {
194
- const tc = topChild as any;
195
- if (!tc.children) continue;
196
- for (const child of tc.children) {
197
- const cc = child as any;
198
- if (cc[ANIM_INTERVAL]) clearInterval(cc[ANIM_INTERVAL]);
199
- if (cc[DEBOUNCE_TIMER]) clearTimeout(cc[DEBOUNCE_TIMER]);
200
- }
201
- }
202
- } catch {
203
- /* TUI structure may have changed — ignore */
204
- }
205
- }
206
-
207
- // Clear editor component override
208
- if (coreCtx) { coreCtx.ui.setEditorComponent(undefined); }
209
- });
210
-
211
- // session_start handler
212
- pi.on("session_start", (_event, ctx: ExtensionContext) => {
213
- // Initialize bus (flushes queued events)
214
- initBus();
215
-
216
- // Save context for shutdown cleanup
217
- coreCtx = ctx;
218
-
219
- // Spin-prompt flag (drives the editor self-timer + Working-line visibility)
220
- const config = loadCoreConfig();
221
- spinFlag = config.editorSpinBorder;
222
-
223
- // Set animated header
224
- coreRef = {
225
- sections: [],
226
- frame: 0,
227
- revealed: false,
228
- revealedAt: 0,
229
- scaffoldAt: 0,
230
- settled: false,
231
- };
232
- const ref = coreRef;
233
- const headerFactory = (tui: TUI, theme: Theme): Component & { dispose?(): void } => {
234
- coreTui = tui; // Capture for shutdown cleanup
235
- const comp: Component & { dispose?(): void } = {
236
- invalidate(): void { /* no-op */ },
237
- render(width: number): string[] {
238
- return renderHeader(theme, ref, width, tui.terminal.rows - 3);
239
- },
240
- };
241
- patchStartupListing(tui, theme, ref);
242
- return comp;
243
- };
244
- ctx.ui.setHeader(headerFactory);
245
-
246
- // Set editor component (reap any orphaned timer from a previous editor /
247
- // /reload rebind first — pi does not dispose the old editor's timer)
248
- clearSpinInterval();
249
- // Unconditional: OFF idempotently recovers a carried-over hidden state
250
- // (pi's resetExtensionUI() resets workingVisible = true before session_start
251
- // re-applies it, so this is always safe)
252
- ctx.ui.setWorkingVisible(!spinFlag);
253
- ctx.ui.setEditorComponent((tui: TUI, editorTheme: EditorTheme, keybindings: KeybindingsManager) => {
254
- const theme = ctx.ui.theme;
255
- return new HephaestusEditor(tui, editorTheme, keybindings, {
256
- getTheme: () => theme,
257
- isIdle: () => ctx.isIdle(),
258
- shutdown: () => ctx.shutdown(),
259
- spin: spinFlag,
260
- spinSpeed: config.editorSpinSpeed,
261
- spinStyle: config.editorSpinStyle,
262
- spinLabel: config.editorSpinLabel,
263
- onSpinInterval: (i) => { spinInterval = i; },
264
- });
265
- });
266
-
267
- // Patch thinking renderer (config was hoisted above the editor factory)
268
- patchThinkingRenderer(() => ctx.ui.theme, {
269
- labelText: config.labelText,
270
- labelColor: config.labelColor,
271
- });
272
-
273
- // Register events
274
- pi.on("message_end", (event, _ctx) => {
275
- // Transform thinking content (unindent code blocks if enabled)
276
- if (config.codeUnindent) {
277
- transformThinkingContent(event.message as any);
278
- }
279
- });
280
- });
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);
281
18
  }
282
19
 
283
20
  // ── Default export (for standalone pi.extensions loading) ─────────────────
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Shared tool-row rendering helpers.
3
3
  *
4
- * Archimedes tools (mcp, todo, …) render a consistent two-part row:
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: