gentle-shell-bridge 0.3.0 → 0.3.1

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.
Files changed (75) hide show
  1. package/LICENSE +202 -0
  2. package/docs/contract.md +1 -1
  3. package/package.json +13 -1
  4. package/src/api.ts +1 -1
  5. package/.codegraph/.gitignore +0 -5
  6. package/PRD.md +0 -144
  7. package/examples/bash-card.ts +0 -1694
  8. package/gentle-shell-bridge-0.3.0.tgz +0 -0
  9. package/odd/tasks/palette-sections.md +0 -30
  10. package/odd/tasks/plugin-keymap.md +0 -32
  11. package/odd/tasks/plugin-surface-contract.md +0 -55
  12. package/test/api.test.ts +0 -395
  13. package/test/apply-on-move.test.ts +0 -477
  14. package/test/arm.test.ts +0 -220
  15. package/test/bridge-ready.test.ts +0 -309
  16. package/test/bus.test.ts +0 -165
  17. package/test/button-rows.test.ts +0 -409
  18. package/test/capture.test.ts +0 -356
  19. package/test/card-style-rows.test.ts +0 -510
  20. package/test/card-style-transform.test.ts +0 -379
  21. package/test/card-style-wiring.test.ts +0 -258
  22. package/test/card-styles-e2e.test.ts +0 -491
  23. package/test/card-styles.test.ts +0 -324
  24. package/test/card-transcript-path.test.ts +0 -178
  25. package/test/config.test.ts +0 -93
  26. package/test/e2e.test.ts +0 -443
  27. package/test/example-bash-identity.test.ts +0 -372
  28. package/test/example-bash-matrix.test.ts +0 -856
  29. package/test/example-designs.test.ts +0 -734
  30. package/test/example-neon-transform.test.ts +0 -283
  31. package/test/example-plain-style.test.ts +0 -1268
  32. package/test/extension.test.ts +0 -160
  33. package/test/fake-events.ts +0 -58
  34. package/test/fake-host.ts +0 -211
  35. package/test/fake-sidebar.ts +0 -516
  36. package/test/fake-transcript.ts +0 -486
  37. package/test/frame-telemetry.test.ts +0 -417
  38. package/test/hook.test.ts +0 -196
  39. package/test/manifest.test.ts +0 -105
  40. package/test/new-category.test.ts +0 -979
  41. package/test/notices.test.ts +0 -328
  42. package/test/plugin-integration-lines.test.ts +0 -979
  43. package/test/plugin-surface.test.ts +0 -152
  44. package/test/presence.test.ts +0 -459
  45. package/test/rail-surfaces.test.ts +0 -457
  46. package/test/rail-width.test.ts +0 -423
  47. package/test/regression.test.ts +0 -483
  48. package/test/repaint-api.test.ts +0 -200
  49. package/test/reserved-categories.test.ts +0 -361
  50. package/test/row-labels.test.ts +0 -284
  51. package/test/row-names.test.ts +0 -473
  52. package/test/row-origin.test.ts +0 -395
  53. package/test/section-interaction.test.ts +0 -364
  54. package/test/section-panel.test.ts +0 -403
  55. package/test/sections-rows.test.ts +0 -291
  56. package/test/sidebar-backend.test.ts +0 -471
  57. package/test/sidebar-e2e.test.ts +0 -331
  58. package/test/sidebar-hover.test.ts +0 -276
  59. package/test/sidebar-independent.test.ts +0 -349
  60. package/test/sidebar-render.test.ts +0 -281
  61. package/test/sidebar-sections.test.ts +0 -245
  62. package/test/status-card-styles.test.ts +0 -201
  63. package/test/status-section-frame.test.ts +0 -181
  64. package/test/status-sections.test.ts +0 -243
  65. package/test/status.test.ts +0 -209
  66. package/test/store.test.ts +0 -192
  67. package/test/surface-repaint-cost.test.ts +0 -520
  68. package/test/tool-design-chain.test.ts +0 -483
  69. package/test/tool-design-cost.test.ts +0 -473
  70. package/test/tool-design-rows.test.ts +0 -359
  71. package/test/tool-designs.test.ts +0 -745
  72. package/test/transform-pipeline.test.ts +0 -465
  73. package/test/transforms-bus.test.ts +0 -233
  74. package/test/verifier-width-regressions.test.ts +0 -164
  75. package/tsconfig.json +0 -18
@@ -1,979 +0,0 @@
1
- import assert from "node:assert/strict";
2
- import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
- import { tmpdir } from "node:os";
4
- import { dirname, join } from "node:path";
5
- import test from "node:test";
6
- import { pathToFileURL } from "node:url";
7
-
8
- import { bridgeApi, publishBridgeApi } from "../src/api.ts";
9
- import {
10
- registerRows,
11
- resetBusForTests,
12
- resetCardStylesForTests,
13
- resetSectionsForTests,
14
- resetTransformsForTests,
15
- snapshotSections,
16
- } from "../src/bus.ts";
17
- import gentleShellBridge, {
18
- type BridgeCommandContext,
19
- type BridgeCommandHandler,
20
- type BridgeExtensionApi,
21
- type BridgeSessionHandler,
22
- } from "../src/extension.ts";
23
- import {
24
- HOST_OWN_STATUS_KEYS,
25
- PLUGIN_LINE_PREFIX,
26
- derivePluginName,
27
- getPluginLineReport,
28
- installPluginStatus,
29
- resetPluginLinesForTests,
30
- } from "../src/plugin-lines.ts";
31
- import { rowNameProblem } from "../src/row-names.ts";
32
- import { setRepaintHost, resetRepaintForTests } from "../src/repaint.ts";
33
- import { resetSectionPanelForTests, setSectionTheme } from "../src/section-panel.ts";
34
- import { resetSectionPointerForTests } from "../src/section-pointer.ts";
35
- import {
36
- SIDEBAR_RAIL_KEY,
37
- disableSidebarBackend,
38
- installSidebarBackend,
39
- resetSidebarStateForTests,
40
- } from "../src/sidebar.ts";
41
- import { installSectionRail, resetSectionFailuresForTests } from "../src/sidebar-render.ts";
42
- import { collectStatus, formatStatus } from "../src/status.ts";
43
- import { resetStoreStateForTests } from "../src/store.ts";
44
- import {
45
- fakeState,
46
- fakeTerminal,
47
- fakeTui,
48
- registerNativeFooter,
49
- renderSidebar,
50
- resetSidebar,
51
- } from "./fake-sidebar.ts";
52
-
53
- /**
54
- * The integration line is proven against a FAKE footer: an object with `setStatus(key, text)` that
55
- * stores what it was handed, which is the whole of Pi's documented contract
56
- * (`docs/extensions.md:2895-2896`) and the whole of what the bridge can observe. The host's real
57
- * map, its sort order and the shared status role it paints with are read-only evidence from
58
- * gentle-shell (`extensions/gentle-shell.ts:357-385`, `lib/shell-bar.ts:289-298`), not something
59
- * these tests can execute.
60
- *
61
- * Attribution is NOT faked: every plugin below is a real module in its own temporary package, so
62
- * the caller frame, the `package.json` walk and the per-module cache run exactly as they do in Pi.
63
- */
64
-
65
- interface FakeFooter {
66
- ui: { setStatus(key: string, text?: string): void; notify(): void };
67
- statuses: Map<string, string>;
68
- calls: Array<{ key: string; text?: string }>;
69
- }
70
-
71
- const createFakeFooter = (initial: Record<string, string> = {}): FakeFooter => {
72
- const statuses = new Map<string, string>(Object.entries(initial));
73
- const calls: Array<{ key: string; text?: string }> = [];
74
- return {
75
- statuses,
76
- calls,
77
- ui: {
78
- setStatus(key, text) {
79
- calls.push({ key, text });
80
- if (text === undefined) statuses.delete(key);
81
- else statuses.set(key, text);
82
- },
83
- notify() {
84
- // A plugin may deliver notices through the same ui; this footer only records rows.
85
- },
86
- },
87
- };
88
- };
89
-
90
- interface FakePi {
91
- api: BridgeExtensionApi;
92
- sessions: BridgeSessionHandler[];
93
- shutdowns: BridgeSessionHandler[];
94
- commands: Array<{ name: string; handler: BridgeCommandHandler }>;
95
- }
96
-
97
- const createFakePi = (): FakePi => {
98
- const sessions: BridgeSessionHandler[] = [];
99
- const shutdowns: BridgeSessionHandler[] = [];
100
- const commands: Array<{ name: string; handler: BridgeCommandHandler }> = [];
101
- const api: BridgeExtensionApi = {
102
- // `gentle-pi` is present, so `session_start` installs the INTEGRATIONS surface (S9/T12).
103
- getCommands: () => [{ name: "gentle:customize" }],
104
- on(event, handler) {
105
- if (event === "session_start") sessions.push(handler);
106
- else if (event === "session_shutdown") shutdowns.push(handler);
107
- else assert.fail(`the extension must not listen to ${String(event)}`);
108
- },
109
- registerCommand(name, command) {
110
- commands.push({ name, handler: command.handler });
111
- },
112
- };
113
- return { api, sessions, shutdowns, commands };
114
- };
115
-
116
- // ---------------------------------------------------------------------------------------------
117
- // Real plugin modules in their own packages, outside the worktree
118
- // ---------------------------------------------------------------------------------------------
119
-
120
- const scratchDirs: string[] = [];
121
- let fixtureCount = 0;
122
-
123
- const PLUGIN_SOURCE_HEAD = `const api = globalThis[Symbol.for("gentle-shell-bridge/api/v1")];\n`;
124
-
125
- /**
126
- * Write one plugin module into a fresh temporary package. `packageName: null` writes no manifest, so
127
- * the module has no owning package at all — the fail-closed case I2 describes.
128
- */
129
- const writePlugin = (packageName: string | null, body: string): string => {
130
- fixtureCount += 1;
131
- const root = mkdtempSync(join(tmpdir(), "gsb-plugin-lines-"));
132
- scratchDirs.push(root);
133
- const dir = join(root, `plugin-${String(fixtureCount)}`);
134
- mkdirSync(dir, { recursive: true });
135
- if (packageName !== null) {
136
- writeFileSync(join(dir, "package.json"), JSON.stringify({ name: packageName, type: "module" }));
137
- }
138
- const file = join(dir, `plugin-${String(fixtureCount)}.mjs`);
139
- writeFileSync(file, `${PLUGIN_SOURCE_HEAD}${body}\n`);
140
- return file;
141
- };
142
-
143
- const loadPlugin = async (file: string): Promise<void> => {
144
- await import(`${pathToFileURL(file).href}?load=${String(Date.now())}-${String(fixtureCount)}`);
145
- };
146
-
147
- /** One temporary package root, with the manifest (and any extra fields) a real one would have. */
148
- const writePackageRoot = (
149
- packageName: string,
150
- manifestExtra: Record<string, unknown> = {},
151
- ): string => {
152
- const root = mkdtempSync(join(tmpdir(), "gsb-plugin-identity-"));
153
- scratchDirs.push(root);
154
- writeFileSync(
155
- join(root, "package.json"),
156
- JSON.stringify({ name: packageName, type: "module", ...manifestExtra }),
157
- );
158
- return root;
159
- };
160
-
161
- /** A plugin module at a relative path inside an existing package root (no new identity). */
162
- const writeModuleAt = (root: string, relative: string, body: string): string => {
163
- fixtureCount += 1;
164
- const file = join(root, relative);
165
- mkdirSync(dirname(file), { recursive: true });
166
- writeFileSync(file, `${PLUGIN_SOURCE_HEAD}${body}\n`);
167
- return file;
168
- };
169
-
170
- /** Four registrations, four surfaces, one plugin: the I1 case in its strongest form. */
171
- const FOUR_SURFACES = `
172
- api.registerRows({ id: "loon.rows", category: "Banner", rows: () => [] });
173
- api.registerSection({ id: "loon.section", title: "Loon", lines: () => ["loon"] });
174
- api.registerCardStyle({ id: "loon.plain", label: "plain", frame: { top: () => "", line: () => "", bottom: () => "" } });
175
- api.registerTransform({ id: "loon.restyle", transform: (lines) => lines });
176
- `;
177
-
178
- test.beforeEach(() => {
179
- resetBusForTests();
180
- resetSectionsForTests();
181
- resetCardStylesForTests();
182
- resetTransformsForTests();
183
- resetPluginLinesForTests();
184
- resetStoreStateForTests();
185
- });
186
-
187
- test.after(() => {
188
- for (const dir of scratchDirs) rmSync(dir, { recursive: true, force: true });
189
- });
190
-
191
- test("a plugin with four registrations gets exactly one line", async () => {
192
- const footer = createFakeFooter();
193
- installPluginStatus(footer.ui);
194
-
195
- await loadPlugin(writePlugin("@acme/loon-kit", FOUR_SURFACES));
196
-
197
- assert.equal(footer.statuses.size, 1, "four registrations, one footer entry");
198
- const [entry] = [...footer.statuses.entries()];
199
- assert.equal(entry[1], "Loon kit · ready", "the row is the plugin name plus its derived state");
200
- assert.equal(
201
- new Set(footer.calls.map((call) => call.key)).size,
202
- 1,
203
- "every repaint uses the same key, so the host updates one row instead of adding four",
204
- );
205
- assert.equal(footer.calls.length, 1, "an unchanged status is not repainted: one host call");
206
- const report = getPluginLineReport();
207
- assert.equal(report.plugins.length, 1);
208
- assert.equal(report.plugins[0].accepted, 4);
209
- assert.equal(report.plugins[0].rejected.length, 0);
210
- assert.equal(report.plugins[0].nameSource, "derived");
211
- assert.equal(report.plugins[0].status, "ready");
212
- });
213
-
214
- test("two plugins get two lines with distinct keys", async () => {
215
- const footer = createFakeFooter();
216
- installPluginStatus(footer.ui);
217
-
218
- await loadPlugin(
219
- writePlugin(
220
- "@acme/loon-kit",
221
- `api.registerRows({ id: "loon.rows", category: "Banner", rows: () => [] });`,
222
- ),
223
- );
224
- await loadPlugin(
225
- writePlugin(
226
- "other-tools",
227
- `api.registerSection({ id: "other.section", title: "Other", lines: () => [] });`,
228
- ),
229
- );
230
-
231
- assert.deepEqual([...footer.statuses.values()].sort(), [
232
- "Loon kit · ready",
233
- "Other tools · ready",
234
- ]);
235
- assert.equal(new Set(footer.statuses.keys()).size, 2);
236
- });
237
-
238
- test("an explicit name on the first registration wins over the package name", async () => {
239
- const footer = createFakeFooter();
240
- installPluginStatus(footer.ui);
241
-
242
- await loadPlugin(
243
- writePlugin(
244
- "acme-loon",
245
- `api.registerRows({ id: "loon.rows", category: "Banner", name: "Loon kit", rows: () => [] });`,
246
- ),
247
- );
248
-
249
- assert.deepEqual([...footer.statuses.values()], ["Loon kit · ready"]);
250
- const report = getPluginLineReport();
251
- assert.equal(report.plugins[0].nameSource, "explicit");
252
- });
253
-
254
- test("a derived name is cleaned of scope and marks", async () => {
255
- const footer = createFakeFooter();
256
- installPluginStatus(footer.ui);
257
-
258
- await loadPlugin(
259
- writePlugin(
260
- "@acme/loon-kit",
261
- `api.registerRows({ id: "loon.rows", category: "Banner", rows: () => [] });`,
262
- ),
263
- );
264
-
265
- assert.deepEqual([...footer.statuses.values()], ["Loon kit · ready"]);
266
- assert.equal(getPluginLineReport().plugins[0].packageName, "@acme/loon-kit");
267
- });
268
-
269
- test("the derived name is clamped to three words, stripped of marks, and always conforms", () => {
270
- assert.equal(derivePluginName("@acme/loon-kit"), "Loon kit");
271
- assert.equal(derivePluginName("a-b-c-d-e"), "A b c");
272
- assert.equal(derivePluginName("!!!"), undefined);
273
- for (const packageName of ["@acme/loon-kit", "gentle.shell.bridge", "a-b-c-d-e"]) {
274
- const derived = derivePluginName(packageName);
275
- assert.ok(derived !== undefined);
276
- assert.equal(rowNameProblem(derived), undefined, `"${derived}" must satisfy the shape rule`);
277
- }
278
- });
279
-
280
- test("a package name with no usable words gets no line, and says why", async () => {
281
- const footer = createFakeFooter();
282
- installPluginStatus(footer.ui);
283
-
284
- await loadPlugin(
285
- writePlugin("!!!", `api.registerRows({ id: "loon.rows", category: "Banner", rows: () => [] });`),
286
- );
287
-
288
- assert.equal(footer.statuses.size, 0, "fail closed: no invented name");
289
- const report = getPluginLineReport();
290
- assert.equal(report.plugins.length, 1, "the registration itself still happened");
291
- assert.equal(report.plugins[0].name, undefined);
292
- assert.equal(report.plugins[0].accepted, 1);
293
- assert.match(report.plugins[0].reason ?? "", /no usable words/u);
294
- const printed = formatStatus(collectStatus()).join("\n");
295
- assert.match(printed, /^plugin lines: 1$/m);
296
- assert.match(printed, /\(no line\)/u);
297
- assert.match(printed, /no usable words/u);
298
- });
299
-
300
- test("a module with no owning package gets no line, and says why", async () => {
301
- const footer = createFakeFooter();
302
- installPluginStatus(footer.ui);
303
- const file = writePlugin(
304
- null,
305
- `api.registerRows({ id: "loose.rows", category: "Banner", rows: () => [] });`,
306
- );
307
-
308
- await loadPlugin(file);
309
-
310
- assert.equal(footer.statuses.size, 0);
311
- const printed = formatStatus(collectStatus()).join("\n");
312
- assert.match(printed, /\(no line\)/u);
313
- assert.match(printed, /no package\.json/u);
314
- // The module path is a DIAGNOSTIC here, never a row name: the footer stayed empty.
315
- assert.ok(printed.includes(file), "the report names the module so the author can find it");
316
- });
317
-
318
- test("an unattributed module can still name itself explicitly", async () => {
319
- const footer = createFakeFooter();
320
- installPluginStatus(footer.ui);
321
-
322
- await loadPlugin(
323
- writePlugin(
324
- null,
325
- `api.registerRows({ id: "loose.rows", category: "Banner", name: "Bash card", rows: () => [] });`,
326
- ),
327
- );
328
-
329
- assert.deepEqual([...footer.statuses.values()], ["Bash card · ready"]);
330
- });
331
-
332
- test("a rejected registration degrades the line and names the reason in the report", async () => {
333
- const footer = createFakeFooter();
334
- installPluginStatus(footer.ui);
335
-
336
- await loadPlugin(
337
- writePlugin(
338
- "@acme/loon-kit",
339
- `
340
- api.registerSection({ id: "loon.bad", title: "Bad", rowLabel: "Loon → kit", lines: () => [] });
341
- try {
342
- api.registerSection({ id: "", title: "Bad id", lines: () => [] });
343
- } catch {}
344
- `,
345
- ),
346
- );
347
-
348
- const texts = [...footer.statuses.values()];
349
- assert.deepEqual(texts, ["Loon kit · degraded"], "one row, and it does not promise ready");
350
- const report = getPluginLineReport();
351
- assert.equal(report.plugins[0].accepted, 0);
352
- assert.equal(report.plugins[0].rejected.length, 2);
353
- const printed = formatStatus(collectStatus()).join("\n");
354
- assert.match(printed, /"Loon kit" \(derived from package "@acme\/loon-kit"\): degraded, 0 accepted, 2 rejected/u);
355
- assert.match(printed, /Loon → kit/u, "the rejected label is printed verbatim");
356
- assert.match(printed, /invalid section id/u, "the invalid id keeps the bus's own message");
357
- });
358
-
359
- test("the same key is updated in place when the derived status changes", async () => {
360
- const footer = createFakeFooter();
361
- installPluginStatus(footer.ui);
362
-
363
- await loadPlugin(
364
- writePlugin(
365
- "@acme/loon-kit",
366
- `
367
- api.registerSection({ id: "loon.section", title: "Loon", lines: () => [] });
368
- try {
369
- api.registerRows({ id: "loon.rows", category: "Banner", rows: () => [] });
370
- api.registerSection({ id: "", title: "Bad id", lines: () => [] });
371
- } catch {}
372
- `,
373
- ),
374
- );
375
-
376
- assert.equal(footer.statuses.size, 1, "still one row");
377
- assert.deepEqual([...footer.statuses.values()], ["Loon kit · degraded"]);
378
- const keys = new Set(footer.calls.map((call) => call.key));
379
- assert.equal(keys.size, 1, "the same key was reused for the update");
380
- assert.deepEqual(
381
- footer.calls.map((call) => call.text),
382
- ["Loon kit · ready", "Loon kit · degraded"],
383
- "the unchanged registrations cost no repaint; the change repaints the same key",
384
- );
385
- });
386
-
387
- test("an explicit name that breaks the shape rule gets no line, never a rejected name", async () => {
388
- const footer = createFakeFooter();
389
- installPluginStatus(footer.ui);
390
-
391
- await loadPlugin(
392
- writePlugin(
393
- "@acme/loon-kit",
394
- `api.registerRows({ id: "loon.rows", category: "Banner", name: "Loon → kit", rows: () => [] });`,
395
- ),
396
- );
397
-
398
- assert.equal(footer.statuses.size, 0);
399
- const record = getPluginLineReport().plugins[0];
400
- assert.equal(record.name, undefined);
401
- assert.equal(record.rejected.length, 1);
402
- assert.match(record.reason ?? "", /breaks the row-name shape/u);
403
- });
404
-
405
- test("session_start paints the line and session_shutdown clears it with undefined", async () => {
406
- const footer = createFakeFooter();
407
- await loadPlugin(
408
- writePlugin("@acme/loon-kit", `api.registerRows({ id: "loon.rows", category: "Banner", rows: () => [] });`),
409
- );
410
- assert.equal(footer.statuses.size, 0, "registered before any ui existed");
411
-
412
- const pi = createFakePi();
413
- gentleShellBridge(pi.api);
414
- pi.sessions[0]({}, { hasUI: true, ui: footer.ui });
415
- assert.deepEqual([...footer.statuses.values()], ["Loon kit · ready"]);
416
-
417
- pi.shutdowns[0]({}, { hasUI: true, ui: footer.ui });
418
-
419
- assert.equal(footer.statuses.size, 0, "the bridge's own row is gone");
420
- const last = footer.calls[footer.calls.length - 1];
421
- assert.deepEqual(last, { key: [...footer.calls][0].key, text: undefined });
422
- });
423
-
424
- test("a ui without setStatus never throws into registration, and the report says unavailable", async () => {
425
- assert.equal(installPluginStatus({}), false);
426
-
427
- await assert.doesNotReject(
428
- loadPlugin(
429
- writePlugin("@acme/loon-kit", `api.registerRows({ id: "loon.rows", category: "Banner", rows: () => [] });`),
430
- ),
431
- );
432
-
433
- const report = getPluginLineReport();
434
- assert.equal(report.availability, "unavailable");
435
- assert.match(report.reason ?? "", /no setStatus/u);
436
- const printed = formatStatus(collectStatus()).join("\n");
437
- assert.match(printed, /^plugin lines: 1 \(status unavailable: ctx\.ui has no setStatus\)$/m);
438
- assert.match(printed, /no line set \(ctx\.ui has no setStatus\)/u);
439
- });
440
-
441
- test("a setStatus that throws is contained and reported, not thrown at the plugin", async () => {
442
- installPluginStatus({
443
- setStatus() {
444
- throw new Error("footer is gone");
445
- },
446
- });
447
-
448
- await assert.doesNotReject(
449
- loadPlugin(
450
- writePlugin("@acme/loon-kit", `api.registerRows({ id: "loon.rows", category: "Banner", rows: () => [] });`),
451
- ),
452
- );
453
-
454
- const record = getPluginLineReport().plugins[0];
455
- assert.equal(record.line, "not-set");
456
- assert.match(record.reason ?? "", /footer is gone/u);
457
- });
458
-
459
- test("the host's own filters still exclude its reserved keys, and keep one plugin line", async () => {
460
- // The host's own statuses, including the four keys it never shows in INTEGRATIONS
461
- // (`extensions/gentle-shell.ts:357-385`).
462
- const footer = createFakeFooter({
463
- "gentle-pi": "🧠 gentle-pi · ready",
464
- "gentle:mode": "Permissions: edit",
465
- "gentle:yolo": "YOLO — edit",
466
- "gentle-jobs": "2 jobs",
467
- "gentle-fast": "fast",
468
- });
469
- installPluginStatus(footer.ui);
470
-
471
- await loadPlugin(
472
- writePlugin("@acme/loon-kit", `api.registerRows({ id: "loon.rows", category: "Banner", rows: () => [] });`),
473
- );
474
-
475
- const own = getPluginLineReport().plugins[0].key;
476
- assert.ok(own.startsWith(PLUGIN_LINE_PREFIX));
477
- assert.equal(HOST_OWN_STATUS_KEYS.includes(own), false);
478
-
479
- // The host's own INTEGRATIONS filter, as it reads the map.
480
- const integrations = [...footer.statuses.entries()]
481
- .filter(([key]) => key !== "gentle-fast" && key !== "gentle-jobs")
482
- .sort(([a], [b]) => a.localeCompare(b))
483
- .filter(([key]) => key !== "gentle:mode" && key !== "gentle:yolo")
484
- .map(([, text]) => text);
485
-
486
- assert.equal(
487
- integrations.filter((text) => text === "🧠 gentle-pi · ready").length,
488
- 1,
489
- "the host's own row is untouched",
490
- );
491
- assert.equal(
492
- integrations.filter((text) => text === "Loon kit · ready").length,
493
- 1,
494
- "the plugin row survives the host's filter exactly once",
495
- );
496
- });
497
-
498
- test("registering from the bridge's own package claims no footer line", async () => {
499
- const footer = createFakeFooter();
500
- installPluginStatus(footer.ui);
501
-
502
- // This test module is inside the bridge's own package, so it is plumbing, not a plugin.
503
- registerRows({ id: "bridge.internal", category: "Banner", rows: () => [] });
504
-
505
- assert.equal(footer.statuses.size, 0);
506
- assert.equal(getPluginLineReport().plugins.length, 0);
507
- assert.equal(collectStatus().pluginLines, undefined, "the report keeps its historical shape");
508
- });
509
-
510
- test("the status command prints the per-plugin list and every existing block", async () => {
511
- const footer = createFakeFooter();
512
- installPluginStatus(footer.ui);
513
- await loadPlugin(
514
- writePlugin("@acme/loon-kit", `api.registerRows({ id: "loon.rows", category: "Banner", rows: () => [] });`),
515
- );
516
-
517
- const pi = createFakePi();
518
- gentleShellBridge(pi.api);
519
- const printed: string[] = [];
520
- const context: BridgeCommandContext = {
521
- ui: { notify: (message: string) => printed.push(message) },
522
- };
523
- pi.commands[0].handler("", context);
524
-
525
- assert.equal(printed.length, 1);
526
- assert.match(printed[0], /^gentle-shell-bridge status$/m);
527
- assert.match(printed[0], /^providers: 1$/m);
528
- assert.match(printed[0], /^backend: none$/m);
529
- assert.match(printed[0], /^duplicate settings: no build yet$/m);
530
- assert.match(printed[0], /^plugin lines: 1$/m);
531
- assert.match(printed[0], /"Loon kit" \(derived from package "@acme\/loon-kit"\): ready, 1 accepted, 0 rejected/u);
532
- });
533
-
534
- test("bridgeApi() is what the fixture plugins read", () => {
535
- assert.ok(bridgeApi() !== undefined);
536
- });
537
-
538
- // ---------------------------------------------------------------------------------------------
539
- // Plugin identity (I8–I11): one identity, one row, whatever names the registrations carry
540
- // ---------------------------------------------------------------------------------------------
541
-
542
- test("a later registration names an identity its first registration left unnamed", async () => {
543
- const footer = createFakeFooter();
544
- installPluginStatus(footer.ui);
545
-
546
- await loadPlugin(
547
- writePlugin(
548
- "@acme/loon-kit",
549
- `
550
- api.registerRows({ id: "loon.rows", category: "Banner", rows: () => [] });
551
- api.registerSection({ id: "loon.section", title: "Loon", name: "Bash card", lines: () => [] });
552
- `,
553
- ),
554
- );
555
-
556
- assert.deepEqual([...footer.statuses.values()], ["Bash card · ready"]);
557
- assert.equal(footer.statuses.size, 1, "one identity still means one row");
558
- const report = getPluginLineReport();
559
- assert.equal(report.plugins.length, 1);
560
- assert.equal(report.plugins[0].name, "Bash card");
561
- assert.equal(report.plugins[0].nameSource, "explicit");
562
- assert.equal(report.plugins[0].accepted, 2);
563
- assert.equal(report.plugins[0].nameConflicts.length, 0);
564
- });
565
-
566
- test("one named registration and one unnamed from the same module keep the explicit name", async () => {
567
- const footer = createFakeFooter();
568
- installPluginStatus(footer.ui);
569
-
570
- await loadPlugin(
571
- writePlugin(
572
- "@acme/loon-kit",
573
- `
574
- api.registerRows({ id: "loon.rows", category: "Banner", name: "Bash card", rows: () => [] });
575
- api.registerSection({ id: "loon.section", title: "Loon", lines: () => [] });
576
- `,
577
- ),
578
- );
579
-
580
- assert.deepEqual([...footer.statuses.values()], ["Bash card · ready"]);
581
- assert.equal(new Set(footer.calls.map((entry) => entry.key)).size, 1);
582
- const report = getPluginLineReport();
583
- assert.equal(report.plugins.length, 1);
584
- assert.equal(report.plugins[0].nameSource, "explicit");
585
- assert.equal(report.plugins[0].accepted, 2);
586
- });
587
-
588
- test("repeating the same explicit name is inheritance, not a conflict", async () => {
589
- const footer = createFakeFooter();
590
- installPluginStatus(footer.ui);
591
-
592
- await loadPlugin(
593
- writePlugin(
594
- "@acme/loon-kit",
595
- `
596
- api.registerRows({ id: "loon.rows", category: "Banner", name: "Bash card", rows: () => [] });
597
- api.registerSection({ id: "loon.section", title: "Loon", name: "Bash card", lines: () => [] });
598
- `,
599
- ),
600
- );
601
-
602
- assert.deepEqual([...footer.statuses.values()], ["Bash card · ready"]);
603
- const record = getPluginLineReport().plugins[0];
604
- assert.equal(record.nameConflicts.length, 0);
605
- assert.equal(record.rejected.length, 0);
606
- assert.equal(record.accepted, 2);
607
- });
608
-
609
- test("a different name from the same identity keeps one row and reports the conflict", async () => {
610
- const footer = createFakeFooter();
611
- installPluginStatus(footer.ui);
612
-
613
- const root = writePackageRoot("@acme/loon-kit");
614
- const first = writeModuleAt(
615
- root,
616
- "src/first.mjs",
617
- `api.registerRows({ id: "loon.rows", category: "Banner", name: "Bash card", rows: () => [] });`,
618
- );
619
- await loadPlugin(first);
620
-
621
- assert.deepEqual([...footer.statuses.values()], ["Bash card · ready"], "the first name stays");
622
- assert.equal(footer.statuses.size, 1, "a conflicting name adds no second row");
623
- assert.deepEqual(
624
- footer.calls.map((entry) => entry.text),
625
- ["Bash card · ready"],
626
- "the row is never repainted under the conflicting name",
627
- );
628
-
629
- const second = writeModuleAt(
630
- root,
631
- "src/second.mjs",
632
- `api.registerSection({ id: "loon.section", title: "Loon", name: "Loon kit", lines: () => [] });`,
633
- );
634
- await loadPlugin(second);
635
-
636
- assert.deepEqual([...footer.statuses.values()], ["Bash card · ready"]);
637
- assert.equal(footer.statuses.size, 1);
638
- const record = getPluginLineReport().plugins[0];
639
- assert.equal(record.name, "Bash card");
640
- assert.deepEqual(record.nameConflicts, [
641
- { surface: "section", id: "loon.section", name: "Loon kit", kept: "Bash card" },
642
- ]);
643
- assert.equal(record.rejected.length, 0, "the registration itself was accepted");
644
- assert.equal(record.accepted, 2);
645
- assert.equal(record.status, "ready");
646
-
647
- const printed = formatStatus(collectStatus()).join("\n");
648
- assert.match(
649
- printed,
650
- /name conflict on section "loon\.section": declared "Loon kit", kept "Bash card"/u,
651
- "the conflict is reported where the author looks",
652
- );
653
-
654
- // Registering the same conflicting declaration again reports it once, not once per call.
655
- const third = writeModuleAt(
656
- root,
657
- "src/third.mjs",
658
- `api.registerSection({ id: "loon.section", title: "Loon", name: "Loon kit", lines: () => [] });`,
659
- );
660
- await loadPlugin(third);
661
- assert.equal(
662
- getPluginLineReport().plugins.length,
663
- 1,
664
- "a third module in the same package is the same plugin",
665
- );
666
- assert.equal(getPluginLineReport().plugins[0].nameConflicts.length, 1);
667
- });
668
-
669
- test("one identity survives another module in the same package, and a package rename keeps its key", async () => {
670
- const root = writePackageRoot("acme-loon");
671
- const footer = createFakeFooter();
672
- installPluginStatus(footer.ui);
673
-
674
- const first = writeModuleAt(
675
- root,
676
- "src/first.mjs",
677
- `api.registerRows({ id: "loon.rows", category: "Banner", rows: () => [] });`,
678
- );
679
- await loadPlugin(first);
680
-
681
- assert.deepEqual([...footer.statuses.values()], ["Acme loon · ready"]);
682
- const keyBefore = getPluginLineReport().plugins[0].key;
683
-
684
- // A second module in the same package is the same plugin (I8), and it may name it (I9).
685
- const second = writeModuleAt(
686
- root,
687
- "src/second.mjs",
688
- `api.registerSection({ id: "loon.section", title: "Loon", name: "Bash card", lines: () => [] });`,
689
- );
690
- await loadPlugin(second);
691
-
692
- const afterName = getPluginLineReport();
693
- assert.equal(afterName.plugins.length, 1);
694
- assert.equal(afterName.plugins[0].key, keyBefore, "an explicit name never changes the key");
695
- assert.equal(afterName.plugins[0].name, "Bash card");
696
- assert.deepEqual([...footer.statuses.values()], ["Bash card · ready"]);
697
-
698
- // Renaming the PACKAGE is the same identity at the same root: same key, no orphan (I10).
699
- writeFileSync(join(root, "package.json"), JSON.stringify({ name: "acme-falcon", type: "module" }));
700
- resetPluginLinesForTests();
701
- const renamedFooter = createFakeFooter();
702
- installPluginStatus(renamedFooter.ui);
703
- const third = writeModuleAt(
704
- root,
705
- "src/third.mjs",
706
- `api.registerRows({ id: "loon.reloaded", category: "Banner", rows: () => [] });`,
707
- );
708
- await loadPlugin(third);
709
-
710
- const afterRename = getPluginLineReport();
711
- assert.equal(afterRename.plugins[0].key, keyBefore, "the key is the identity, not the package name");
712
- assert.equal(afterRename.plugins[0].name, "Acme falcon", "the derived label follows the new package name");
713
- assert.deepEqual([...renamedFooter.statuses.values()], ["Acme falcon · ready"]);
714
- });
715
-
716
- test("a module owned by the Pi distribution gets no row, and says why", async () => {
717
- const root = writePackageRoot("@acme/pi-coding-agent", { bin: { pi: "./dist/cli.js" } });
718
- const file = writeModuleAt(
719
- root,
720
- "extensions/bash-card.mjs",
721
- `api.registerRows({ id: "loon.rows", category: "Banner", rows: () => [] });`,
722
- );
723
- const footer = createFakeFooter();
724
- installPluginStatus(footer.ui);
725
-
726
- await loadPlugin(file);
727
-
728
- assert.equal(footer.statuses.size, 0, "the Pi distribution is never a plugin identity");
729
- const record = getPluginLineReport().plugins[0];
730
- assert.equal(record.name, undefined);
731
- assert.equal(record.accepted, 1, "the registration itself still happened");
732
- assert.match(record.reason ?? "", /Pi installation/u);
733
-
734
- const printed = formatStatus(collectStatus()).join("\n");
735
- assert.match(printed, /\(no line\)/u);
736
- assert.match(printed, /Pi installation/u);
737
- assert.ok(printed.includes(file), "the module is named as a diagnostic, never as a row");
738
- });
739
-
740
- test("a Pi-owned module can still name itself explicitly, and gets exactly one row", async () => {
741
- const root = writePackageRoot("@acme/pi-coding-agent", { bin: { pi: "./dist/cli.js" } });
742
- const file = writeModuleAt(
743
- root,
744
- "extensions/bash-card.mjs",
745
- `
746
- api.registerRows({ id: "loon.rows", category: "Banner", name: "Bash card", rows: () => [] });
747
- api.registerSection({ id: "loon.section", title: "Loon", lines: () => [] });
748
- `,
749
- );
750
- const footer = createFakeFooter();
751
- installPluginStatus(footer.ui);
752
-
753
- await loadPlugin(file);
754
-
755
- assert.deepEqual([...footer.statuses.values()], ["Bash card · ready"]);
756
- assert.equal(new Set(footer.calls.map((entry) => entry.key)).size, 1);
757
- const record = getPluginLineReport().plugins[0];
758
- assert.equal(record.nameSource, "explicit");
759
- assert.equal(record.accepted, 2);
760
- });
761
-
762
- test("the package that owns the running entry point is recognised as the Pi distribution", async () => {
763
- const root = writePackageRoot("acme-pi-runtime");
764
- const cli = writeModuleAt(root, "dist/cli.mjs", "");
765
- const file = writeModuleAt(
766
- root,
767
- "extensions/other.mjs",
768
- `api.registerRows({ id: "loon.rows", category: "Banner", rows: () => [] });`,
769
- );
770
- const footer = createFakeFooter();
771
- installPluginStatus(footer.ui);
772
-
773
- const previous = process.argv[1];
774
- (process.argv as string[])[1] = cli;
775
- try {
776
- await loadPlugin(file);
777
- } finally {
778
- (process.argv as string[])[1] = previous;
779
- }
780
-
781
- assert.equal(footer.statuses.size, 0, "recognised as the running pi installation, not derived");
782
- assert.match(getPluginLineReport().plugins[0].reason ?? "", /Pi installation/u);
783
- });
784
-
785
- test("the example installed under the Pi distribution registers one identity and one row", async () => {
786
- const distRoot = writePackageRoot("@acme/pi-coding-agent", { bin: { pi: "./dist/cli.js" } });
787
- const extensions = join(distRoot, "extensions");
788
- mkdirSync(extensions, { recursive: true });
789
- const installed = join(extensions, "bash-card.ts");
790
- writeFileSync(
791
- installed,
792
- readFileSync(new URL("../examples/bash-card.ts", import.meta.url), "utf8"),
793
- );
794
- const configHome = mkdtempSync(join(tmpdir(), "gsb-example-config-"));
795
- scratchDirs.push(configHome);
796
- const previousConfigHome = process.env.GENTLE_PI_CONFIG_HOME;
797
- process.env.GENTLE_PI_CONFIG_HOME = configHome;
798
- publishBridgeApi();
799
-
800
- const footer = createFakeFooter();
801
- installPluginStatus(footer.ui);
802
- const sessionHandlers: Array<
803
- (event: unknown, ctx: { hasUI?: boolean; ui?: unknown }) => void
804
- > = [];
805
- try {
806
- const loaded = (await import(`${pathToFileURL(installed).href}?load=${String(Date.now())}`)) as {
807
- default: (pi: {
808
- on?: (event: string, handler: (event: unknown, ctx: { hasUI?: boolean; ui?: unknown }) => void) => void;
809
- }) => () => void;
810
- };
811
- const dispose = loaded.default({
812
- on(_event, handler) {
813
- sessionHandlers.push(handler);
814
- },
815
- });
816
- try {
817
- assert.equal(sessionHandlers.length, 1, "the example registers from its session_start handler");
818
- sessionHandlers[0]({}, { hasUI: true, ui: footer.ui });
819
- } finally {
820
- dispose();
821
- }
822
- } finally {
823
- if (previousConfigHome === undefined) delete process.env.GENTLE_PI_CONFIG_HOME;
824
- else process.env.GENTLE_PI_CONFIG_HOME = previousConfigHome;
825
- }
826
-
827
- assert.deepEqual(
828
- [...footer.statuses.values()],
829
- ["Card bash · ready"],
830
- "the design, the demo provider, the section and the card style: one identity, one row",
831
- );
832
- assert.equal(footer.statuses.size, 1);
833
- assert.equal(new Set(footer.calls.map((entry) => entry.key)).size, 1);
834
- const report = getPluginLineReport();
835
- assert.equal(report.plugins.length, 1);
836
- assert.equal(report.plugins[0].name, "Card bash");
837
- assert.equal(report.plugins[0].nameSource, "explicit");
838
- assert.ok(
839
- report.plugins[0].key.startsWith(`${PLUGIN_LINE_PREFIX}bash-card.`),
840
- "the key names the plugin module, never the Pi distribution (I10, I11)",
841
- );
842
- assert.equal(
843
- report.plugins[0].accepted,
844
- 4,
845
- "the bash design, the demo row provider, the CARD BASH section and the plain card style",
846
- );
847
- assert.equal(report.plugins[0].rejected.length, 0);
848
- assert.equal(report.plugins[0].nameConflicts.length, 0);
849
- });
850
-
851
- /** The example's OWN store value, read the way a user reads the file (`bash-card.json`). */
852
- const storedPresses = (configHome: string): unknown => {
853
- const document = JSON.parse(
854
- readFileSync(join(configHome, "gentle-shell-bridge", "bash-card.json"), "utf8"),
855
- ) as { values?: Record<string, unknown> };
856
- return document.values?.demoPresses;
857
- };
858
-
859
- /** The `demo presses` row of the example's section, as it is drawn right now. */
860
- const pressesRow = (
861
- section: { lines(ctx: { theme: undefined; configHome: string }): unknown[] },
862
- configHome: string,
863
- ): { label?: string; meta?: string; onClick?: () => void } | undefined => {
864
- const row = section.lines({ theme: undefined, configHome }).find(
865
- (candidate) =>
866
- typeof candidate === "object" &&
867
- candidate !== null &&
868
- (candidate as { label?: unknown }).label === "demo presses",
869
- );
870
- return row as { label?: string; meta?: string; onClick?: () => void } | undefined;
871
- };
872
-
873
- test("T42: the example's `demo presses` row is clickable, prints once, and hovers through the host's rail", async () => {
874
- const configHome = mkdtempSync(join(tmpdir(), "gsb-example-demo-"));
875
- scratchDirs.push(configHome);
876
- const previousConfigHome = process.env.GENTLE_PI_CONFIG_HOME;
877
- process.env.GENTLE_PI_CONFIG_HOME = configHome;
878
- try {
879
- resetSidebar();
880
- resetSidebarStateForTests();
881
- resetSectionFailuresForTests();
882
- resetSectionPanelForTests();
883
- resetSectionPointerForTests();
884
- resetRepaintForTests();
885
- disableSidebarBackend();
886
-
887
- // The real entry: the example's default export, loaded from its own file, registers the section
888
- // through the published bridge object exactly as Pi loads it.
889
- publishBridgeApi();
890
- const installed = new URL("../examples/bash-card.ts", import.meta.url);
891
- const sessionHandlers: Array<
892
- (event: unknown, ctx: { hasUI?: boolean; ui?: unknown }) => void
893
- > = [];
894
- const loaded = (await import(
895
- `${pathToFileURL(installed.pathname).href}?load=${String(Date.now())}`
896
- )) as {
897
- default: (pi: {
898
- on?: (event: string, handler: (event: unknown, ctx: { hasUI?: boolean; ui?: unknown }) => void) => void;
899
- }) => () => void;
900
- };
901
- const dispose = loaded.default({
902
- on(_event, handler) {
903
- sessionHandlers.push(handler);
904
- },
905
- });
906
- try {
907
- sessionHandlers[0]({}, { hasUI: true, ui: createFakeFooter().ui });
908
-
909
- const section = snapshotSections().find((candidate) => candidate.id === "bash-card.status");
910
- assert.ok(section !== undefined, "the example registered its section through the real entry");
911
- const ctx = { theme: undefined, configHome };
912
- assert.equal(section.meta?.(ctx), "native", "the header meta is the live selection");
913
- assert.equal(
914
- section
915
- .lines(ctx)
916
- .some((row) => typeof row !== "string" && row.label === "selected"),
917
- false,
918
- "the selection is printed once, never as a duplicate body row",
919
- );
920
-
921
- const presses = pressesRow(section, configHome);
922
- assert.ok(presses !== undefined, "the demo presses row is a structured row");
923
- assert.equal(presses.meta, "0", "its meta is the live press count");
924
- assert.equal(typeof presses.onClick, "function", "and it is a clickable action row");
925
-
926
- presses.onClick?.();
927
- assert.equal(storedPresses(configHome), 1, "one action, one stored increment");
928
- assert.equal(pressesRow(section, configHome)?.meta, "1", "the next frame reads the new count");
929
- presses.onClick?.();
930
- assert.equal(storedPresses(configHome), 2, "and one more action, one more increment");
931
-
932
- // The same row, mounted on the host's rail: hover paints it, a click runs it once.
933
- let renders = 0;
934
- setRepaintHost({
935
- terminal: fakeTerminal(),
936
- requestRender: () => {
937
- renders += 1;
938
- },
939
- });
940
- setSectionTheme({
941
- fg: (role: string, text: string) => (role === "warning" ? `\u001b[33m${text}\u001b[39m` : text),
942
- bold: (text: string) => text,
943
- });
944
- registerNativeFooter();
945
- installSidebarBackend(() => fakeTui());
946
- installSectionRail();
947
- renderSidebar();
948
-
949
- const rail = fakeState().parts.get(SIDEBAR_RAIL_KEY);
950
- const frame = rail?.render(40) ?? [];
951
- const y = frame.findIndex((row) => row.includes("demo presses"));
952
- assert.ok(y > 0, "the action row is inside the host part's frame");
953
- assert.ok(!frame[y]?.includes("\u001b[33m"), "it starts idle");
954
-
955
- const before = renders;
956
- assert.deepEqual(
957
- rail?.handleMouse?.({ type: "move", button: "none", x: 4, y, width: 40, height: 40 }),
958
- { handled: true, render: true },
959
- "the row answers a hover through the host's dispatch",
960
- );
961
- assert.equal(renders, before + 1, "one frame is asked for");
962
- assert.ok(
963
- (rail?.render(40) ?? [])[y]?.includes("\u001b[33m"),
964
- "and it is painted with the host's hover role",
965
- );
966
-
967
- assert.deepEqual(
968
- rail?.handleMouse?.({ type: "click", button: "left", x: 4, y, width: 40, height: 40 }),
969
- { handled: true, render: true },
970
- );
971
- assert.equal(storedPresses(configHome), 3, "one rail click, one increment");
972
- } finally {
973
- dispose();
974
- }
975
- } finally {
976
- if (previousConfigHome === undefined) delete process.env.GENTLE_PI_CONFIG_HOME;
977
- else process.env.GENTLE_PI_CONFIG_HOME = previousConfigHome;
978
- }
979
- });