@pi-archimedes/ui 2.13.0 → 2.14.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-archimedes/ui",
3
- "version": "2.13.0",
3
+ "version": "2.14.0",
4
4
  "description": "Terminal UI enhancements, styled bash tool rendering, and visual presentation components for Pi",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -28,7 +28,7 @@
28
28
  "./config": "./src/config.ts"
29
29
  },
30
30
  "dependencies": {
31
- "@pi-archimedes/core": "2.13.0"
31
+ "@pi-archimedes/core": "2.14.0"
32
32
  },
33
33
  "devDependencies": {
34
34
  "@earendil-works/pi-ai": "1.4.0",
package/src/index.test.ts CHANGED
@@ -706,11 +706,14 @@ describe("packages/ui lifecycle and registration", () => {
706
706
  expect(patchStartupListing).not.toHaveBeenCalled();
707
707
  expect(revealStartupListing).not.toHaveBeenCalled();
708
708
 
709
- // Teardown still runs its one unconditional stop — cheap and idempotent —
710
- // while the ref freeze is guarded on there being a ref, so an off session
711
- // cannot resurrect an animation that was never built.
709
+ // Teardown runs NO stop at all: `stopStartupListing()` stops the module
710
+ // pair of timers in `startup/index.ts`, and a session that never installed
711
+ // a header never armed them — so it has no claim on them (#156: that
712
+ // unguarded call is how a subagent child's shutdown froze the live main
713
+ // session's splash). The ref freeze stays guarded on there being a ref, so
714
+ // an off session cannot resurrect an animation that was never built.
712
715
  triggerShutdown(ctx);
713
- expect(stopStartupListing).toHaveBeenCalledTimes(1);
716
+ expect(stopStartupListing).not.toHaveBeenCalled();
714
717
  expect(renderHeader).not.toHaveBeenCalled();
715
718
  });
716
719
  });
package/src/index.ts CHANGED
@@ -56,29 +56,19 @@ export {
56
56
  type ToolStyle,
57
57
  } from "./config.js";
58
58
 
59
- // Module-level state for session lifecycle
60
- let uiRef: ListingRef | undefined;
61
- let uiCtx: ExtensionContext | undefined;
62
-
63
- let spinFlag = false;
64
- let spinInterval: ReturnType<typeof setInterval> | undefined;
65
-
66
- /**
67
- * Which Archimedes tool rows the current session asks for, refreshed at every
68
- * `session_start` and read by the renderer resolver. The resolver itself is
69
- * registered once per extension load — pi keeps a list of resolvers per
70
- * extension, so registering it from `session_start` would stack a duplicate on
71
- * every `/reload` — which means the settings have to be read here rather than
72
- * inside the resolver: `renderCall`/`renderResult` run on every frame.
73
- */
74
- let styledTools = { bash: false, codemode: false };
75
-
76
- function clearSpinInterval(): void {
77
- if (spinInterval) {
78
- clearInterval(spinInterval);
79
- spinInterval = undefined;
80
- }
81
- }
59
+ // No module-scope session state (#156).
60
+ //
61
+ // pi caches an extension MODULE per process and re-invokes its factory with a
62
+ // fresh `pi` per session, so anything kept at module scope is shared by every
63
+ // session in the process — including a SUBORDINATE in-process session (a
64
+ // subagent child, bound with no uiContext, so `ctx.hasUI` is false). When that
65
+ // child is reaped its `session_shutdown` runs against this same module, and any
66
+ // ref it could write or clear was the LIVE main session's: the border spinner's
67
+ // interval cleared, `spinFlag` flipped off so the main session's own teardown
68
+ // stopped handing pi's working row back, and the splash's timers stopped through
69
+ // `stopStartupListing()`. Every one of those refs therefore lives in the
70
+ // `registerUI` closure below — one set per registration — which is what makes
71
+ // the child's teardown able to reach only the child's own state.
82
72
 
83
73
  /**
84
74
  * Draw the bash row through pi's renderer resolution (ADR 0029).
@@ -91,7 +81,10 @@ function clearSpinInterval(): void {
91
81
  * `createBashToolDefinition()` with our renderers bolted on — which also means
92
82
  * anything pi later adds to that tool cannot be silently dropped by us.
93
83
  */
94
- function registerToolRenderers(pi: ExtensionAPI): void {
84
+ function registerToolRenderers(
85
+ pi: ExtensionAPI,
86
+ styled: () => { bash: boolean; codemode: boolean },
87
+ ): void {
95
88
  // Capability check, not a version wish: on a host without the API there is
96
89
  // nothing to resolve through, and pi's own rows stand in.
97
90
  if (typeof pi.registerToolRenderer !== "function") return;
@@ -100,10 +93,18 @@ function registerToolRenderers(pi: ExtensionAPI): void {
100
93
  // `next()` is spread first so that whatever the built-in definition
101
94
  // declares besides the two renderers — `renderShell` is the one that matters
102
95
  // today — survives resolution instead of being silently dropped by us.
96
+ //
97
+ // `styled()` is read HERE rather than captured at registration: the resolver
98
+ // is registered once per `registerUI` (pi keeps a list per extension, so
99
+ // registering it from `session_start` would stack a duplicate on every
100
+ // `/reload`), while the settings are deliberately re-read every
101
+ // `session_start` so a change made between sessions takes effect. A getter,
102
+ // not a value, is what keeps those two lifetimes compatible.
103
+ const styledNow = styled();
103
104
  const ours =
104
- toolName === "bash" && styledTools.bash
105
+ toolName === "bash" && styledNow.bash
105
106
  ? { renderCall: renderBashCall, renderResult: renderBashResult }
106
- : toolName === "codemode" && styledTools.codemode
107
+ : toolName === "codemode" && styledNow.codemode
107
108
  ? { renderCall: renderCodemodeCall, renderResult: renderCodemodeResult }
108
109
  : undefined;
109
110
  return ours ? { ...next(), ...ours } : next();
@@ -119,8 +120,51 @@ export function registerUI(pi: ExtensionAPI): void {
119
120
  // would make /plugins report an enabled package that did nothing.
120
121
  reportPiSupport("ui", "1.2.0");
121
122
 
123
+ // ── This registration's session state (#156) ────────────────────────────────
124
+ //
125
+ // Everything a single session owns, held per `registerUI` call rather than at
126
+ // module scope — see the note above `registerToolRenderers`.
127
+
128
+ /** The splash ref this session installed (`undefined` = no splash on screen). */
129
+ let uiRef: ListingRef | undefined;
130
+ /** The ctx of the last `session_start` THIS registration handled. */
131
+ let uiCtx: ExtensionContext | undefined;
132
+ /** Whether this session hid pi's working row for the border spinner. */
133
+ let spinFlag = false;
134
+ /** The border spinner's interval, handed over by `HephaestusEditor`. */
135
+ let spinInterval: ReturnType<typeof setInterval> | undefined;
136
+ /**
137
+ * Which Archimedes tool rows this session asks for, refreshed at every
138
+ * `session_start` and read by the renderer resolver through the getter below
139
+ * (ADR 0029).
140
+ */
141
+ let styledTools = { bash: false, codemode: false };
142
+ /**
143
+ * Whether THIS registration put the splash in pi's header slot and has not
144
+ * handed it back — the ownership flag that scopes `stopStartupListing()`.
145
+ *
146
+ * `stopStartupListing()` stops the frame/reveal timers in
147
+ * `./startup/index.js`, and those two stay module-scope there on purpose (see
148
+ * the note in that file: one splash is on screen at a time, and pi disposes
149
+ * the header it is replacing before it builds the next, so a module pair of
150
+ * timers cannot leak a second animation). What must not be module-scope is the
151
+ * *decision to ask that pair to stop*: on a shutdown that does not own the
152
+ * splash — a subagent child reaped while the main session's logo is still
153
+ * animating — calling it freezes the parent's animation mid-reveal. So the
154
+ * call is gated on having installed the header ourselves, which is also why
155
+ * `startup/index.ts` needed no change at all.
156
+ */
157
+ let weInstalledTheSplash = false;
158
+
159
+ function clearSpinInterval(): void {
160
+ if (spinInterval) {
161
+ clearInterval(spinInterval);
162
+ spinInterval = undefined;
163
+ }
164
+ }
165
+
122
166
  // Registered once per extension load, before any session exists (ADR 0029).
123
- registerToolRenderers(pi);
167
+ registerToolRenderers(pi, () => styledTools);
124
168
 
125
169
  // One-time migration of any legacy core config to UI config
126
170
  migrateCoreToUIConfig();
@@ -137,6 +181,11 @@ export function registerUI(pi: ExtensionAPI): void {
137
181
 
138
182
  // session_shutdown handler (top-level to prevent accumulation on /reload)
139
183
  pi.on("session_shutdown", (_event, ctx) => {
184
+ // `uiCtx` is the closure ref of THIS registration, so the fallback can only
185
+ // ever find the ctx this instance last saw — a foreign session's shutdown
186
+ // cannot reach another instance's ctx through it (#156). The shape is kept
187
+ // rather than reduced to `ctx`: pi hands a ctx built off its own runner, and
188
+ // asserting its defaults verbatim is exactly what ADR 0031 refuses to do.
140
189
  const targetCtx = ctx?.hasUI ? ctx : uiCtx;
141
190
  if (targetCtx?.hasUI) {
142
191
  // Give back what this session took: the row we hid for the border spinner,
@@ -155,10 +204,17 @@ export function registerUI(pi: ExtensionAPI): void {
155
204
  spinFlag = false;
156
205
 
157
206
  // The splash: stop the frame/reveal timers, then freeze the ref so a late
158
- // render of a header still on screen cannot restart the animation. Nothing
159
- // else to undo — the startup listing interception and the console.log patch
160
- // this used to un-install are gone (pi 1.x alignment, Task 8).
161
- stopStartupListing();
207
+ // render of a header still on screen cannot restart the animation. Only if
208
+ // THIS session installed the header and has not handed it back — the timers
209
+ // `stopStartupListing()` stops are module state in `startup/index.ts`, so an
210
+ // unguarded call here let a subagent child's shutdown freeze the live main
211
+ // session's logo mid-reveal (#156). Nothing else to undo — the startup
212
+ // listing interception and the console.log patch this used to un-install are
213
+ // gone (pi 1.x alignment, Task 8).
214
+ if (weInstalledTheSplash) {
215
+ stopStartupListing();
216
+ weInstalledTheSplash = false;
217
+ }
162
218
  if (uiRef) {
163
219
  uiRef.settled = true;
164
220
  }
@@ -234,6 +290,9 @@ export function registerUI(pi: ExtensionAPI): void {
234
290
  if (!startupSplash) {
235
291
  ctx.ui.setHeader(undefined);
236
292
  uiRef = undefined;
293
+ // The slot is pi's again (or was never ours) — this session owns no
294
+ // splash, so its teardown must not stop the animation module's timers.
295
+ weInstalledTheSplash = false;
237
296
  } else if (quietStartup === true) {
238
297
  // pi's own header is an empty component in this mode. Handing ours back
239
298
  // is what keeps that silence: a splash left installed from an earlier
@@ -241,6 +300,7 @@ export function registerUI(pi: ExtensionAPI): void {
241
300
  // otherwise animate over it.
242
301
  ctx.ui.setHeader(undefined);
243
302
  uiRef = undefined;
303
+ weInstalledTheSplash = false;
244
304
  } else {
245
305
  // What the splash reveals after the logo. The version row belongs to
246
306
  // the header itself; the model scope is a startup *detail*, so it
@@ -293,6 +353,10 @@ export function registerUI(pi: ExtensionAPI): void {
293
353
  // component, whose timers then run until they settle themselves.
294
354
  dispose(): void {
295
355
  stopStartupListing();
356
+ // We no longer own the animation: pi is replacing (or removing)
357
+ // the header, so the timers are already stopped and a later
358
+ // `session_shutdown` has nothing left to stop here.
359
+ weInstalledTheSplash = false;
296
360
  },
297
361
  };
298
362
  patchStartupListing(tui, ref);
@@ -300,6 +364,10 @@ export function registerUI(pi: ExtensionAPI): void {
300
364
  return comp;
301
365
  };
302
366
  ctx.ui.setHeader(headerFactory);
367
+ // From here on this registration owns the splash: it is the one that put
368
+ // the animation in pi's header slot, so it is the only one allowed to
369
+ // stop it on the way out (`session_shutdown`, or `dispose()` above).
370
+ weInstalledTheSplash = true;
303
371
  }
304
372
 
305
373
  clearSpinInterval();
@@ -0,0 +1,317 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import type { Mock } from "vitest";
3
+ import type {
4
+ ExtensionAPI,
5
+ ExtensionContext,
6
+ Theme,
7
+ KeybindingsManager,
8
+ } from "@earendil-works/pi-coding-agent";
9
+ import type { TUI, EditorTheme } from "@earendil-works/pi-tui";
10
+
11
+ // ── One module instance, two sessions (#156) ────────────────────────────────
12
+ //
13
+ // pi caches an extension MODULE per process and re-invokes its factory with a
14
+ // fresh `pi` per session. Anything `registerUI` keeps at module scope is
15
+ // therefore shared by every session in the process, and a SUBORDINATE in-process
16
+ // session — a subagent child, bound with no uiContext, so `hasUI` is false —
17
+ // tears the LIVE main session's state down when it is reaped: its
18
+ // `session_shutdown` clears the spinner interval the main session's border
19
+ // spinner is running on, stops the main session's splash animation, and can hand
20
+ // back the main session's editor row through a ref the child overwrote.
21
+ //
22
+ // The three cases below drive TWO registrations against ONE module instance and
23
+ // fake the clock, because the damage is a timer that stops: `index.test.ts` runs
24
+ // on real timers and so cannot see the spinner handle at all — which is exactly
25
+ // why the leak survived it. `startup-splash-gate.test.ts` uses fake timers but
26
+ // stubs nothing in `./startup/index.js`, and neither does this file: "the timer
27
+ // is still armed" is only evidence when the real animation module armed it.
28
+
29
+ vi.mock("@pi-archimedes/core/settings-io", async (importOriginal) => {
30
+ const actual = await importOriginal<typeof import("@pi-archimedes/core/settings-io")>();
31
+ return {
32
+ ...actual,
33
+ loadConfig: vi.fn(),
34
+ saveConfig: vi.fn(),
35
+ removeConfig: vi.fn(),
36
+ };
37
+ });
38
+
39
+ vi.mock("./config.js", async (importOriginal) => {
40
+ const actual = await importOriginal<typeof import("./config.js")>();
41
+ return { ...actual, loadUIConfig: vi.fn() };
42
+ });
43
+
44
+ // Not under test here, and both touch state outside this module (pi's thinking
45
+ // renderer prototype, message content).
46
+ vi.mock("./thinking/patch.js", () => ({ patchThinkingRenderer: vi.fn() }));
47
+ vi.mock("./thinking/transform.js", () => ({ transformThinkingContent: vi.fn() }));
48
+ vi.mock("./migration.js", () => ({
49
+ migrateCoreToUIConfig: vi.fn(),
50
+ migrateCompactThinkingToStyle: vi.fn(),
51
+ migrateRemovedToolPatch: vi.fn(),
52
+ UI_CONFIG_KEYS: [],
53
+ }));
54
+
55
+ import { registerUI } from "./index.js";
56
+ import { loadUIConfig, DEFAULT_UI_CONFIG, type UIConfig } from "./config.js";
57
+ import { stopStartupListing } from "./startup/index.js";
58
+
59
+ // ── Harness ─────────────────────────────────────────────────────────────────
60
+
61
+ /** What a session's `ui` surface must offer; everything else answers a no-op. */
62
+ interface UiSpies {
63
+ setHeader: Mock;
64
+ setEditorComponent: Mock;
65
+ setWorkingVisible: Mock;
66
+ setHiddenThinkingLabel: Mock;
67
+ notify: Mock;
68
+ onTerminalInput: Mock;
69
+ }
70
+
71
+ interface Session {
72
+ /** The per-session `pi` handle pi builds for a runner. */
73
+ pi: ExtensionAPI;
74
+ /** The ctx pi hands that runner's handlers. */
75
+ ctx: ExtensionContext;
76
+ ui: UiSpies;
77
+ start: (uiConfig?: Partial<UIConfig>) => void;
78
+ /**
79
+ * Fire `session_shutdown`. `withCtx` replaces the ctx pi hands the handler —
80
+ * pass a UI-less one to model a shutdown that arrives after the UI context is
81
+ * gone, which is the case the `uiCtx` fallback exists for.
82
+ */
83
+ shutdown: (withCtx?: unknown) => void;
84
+ }
85
+
86
+ /**
87
+ * One registration + one session, driven the way pi drives it: `registerUI(pi)`
88
+ * with a fresh `pi`, then `session_start` / `session_shutdown` with that
89
+ * session's own ctx (`emit()` builds a ctx off the runner and passes it, so the
90
+ * ctx a handler sees belongs to the session that emitted the event).
91
+ */
92
+ function makeSession(hasUI: boolean): Session {
93
+ const listeners: Record<string, ((event: any, ctx: any) => void)[]> = {};
94
+ const pi = {
95
+ on: vi.fn((event: string, handler: (event: any, ctx: any) => void) => {
96
+ (listeners[event] ??= []).push(handler);
97
+ }),
98
+ registerTool: vi.fn(),
99
+ registerToolRenderer: vi.fn(),
100
+ // `quietStartup` is what the splash gate reads out of this; `{}` is pi's
101
+ // default (`false` = header and details shown).
102
+ getSettings: vi.fn(() => ({})),
103
+ } as unknown as ExtensionAPI;
104
+
105
+ const ui: UiSpies = {
106
+ setHeader: vi.fn(),
107
+ setEditorComponent: vi.fn(),
108
+ setWorkingVisible: vi.fn(),
109
+ setHiddenThinkingLabel: vi.fn(),
110
+ notify: vi.fn(),
111
+ onTerminalInput: vi.fn(() => () => {}),
112
+ };
113
+ // The `ui` object pi hands a context is a fixed surface; anything this suite
114
+ // has not spelled out answers an inert spy rather than throwing.
115
+ const uiSurface = new Proxy(ui as unknown as Record<string, unknown>, {
116
+ get(target, prop) {
117
+ if (prop === "theme") return theme;
118
+ if (prop in target) return target[prop as string];
119
+ return vi.fn();
120
+ },
121
+ has: () => true,
122
+ });
123
+
124
+ const ctx = {
125
+ hasUI,
126
+ mode: hasUI ? "tui" : "print",
127
+ cwd: "/workspace/test-project",
128
+ ui: uiSurface,
129
+ isIdle: vi.fn(() => true),
130
+ shutdown: vi.fn(),
131
+ scopedModels: [],
132
+ } as unknown as ExtensionContext;
133
+
134
+ const fire = (
135
+ event: string,
136
+ config?: Partial<UIConfig>,
137
+ eventCtx: unknown = ctx,
138
+ ): void => {
139
+ if (event === "session_start") {
140
+ vi.mocked(loadUIConfig).mockReturnValue({
141
+ ...DEFAULT_UI_CONFIG,
142
+ ...config,
143
+ } as UIConfig);
144
+ }
145
+ for (const handler of listeners[event] ?? []) {
146
+ handler({ type: event, reason: "quit" }, eventCtx);
147
+ }
148
+ };
149
+
150
+ return {
151
+ pi,
152
+ ctx,
153
+ ui,
154
+ start: (config) => fire("session_start", config),
155
+ shutdown: (eventCtx) => fire("session_shutdown", undefined, eventCtx),
156
+ };
157
+ }
158
+
159
+ const theme = {
160
+ fg: (_token: string, text: string) => text,
161
+ bg: (_token: string, text: string) => text,
162
+ bold: (text: string) => text,
163
+ getBgAnsi: () => "",
164
+ } as unknown as Theme;
165
+
166
+ /** A renderer the editor and the splash can both be handed. */
167
+ function makeTui(): TUI & { requestRender: Mock } {
168
+ return {
169
+ terminal: { rows: 24, cols: 80 },
170
+ requestRender: vi.fn(),
171
+ add: vi.fn(),
172
+ remove: vi.fn(),
173
+ start: vi.fn(),
174
+ stop: vi.fn(),
175
+ } as unknown as TUI & { requestRender: Mock };
176
+ }
177
+
178
+ /** Build the editor pi was handed, so the border spinner's interval is armed. */
179
+ function buildEditor(session: Session, tui: TUI): { dispose(): void } {
180
+ const factory = session.ui.setEditorComponent.mock.calls[0]![0] as (
181
+ tui: TUI,
182
+ editorTheme: EditorTheme,
183
+ keybindings: KeybindingsManager,
184
+ ) => { dispose(): void };
185
+ return factory(tui, { borderColor: (s: string) => s } as unknown as EditorTheme, {} as KeybindingsManager);
186
+ }
187
+
188
+ /** Build the header pi was handed, i.e. put the splash where pi puts it. */
189
+ function buildHeader(session: Session, tui: TUI): { dispose(): void } {
190
+ const factory = session.ui.setHeader.mock.calls.at(-1)![0] as (
191
+ tui: TUI,
192
+ theme: Theme,
193
+ ) => { dispose(): void };
194
+ return factory(tui, theme);
195
+ }
196
+
197
+ describe("session isolation: one module instance, several sessions (#156)", () => {
198
+ beforeEach(() => {
199
+ vi.clearAllMocks();
200
+ vi.useFakeTimers();
201
+ });
202
+
203
+ afterEach(() => {
204
+ // The splash's timers are module state in `startup/index.ts`; a test that
205
+ // leaves one armed would leak it into the next file's timer counts.
206
+ stopStartupListing();
207
+ vi.useRealTimers();
208
+ });
209
+
210
+ it("a foreign session's shutdown does not stop the main session's border spinner", () => {
211
+ // The main session: interactive, spinner on, no splash (so the ONLY timer in
212
+ // the process is the border spinner's).
213
+ const main = makeSession(true);
214
+ registerUI(main.pi);
215
+ main.start({ startupSplash: false, editorSpinBorder: true });
216
+
217
+ const tui = makeTui();
218
+ buildEditor(main, tui);
219
+ const armed = vi.getTimerCount();
220
+ expect(armed, "the harness armed no timer — the editor factory was never invoked").toBeGreaterThan(0);
221
+
222
+ // A subagent child in the SAME process: registered through the same module,
223
+ // bound with no uiContext (`hasUI: false`), and now reaped by its parent.
224
+ const child = makeSession(false);
225
+ registerUI(child.pi);
226
+ child.start();
227
+ child.shutdown();
228
+
229
+ // The child's teardown killed nothing of the main session's.
230
+ expect(vi.getTimerCount()).toBe(armed);
231
+
232
+ // The control: the same shutdown on the session that OWNS the spinner does
233
+ // clear it, so the assertion above is not passing because nothing was ever
234
+ // armed or because the handler never runs.
235
+ main.shutdown();
236
+ expect(vi.getTimerCount()).toBe(0);
237
+ });
238
+
239
+ it("a UI-less session_start does not replace the main session's ctx", () => {
240
+ // Main: interactive, spinner on. It hid pi's working row and installed our
241
+ // editor — both of which its own teardown has to hand back.
242
+ const main = makeSession(true);
243
+ registerUI(main.pi);
244
+ main.start({ startupSplash: false, editorSpinBorder: true });
245
+ buildEditor(main, makeTui());
246
+ expect(main.ui.setWorkingVisible).toHaveBeenLastCalledWith(false);
247
+
248
+ // The UI-less session starts (the subagent child's `session_start`), and is
249
+ // then reaped. Its `session_start` is the assignment that, at module scope,
250
+ // moved the ref every later handler falls back to.
251
+ const child = makeSession(false);
252
+ registerUI(child.pi);
253
+ child.start();
254
+ child.shutdown();
255
+
256
+ // Nothing of the handback happened on the main ctx while the child was being
257
+ // reaped — the child never took any of it.
258
+ expect(main.ui.setEditorComponent).not.toHaveBeenCalledWith(undefined);
259
+ expect(main.ui.setWorkingVisible).not.toHaveBeenCalledWith(true);
260
+
261
+ // Now the main session's own shutdown: the handback reaches the main ctx,
262
+ // and it still happens — the spinner was on when the child tore itself down,
263
+ // which at module scope had flipped the shared "we hid the row" flag off.
264
+ main.shutdown();
265
+ expect(main.ui.setEditorComponent).toHaveBeenLastCalledWith(undefined);
266
+ expect(main.ui.setWorkingVisible).toHaveBeenLastCalledWith(true);
267
+
268
+ // The same teardown arriving with a UI-less ctx (pi hands the ctx built off
269
+ // its runner, so this is what a shutdown looks like once the UI context is
270
+ // gone): the handler falls back to the ref it kept, and that ref has to be
271
+ // THIS registration's main ctx. At module scope the child's `session_start`
272
+ // had overwritten it, so the handback went to the child instead.
273
+ const before = main.ui.setEditorComponent.mock.calls.length;
274
+ main.shutdown({ hasUI: false, ui: main.ui, isIdle: () => true });
275
+ expect(main.ui.setEditorComponent.mock.calls.length).toBe(before + 1);
276
+ expect(main.ui.setEditorComponent).toHaveBeenLastCalledWith(undefined);
277
+ // And it stayed off the child's ctx, which has nothing of ours to hand back.
278
+ expect(child.ui.setEditorComponent).not.toHaveBeenCalled();
279
+ });
280
+
281
+ it("the splash's dispose() path survives a foreign shutdown, and the splash outlives one", () => {
282
+ // Main with the splash on: pi's header slot holds our animated header, so
283
+ // the real animation module has its frame + reveal timers armed.
284
+ const main = makeSession(true);
285
+ registerUI(main.pi);
286
+ main.start({ startupSplash: true, editorSpinBorder: false });
287
+ const tui = makeTui();
288
+ const comp = buildHeader(main, tui);
289
+ const armed = vi.getTimerCount();
290
+ expect(armed, "the harness armed no splash timer — no header was built").toBeGreaterThan(0);
291
+
292
+ const child = makeSession(false);
293
+ registerUI(child.pi);
294
+ child.start();
295
+
296
+ // The child's shutdown must not stop an animation it never installed.
297
+ child.shutdown();
298
+ expect(vi.getTimerCount()).toBe(armed);
299
+
300
+ // pi replaces or removes the header → the component's `dispose()` runs, and
301
+ // that IS ours to act on: it stops the splash and records that we no longer
302
+ // own it.
303
+ expect(() => comp.dispose()).not.toThrow();
304
+ expect(vi.getTimerCount()).toBe(0);
305
+
306
+ // The plan's ordering, and the one no existing test combines: a foreign
307
+ // shutdown AFTER our own dispose() handed the animation back. Nothing to
308
+ // stop, nothing to throw.
309
+ expect(() => child.shutdown()).not.toThrow();
310
+ expect(vi.getTimerCount()).toBe(0);
311
+
312
+ // And the main session's own teardown, with the splash already disposed, is
313
+ // still safe.
314
+ expect(() => main.shutdown()).not.toThrow();
315
+ expect(vi.getTimerCount()).toBe(0);
316
+ });
317
+ });