@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 +2 -2
- package/src/index.test.ts +7 -4
- package/src/index.ts +99 -31
- package/src/session-isolation.test.ts +317 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pi-archimedes/ui",
|
|
3
|
-
"version": "2.
|
|
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.
|
|
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
|
|
710
|
-
//
|
|
711
|
-
//
|
|
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).
|
|
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
|
-
//
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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(
|
|
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" &&
|
|
105
|
+
toolName === "bash" && styledNow.bash
|
|
105
106
|
? { renderCall: renderBashCall, renderResult: renderBashResult }
|
|
106
|
-
: toolName === "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.
|
|
159
|
-
//
|
|
160
|
-
//
|
|
161
|
-
|
|
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
|
+
});
|