@blackbelt-technology/pi-agent-dashboard 0.3.0 → 0.4.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.
- package/AGENTS.md +87 -114
- package/README.md +408 -430
- package/docs/architecture.md +465 -12
- package/package.json +10 -5
- package/packages/extension/package.json +14 -4
- package/packages/extension/src/__tests__/ask-user-tool.test.ts +40 -8
- package/packages/extension/src/__tests__/bridge-entry-id-pi-070.test.ts +174 -0
- package/packages/extension/src/__tests__/enrich-model-metadata.test.ts +201 -0
- package/packages/extension/src/__tests__/event-forwarder.test.ts +30 -0
- package/packages/extension/src/__tests__/fork-entryid-timing.test.ts +64 -76
- package/packages/extension/src/__tests__/git-info.test.ts +67 -55
- package/packages/extension/src/__tests__/multiselect-list.test.ts +137 -0
- package/packages/extension/src/__tests__/no-session-replacement-calls.test.ts +99 -0
- package/packages/extension/src/__tests__/openspec-poller.test.ts +101 -96
- package/packages/extension/src/__tests__/process-scanner-kill.test.ts +61 -0
- package/packages/extension/src/__tests__/provider-register-reload.test.ts +394 -0
- package/packages/extension/src/__tests__/server-auto-start.test.ts +95 -4
- package/packages/extension/src/__tests__/server-launcher.test.ts +16 -0
- package/packages/extension/src/ask-user-tool.ts +5 -4
- package/packages/extension/src/bridge.ts +171 -17
- package/packages/extension/src/dev-build.ts +1 -1
- package/packages/extension/src/git-info.ts +9 -19
- package/packages/extension/src/multiselect-list.ts +146 -0
- package/packages/extension/src/multiselect-polyfill.ts +43 -0
- package/packages/extension/src/pi-env.d.ts +1 -0
- package/packages/extension/src/process-scanner.ts +72 -38
- package/packages/extension/src/provider-register.ts +304 -16
- package/packages/extension/src/server-auto-start.ts +27 -1
- package/packages/extension/src/server-launcher.ts +83 -27
- package/packages/server/package.json +16 -2
- package/packages/server/src/__tests__/bootstrap-queue.test.ts +120 -0
- package/packages/server/src/__tests__/bootstrap-routes.test.ts +125 -0
- package/packages/server/src/__tests__/bootstrap-state.test.ts +119 -0
- package/packages/server/src/__tests__/browse-endpoint.test.ts +17 -0
- package/packages/server/src/__tests__/cli-parse.test.ts +11 -0
- package/packages/server/src/__tests__/concurrent-launch.test.ts +110 -0
- package/packages/server/src/__tests__/config-api.test.ts +68 -0
- package/packages/server/src/__tests__/crash-recovery.test.ts +88 -0
- package/packages/server/src/__tests__/directory-service.test.ts +234 -8
- package/packages/server/src/__tests__/editor-registry.test.ts +28 -15
- package/packages/server/src/__tests__/extension-register-appimage.test.ts +5 -1
- package/packages/server/src/__tests__/extension-register.test.ts +3 -1
- package/packages/server/src/__tests__/find-port-holders.test.ts +94 -0
- package/packages/server/src/__tests__/fixtures/fork-jsonl-roundtrip.jsonl +8 -0
- package/packages/server/src/__tests__/force-kill-handler.test.ts +57 -8
- package/packages/server/src/__tests__/fork-jsonl-roundtrip.test.ts +49 -0
- package/packages/server/src/__tests__/home-lock-escape-hatch.test.ts +60 -0
- package/packages/server/src/__tests__/home-lock-release.test.ts +85 -0
- package/packages/server/src/__tests__/home-lock.test.ts +308 -0
- package/packages/server/src/__tests__/is-pi-process.test.ts +36 -0
- package/packages/server/src/__tests__/node-guard.test.ts +85 -0
- package/packages/server/src/__tests__/package-manager-wrapper-resolve.test.ts +5 -1
- package/packages/server/src/__tests__/package-manager-wrapper.test.ts +45 -10
- package/packages/server/src/__tests__/pi-version-skew.test.ts +237 -0
- package/packages/server/src/__tests__/preferences-store.test.ts +73 -4
- package/packages/server/src/__tests__/process-manager.test.ts +45 -18
- package/packages/server/src/__tests__/provider-probe.test.ts +287 -0
- package/packages/server/src/__tests__/provider-test-route.test.ts +149 -0
- package/packages/server/src/__tests__/restart-helper.test.ts +111 -0
- package/packages/server/src/__tests__/session-action-handler-headless-reload.test.ts +467 -0
- package/packages/server/src/__tests__/session-action-handler-reload-predicate.test.ts +73 -0
- package/packages/server/src/__tests__/session-action-handler-spawn-error.test.ts +74 -0
- package/packages/server/src/__tests__/terminal-manager.test.ts +41 -1
- package/packages/server/src/__tests__/tool-routes.test.ts +277 -0
- package/packages/server/src/__tests__/trusted-networks-config.test.ts +19 -0
- package/packages/server/src/__tests__/trusted-networks-no-oauth-roundtrip.test.ts +126 -0
- package/packages/server/src/__tests__/tunnel-cleanup.test.ts +90 -0
- package/packages/server/src/__tests__/tunnel.test.ts +13 -7
- package/packages/server/src/__tests__/wsl-tmux-probe-cache.test.ts +44 -0
- package/packages/server/src/bootstrap-queue.ts +130 -0
- package/packages/server/src/bootstrap-state.ts +131 -0
- package/packages/server/src/browse.ts +8 -3
- package/packages/server/src/browser-handlers/directory-handler.ts +23 -8
- package/packages/server/src/browser-handlers/session-action-handler.ts +213 -79
- package/packages/server/src/browser-handlers/session-action-helpers.ts +36 -0
- package/packages/server/src/cli.ts +310 -39
- package/packages/server/src/config-api.ts +16 -0
- package/packages/server/src/directory-service.ts +270 -39
- package/packages/server/src/editor-detection.ts +12 -9
- package/packages/server/src/editor-manager.ts +19 -4
- package/packages/server/src/editor-pid-registry.ts +9 -8
- package/packages/server/src/editor-registry.ts +22 -25
- package/packages/server/src/git-operations.ts +1 -1
- package/packages/server/src/headless-pid-registry.ts +7 -20
- package/packages/server/src/home-lock-release.ts +72 -0
- package/packages/server/src/home-lock.ts +389 -0
- package/packages/server/src/node-guard.ts +52 -0
- package/packages/server/src/package-manager-wrapper.ts +207 -47
- package/packages/server/src/pi-core-checker.ts +1 -1
- package/packages/server/src/pi-core-updater.ts +7 -1
- package/packages/server/src/pi-resource-scanner.ts +5 -8
- package/packages/server/src/pi-version-skew.ts +207 -0
- package/packages/server/src/preferences-store.ts +17 -3
- package/packages/server/src/process-manager.ts +403 -222
- package/packages/server/src/provider-probe.ts +234 -0
- package/packages/server/src/restart-helper.ts +141 -0
- package/packages/server/src/routes/bootstrap-routes.ts +88 -0
- package/packages/server/src/routes/openspec-routes.ts +25 -1
- package/packages/server/src/routes/pi-core-routes.ts +24 -1
- package/packages/server/src/routes/provider-auth-routes.ts +8 -8
- package/packages/server/src/routes/provider-routes.ts +43 -0
- package/packages/server/src/routes/recommended-routes.ts +10 -12
- package/packages/server/src/routes/system-routes.ts +20 -33
- package/packages/server/src/routes/tool-routes.ts +153 -0
- package/packages/server/src/server-pid.ts +5 -9
- package/packages/server/src/server.ts +211 -10
- package/packages/server/src/session-api.ts +77 -8
- package/packages/server/src/session-bootstrap.ts +17 -3
- package/packages/server/src/session-diff.ts +21 -21
- package/packages/server/src/terminal-manager.ts +61 -20
- package/packages/server/src/tunnel.ts +42 -28
- package/packages/shared/package.json +10 -3
- package/packages/shared/src/__tests__/{tool-resolver.test.ts → binary-lookup.test.ts} +32 -12
- package/packages/shared/src/__tests__/bootstrap/README.md +133 -0
- package/packages/shared/src/__tests__/bootstrap/__snapshots__/cube.test.ts.snap +370 -0
- package/packages/shared/src/__tests__/bootstrap/assertions.ts +136 -0
- package/packages/shared/src/__tests__/bootstrap/cube.test.ts +47 -0
- package/packages/shared/src/__tests__/bootstrap/cube.ts +66 -0
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/a-electron.test.ts.snap +83 -0
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/b-npm-global.test.ts.snap +89 -0
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/c-dev-monorepo.test.ts.snap +33 -0
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/d-overrides.test.ts.snap +20 -0
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/e-stale-partial.test.ts.snap +61 -0
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/f-cwd-variants.test.ts.snap +33 -0
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/g-windows-specifics.test.ts.snap +46 -0
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/j-path-gui-minimal.test.ts.snap +12 -0
- package/packages/shared/src/__tests__/bootstrap/families/a-electron.test.ts +156 -0
- package/packages/shared/src/__tests__/bootstrap/families/b-npm-global.test.ts +157 -0
- package/packages/shared/src/__tests__/bootstrap/families/c-dev-monorepo.test.ts +102 -0
- package/packages/shared/src/__tests__/bootstrap/families/d-overrides.test.ts +76 -0
- package/packages/shared/src/__tests__/bootstrap/families/e-stale-partial.test.ts +94 -0
- package/packages/shared/src/__tests__/bootstrap/families/f-cwd-variants.test.ts +87 -0
- package/packages/shared/src/__tests__/bootstrap/families/g-windows-specifics.test.ts +143 -0
- package/packages/shared/src/__tests__/bootstrap/families/h-home-drift.test.ts +64 -0
- package/packages/shared/src/__tests__/bootstrap/families/i-malformed-settings.test.ts +77 -0
- package/packages/shared/src/__tests__/bootstrap/families/index.ts +19 -0
- package/packages/shared/src/__tests__/bootstrap/families/j-path-gui-minimal.test.ts +61 -0
- package/packages/shared/src/__tests__/bootstrap/families/k-dashboard-absent.test.ts +50 -0
- package/packages/shared/src/__tests__/bootstrap/families/l-instance-coordination.test.ts +272 -0
- package/packages/shared/src/__tests__/bootstrap/fixtures/dev-monorepo.ts +58 -0
- package/packages/shared/src/__tests__/bootstrap/fixtures/electron-layout.ts +84 -0
- package/packages/shared/src/__tests__/bootstrap/fixtures/index.ts +9 -0
- package/packages/shared/src/__tests__/bootstrap/fixtures/managed-install.ts +85 -0
- package/packages/shared/src/__tests__/bootstrap/fixtures/npm-global-layout.ts +122 -0
- package/packages/shared/src/__tests__/bootstrap/fixtures/pi-versions.ts +36 -0
- package/packages/shared/src/__tests__/bootstrap/fixtures/settings-json.ts +39 -0
- package/packages/shared/src/__tests__/bootstrap/harness.smoke.test.ts +220 -0
- package/packages/shared/src/__tests__/bootstrap/harness.ts +413 -0
- package/packages/shared/src/__tests__/bootstrap/scenarios-skipped.ts +125 -0
- package/packages/shared/src/__tests__/bootstrap/scenarios.ts +132 -0
- package/packages/shared/src/__tests__/bridge-register.test.ts +29 -6
- package/packages/shared/src/__tests__/config-openspec.test.ts +106 -0
- package/packages/shared/src/__tests__/config.test.ts +56 -0
- package/packages/shared/src/__tests__/detached-spawn.test.ts +243 -0
- package/packages/shared/src/__tests__/managed-paths.test.ts +60 -0
- package/packages/shared/src/__tests__/no-direct-child-process.test.ts +112 -0
- package/packages/shared/src/__tests__/no-direct-platform-branch.test.ts +174 -0
- package/packages/shared/src/__tests__/no-direct-process-kill.test.ts +105 -0
- package/packages/shared/src/__tests__/no-hardcoded-node-modules-paths.test.ts +176 -0
- package/packages/shared/src/__tests__/no-raw-node-import.test.ts +146 -0
- package/packages/shared/src/__tests__/node-spawn.test.ts +210 -0
- package/packages/shared/src/__tests__/platform-commands.test.ts +108 -0
- package/packages/shared/src/__tests__/platform-exec.test.ts +103 -0
- package/packages/shared/src/__tests__/platform-git.test.ts +194 -0
- package/packages/shared/src/__tests__/platform-npm.test.ts +137 -0
- package/packages/shared/src/__tests__/platform-openspec.test.ts +92 -0
- package/packages/shared/src/__tests__/platform-paths.test.ts +284 -0
- package/packages/shared/src/__tests__/platform-process-scan.test.ts +55 -0
- package/packages/shared/src/__tests__/platform-process.test.ts +160 -0
- package/packages/shared/src/__tests__/platform-runner.test.ts +173 -0
- package/packages/shared/src/__tests__/platform-shell.test.ts +74 -0
- package/packages/shared/src/__tests__/process-identify.test.ts +113 -0
- package/packages/shared/src/__tests__/recommended-extensions.test.ts +40 -7
- package/packages/shared/src/__tests__/resolve-jiti.test.ts +43 -7
- package/packages/shared/src/__tests__/resolve-tool-cli.test.ts +105 -0
- package/packages/shared/src/__tests__/semaphore.test.ts +119 -0
- package/packages/shared/src/__tests__/spawn-mechanism.test.ts +131 -0
- package/packages/shared/src/__tests__/state-replay-entry-id.test.ts +69 -0
- package/packages/shared/src/__tests__/tool-registry-definitions.test.ts +239 -0
- package/packages/shared/src/__tests__/tool-registry-overrides.test.ts +137 -0
- package/packages/shared/src/__tests__/tool-registry-registry.test.ts +343 -0
- package/packages/shared/src/bootstrap-install.ts +212 -0
- package/packages/shared/src/bridge-register.ts +87 -20
- package/packages/shared/src/browser-protocol.ts +71 -1
- package/packages/shared/src/config.ts +87 -15
- package/packages/shared/src/managed-paths.ts +31 -4
- package/packages/shared/src/openspec-poller.ts +63 -46
- package/packages/shared/src/{tool-resolver.ts → platform/binary-lookup.ts} +125 -25
- package/packages/shared/src/platform/commands.ts +100 -0
- package/packages/shared/src/platform/detached-spawn.ts +305 -0
- package/packages/shared/src/platform/exec.ts +220 -0
- package/packages/shared/src/platform/git.ts +155 -0
- package/packages/shared/src/platform/index.ts +16 -0
- package/packages/shared/src/platform/node-spawn.ts +154 -0
- package/packages/shared/src/platform/npm.ts +162 -0
- package/packages/shared/src/platform/openspec.ts +91 -0
- package/packages/shared/src/platform/paths.ts +276 -0
- package/packages/shared/src/platform/process-identify.ts +126 -0
- package/packages/shared/src/platform/process-scan.ts +94 -0
- package/packages/shared/src/platform/process.ts +168 -0
- package/packages/shared/src/platform/runner.ts +369 -0
- package/packages/shared/src/platform/shell.ts +44 -0
- package/packages/shared/src/platform/spawn-mechanism.ts +124 -0
- package/packages/shared/src/platform/subprocess-adapter.ts +124 -0
- package/packages/shared/src/protocol.ts +23 -0
- package/packages/shared/src/recommended-extensions.ts +18 -2
- package/packages/shared/src/resolve-jiti.ts +62 -3
- package/packages/shared/src/rest-api.ts +26 -0
- package/packages/shared/src/semaphore.ts +83 -0
- package/packages/shared/src/state-replay.ts +9 -0
- package/packages/shared/src/tool-registry/definitions.ts +434 -0
- package/packages/shared/src/tool-registry/index.ts +56 -0
- package/packages/shared/src/tool-registry/overrides.ts +118 -0
- package/packages/shared/src/tool-registry/registry.ts +262 -0
- package/packages/shared/src/tool-registry/strategies.ts +198 -0
- package/packages/shared/src/tool-registry/types.ts +180 -0
|
@@ -112,10 +112,13 @@ describe("autoStartServer", () => {
|
|
|
112
112
|
|
|
113
113
|
const result = await autoStartServer(baseConfig, deps);
|
|
114
114
|
|
|
115
|
-
expect(deps.notify).
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
)
|
|
115
|
+
expect(deps.notify).toHaveBeenCalledTimes(1);
|
|
116
|
+
const [msg, level] = (deps.notify as any).mock.calls[0];
|
|
117
|
+
expect(msg).toMatch(/Dashboard server failed to start: exited/);
|
|
118
|
+
// Spec requirement (fix-windows-server-parity): failure notification
|
|
119
|
+
// MUST include the absolute path to ~/.pi/dashboard/server.log.
|
|
120
|
+
expect(msg).toMatch(/server\.log/);
|
|
121
|
+
expect(level).toBe("warning");
|
|
119
122
|
expect(result.server).toBeUndefined();
|
|
120
123
|
});
|
|
121
124
|
|
|
@@ -164,4 +167,92 @@ describe("autoStartServer", () => {
|
|
|
164
167
|
|
|
165
168
|
expect(result.server).toEqual({ host: "myhost.local", port: 8000, piPort: 9999 });
|
|
166
169
|
});
|
|
170
|
+
|
|
171
|
+
describe("onLaunchStart / onLaunchEnd callbacks", () => {
|
|
172
|
+
it("fires onLaunchStart then onLaunchEnd(true) when launch succeeds", async () => {
|
|
173
|
+
const onLaunchStart = vi.fn();
|
|
174
|
+
const onLaunchEnd = vi.fn();
|
|
175
|
+
const deps = makeDeps({
|
|
176
|
+
launchServer: vi.fn().mockResolvedValue({ success: true, message: "ok" }),
|
|
177
|
+
onLaunchStart,
|
|
178
|
+
onLaunchEnd,
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
await autoStartServer(baseConfig, deps);
|
|
182
|
+
|
|
183
|
+
expect(onLaunchStart).toHaveBeenCalledTimes(1);
|
|
184
|
+
expect(onLaunchEnd).toHaveBeenCalledTimes(1);
|
|
185
|
+
expect(onLaunchEnd).toHaveBeenCalledWith(true);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("fires onLaunchStart then onLaunchEnd(false) when launch fails", async () => {
|
|
189
|
+
const onLaunchStart = vi.fn();
|
|
190
|
+
const onLaunchEnd = vi.fn();
|
|
191
|
+
const deps = makeDeps({
|
|
192
|
+
launchServer: vi.fn().mockResolvedValue({ success: false, message: "boom" }),
|
|
193
|
+
isDashboardRunning: vi.fn().mockResolvedValue({ running: false }),
|
|
194
|
+
onLaunchStart,
|
|
195
|
+
onLaunchEnd,
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
await autoStartServer(baseConfig, deps);
|
|
199
|
+
|
|
200
|
+
expect(onLaunchStart).toHaveBeenCalledTimes(1);
|
|
201
|
+
expect(onLaunchEnd).toHaveBeenCalledTimes(1);
|
|
202
|
+
expect(onLaunchEnd).toHaveBeenCalledWith(false);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("fires onLaunchEnd(true) when launch fails but recheck finds running server", async () => {
|
|
206
|
+
// Race scenario: another agent started the server during our launch attempt.
|
|
207
|
+
const onLaunchStart = vi.fn();
|
|
208
|
+
const onLaunchEnd = vi.fn();
|
|
209
|
+
const deps = makeDeps({
|
|
210
|
+
launchServer: vi.fn().mockResolvedValue({ success: false, message: "EADDRINUSE" }),
|
|
211
|
+
isDashboardRunning: vi.fn()
|
|
212
|
+
.mockResolvedValueOnce({ running: false }) // before launch
|
|
213
|
+
.mockResolvedValueOnce({ running: true }), // after launch (recheck)
|
|
214
|
+
onLaunchStart,
|
|
215
|
+
onLaunchEnd,
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
await autoStartServer(baseConfig, deps);
|
|
219
|
+
|
|
220
|
+
expect(onLaunchStart).toHaveBeenCalledTimes(1);
|
|
221
|
+
expect(onLaunchEnd).toHaveBeenCalledWith(true);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("does NOT fire onLaunchStart when mDNS finds a local server (no launch happens)", async () => {
|
|
225
|
+
const onLaunchStart = vi.fn();
|
|
226
|
+
const onLaunchEnd = vi.fn();
|
|
227
|
+
const local: DiscoveredServer = {
|
|
228
|
+
host: "localhost", port: 8000, piPort: 9999,
|
|
229
|
+
isLocal: true, source: "mdns",
|
|
230
|
+
};
|
|
231
|
+
const deps = makeDeps({
|
|
232
|
+
discoverDashboard: vi.fn().mockResolvedValue([local]),
|
|
233
|
+
onLaunchStart,
|
|
234
|
+
onLaunchEnd,
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
await autoStartServer(baseConfig, deps);
|
|
238
|
+
|
|
239
|
+
expect(onLaunchStart).not.toHaveBeenCalled();
|
|
240
|
+
expect(onLaunchEnd).not.toHaveBeenCalled();
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it("does NOT fire onLaunchStart when health check finds an already-running server", async () => {
|
|
244
|
+
const onLaunchStart = vi.fn();
|
|
245
|
+
const onLaunchEnd = vi.fn();
|
|
246
|
+
const deps = makeDeps({
|
|
247
|
+
isDashboardRunning: vi.fn().mockResolvedValue({ running: true }),
|
|
248
|
+
onLaunchStart,
|
|
249
|
+
onLaunchEnd,
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
await autoStartServer(baseConfig, deps);
|
|
253
|
+
|
|
254
|
+
expect(onLaunchStart).not.toHaveBeenCalled();
|
|
255
|
+
expect(onLaunchEnd).not.toHaveBeenCalled();
|
|
256
|
+
});
|
|
257
|
+
});
|
|
167
258
|
});
|
|
@@ -17,6 +17,22 @@ describe("server-launcher", () => {
|
|
|
17
17
|
it("should point to a file that actually exists on disk", () => {
|
|
18
18
|
expect(existsSync(resolveServerCliPath())).toBe(true);
|
|
19
19
|
});
|
|
20
|
+
|
|
21
|
+
it("uses require.resolve so it adapts to installed layout", () => {
|
|
22
|
+
// Regression: the monorepo-relative path math
|
|
23
|
+
// (`<extension>/../../server/src/cli.ts`) produced
|
|
24
|
+
// `<scope>/server/src/cli.ts` instead of
|
|
25
|
+
// `<scope>/pi-dashboard-server/src/cli.ts` when the extension
|
|
26
|
+
// was installed into `node_modules/@blackbelt-technology/`. The
|
|
27
|
+
// resolver must locate the server via package name, not sibling
|
|
28
|
+
// path arithmetic.
|
|
29
|
+
const cliPath = resolveServerCliPath();
|
|
30
|
+
// Either layout is fine; we just must NOT produce the broken
|
|
31
|
+
// `@blackbelt-technology/server/src/cli.ts` shape.
|
|
32
|
+
expect(cliPath).not.toMatch(/@blackbelt-technology[\\/]+server[\\/]+src[\\/]+cli\.ts$/);
|
|
33
|
+
// And must land on pi-dashboard-server (installed) or packages/server (dev).
|
|
34
|
+
expect(cliPath).toMatch(/(pi-dashboard-server|packages[\\/]+server)[\\/]+src[\\/]+cli\.ts$/);
|
|
35
|
+
});
|
|
20
36
|
});
|
|
21
37
|
|
|
22
38
|
describe("buildSpawnArgs", () => {
|
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
* register ask_user. Runtime registration bypasses detectExtensionConflicts.
|
|
7
7
|
*/
|
|
8
8
|
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
9
|
-
import { Type } from "
|
|
9
|
+
import { Type } from "typebox";
|
|
10
|
+
import { polyfillMultiselect } from "./multiselect-polyfill.js";
|
|
10
11
|
|
|
11
12
|
// ──────────────────────────────────────────────────────────────────────────
|
|
12
13
|
// Single-question schema arms (reused inside the batch arm's questions array)
|
|
@@ -121,7 +122,7 @@ export function registerAskUserTool(pi: ExtensionAPI): void {
|
|
|
121
122
|
name: "ask_user",
|
|
122
123
|
label: "Ask User",
|
|
123
124
|
description:
|
|
124
|
-
"Ask the user a question interactively. Use this when you need clarification, confirmation, or a choice from the user before proceeding.",
|
|
125
|
+
"Ask the user a question interactively. Use this when you need clarification, confirmation, or a choice from the user before proceeding. UI provides a Select all toggle; do not add one.",
|
|
125
126
|
promptSnippet:
|
|
126
127
|
"Ask the user interactive questions (confirm, select, multiselect, input, or batch — multiple related questions at once)",
|
|
127
128
|
promptGuidelines: [
|
|
@@ -254,7 +255,7 @@ export function registerAskUserTool(pi: ExtensionAPI): void {
|
|
|
254
255
|
`ask_user batch: sub-question method "multiselect" requires a non-empty "options" array.`,
|
|
255
256
|
);
|
|
256
257
|
}
|
|
257
|
-
answer = await (ctx
|
|
258
|
+
answer = await polyfillMultiselect(ctx, subTitle, opts, subMsg);
|
|
258
259
|
break;
|
|
259
260
|
}
|
|
260
261
|
case "input":
|
|
@@ -336,7 +337,7 @@ export function registerAskUserTool(pi: ExtensionAPI): void {
|
|
|
336
337
|
result = await ctx.ui.select(title, options, msgOpts);
|
|
337
338
|
break;
|
|
338
339
|
case "multiselect":
|
|
339
|
-
result = await (ctx
|
|
340
|
+
result = await polyfillMultiselect(ctx, title, options, msgOpts);
|
|
340
341
|
break;
|
|
341
342
|
case "input":
|
|
342
343
|
result = await ctx.ui.input(title, params.placeholder, msgOpts);
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* forwards all pi events, and relays commands back.
|
|
6
6
|
*/
|
|
7
7
|
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
8
|
+
import { Loader } from "@mariozechner/pi-tui";
|
|
8
9
|
import { ConnectionManager } from "./connection.js";
|
|
9
10
|
import { detectSessionSource } from "./source-detector.js";
|
|
10
11
|
import { mapEventToProtocol } from "./event-forwarder.js";
|
|
@@ -25,7 +26,7 @@ import { expandPromptTemplateFromDisk } from "./prompt-expander.js";
|
|
|
25
26
|
import { PromptBus } from "./prompt-bus.js";
|
|
26
27
|
import { DashboardDefaultAdapter } from "./dashboard-default-adapter.js";
|
|
27
28
|
import { registerAskUserTool } from "./ask-user-tool.js";
|
|
28
|
-
import { activate as activateProviderRegister, onProviderChanged } from "./provider-register.js";
|
|
29
|
+
import { activate as activateProviderRegister, onProviderChanged, reloadProviders } from "./provider-register.js";
|
|
29
30
|
import type { FlowInfo } from "@blackbelt-technology/pi-dashboard-shared/types.js";
|
|
30
31
|
import { startMetricsMonitor, stopMetricsMonitor, collectMetrics } from "./process-metrics.js";
|
|
31
32
|
import { scanChildProcesses } from "./process-scanner.js";
|
|
@@ -185,6 +186,61 @@ function initBridge(pi: ExtensionAPI) {
|
|
|
185
186
|
let lastThinkingLevel: string | undefined;
|
|
186
187
|
let promptBus: PromptBus | undefined;
|
|
187
188
|
|
|
189
|
+
// ── Per-message entry id tracking (for fix-per-message-fork) ──
|
|
190
|
+
// Pi 0.69+ awaits extension handlers BEFORE sessionManager.appendMessage runs,
|
|
191
|
+
// which means getLeafId() at emit time returns the previous leaf, not the
|
|
192
|
+
// entry id of the message currently being emitted. We solve this by:
|
|
193
|
+
// 1. Wrapping ctx.sessionManager.appendMessage at session_start to stamp
|
|
194
|
+
// the just-generated entry id onto the message object reference.
|
|
195
|
+
// 2. Deferring the message_end enrichment-and-send via setTimeout(0) so
|
|
196
|
+
// the awaited dispatcher unwinds and appendMessage runs in between.
|
|
197
|
+
// 3. Stamping a nonce on message_start/message_end events; emitting an
|
|
198
|
+
// entry_persisted event after appendMessage so the client reducer can
|
|
199
|
+
// back-fill user-message ChatMessage.entryId.
|
|
200
|
+
// See change: fix-per-message-fork.
|
|
201
|
+
const idByMessage = new WeakMap<object, string>();
|
|
202
|
+
const pendingNonces = new WeakMap<object, string>();
|
|
203
|
+
let nonceCounter = 0;
|
|
204
|
+
const nextNonce = (): string => `n-${++nonceCounter}-${Date.now()}`;
|
|
205
|
+
let appendMessageWrapped = false;
|
|
206
|
+
let lastWrappedSm: any = null;
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Wrap ctx.sessionManager.appendMessage once per session so that when pi
|
|
210
|
+
* generates an entry id we capture it in the WeakMap and emit
|
|
211
|
+
* entry_persisted to the server.
|
|
212
|
+
*/
|
|
213
|
+
function wrapAppendMessageForCtx(ctx: any): void {
|
|
214
|
+
const sm = ctx?.sessionManager;
|
|
215
|
+
if (!sm || typeof sm.appendMessage !== "function") return;
|
|
216
|
+
// Re-wrap when sessionManager identity changes (session replacement).
|
|
217
|
+
if (sm === lastWrappedSm && appendMessageWrapped) return;
|
|
218
|
+
const original = sm.appendMessage.bind(sm);
|
|
219
|
+
sm.appendMessage = (msg: any, ...rest: any[]) => {
|
|
220
|
+
const result = original(msg, ...rest);
|
|
221
|
+
try {
|
|
222
|
+
if (msg && typeof msg === "object" && typeof msg.id === "string") {
|
|
223
|
+
idByMessage.set(msg as object, msg.id);
|
|
224
|
+
const nonce = pendingNonces.get(msg as object);
|
|
225
|
+
if (nonce && sessionReady && isActive()) {
|
|
226
|
+
const ev = {
|
|
227
|
+
type: "entry_persisted",
|
|
228
|
+
entryId: msg.id,
|
|
229
|
+
nonce,
|
|
230
|
+
};
|
|
231
|
+
connection.send(mapEventToProtocol(sessionId, ev));
|
|
232
|
+
pendingNonces.delete(msg as object);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
} catch (err) {
|
|
236
|
+
console.error("[dashboard] entry_persisted emit failed:", err);
|
|
237
|
+
}
|
|
238
|
+
return result;
|
|
239
|
+
};
|
|
240
|
+
lastWrappedSm = sm;
|
|
241
|
+
appendMessageWrapped = true;
|
|
242
|
+
}
|
|
243
|
+
|
|
188
244
|
/** Wrap a callback so errors log instead of crashing the host pi agent. */
|
|
189
245
|
function safe<T extends (...args: any[]) => any>(fn: T): T {
|
|
190
246
|
return ((...args: any[]) => {
|
|
@@ -216,6 +272,17 @@ function initBridge(pi: ExtensionAPI) {
|
|
|
216
272
|
// Reload auth credentials when dashboard notifies of changes
|
|
217
273
|
if (msg.type === "credentials_updated") {
|
|
218
274
|
try {
|
|
275
|
+
// Hot-reload providers.json diff BEFORE refreshing the registry,
|
|
276
|
+
// so any newly added providers are registered before getAvailable() runs.
|
|
277
|
+
const diff = await reloadProviders(pi).catch((err) => {
|
|
278
|
+
console.error("[dashboard] reloadProviders failed:", err);
|
|
279
|
+
return { added: [], removed: [], changed: [] };
|
|
280
|
+
});
|
|
281
|
+
if (diff.added.length || diff.removed.length || diff.changed.length) {
|
|
282
|
+
console.log(
|
|
283
|
+
`[dashboard] hot-reloaded providers: added=${JSON.stringify(diff.added)} removed=${JSON.stringify(diff.removed)} changed=${JSON.stringify(diff.changed)}`,
|
|
284
|
+
);
|
|
285
|
+
}
|
|
219
286
|
cachedModelRegistry?.authStorage?.reload?.();
|
|
220
287
|
cachedModelRegistry?.refresh?.();
|
|
221
288
|
} catch (err) { console.error("[dashboard] credentials reload failed:", err); }
|
|
@@ -600,30 +667,53 @@ function initBridge(pi: ExtensionAPI) {
|
|
|
600
667
|
}
|
|
601
668
|
}
|
|
602
669
|
|
|
603
|
-
// For message_start
|
|
670
|
+
// For message_start: stamp a nonce on the event so the client reducer
|
|
671
|
+
// can correlate a later entry_persisted back-fill with this bubble.
|
|
672
|
+
// We do NOT attach entryId here — the message has no id yet on pi
|
|
673
|
+
// 0.69+ (persistence is deferred to message_end). See change:
|
|
674
|
+
// fix-per-message-fork.
|
|
604
675
|
if (eventType === "message_start") {
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
676
|
+
wrapAppendMessageForCtx(ctx);
|
|
677
|
+
const messageRef = (event as any).message;
|
|
678
|
+
if (messageRef && typeof messageRef === "object") {
|
|
679
|
+
const nonce = nextNonce();
|
|
680
|
+
pendingNonces.set(messageRef as object, nonce);
|
|
681
|
+
const enriched = { ...event, nonce };
|
|
608
682
|
const msg = mapEventToProtocol(sessionId, enriched);
|
|
609
683
|
connection.send(msg);
|
|
610
684
|
return;
|
|
611
685
|
}
|
|
612
686
|
}
|
|
613
687
|
|
|
614
|
-
// For message_end
|
|
615
|
-
//
|
|
616
|
-
//
|
|
617
|
-
//
|
|
688
|
+
// For message_end: defer the SEND via setTimeout(0). Pi 0.69+ runs
|
|
689
|
+
// sessionManager.appendMessage AFTER the awaited extension dispatcher
|
|
690
|
+
// returns, so a queueMicrotask deferral is no longer enough. By the
|
|
691
|
+
// time the macrotask fires, appendMessage has run, pi has mutated
|
|
692
|
+
// event.message.id in place, and the wrapped appendMessage above has
|
|
693
|
+
// populated idByMessage. We also stamp a nonce so a downstream
|
|
694
|
+
// entry_persisted can correlate (covers user message_end where the
|
|
695
|
+
// earlier message_start nonce is what the reducer is waiting on).
|
|
696
|
+
// See change: fix-per-message-fork.
|
|
618
697
|
if (eventType === "message_end") {
|
|
619
|
-
|
|
620
|
-
const
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
698
|
+
wrapAppendMessageForCtx(ctx);
|
|
699
|
+
const messageRef = (event as any).message;
|
|
700
|
+
const nonce = messageRef && typeof messageRef === "object"
|
|
701
|
+
? (pendingNonces.get(messageRef as object) ?? nextNonce())
|
|
702
|
+
: nextNonce();
|
|
703
|
+
if (messageRef && typeof messageRef === "object" && !pendingNonces.has(messageRef as object)) {
|
|
704
|
+
pendingNonces.set(messageRef as object, nonce);
|
|
626
705
|
}
|
|
706
|
+
setTimeout(() => {
|
|
707
|
+
if (!isActive() || !sessionReady) return;
|
|
708
|
+
const entryId =
|
|
709
|
+
(messageRef && typeof messageRef === "object" && typeof messageRef.id === "string" ? messageRef.id : undefined)
|
|
710
|
+
?? (messageRef ? idByMessage.get(messageRef as object) : undefined)
|
|
711
|
+
?? ctx.sessionManager?.getLeafId?.();
|
|
712
|
+
const enriched = { ...event, entryId, nonce };
|
|
713
|
+
const protoMsg = mapEventToProtocol(sessionId, enriched);
|
|
714
|
+
connection.send(protoMsg);
|
|
715
|
+
}, 0);
|
|
716
|
+
return;
|
|
627
717
|
}
|
|
628
718
|
|
|
629
719
|
const msg = mapEventToProtocol(sessionId, event);
|
|
@@ -682,6 +772,15 @@ function initBridge(pi: ExtensionAPI) {
|
|
|
682
772
|
cachedCtx = ctx;
|
|
683
773
|
sessionId = newSessionId;
|
|
684
774
|
|
|
775
|
+
// Wrap sessionManager.appendMessage so that future message_end events can
|
|
776
|
+
// recover the just-generated entry id, even when their setTimeout(0)
|
|
777
|
+
// fires before pi has finished mutating event.message in place. The
|
|
778
|
+
// helper is idempotent and re-wraps on session replacement.
|
|
779
|
+
// See change: fix-per-message-fork.
|
|
780
|
+
appendMessageWrapped = false;
|
|
781
|
+
lastWrappedSm = null;
|
|
782
|
+
wrapAppendMessageForCtx(ctx);
|
|
783
|
+
|
|
685
784
|
// Register ask_user at runtime (not at load time) to avoid static
|
|
686
785
|
// tool-name conflicts with other extensions like pi-flows.
|
|
687
786
|
registerAskUserTool(pi);
|
|
@@ -951,17 +1050,72 @@ function initBridge(pi: ExtensionAPI) {
|
|
|
951
1050
|
}
|
|
952
1051
|
|
|
953
1052
|
// Discover or auto-start server (non-blocking — connection will reconnect)
|
|
1053
|
+
//
|
|
1054
|
+
// When a real launchServer() is about to run (not on mDNS/health-check
|
|
1055
|
+
// paths), mount an animated TUI widget above the editor using pi-tui's
|
|
1056
|
+
// Loader (a real Component, self-animating at 80ms, like pi-flows'
|
|
1057
|
+
// architect-widget). The previous implementation used
|
|
1058
|
+
// ctx.ui.setStatus(...) which only writes a footer string and relies on
|
|
1059
|
+
// the TUI render loop being ticked elsewhere — on the cold-start path
|
|
1060
|
+
// nothing else requests renders, so the spinner never animated and often
|
|
1061
|
+
// never appeared. setWidget(key, factory, {placement:"aboveEditor"}) gives
|
|
1062
|
+
// us a managed component that owns its own render loop and is always
|
|
1063
|
+
// visible while the launch is in flight.
|
|
1064
|
+
let spinnerTimer: NodeJS.Timeout | null = null;
|
|
1065
|
+
let spinnerStart = 0;
|
|
1066
|
+
let activeLoader: Loader | null = null;
|
|
1067
|
+
const stopSpinner = () => {
|
|
1068
|
+
if (spinnerTimer) {
|
|
1069
|
+
clearInterval(spinnerTimer);
|
|
1070
|
+
spinnerTimer = null;
|
|
1071
|
+
}
|
|
1072
|
+
activeLoader = null;
|
|
1073
|
+
ctx.ui.setWidget("pi-dashboard-launch", undefined);
|
|
1074
|
+
};
|
|
954
1075
|
autoStartServer(config, {
|
|
955
1076
|
discoverDashboard,
|
|
956
1077
|
isDashboardRunning,
|
|
957
1078
|
launchServer,
|
|
958
1079
|
notify: (msg, level) => ctx.ui.notify(msg, level),
|
|
1080
|
+
onLaunchStart: () => {
|
|
1081
|
+
spinnerStart = Date.now();
|
|
1082
|
+
const buildMessage = () => {
|
|
1083
|
+
const elapsed = Math.floor((Date.now() - spinnerStart) / 1000);
|
|
1084
|
+
return `starting dashboard server … (${elapsed}s)`;
|
|
1085
|
+
};
|
|
1086
|
+
ctx.ui.setWidget(
|
|
1087
|
+
"pi-dashboard-launch",
|
|
1088
|
+
(tui: unknown, theme: { fg: (role: string, s: string) => string }) => {
|
|
1089
|
+
const loader = new Loader(
|
|
1090
|
+
tui as ConstructorParameters<typeof Loader>[0],
|
|
1091
|
+
(s: string) => theme.fg("accent", s),
|
|
1092
|
+
(s: string) => theme.fg("muted", s),
|
|
1093
|
+
buildMessage(),
|
|
1094
|
+
);
|
|
1095
|
+
activeLoader = loader;
|
|
1096
|
+
// Loader has stop() but no dispose(); wire dispose so that
|
|
1097
|
+
// setExtensionWidget's teardown stops the 80ms animation interval.
|
|
1098
|
+
(loader as Loader & { dispose?: () => void }).dispose = () => loader.stop();
|
|
1099
|
+
return loader;
|
|
1100
|
+
},
|
|
1101
|
+
{ placement: "aboveEditor" },
|
|
1102
|
+
);
|
|
1103
|
+
// Refresh the elapsed-seconds label every second. Frame animation is
|
|
1104
|
+
// driven by the Loader's own 80ms interval.
|
|
1105
|
+
spinnerTimer = setInterval(() => {
|
|
1106
|
+
activeLoader?.setMessage(buildMessage());
|
|
1107
|
+
}, 1000);
|
|
1108
|
+
},
|
|
1109
|
+
onLaunchEnd: () => {
|
|
1110
|
+
stopSpinner();
|
|
1111
|
+
},
|
|
959
1112
|
}).then((result) => {
|
|
1113
|
+
stopSpinner(); // safety net — covers onLaunchEnd not firing
|
|
960
1114
|
if (result.server && result.server.piPort !== config.piPort) {
|
|
961
1115
|
// Server found on a different piPort than configured — update connection URL
|
|
962
1116
|
connection.updateUrl(`ws://${result.server.host === 'localhost' ? 'localhost' : result.server.host}:${result.server.piPort}`);
|
|
963
1117
|
}
|
|
964
|
-
}).catch(() => {});
|
|
1118
|
+
}).catch(() => { stopSpinner(); });
|
|
965
1119
|
|
|
966
1120
|
// Send initial git info
|
|
967
1121
|
sendGitInfoIfChanged(ctx.cwd);
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Dev build-on-reload helper.
|
|
3
3
|
* Builds the Vite client and requests server shutdown.
|
|
4
4
|
*/
|
|
5
|
-
import { execSync as defaultExecSync } from "
|
|
5
|
+
import { execSync as defaultExecSync } from "@blackbelt-technology/pi-dashboard-shared/platform/exec.js";
|
|
6
6
|
|
|
7
7
|
export interface DevBuildOptions {
|
|
8
8
|
packageRoot: string;
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Git info gathering — detects branch, remote URL, and PR number.
|
|
3
|
+
* Delegates to the shared git tool module so there's no inline execSync
|
|
4
|
+
* and every call benefits from the runner's safety defaults (windowsHide,
|
|
5
|
+
* timeout, tolerated exit codes).
|
|
6
|
+
* See change: platform-command-executor.
|
|
3
7
|
*/
|
|
4
|
-
import
|
|
8
|
+
import * as git from "@blackbelt-technology/pi-dashboard-shared/platform/git.js";
|
|
5
9
|
import { buildGitLinks, type GitLinks } from "./git-link-builder.js";
|
|
6
10
|
|
|
7
11
|
export interface GitInfo {
|
|
@@ -11,39 +15,25 @@ export interface GitInfo {
|
|
|
11
15
|
gitPrUrl?: string;
|
|
12
16
|
}
|
|
13
17
|
|
|
14
|
-
/** Run a shell command and return trimmed stdout, or undefined on failure. */
|
|
15
|
-
function runGit(command: string, cwd: string): string | undefined {
|
|
16
|
-
try {
|
|
17
|
-
return execSync(command, { cwd, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"], timeout: 5000 }).trim();
|
|
18
|
-
} catch {
|
|
19
|
-
return undefined;
|
|
20
|
-
}
|
|
21
|
-
}
|
|
22
|
-
|
|
23
18
|
/** Detect the current git branch. Returns short SHA for detached HEAD. */
|
|
24
19
|
export function detectBranch(cwd: string): string | undefined {
|
|
25
|
-
const ref =
|
|
20
|
+
const ref = git.currentBranchOr({ cwd });
|
|
26
21
|
if (!ref) return undefined;
|
|
27
22
|
if (ref === "HEAD") {
|
|
28
23
|
// Detached HEAD — return short commit SHA
|
|
29
|
-
return
|
|
24
|
+
return git.headShaOr({ cwd, short: true }) ?? "HEAD";
|
|
30
25
|
}
|
|
31
26
|
return ref;
|
|
32
27
|
}
|
|
33
28
|
|
|
34
29
|
/** Detect the remote origin URL. */
|
|
35
30
|
export function detectRemoteUrl(cwd: string): string | undefined {
|
|
36
|
-
return
|
|
31
|
+
return git.remoteUrlOr({ cwd });
|
|
37
32
|
}
|
|
38
33
|
|
|
39
34
|
/** Detect the PR number via gh CLI (best effort). */
|
|
40
35
|
export function detectPrNumber(cwd: string): number | undefined {
|
|
41
|
-
|
|
42
|
-
if (result) {
|
|
43
|
-
const num = parseInt(result, 10);
|
|
44
|
-
if (!isNaN(num)) return num;
|
|
45
|
-
}
|
|
46
|
-
return undefined;
|
|
36
|
+
return git.prNumberOr({ cwd });
|
|
47
37
|
}
|
|
48
38
|
|
|
49
39
|
/** Gather all git info for a directory. Returns undefined if not a git repo. */
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MultiSelectList — a TUI multi-select component implementing pi-tui's
|
|
3
|
+
* `Component` interface. Used by `polyfillMultiselect` to emulate the
|
|
4
|
+
* `ctx.ui.multiselect(...)` call that `pi-coding-agent`'s `ExtensionUIContext`
|
|
5
|
+
* does not expose natively.
|
|
6
|
+
*
|
|
7
|
+
* Keyboard contract (intentional — no "select all" binding in TUI):
|
|
8
|
+
* ↑ / k move cursor up
|
|
9
|
+
* ↓ / j move cursor down
|
|
10
|
+
* space toggle the checked state of the current item
|
|
11
|
+
* enter confirm → onConfirm(selected[])
|
|
12
|
+
* esc cancel → onCancel()
|
|
13
|
+
*
|
|
14
|
+
* The selected array preserves the original option order, not toggle order.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
interface Item {
|
|
18
|
+
value: string;
|
|
19
|
+
label: string;
|
|
20
|
+
description?: string;
|
|
21
|
+
checked: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Minimal shape of pi-tui's `Component` interface — we avoid importing from
|
|
26
|
+
* `@mariozechner/pi-tui` directly so this module stays compile-friendly when
|
|
27
|
+
* that peer dep isn't present (e.g. in unit tests running via vitest without
|
|
28
|
+
* the full pi runtime).
|
|
29
|
+
*/
|
|
30
|
+
export interface ComponentLike {
|
|
31
|
+
render(width: number): string[];
|
|
32
|
+
handleInput?(data: string): void;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const CURSOR = "▸ ";
|
|
36
|
+
const NO_CURSOR = " ";
|
|
37
|
+
const CHECKED = "[x]";
|
|
38
|
+
const UNCHECKED = "[ ]";
|
|
39
|
+
const FOOTER_HINT = "space toggle · enter confirm · esc cancel";
|
|
40
|
+
|
|
41
|
+
const MAX_VISIBLE = 10;
|
|
42
|
+
|
|
43
|
+
function truncate(text: string, maxWidth: number): string {
|
|
44
|
+
if (maxWidth <= 1) return "";
|
|
45
|
+
if (text.length <= maxWidth) return text;
|
|
46
|
+
if (maxWidth <= 1) return "…";
|
|
47
|
+
return text.slice(0, Math.max(0, maxWidth - 1)) + "…";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export class MultiSelectList implements ComponentLike {
|
|
51
|
+
private items: Item[];
|
|
52
|
+
private cursor = 0;
|
|
53
|
+
private scrollOffset = 0;
|
|
54
|
+
|
|
55
|
+
onConfirm?: (selectedValues: string[]) => void;
|
|
56
|
+
onCancel?: () => void;
|
|
57
|
+
|
|
58
|
+
constructor(
|
|
59
|
+
private title: string,
|
|
60
|
+
options: string[],
|
|
61
|
+
private message?: string,
|
|
62
|
+
) {
|
|
63
|
+
this.items = options.map((opt) => ({
|
|
64
|
+
value: opt,
|
|
65
|
+
label: opt,
|
|
66
|
+
checked: false,
|
|
67
|
+
}));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Expose current state for testing / adapters. */
|
|
71
|
+
getItems(): readonly Item[] {
|
|
72
|
+
return this.items;
|
|
73
|
+
}
|
|
74
|
+
getCursor(): number {
|
|
75
|
+
return this.cursor;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Return values of currently checked items in original option order. */
|
|
79
|
+
private selectedValues(): string[] {
|
|
80
|
+
return this.items.filter((it) => it.checked).map((it) => it.value);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
render(width: number): string[] {
|
|
84
|
+
const lines: string[] = [];
|
|
85
|
+
if (this.title) lines.push(truncate(this.title, width));
|
|
86
|
+
if (this.message) lines.push(truncate(this.message, width));
|
|
87
|
+
if (lines.length > 0) lines.push("");
|
|
88
|
+
|
|
89
|
+
// Scroll window around cursor.
|
|
90
|
+
const visible = Math.min(MAX_VISIBLE, this.items.length);
|
|
91
|
+
if (this.cursor < this.scrollOffset) {
|
|
92
|
+
this.scrollOffset = this.cursor;
|
|
93
|
+
} else if (this.cursor >= this.scrollOffset + visible) {
|
|
94
|
+
this.scrollOffset = this.cursor - visible + 1;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
for (let i = 0; i < visible; i++) {
|
|
98
|
+
const idx = this.scrollOffset + i;
|
|
99
|
+
const item = this.items[idx];
|
|
100
|
+
if (!item) break;
|
|
101
|
+
const marker = idx === this.cursor ? CURSOR : NO_CURSOR;
|
|
102
|
+
const box = item.checked ? CHECKED : UNCHECKED;
|
|
103
|
+
let line = `${marker}${box} ${item.label}`;
|
|
104
|
+
if (item.description) line += ` — ${item.description}`;
|
|
105
|
+
lines.push(truncate(line, width));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (this.items.length > visible) {
|
|
109
|
+
lines.push(` (${this.cursor + 1}/${this.items.length})`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
lines.push("");
|
|
113
|
+
lines.push(truncate(FOOTER_HINT, width));
|
|
114
|
+
return lines;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
handleInput(data: string): void {
|
|
118
|
+
// Escape
|
|
119
|
+
if (data === "\u001b" || data === "\x1b") {
|
|
120
|
+
this.onCancel?.();
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
// Enter (CR or LF)
|
|
124
|
+
if (data === "\r" || data === "\n") {
|
|
125
|
+
this.onConfirm?.(this.selectedValues());
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
// Space — toggle current
|
|
129
|
+
if (data === " ") {
|
|
130
|
+
const item = this.items[this.cursor];
|
|
131
|
+
if (item) item.checked = !item.checked;
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
// Arrow up / k
|
|
135
|
+
if (data === "\u001b[A" || data === "k") {
|
|
136
|
+
if (this.cursor > 0) this.cursor--;
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
// Arrow down / j
|
|
140
|
+
if (data === "\u001b[B" || data === "j") {
|
|
141
|
+
if (this.cursor < this.items.length - 1) this.cursor++;
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
// Everything else (including "a", "A", bulk-toggle attempts) is a no-op.
|
|
145
|
+
}
|
|
146
|
+
}
|