@blackbelt-technology/pi-agent-dashboard 0.4.0 → 0.4.2

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 (129) hide show
  1. package/AGENTS.md +104 -35
  2. package/README.md +390 -494
  3. package/docs/architecture.md +423 -20
  4. package/package.json +11 -8
  5. package/packages/extension/package.json +11 -4
  6. package/packages/extension/src/__tests__/ask-user-schema-discriminator.test.ts +141 -0
  7. package/packages/extension/src/__tests__/ask-user-tool.test.ts +91 -15
  8. package/packages/extension/src/__tests__/bridge-entry-id-pi-070.test.ts +174 -0
  9. package/packages/extension/src/__tests__/event-forwarder.test.ts +30 -0
  10. package/packages/extension/src/__tests__/fork-entryid-timing.test.ts +64 -76
  11. package/packages/extension/src/__tests__/multiselect-dashboard-routing.test.ts +203 -0
  12. package/packages/extension/src/__tests__/multiselect-list.test.ts +137 -0
  13. package/packages/extension/src/__tests__/multiselect-polyfill.test.ts +92 -0
  14. package/packages/extension/src/__tests__/no-session-replacement-calls.test.ts +99 -0
  15. package/packages/extension/src/__tests__/no-tui-multiselect-arm-regression.test.ts +81 -0
  16. package/packages/extension/src/__tests__/openspec-activity-detector.test.ts +37 -0
  17. package/packages/extension/src/__tests__/ui-decorators.test.ts +309 -0
  18. package/packages/extension/src/__tests__/ui-modules.test.ts +293 -0
  19. package/packages/extension/src/ask-user-tool.ts +170 -61
  20. package/packages/extension/src/bridge.ts +199 -19
  21. package/packages/extension/src/multiselect-decode.ts +40 -0
  22. package/packages/extension/src/multiselect-list.ts +146 -0
  23. package/packages/extension/src/multiselect-polyfill.ts +73 -0
  24. package/packages/extension/src/server-launcher.ts +15 -3
  25. package/packages/extension/src/ui-modules.ts +272 -0
  26. package/packages/server/package.json +11 -5
  27. package/packages/server/src/__tests__/auto-attach.test.ts +61 -8
  28. package/packages/server/src/__tests__/browse-endpoint.test.ts +295 -19
  29. package/packages/server/src/__tests__/cli-bootstrap.test.ts +36 -0
  30. package/packages/server/src/__tests__/directory-service-refresh-force.test.ts +163 -0
  31. package/packages/server/src/__tests__/directory-service-specs-mtime.test.ts +315 -0
  32. package/packages/server/src/__tests__/directory-service-toctou.test.ts +303 -0
  33. package/packages/server/src/__tests__/directory-service.test.ts +174 -0
  34. package/packages/server/src/__tests__/fixtures/fork-jsonl-roundtrip.jsonl +8 -0
  35. package/packages/server/src/__tests__/fork-jsonl-roundtrip.test.ts +49 -0
  36. package/packages/server/src/__tests__/installed-package-enricher.test.ts +225 -0
  37. package/packages/server/src/__tests__/package-manager-wrapper-move.test.ts +414 -0
  38. package/packages/server/src/__tests__/package-routes.test.ts +136 -3
  39. package/packages/server/src/__tests__/package-source-helpers.test.ts +101 -0
  40. package/packages/server/src/__tests__/pending-attach-registry.test.ts +123 -0
  41. package/packages/server/src/__tests__/pending-resume-intent-registry.test.ts +138 -0
  42. package/packages/server/src/__tests__/pi-core-checker.test.ts +73 -30
  43. package/packages/server/src/__tests__/pi-gateway-consume-pending-attach.test.ts +112 -0
  44. package/packages/server/src/__tests__/pi-version-skew.test.ts +72 -0
  45. package/packages/server/src/__tests__/post-install-openspec-refresh.test.ts +180 -0
  46. package/packages/server/src/__tests__/post-install-rescan.test.ts +134 -0
  47. package/packages/server/src/__tests__/proposal-attach-naming.test.ts +79 -0
  48. package/packages/server/src/__tests__/restart-helper.test.ts +34 -6
  49. package/packages/server/src/__tests__/session-action-handler-spawn-with-attach.test.ts +108 -0
  50. package/packages/server/src/__tests__/session-order-manager.test.ts +55 -0
  51. package/packages/server/src/__tests__/session-order-reboot.test.ts +242 -0
  52. package/packages/server/src/__tests__/session-scanner.test.ts +44 -0
  53. package/packages/server/src/__tests__/subscription-handler.test.ts +40 -0
  54. package/packages/server/src/__tests__/translate-path-source.test.ts +77 -0
  55. package/packages/server/src/__tests__/ui-decorators-replay.test.ts +209 -0
  56. package/packages/server/src/__tests__/ui-modules-replay.test.ts +221 -0
  57. package/packages/server/src/browse.ts +118 -13
  58. package/packages/server/src/browser-gateway.ts +19 -0
  59. package/packages/server/src/browser-handlers/__tests__/session-meta-handler.test.ts +183 -0
  60. package/packages/server/src/browser-handlers/directory-handler.ts +7 -1
  61. package/packages/server/src/browser-handlers/handler-context.ts +15 -0
  62. package/packages/server/src/browser-handlers/session-action-handler.ts +29 -3
  63. package/packages/server/src/browser-handlers/session-meta-handler.ts +46 -12
  64. package/packages/server/src/browser-handlers/subscription-handler.ts +46 -1
  65. package/packages/server/src/cli.ts +61 -15
  66. package/packages/server/src/directory-service.ts +156 -15
  67. package/packages/server/src/event-wiring.ts +111 -10
  68. package/packages/server/src/installed-package-enricher.ts +143 -0
  69. package/packages/server/src/package-manager-wrapper.ts +305 -8
  70. package/packages/server/src/package-source-helpers.ts +104 -0
  71. package/packages/server/src/pending-attach-registry.ts +112 -0
  72. package/packages/server/src/pending-resume-intent-registry.ts +107 -0
  73. package/packages/server/src/pi-core-checker.ts +9 -14
  74. package/packages/server/src/pi-gateway.ts +14 -0
  75. package/packages/server/src/pi-version-skew.ts +12 -1
  76. package/packages/server/src/proposal-attach-naming.ts +47 -0
  77. package/packages/server/src/restart-helper.ts +13 -2
  78. package/packages/server/src/routes/file-routes.ts +29 -3
  79. package/packages/server/src/routes/package-routes.ts +72 -3
  80. package/packages/server/src/routes/plugin-config-routes.ts +129 -0
  81. package/packages/server/src/routes/system-routes.ts +2 -0
  82. package/packages/server/src/server.ts +339 -10
  83. package/packages/server/src/session-api.ts +30 -5
  84. package/packages/server/src/session-order-manager.ts +22 -0
  85. package/packages/server/src/session-scanner.ts +10 -1
  86. package/packages/shared/package.json +9 -2
  87. package/packages/shared/src/__tests__/browser-protocol-types.test.ts +59 -0
  88. package/packages/shared/src/__tests__/config-plugins.test.ts +68 -0
  89. package/packages/shared/src/__tests__/extension-ui-module-shape.test.ts +265 -0
  90. package/packages/shared/src/__tests__/no-hardcoded-node-modules-paths.test.ts +176 -0
  91. package/packages/shared/src/__tests__/no-raw-node-import.test.ts +146 -0
  92. package/packages/shared/src/__tests__/no-raw-openspec-status-in-skills.test.ts +81 -0
  93. package/packages/shared/src/__tests__/node-spawn.test.ts +210 -0
  94. package/packages/shared/src/__tests__/openspec-design-evidence.test.ts +288 -0
  95. package/packages/shared/src/__tests__/openspec-effective-status-script.test.ts +174 -0
  96. package/packages/shared/src/__tests__/openspec-poller-design-override.test.ts +225 -0
  97. package/packages/shared/src/__tests__/openspec-poller-specs-override.test.ts +284 -0
  98. package/packages/shared/src/__tests__/openspec-specs-evidence.test.ts +144 -0
  99. package/packages/shared/src/__tests__/platform/is-appimage-self-hit.test.ts +164 -0
  100. package/packages/shared/src/__tests__/plugin-bridge-register-extended.test.ts +72 -0
  101. package/packages/shared/src/__tests__/plugin-bridge-register.test.ts +113 -0
  102. package/packages/shared/src/__tests__/plugin-config-update-protocol.test.ts +41 -0
  103. package/packages/shared/src/__tests__/recommended-extensions.test.ts +5 -1
  104. package/packages/shared/src/__tests__/resolve-tool-cli.test.ts +105 -0
  105. package/packages/shared/src/__tests__/spawn-session-attach-proposal.test.ts +47 -0
  106. package/packages/shared/src/__tests__/state-replay-entry-id.test.ts +69 -0
  107. package/packages/shared/src/__tests__/tool-registry-strategies-appimage.test.ts +118 -0
  108. package/packages/shared/src/browser-protocol.ts +110 -4
  109. package/packages/shared/src/config.ts +45 -0
  110. package/packages/shared/src/dashboard-plugin/index.ts +11 -0
  111. package/packages/shared/src/dashboard-plugin/manifest-types.ts +58 -0
  112. package/packages/shared/src/dashboard-plugin/plugin-status.ts +26 -0
  113. package/packages/shared/src/dashboard-plugin/slot-props.ts +92 -0
  114. package/packages/shared/src/dashboard-plugin/slot-types.ts +151 -0
  115. package/packages/shared/src/openspec-activity-detector.ts +18 -22
  116. package/packages/shared/src/openspec-design-evidence.ts +109 -0
  117. package/packages/shared/src/openspec-poller.ts +117 -3
  118. package/packages/shared/src/openspec-specs-evidence.ts +79 -0
  119. package/packages/shared/src/platform/binary-lookup.ts +96 -1
  120. package/packages/shared/src/platform/index.ts +1 -0
  121. package/packages/shared/src/platform/node-spawn.ts +154 -0
  122. package/packages/shared/src/plugin-bridge-register.ts +139 -0
  123. package/packages/shared/src/protocol.ts +79 -2
  124. package/packages/shared/src/recommended-extensions.ts +7 -1
  125. package/packages/shared/src/rest-api.ts +68 -3
  126. package/packages/shared/src/state-replay.ts +20 -1
  127. package/packages/shared/src/tool-registry/definitions.ts +92 -0
  128. package/packages/shared/src/tool-registry/strategies.ts +17 -3
  129. package/packages/shared/src/types.ts +160 -0
@@ -1,100 +1,88 @@
1
1
  /**
2
- * Tests for fork entryId timing fix.
2
+ * Tests for bridge entryId stamping on message_end events.
3
3
  *
4
- * Verifies that the bridge's message_end entryId enrichment captures the
5
- * correct leaf ID (after pi core persists the entry), not the stale one
6
- * (before appendMessage runs).
4
+ * HISTORY: Originally this file modelled pi <0.69's synchronous emit pattern,
5
+ * where the bridge's `queueMicrotask` deferral ran BEFORE
6
+ * sessionManager.appendMessage. That design no longer reflects pi 0.70.x:
7
+ * pi awaits extension handlers inside _emitExtensionEvent, so the microtask
8
+ * resolves *inside* the awaited dispatcher, before persistence. The fix
9
+ * (see change: fix-per-message-fork) is `setTimeout(0)` (a macrotask)
10
+ * combined with reading `event.message.id` (which pi mutates in place
11
+ * during appendMessage) or a WeakMap populated by the wrapped appendMessage.
7
12
  *
8
- * The bug: pi core emits message_end via _emit() BEFORE calling
9
- * sessionManager.appendMessage(), so getLeafId() returns the previous leaf.
10
- * The fix: the bridge defers getLeafId() for message_end using queueMicrotask,
11
- * allowing appendMessage to run first.
13
+ * The previous test "message_start should still capture entryId immediately
14
+ * (no deferral)" codified the off-by-one bug as expected behavior — it has
15
+ * been REMOVED. The current test suite below models pi 0.70.x semantics
16
+ * directly. Detailed pi-0.70-specific scenarios live in
17
+ * `bridge-entry-id-pi-070.test.ts`.
12
18
  */
13
- import { describe, it, expect, vi } from "vitest";
19
+ import { describe, it, expect } from "vitest";
14
20
 
15
- /**
16
- * Simulates the pi core + bridge interaction for entryId enrichment.
17
- *
18
- * Pi core's _processAgentEvent does:
19
- * 1. _emit(event) — bridge handler called (async, not awaited)
20
- * 2. appendMessage(msg) — updates leafId synchronously
21
- *
22
- * The bridge handler (async) should yield via queueMicrotask before reading
23
- * getLeafId(), so that appendMessage has already run.
24
- */
25
- describe("message_end entryId timing", () => {
26
- it("deferred getLeafId() captures the post-persist entry ID", async () => {
27
- // Simulate sessionManager with mutable leafId
28
- let leafId = "user-entry-100"; // stale leaf before appendMessage
21
+ describe("message_end entryId timing on pi 0.70.x", () => {
22
+ it("setTimeout(0) deferral captures the post-persist entry ID", async () => {
23
+ // Simulate pi 0.70.x: bridge handler runs awaited, THEN appendMessage runs.
24
+ let leafId = "user-entry-100";
29
25
  const sessionManager = {
30
26
  getLeafId: () => leafId,
27
+ appendMessage: (msg: any) => {
28
+ msg.id = "assistant-entry-101";
29
+ leafId = msg.id;
30
+ return msg.id;
31
+ },
31
32
  };
32
33
 
33
- let capturedEntryId: string | undefined;
34
-
35
- // Simulate the bridge handler (with the fix: defers via queueMicrotask)
36
- const bridgeHandler = async () => {
37
- // This is what the fixed bridge does for message_end:
38
- await new Promise<void>(resolve => queueMicrotask(resolve));
39
- capturedEntryId = sessionManager.getLeafId();
34
+ const event = { type: "message_end", message: { role: "assistant" } as any };
35
+ let captured: string | undefined;
36
+ let sendDone!: () => void;
37
+ const sentP = new Promise<void>((r) => { sendDone = r; });
38
+
39
+ // Bridge handler: schedules a setTimeout(0) and returns synchronously.
40
+ // The awaited dispatcher then unwinds, appendMessage runs, AND finally
41
+ // the timeout fires.
42
+ const bridgeHandler = (ev: any) => {
43
+ setTimeout(() => {
44
+ captured = ev.message.id ?? sessionManager.getLeafId();
45
+ sendDone();
46
+ }, 0);
40
47
  };
41
48
 
42
- // Simulate pi core's _processAgentEvent:
43
- // 1. _emit calls handler (async, NOT awaited)
44
- const handlerPromise = bridgeHandler();
45
- // 2. appendMessage runs synchronously, updating leafId
46
- leafId = "assistant-entry-101";
47
-
48
- // Wait for the deferred handler to complete
49
- await handlerPromise;
49
+ // Simulate the dispatcher: await handler, then call appendMessage.
50
+ await bridgeHandler(event);
51
+ sessionManager.appendMessage(event.message);
52
+ await sentP;
50
53
 
51
- expect(capturedEntryId).toBe("assistant-entry-101");
54
+ expect(captured).toBe("assistant-entry-101");
52
55
  });
53
56
 
54
- it("immediate getLeafId() would capture the stale entry ID (demonstrates the bug)", async () => {
57
+ it("queueMicrotask deferral would NOT work on pi 0.70.x (regression demonstration)", async () => {
58
+ // Reproduces why we abandoned queueMicrotask: it resolves inside the
59
+ // awaited dispatcher, before appendMessage runs.
55
60
  let leafId = "user-entry-100";
56
- const sessionManager = {
57
- getLeafId: () => leafId,
58
- };
61
+ let captured: string | undefined;
59
62
 
60
- let capturedEntryId: string | undefined;
61
-
62
- // Simulate the OLD (buggy) bridge handler: reads getLeafId() immediately
63
- const buggyBridgeHandler = async () => {
64
- // No deferral — reads leafId before appendMessage runs
65
- capturedEntryId = sessionManager.getLeafId();
63
+ const buggyBridge = async () => {
64
+ await new Promise<void>((resolve) => queueMicrotask(resolve));
65
+ captured = leafId;
66
66
  };
67
67
 
68
- // Simulate pi core's _processAgentEvent:
69
- const handlerPromise = buggyBridgeHandler();
70
- leafId = "assistant-entry-101"; // too late — handler already read it
71
-
72
- await handlerPromise;
68
+ // Pi 0.70.x: await the bridge, THEN persist. Mirroring the real ordering:
69
+ const handlerP = buggyBridge();
70
+ await handlerP;
71
+ leafId = "assistant-entry-101"; // appendMessage runs after await
73
72
 
74
- // Bug: captures the stale leaf, not the assistant's entry
75
- expect(capturedEntryId).toBe("user-entry-100");
73
+ expect(captured).toBe("user-entry-100");
74
+ expect(captured).not.toBe("assistant-entry-101");
76
75
  });
77
76
 
78
- it("message_start should still capture entryId immediately (no deferral)", async () => {
79
- let leafId = "previous-assistant-entry-99";
80
- const sessionManager = {
81
- getLeafId: () => leafId,
82
- };
83
-
84
- let capturedEntryId: string | undefined;
85
-
86
- // Simulate bridge handler for message_start (immediate, no deferral)
87
- const messageStartHandler = async () => {
88
- capturedEntryId = sessionManager.getLeafId();
89
- };
90
-
91
- const handlerPromise = messageStartHandler();
92
- // User entry gets written after message_start
93
- leafId = "user-entry-100";
94
-
95
- await handlerPromise;
77
+ it("entry_persisted is the back-fill mechanism for user message_start (where event.message.id is unavailable)", () => {
78
+ // Behavioural assertion (pure data shape): when the bridge sends a
79
+ // user message_start, it stamps a nonce; later when pi persists the
80
+ // user entry, the bridge sends entry_persisted { nonce, entryId }.
81
+ // The reducer pairs them by nonce. See change: fix-per-message-fork.
82
+ const start = { type: "message_start", message: { role: "user" }, nonce: "n-1" };
83
+ const persisted = { type: "entry_persisted", entryId: "user-200", nonce: "n-1" };
96
84
 
97
- // message_start should capture the leaf BEFORE the user entry is written
98
- expect(capturedEntryId).toBe("previous-assistant-entry-99");
85
+ expect(start.nonce).toBe(persisted.nonce);
86
+ expect(persisted.entryId).toBe("user-200");
99
87
  });
100
88
  });
@@ -0,0 +1,203 @@
1
+ /**
2
+ * Tests for the bridge's `ctx.ui.multiselect` PromptBus patch and the TUI
3
+ * adapter's multiselect handling. The patch lives in `bridge.ts` but is
4
+ * exercised here through a small reproduction harness so we don't pull in
5
+ * the full session lifecycle.
6
+ *
7
+ * See change: fix-multiselect-auto-cancel-on-dashboard.
8
+ */
9
+ import { describe, it, expect, vi, beforeEach } from "vitest";
10
+ import { decodeMultiselectAnswer } from "../multiselect-decode.js";
11
+
12
+ // ──────────────────────────────────────────────────────────────────────
13
+ // `decodeMultiselectAnswer` — pure helper used by both the runtime patch
14
+ // and the TUI adapter's response encoding round-trip.
15
+ // ──────────────────────────────────────────────────────────────────────
16
+
17
+ describe("decodeMultiselectAnswer", () => {
18
+ it("resolves cancellation as undefined", () => {
19
+ expect(decodeMultiselectAnswer({ cancelled: true })).toBeUndefined();
20
+ expect(decodeMultiselectAnswer({ cancelled: true, answer: '["x"]' })).toBeUndefined();
21
+ });
22
+
23
+ it("resolves successful selection from JSON-encoded array", () => {
24
+ expect(decodeMultiselectAnswer({ cancelled: false, answer: '["a","c"]' })).toEqual(["a", "c"]);
25
+ });
26
+
27
+ it("resolves empty selection as []", () => {
28
+ expect(decodeMultiselectAnswer({ cancelled: false, answer: "[]" })).toEqual([]);
29
+ });
30
+
31
+ it("resolves null / undefined / empty answer as [] (not undefined)", () => {
32
+ expect(decodeMultiselectAnswer({ cancelled: false, answer: undefined })).toEqual([]);
33
+ expect(decodeMultiselectAnswer({ cancelled: false, answer: "" })).toEqual([]);
34
+ });
35
+
36
+ it("resolves unparseable JSON as [] without throwing", () => {
37
+ expect(decodeMultiselectAnswer({ cancelled: false, answer: "not-json" })).toEqual([]);
38
+ expect(decodeMultiselectAnswer({ cancelled: false, answer: "{not:array}" })).toEqual([]);
39
+ });
40
+
41
+ it("resolves valid JSON that is not an array as []", () => {
42
+ expect(decodeMultiselectAnswer({ cancelled: false, answer: '"just-a-string"' })).toEqual([]);
43
+ expect(decodeMultiselectAnswer({ cancelled: false, answer: '{"k":"v"}' })).toEqual([]);
44
+ });
45
+ });
46
+
47
+ // ──────────────────────────────────────────────────────────────────────
48
+ // Bridge patch reproduction — emulate the assignment block in
49
+ // `bridge.ts:935-948` plus the new multiselect arm and assert behavior.
50
+ // We cannot import `bridge.ts` directly (it has heavy session-lifecycle
51
+ // imports), so we reproduce the assignment closure here. The closure
52
+ // shape MUST match `bridge.ts` exactly; if `bridge.ts` drifts, the
53
+ // regression guard test (see "ctx.ui.multiselect is assigned by the
54
+ // bridge patch block" below) re-loads the bridge source and asserts
55
+ // the assignment exists.
56
+ // ──────────────────────────────────────────────────────────────────────
57
+
58
+ interface BusRequestArgs {
59
+ pipeline: string;
60
+ type: string;
61
+ question: string;
62
+ options?: string[];
63
+ metadata?: Record<string, unknown>;
64
+ }
65
+
66
+ interface FakeBus {
67
+ request: (args: BusRequestArgs) => Promise<{ cancelled?: boolean; answer?: string }>;
68
+ }
69
+
70
+ function applyMultiselectPatch(
71
+ ctx: { ui: Record<string, any> },
72
+ bus: FakeBus,
73
+ ): void {
74
+ const existing = (ctx.ui as any).multiselect;
75
+ if (typeof existing === "function") {
76
+ // eslint-disable-next-line no-console
77
+ console.warn("[bridge] ctx.ui.multiselect already exists — overriding for PromptBus routing");
78
+ }
79
+ (ctx.ui as any).multiselect = (title: string, options: string[], opts?: { message?: string }) =>
80
+ bus.request({
81
+ pipeline: "command",
82
+ type: "multiselect",
83
+ question: title,
84
+ options,
85
+ metadata: opts?.message ? { message: opts.message } : undefined,
86
+ }).then((r) => decodeMultiselectAnswer(r));
87
+ }
88
+
89
+ describe("bridge ctx.ui.multiselect patch", () => {
90
+ let ctx: { ui: Record<string, any> };
91
+ let bus: FakeBus;
92
+ let requestSpy: ReturnType<typeof vi.fn>;
93
+
94
+ beforeEach(() => {
95
+ requestSpy = vi.fn();
96
+ bus = { request: requestSpy as any };
97
+ ctx = { ui: {} };
98
+ });
99
+
100
+ it("assigns ctx.ui.multiselect as a function after the patch runs", () => {
101
+ expect(typeof ctx.ui.multiselect).toBe("undefined");
102
+ applyMultiselectPatch(ctx, bus);
103
+ expect(typeof ctx.ui.multiselect).toBe("function");
104
+ });
105
+
106
+ it("dispatches bus.request with the right shape on call", async () => {
107
+ applyMultiselectPatch(ctx, bus);
108
+ requestSpy.mockResolvedValue({ cancelled: false, answer: '["a","c"]' });
109
+
110
+ const result = await ctx.ui.multiselect("Pick", ["a", "b", "c"], { message: "ctx" });
111
+
112
+ expect(requestSpy).toHaveBeenCalledTimes(1);
113
+ expect(requestSpy).toHaveBeenCalledWith({
114
+ pipeline: "command",
115
+ type: "multiselect",
116
+ question: "Pick",
117
+ options: ["a", "b", "c"],
118
+ metadata: { message: "ctx" },
119
+ });
120
+ expect(result).toEqual(["a", "c"]);
121
+ });
122
+
123
+ it("omits metadata when no message is provided", async () => {
124
+ applyMultiselectPatch(ctx, bus);
125
+ requestSpy.mockResolvedValue({ cancelled: false, answer: "[]" });
126
+
127
+ await ctx.ui.multiselect("Pick", ["a", "b"]);
128
+
129
+ expect(requestSpy).toHaveBeenCalledWith(
130
+ expect.objectContaining({ metadata: undefined }),
131
+ );
132
+ });
133
+
134
+ it("resolves successful selection through the decoder", async () => {
135
+ applyMultiselectPatch(ctx, bus);
136
+ requestSpy.mockResolvedValue({ cancelled: false, answer: '["a","c"]' });
137
+ await expect(ctx.ui.multiselect("t", ["a", "b", "c"])).resolves.toEqual(["a", "c"]);
138
+ });
139
+
140
+ it("resolves empty selection as []", async () => {
141
+ applyMultiselectPatch(ctx, bus);
142
+ requestSpy.mockResolvedValue({ cancelled: false, answer: "[]" });
143
+ await expect(ctx.ui.multiselect("t", ["a"])).resolves.toEqual([]);
144
+ });
145
+
146
+ it("resolves cancellation as undefined", async () => {
147
+ applyMultiselectPatch(ctx, bus);
148
+ requestSpy.mockResolvedValue({ cancelled: true });
149
+ await expect(ctx.ui.multiselect("t", ["a"])).resolves.toBeUndefined();
150
+ });
151
+
152
+ it("resolves unparseable answer as [] without throwing", async () => {
153
+ applyMultiselectPatch(ctx, bus);
154
+ requestSpy.mockResolvedValue({ cancelled: false, answer: "not-json" });
155
+ await expect(ctx.ui.multiselect("t", ["a"])).resolves.toEqual([]);
156
+ });
157
+
158
+ it("warns when ctx.ui.multiselect was already a function before the patch", () => {
159
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
160
+ ctx.ui.multiselect = () => Promise.resolve(["pre-existing"]);
161
+
162
+ applyMultiselectPatch(ctx, bus);
163
+
164
+ expect(warnSpy).toHaveBeenCalledWith(
165
+ expect.stringContaining("already exists"),
166
+ );
167
+ // The patch still wins — subsequent calls go through bus.request.
168
+ requestSpy.mockResolvedValue({ cancelled: false, answer: '["winner"]' });
169
+ return ctx.ui.multiselect("t", ["a"]).then((r: any) => {
170
+ expect(r).toEqual(["winner"]);
171
+ expect(requestSpy).toHaveBeenCalled();
172
+ warnSpy.mockRestore();
173
+ });
174
+ });
175
+ });
176
+
177
+ // ──────────────────────────────────────────────────────────────────────
178
+ // Source regression guard — re-load bridge.ts as text and assert the
179
+ // multiselect patch line exists. Mirrors `no-direct-process-kill.test.ts`
180
+ // pattern: textual scan over a single file, fail with file:line if the
181
+ // expected snippet is missing or moved.
182
+ // ──────────────────────────────────────────────────────────────────────
183
+
184
+ describe("bridge.ts source regression guard", () => {
185
+ it("contains the ctx.ui.multiselect PromptBus assignment", async () => {
186
+ const fs = await import("node:fs");
187
+ const path = await import("node:path");
188
+ const url = await import("node:url");
189
+ const here = path.dirname(url.fileURLToPath(import.meta.url));
190
+ const bridgePath = path.resolve(here, "../bridge.ts");
191
+ const src = fs.readFileSync(bridgePath, "utf8");
192
+
193
+ // The assignment must mention `multiselect` as a key on ctx.ui AND
194
+ // dispatch through bus.request with type:"multiselect".
195
+ expect(src).toMatch(/\(ctx\.ui as any\)\.multiselect\s*=/);
196
+ expect(src).toMatch(/type:\s*"multiselect"/);
197
+ });
198
+
199
+ // Note: the previous-change assertions about `custom: ctx.ui.custom?.bind(...)`
200
+ // capture and the TUI `prompt.type === "multiselect"` arm were removed by
201
+ // change `fix-multiselect-tui-arm-self-cancel`. The inverse-guard for that
202
+ // removal lives in `no-tui-multiselect-arm-regression.test.ts`.
203
+ });
@@ -0,0 +1,137 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import { MultiSelectList } from "../multiselect-list.js";
3
+
4
+ function make(options: string[] = ["A", "B", "C"], title = "Pick", message?: string) {
5
+ const list = new MultiSelectList(title, options, message);
6
+ const onConfirm = vi.fn();
7
+ const onCancel = vi.fn();
8
+ list.onConfirm = onConfirm;
9
+ list.onCancel = onCancel;
10
+ return { list, onConfirm, onCancel };
11
+ }
12
+
13
+ describe("MultiSelectList", () => {
14
+ describe("keybindings", () => {
15
+ it("space toggles the checked state of the current item and nothing else", () => {
16
+ const { list, onConfirm } = make();
17
+ list.handleInput(" "); // toggle index 0 → A checked
18
+ list.handleInput("\r");
19
+ expect(onConfirm).toHaveBeenCalledWith(["A"]);
20
+ });
21
+
22
+ it("arrow down moves cursor and space toggles the new current item", () => {
23
+ const { list, onConfirm } = make();
24
+ list.handleInput("\u001b[B"); // cursor → 1 (B)
25
+ list.handleInput(" "); // B checked
26
+ list.handleInput("\r");
27
+ expect(onConfirm).toHaveBeenCalledWith(["B"]);
28
+ });
29
+
30
+ it("j / k navigation works like arrows", () => {
31
+ const { list, onConfirm } = make();
32
+ list.handleInput("j"); // cursor → 1
33
+ list.handleInput("j"); // cursor → 2
34
+ list.handleInput(" "); // C checked
35
+ list.handleInput("k"); // cursor → 1
36
+ list.handleInput(" "); // B checked
37
+ list.handleInput("\r");
38
+ // selected values returned in ORIGINAL order
39
+ expect(onConfirm).toHaveBeenCalledWith(["B", "C"]);
40
+ });
41
+
42
+ it("enter with nothing checked confirms with []", () => {
43
+ const { list, onConfirm } = make();
44
+ list.handleInput("\r");
45
+ expect(onConfirm).toHaveBeenCalledWith([]);
46
+ });
47
+
48
+ it("escape cancels; no confirm is fired", () => {
49
+ const { list, onConfirm, onCancel } = make();
50
+ list.handleInput(" "); // check A
51
+ list.handleInput("\u001b");
52
+ expect(onCancel).toHaveBeenCalledTimes(1);
53
+ expect(onConfirm).not.toHaveBeenCalled();
54
+ });
55
+
56
+ it("pressing 'a' does NOT bulk-toggle (no select-all in TUI)", () => {
57
+ const { list, onConfirm } = make();
58
+ list.handleInput("a");
59
+ list.handleInput("\r");
60
+ expect(onConfirm).toHaveBeenCalledWith([]);
61
+ });
62
+
63
+ it("cursor does not go below 0", () => {
64
+ const { list } = make();
65
+ list.handleInput("k");
66
+ list.handleInput("k");
67
+ expect(list.getCursor()).toBe(0);
68
+ });
69
+
70
+ it("cursor does not go past last item", () => {
71
+ const { list } = make(["A", "B"]);
72
+ list.handleInput("j");
73
+ list.handleInput("j");
74
+ list.handleInput("j");
75
+ expect(list.getCursor()).toBe(1);
76
+ });
77
+
78
+ it("toggling twice returns item to unchecked", () => {
79
+ const { list, onConfirm } = make();
80
+ list.handleInput(" ");
81
+ list.handleInput(" ");
82
+ list.handleInput("\r");
83
+ expect(onConfirm).toHaveBeenCalledWith([]);
84
+ });
85
+
86
+ it("selected order follows original option order, not toggle order", () => {
87
+ const { list, onConfirm } = make(["A", "B", "C", "D"]);
88
+ // toggle D first, then A, then C
89
+ list.handleInput("j");
90
+ list.handleInput("j");
91
+ list.handleInput("j");
92
+ list.handleInput(" "); // D
93
+ list.handleInput("k");
94
+ list.handleInput("k");
95
+ list.handleInput("k");
96
+ list.handleInput(" "); // A
97
+ list.handleInput("j");
98
+ list.handleInput("j");
99
+ list.handleInput(" "); // C
100
+ list.handleInput("\r");
101
+ expect(onConfirm).toHaveBeenCalledWith(["A", "C", "D"]);
102
+ });
103
+ });
104
+
105
+ describe("render", () => {
106
+ it("includes footer hint", () => {
107
+ const { list } = make();
108
+ const lines = list.render(80);
109
+ expect(lines.some((l) => l.includes("space toggle"))).toBe(true);
110
+ expect(lines.some((l) => l.includes("enter confirm"))).toBe(true);
111
+ expect(lines.some((l) => l.includes("esc cancel"))).toBe(true);
112
+ });
113
+
114
+ it("renders [ ] for unchecked and [x] for checked items", () => {
115
+ const { list } = make();
116
+ list.handleInput(" "); // check A
117
+ const lines = list.render(80);
118
+ expect(lines.some((l) => l.includes("[x] A"))).toBe(true);
119
+ expect(lines.some((l) => l.includes("[ ] B"))).toBe(true);
120
+ });
121
+
122
+ it("renders cursor marker on current item", () => {
123
+ const { list } = make();
124
+ list.handleInput("j"); // cursor → 1
125
+ const lines = list.render(80);
126
+ // Cursor line should start with "▸ " somewhere
127
+ expect(lines.some((l) => l.startsWith("▸ ") && l.includes("B"))).toBe(true);
128
+ });
129
+
130
+ it("includes title and message when provided", () => {
131
+ const { list } = make(["A", "B"], "Pick one or more", "Some context");
132
+ const lines = list.render(80);
133
+ expect(lines.some((l) => l.includes("Pick one or more"))).toBe(true);
134
+ expect(lines.some((l) => l.includes("Some context"))).toBe(true);
135
+ });
136
+ });
137
+ });
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Tests for `polyfillMultiselect`'s fallback chain. After the
3
+ * fix-multiselect-auto-cancel-on-dashboard change, the polyfill prefers a
4
+ * bridge-patched `ctx.ui.multiselect` (the PromptBus path that surfaces in
5
+ * the dashboard browser) and falls back to the legacy `ctx.ui.custom` +
6
+ * `MultiSelectList` overlay only when the patch is absent (older pi
7
+ * versions or non-bridge embeddings).
8
+ *
9
+ * See change: fix-multiselect-auto-cancel-on-dashboard.
10
+ */
11
+ import { describe, it, expect, vi } from "vitest";
12
+ import { polyfillMultiselect } from "../multiselect-polyfill.js";
13
+
14
+ describe("polyfillMultiselect — fallback chain", () => {
15
+ it("delegates to ctx.ui.multiselect when present (primary, bus-routed path)", async () => {
16
+ const multiselectFn = vi.fn().mockResolvedValue(["a"]);
17
+ const customFn = vi.fn();
18
+ const ctx = {
19
+ ui: {
20
+ multiselect: multiselectFn,
21
+ custom: customFn,
22
+ },
23
+ };
24
+
25
+ const result = await polyfillMultiselect(ctx as any, "Pick", ["a", "b"]);
26
+
27
+ expect(result).toEqual(["a"]);
28
+ expect(multiselectFn).toHaveBeenCalledTimes(1);
29
+ expect(multiselectFn).toHaveBeenCalledWith("Pick", ["a", "b"], undefined);
30
+ expect(customFn).not.toHaveBeenCalled();
31
+ });
32
+
33
+ it("forwards opts.message to ctx.ui.multiselect when present", async () => {
34
+ const multiselectFn = vi.fn().mockResolvedValue([]);
35
+ const ctx = { ui: { multiselect: multiselectFn, custom: vi.fn() } };
36
+
37
+ await polyfillMultiselect(ctx as any, "Pick", ["a"], { message: "ctx" });
38
+
39
+ expect(multiselectFn).toHaveBeenCalledWith("Pick", ["a"], { message: "ctx" });
40
+ });
41
+
42
+ it("propagates undefined (cancellation) through the primary path", async () => {
43
+ const multiselectFn = vi.fn().mockResolvedValue(undefined);
44
+ const ctx = { ui: { multiselect: multiselectFn, custom: vi.fn() } };
45
+
46
+ await expect(polyfillMultiselect(ctx as any, "t", ["a"])).resolves.toBeUndefined();
47
+ });
48
+
49
+ it("falls back to ctx.ui.custom when ctx.ui.multiselect is absent (TUI path)", async () => {
50
+ let capturedDone: ((r: string[] | undefined) => void) | undefined;
51
+ const customFn = vi.fn().mockImplementation((factory: any) => {
52
+ return new Promise<string[] | undefined>((resolve) => {
53
+ const done = (r: string[] | undefined) => resolve(r);
54
+ capturedDone = done;
55
+ const component = factory({}, {}, {}, done);
56
+ // Simulate user confirming after the factory wires the component.
57
+ component?.onConfirm?.(["b"]);
58
+ });
59
+ });
60
+ const ctx = { ui: { custom: customFn } };
61
+
62
+ const result = await polyfillMultiselect(ctx as any, "Pick", ["a", "b"]);
63
+
64
+ expect(result).toEqual(["b"]);
65
+ expect(customFn).toHaveBeenCalledTimes(1);
66
+ expect(capturedDone).toBeTypeOf("function");
67
+ });
68
+
69
+ it("falls back to ctx.ui.custom and resolves undefined on cancel", async () => {
70
+ const customFn = vi.fn().mockImplementation((factory: any) => {
71
+ return new Promise<string[] | undefined>((resolve) => {
72
+ const component = factory({}, {}, {}, (r: any) => resolve(r));
73
+ component?.onCancel?.();
74
+ });
75
+ });
76
+ const ctx = { ui: { custom: customFn } };
77
+
78
+ await expect(polyfillMultiselect(ctx as any, "Pick", ["a"])).resolves.toBeUndefined();
79
+ });
80
+
81
+ it("does NOT fall back to ctx.ui.custom when ctx.ui.multiselect resolves to []", async () => {
82
+ // Empty selection is a valid answer — must NOT trigger the legacy fallback.
83
+ const multiselectFn = vi.fn().mockResolvedValue([]);
84
+ const customFn = vi.fn();
85
+ const ctx = { ui: { multiselect: multiselectFn, custom: customFn } };
86
+
87
+ const result = await polyfillMultiselect(ctx as any, "t", ["a"]);
88
+
89
+ expect(result).toEqual([]);
90
+ expect(customFn).not.toHaveBeenCalled();
91
+ });
92
+ });