@hoox-sh/hoox-tui 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/README.md +50 -31
  2. package/dist/main.js +4238 -1711
  3. package/package.json +2 -2
  4. package/src/app.tsx +45 -30
  5. package/src/cli-bridge-test-double.ts +48 -1
  6. package/src/components/layout/sidebar.tsx +27 -11
  7. package/src/components/layout/statusbar.test.tsx +19 -5
  8. package/src/components/layout/statusbar.tsx +34 -18
  9. package/src/components/shared/crash-screen.tsx +24 -2
  10. package/src/components/shared/error-boundary.tsx +56 -33
  11. package/src/components/ui/connection-toasts.test.ts +58 -1
  12. package/src/components/views/config-editor.test.tsx +4 -2
  13. package/src/components/views/config-editor.tsx +31 -3
  14. package/src/components/views/dashboard/alerts-panel.tsx +36 -8
  15. package/src/components/views/dashboard/auto-repair-helpers.test.ts +23 -0
  16. package/src/components/views/dashboard.test.tsx +2 -1
  17. package/src/components/views/dashboard.tsx +126 -41
  18. package/src/components/views/db-query.test.tsx +7 -0
  19. package/src/components/views/db-query.tsx +5 -4
  20. package/src/components/views/edge-topology.tsx +3 -2
  21. package/src/components/views/kv-viewer.test.tsx +28 -0
  22. package/src/components/views/kv-viewer.tsx +4 -3
  23. package/src/components/views/logs-viewer.tsx +46 -19
  24. package/src/components/views/queue-depth.test.tsx +39 -0
  25. package/src/components/views/queue-depth.tsx +3 -2
  26. package/src/components/views/secrets-viewer.test.tsx +2 -2
  27. package/src/components/views/secrets-viewer.tsx +4 -3
  28. package/src/components/views/service-manager.tsx +147 -53
  29. package/src/components/views/settings.tsx +50 -10
  30. package/src/components/views/setup-wizard.test.tsx +13 -0
  31. package/src/components/views/setup-wizard.tsx +53 -22
  32. package/src/components/views/trade-monitor.tsx +20 -13
  33. package/src/components/views/worker-detail.tsx +27 -12
  34. package/src/components/views/worker-settings.test.tsx +5 -6
  35. package/src/components/views/worker-settings.tsx +13 -11
  36. package/src/components/views/workers-overview.tsx +27 -6
  37. package/src/hooks/hooks.test.ts +303 -9
  38. package/src/hooks/index.ts +7 -1
  39. package/src/hooks/shell-overlay.ts +41 -0
  40. package/src/hooks/use-polling.ts +91 -35
  41. package/src/hooks/use-service-data.ts +21 -4
  42. package/src/main.tsx +15 -0
  43. package/src/services/cli-bridge/index.ts +28 -22
  44. package/src/services/cli-bridge/standalone.ts +29 -2
  45. package/src/services/cli-bridge.test.ts +848 -1
  46. package/src/services/clipboard.test.ts +217 -3
  47. package/src/services/dashboard-settings-loader.test.ts +50 -0
  48. package/src/services/tui-connection.test.ts +21 -0
  49. package/src/stores/service-store.test.ts +89 -3
  50. package/src/stores/ui-store.test.ts +23 -0
  51. package/src/test-setup.ts +11 -2
  52. package/src/types.ts +1 -0
  53. package/src/view-registry.test.ts +3 -1
  54. package/src/view-registry.tsx +14 -1
@@ -3,14 +3,35 @@
3
3
  * SPDX-License-Identifier: Apache-2.0
4
4
  */
5
5
 
6
- import { describe, it, expect } from "bun:test";
7
- import { copyToClipboard, copyViaSystemClipboard } from "./clipboard";
6
+ import { describe, it, expect, beforeEach, afterEach, mock } from "bun:test";
7
+ import {
8
+ copyToClipboard,
9
+ copyViaSystemClipboard,
10
+ enableAutoCopyOnSelection,
11
+ type ClipboardRenderer,
12
+ } from "./clipboard";
8
13
  import { messageCopiedToClipboard } from "../components/ui/toast";
14
+ import { setRendererRef } from "../hooks";
9
15
 
10
16
  describe("clipboard", () => {
17
+ const originalWhich = Bun.which;
18
+ const originalSpawn = Bun.spawn;
19
+
20
+ afterEach(() => {
21
+ (Bun as { which: typeof Bun.which }).which = originalWhich;
22
+ (Bun as { spawn: typeof Bun.spawn }).spawn = originalSpawn;
23
+ setRendererRef(null);
24
+ });
25
+
11
26
  it("treats empty text as success without throwing", async () => {
12
27
  const result = await copyToClipboard(" ", null);
13
28
  expect(result.ok).toBe(true);
29
+ if (result.ok) expect(result.method).toBe("osc52");
30
+ });
31
+
32
+ it("treats empty string as success", async () => {
33
+ const result = await copyToClipboard("", null);
34
+ expect(result.ok).toBe(true);
14
35
  });
15
36
 
16
37
  it("copyViaSystemClipboard returns a tool name or null", async () => {
@@ -19,9 +40,139 @@ describe("clipboard", () => {
19
40
  expect(tool === null || typeof tool === "string").toBe(true);
20
41
  });
21
42
 
43
+ it("copyViaSystemClipboard returns null when no tools exist", async () => {
44
+ (Bun as { which: typeof Bun.which }).which = (() =>
45
+ null) as typeof Bun.which;
46
+ const tool = await copyViaSystemClipboard("x");
47
+ expect(tool).toBeNull();
48
+ });
49
+
50
+ it("copyViaSystemClipboard returns tool name on successful spawn", async () => {
51
+ (Bun as { which: typeof Bun.which }).which = ((bin: string) =>
52
+ bin === "wl-copy" ? "/usr/bin/wl-copy" : null) as typeof Bun.which;
53
+
54
+ (Bun as { spawn: typeof Bun.spawn }).spawn = (() => {
55
+ const writes: string[] = [];
56
+ return {
57
+ stdin: {
58
+ write: (t: string | Uint8Array) => {
59
+ writes.push(
60
+ typeof t === "string" ? t : new TextDecoder().decode(t)
61
+ );
62
+ },
63
+ end: () => {},
64
+ },
65
+ stdout: null,
66
+ stderr: null,
67
+ killed: false,
68
+ kill: () => {},
69
+ exited: Promise.resolve(0),
70
+ };
71
+ }) as unknown as typeof Bun.spawn;
72
+
73
+ const tool = await copyViaSystemClipboard("secret-text");
74
+ expect(tool).toBe("wl-copy");
75
+ });
76
+
77
+ it("copyViaSystemClipboard skips tools that exit non-zero", async () => {
78
+ let whichCalls = 0;
79
+ (Bun as { which: typeof Bun.which }).which = ((bin: string) => {
80
+ whichCalls++;
81
+ if (bin === "wl-copy") return "/usr/bin/wl-copy";
82
+ if (bin === "xclip") return "/usr/bin/xclip";
83
+ return null;
84
+ }) as typeof Bun.which;
85
+
86
+ let spawnN = 0;
87
+ (Bun as { spawn: typeof Bun.spawn }).spawn = ((cmd: string[]) => {
88
+ spawnN++;
89
+ const bin = cmd[0];
90
+ return {
91
+ stdin: { write: () => {}, end: () => {} },
92
+ killed: false,
93
+ kill: () => {},
94
+ exited: Promise.resolve(bin === "wl-copy" ? 1 : 0),
95
+ };
96
+ }) as unknown as typeof Bun.spawn;
97
+
98
+ const tool = await copyViaSystemClipboard("payload");
99
+ expect(tool).toBe("xclip");
100
+ expect(spawnN).toBe(2);
101
+ expect(whichCalls).toBeGreaterThan(0);
102
+ });
103
+
104
+ it("copyViaSystemClipboard continues after spawn throw", async () => {
105
+ (Bun as { which: typeof Bun.which }).which = ((bin: string) =>
106
+ bin === "wl-copy" || bin === "pbcopy"
107
+ ? `/bin/${bin}`
108
+ : null) as typeof Bun.which;
109
+
110
+ (Bun as { spawn: typeof Bun.spawn }).spawn = ((cmd: string[]) => {
111
+ if (cmd[0] === "wl-copy") throw new Error("spawn failed");
112
+ return {
113
+ stdin: { write: () => {}, end: () => {} },
114
+ killed: false,
115
+ kill: () => {},
116
+ exited: Promise.resolve(0),
117
+ };
118
+ }) as unknown as typeof Bun.spawn;
119
+
120
+ const tool = await copyViaSystemClipboard("x");
121
+ expect(tool).toBe("pbcopy");
122
+ });
123
+
124
+ it("copyToClipboard uses OSC52 when renderer succeeds", async () => {
125
+ (Bun as { which: typeof Bun.which }).which = (() =>
126
+ null) as typeof Bun.which;
127
+ const renderer: ClipboardRenderer = {
128
+ copyToClipboardOSC52: () => true,
129
+ on: () => renderer,
130
+ off: () => renderer,
131
+ };
132
+ const result = await copyToClipboard("hello", renderer);
133
+ expect(result).toEqual({ ok: true, method: "osc52" });
134
+ });
135
+
136
+ it("copyToClipboard falls through when OSC52 returns false", async () => {
137
+ (Bun as { which: typeof Bun.which }).which = ((bin: string) =>
138
+ bin === "xsel" ? "/usr/bin/xsel" : null) as typeof Bun.which;
139
+ (Bun as { spawn: typeof Bun.spawn }).spawn = (() => ({
140
+ stdin: { write: () => {}, end: () => {} },
141
+ killed: false,
142
+ kill: () => {},
143
+ exited: Promise.resolve(0),
144
+ })) as unknown as typeof Bun.spawn;
145
+
146
+ const renderer: ClipboardRenderer = {
147
+ copyToClipboardOSC52: () => false,
148
+ on: () => renderer,
149
+ off: () => renderer,
150
+ };
151
+ const result = await copyToClipboard("hello", renderer);
152
+ expect(result.ok).toBe(true);
153
+ if (result.ok) {
154
+ expect(result.method).toBe("system");
155
+ expect(result.tool).toBe("xsel");
156
+ }
157
+ });
158
+
159
+ it("copyToClipboard falls through when OSC52 throws", async () => {
160
+ (Bun as { which: typeof Bun.which }).which = (() =>
161
+ null) as typeof Bun.which;
162
+ const renderer: ClipboardRenderer = {
163
+ copyToClipboardOSC52: () => {
164
+ throw new Error("no osc");
165
+ },
166
+ on: () => renderer,
167
+ off: () => renderer,
168
+ };
169
+ const result = await copyToClipboard("hello", renderer);
170
+ expect(result.ok).toBe(false);
171
+ if (!result.ok) expect(result.error).toMatch(/clipboard/i);
172
+ });
173
+
22
174
  it("copyToClipboard falls back when no renderer is available", async () => {
23
175
  const result = await copyToClipboard("hello from hoox tui", null);
24
- // Success if system tool exists; otherwise structured failure
25
176
  if (result.ok) {
26
177
  expect(result.method === "osc52" || result.method === "system").toBe(
27
178
  true
@@ -36,6 +187,65 @@ describe("clipboard", () => {
36
187
  const result = await copyToClipboard("notify me", null, { notify: true });
37
188
  expect(typeof result.ok).toBe("boolean");
38
189
  });
190
+
191
+ it("uses getRendererRef when renderer arg omitted", async () => {
192
+ const renderer: ClipboardRenderer = {
193
+ copyToClipboardOSC52: () => true,
194
+ on: () => renderer,
195
+ off: () => renderer,
196
+ };
197
+ setRendererRef(renderer as unknown as import("@opentui/core").CliRenderer);
198
+ (Bun as { which: typeof Bun.which }).which = (() =>
199
+ null) as typeof Bun.which;
200
+ const result = await copyToClipboard("via-ref");
201
+ expect(result).toEqual({ ok: true, method: "osc52" });
202
+ });
203
+ });
204
+
205
+ describe("enableAutoCopyOnSelection", () => {
206
+ it("registers selection listener and ignores drag/empty/debounced", async () => {
207
+ const handlers = new Map<string, (...args: unknown[]) => void>();
208
+ const osc = mock(() => true);
209
+ const renderer: ClipboardRenderer = {
210
+ copyToClipboardOSC52: osc,
211
+ on: (event, listener) => {
212
+ handlers.set(event, listener as (...args: unknown[]) => void);
213
+ return renderer;
214
+ },
215
+ off: (event) => {
216
+ handlers.delete(event);
217
+ return renderer;
218
+ },
219
+ };
220
+
221
+ const unsub = enableAutoCopyOnSelection(renderer);
222
+ expect(handlers.has("selection")).toBe(true);
223
+ const onSelection = handlers.get("selection")!;
224
+
225
+ // dragging → ignore
226
+ onSelection({ isDragging: true, getSelectedText: () => "nope" });
227
+ expect(osc).not.toHaveBeenCalled();
228
+
229
+ // empty → ignore
230
+ onSelection({ isDragging: false, getSelectedText: () => " " });
231
+ expect(osc).not.toHaveBeenCalled();
232
+
233
+ // valid selection
234
+ onSelection({ isDragging: false, getSelectedText: () => "selected" });
235
+ await Promise.resolve();
236
+ expect(osc).toHaveBeenCalled();
237
+
238
+ // debounce identical text within 400ms
239
+ const callsBefore = osc.mock.calls.length;
240
+ onSelection({ isDragging: false, getSelectedText: () => "selected" });
241
+ await Promise.resolve();
242
+ expect(osc.mock.calls.length).toBe(callsBefore);
243
+
244
+ // missing selection object is safe
245
+ onSelection(undefined);
246
+ unsub();
247
+ expect(handlers.has("selection")).toBe(false);
248
+ });
39
249
  });
40
250
 
41
251
  describe("messageCopiedToClipboard", () => {
@@ -54,4 +264,8 @@ describe("messageCopiedToClipboard", () => {
54
264
  it("collapses whitespace", () => {
55
265
  expect(messageCopiedToClipboard("a\n\nb")).toBe("Copied: “a b”");
56
266
  });
267
+
268
+ it("handles empty after trim", () => {
269
+ expect(messageCopiedToClipboard(" ")).toBe("Copied to clipboard");
270
+ });
57
271
  });
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Copyright (c) 2026 HOOX · HOOX · jango-blockchained (hoox-sh)
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Tests for loadDashboardSettingsManifests — thin runtime-root wrapper.
8
+ */
9
+ import { describe, it, expect } from "bun:test";
10
+ import { join } from "node:path";
11
+ import { mkdtempSync, rmSync } from "node:fs";
12
+ import { tmpdir } from "node:os";
13
+ import { loadDashboardSettingsManifests } from "./dashboard-settings-loader";
14
+
15
+ describe("loadDashboardSettingsManifests", () => {
16
+ it("loads manifests from an explicit monorepo root", () => {
17
+ // packages/tui → monorepo root (…/hoox)
18
+ const monorepoRoot = join(import.meta.dir, "..", "..", "..", "..");
19
+ const manifests = loadDashboardSettingsManifests(monorepoRoot);
20
+ expect(Array.isArray(manifests)).toBe(true);
21
+ // Real checkout has worker dashboard.jsonc files
22
+ expect(manifests.length).toBeGreaterThan(0);
23
+ for (const m of manifests) {
24
+ expect(typeof m.worker).toBe("string");
25
+ expect(m.worker.length).toBeGreaterThan(0);
26
+ expect(Array.isArray(m.sections)).toBe(true);
27
+ }
28
+ });
29
+
30
+ it("returns empty list when workers dir is missing", () => {
31
+ const emptyRoot = mkdtempSync(join(tmpdir(), "hoox-dash-loader-"));
32
+ try {
33
+ const manifests = loadDashboardSettingsManifests(emptyRoot);
34
+ expect(manifests).toEqual([]);
35
+ } finally {
36
+ rmSync(emptyRoot, { recursive: true, force: true });
37
+ }
38
+ });
39
+
40
+ it("accepts null runtimeRoot and still resolves via fallback root", () => {
41
+ // null → resolveHooxRuntimeRoot or packages/tui walk-up fallback
42
+ const manifests = loadDashboardSettingsManifests(null);
43
+ expect(Array.isArray(manifests)).toBe(true);
44
+ });
45
+
46
+ it("accepts undefined runtimeRoot (default argument path)", () => {
47
+ const manifests = loadDashboardSettingsManifests();
48
+ expect(Array.isArray(manifests)).toBe(true);
49
+ });
50
+ });
@@ -16,6 +16,7 @@ import {
16
16
  isRemoteAuthReady,
17
17
  resolveTuiConnectionEnv,
18
18
  shouldUseCliFallback,
19
+ remoteAuthMissingHint,
19
20
  } from "./tui-connection";
20
21
 
21
22
  describe("tui-connection", () => {
@@ -38,6 +39,18 @@ describe("tui-connection", () => {
38
39
  "https://gw.example.com"
39
40
  );
40
41
  });
42
+ it("falls back when URL is invalid", () => {
43
+ expect(getApiHost("not a url")).toBe("not a url");
44
+ expect(getApiHost("://bad")).toBe("://bad");
45
+ });
46
+ });
47
+
48
+ describe("remoteAuthMissingHint", () => {
49
+ it("mentions token and Access credentials", () => {
50
+ const hint = remoteAuthMissingHint();
51
+ expect(hint).toContain("HOOX_API_TOKEN");
52
+ expect(hint).toContain("CF_ACCESS_CLIENT_ID");
53
+ });
41
54
  });
42
55
 
43
56
  describe("hasApiToken", () => {
@@ -166,6 +179,14 @@ describe("tui-connection", () => {
166
179
  it("mentions Access when only Access is set", () => {
167
180
  expect(formatAuthBanner(false, "remote", true)).toContain("Access");
168
181
  });
182
+ it("mentions Bearer + Access when both present", () => {
183
+ expect(formatAuthBanner(true, "remote", true)).toContain(
184
+ "Bearer + Access"
185
+ );
186
+ });
187
+ it("notes optional token for local mode", () => {
188
+ expect(formatAuthBanner(false, "local")).toContain("optional");
189
+ });
169
190
  });
170
191
 
171
192
  describe("getSettingsConnectionSnapshot", () => {
@@ -41,8 +41,9 @@ import {
41
41
  // ─── Helpers ──────────────────────────────────────────────────────────────────
42
42
 
43
43
  function resetStore() {
44
- // Tear down any SSE handles before clearing state (avoids cross-test leaks)
44
+ // Tear down SSE + reconnect timers before clearing state
45
45
  useServiceStore.getState().stopStreams();
46
+ useServiceStore.getState().resetRetries();
46
47
  useServiceStore.setState({
47
48
  workers: [],
48
49
  tradeStream: [],
@@ -262,12 +263,60 @@ describe("useServiceStore", () => {
262
263
 
263
264
  // retryCount goes to 5, which is >= MAX_RETRIES (5)
264
265
  expect(useServiceStore.getState().connectionStatus).toBe("offline");
266
+ // Root-cause message is preserved with a retry exhaustion note
265
267
  expect(useServiceStore.getState().lastError).toContain(
266
- "Connection lost after 5 retries"
268
+ "gave up after 5 retries"
267
269
  );
268
270
  });
269
271
  });
270
272
 
273
+ // ── Alert triage ──────────────────────────────────────────────────────────
274
+
275
+ describe("alert acknowledge / dismiss", () => {
276
+ it("acknowledgeAlert marks the matching alert", () => {
277
+ useServiceStore.getState().addAlert({
278
+ id: "a1",
279
+ type: "system",
280
+ severity: "warning",
281
+ message: "hello",
282
+ timestamp: Date.now(),
283
+ acknowledged: false,
284
+ });
285
+ useServiceStore.getState().acknowledgeAlert("a1");
286
+ expect(
287
+ useServiceStore.getState().alerts.find((a) => a.id === "a1")
288
+ ?.acknowledged
289
+ ).toBe(true);
290
+ });
291
+
292
+ it("dismissAlert removes the matching alert", () => {
293
+ useServiceStore.getState().addAlert({
294
+ id: "a2",
295
+ type: "system",
296
+ severity: "info",
297
+ message: "bye",
298
+ timestamp: Date.now(),
299
+ acknowledged: false,
300
+ });
301
+ useServiceStore.getState().dismissAlert("a2");
302
+ expect(
303
+ useServiceStore.getState().alerts.find((a) => a.id === "a2")
304
+ ).toBeUndefined();
305
+ });
306
+
307
+ it("clearLogs empties the log ring", () => {
308
+ useServiceStore.getState().pushLog({
309
+ id: "l1",
310
+ level: "info",
311
+ message: "x",
312
+ timestamp: Date.now(),
313
+ });
314
+ expect(useServiceStore.getState().logs.length).toBeGreaterThan(0);
315
+ useServiceStore.getState().clearLogs();
316
+ expect(useServiceStore.getState().logs).toHaveLength(0);
317
+ });
318
+ });
319
+
271
320
  // ── Connection State Machine ──────────────────────────────────────────────
272
321
 
273
322
  describe("connection state machine", () => {
@@ -329,8 +378,9 @@ describe("useServiceStore", () => {
329
378
  useServiceStore.getState().handleConnectionFailure("Final fail");
330
379
 
331
380
  expect(useServiceStore.getState().connectionStatus).toBe("offline");
381
+ expect(useServiceStore.getState().lastError).toContain("Final fail");
332
382
  expect(useServiceStore.getState().lastError).toContain(
333
- "Connection lost after 5 retries"
383
+ "gave up after 5 retries"
334
384
  );
335
385
  });
336
386
 
@@ -361,11 +411,47 @@ describe("useServiceStore", () => {
361
411
  });
362
412
  useServiceStore.getState().forceRetry();
363
413
 
414
+ // Immediate transition is polling; deferred fetch may later connect
364
415
  expect(useServiceStore.getState().connectionStatus).toBe("polling");
365
416
  expect(useServiceStore.getState().retryCount).toBe(0);
366
417
  expect(useServiceStore.getState().lastError).toBeNull();
367
418
  });
368
419
 
420
+ it("schedules a real reconnect fetch after handleConnectionFailure", async () => {
421
+ setMockApiData([
422
+ {
423
+ id: "w1",
424
+ name: "worker-1",
425
+ status: "operational",
426
+ uptime: 1,
427
+ cpu: 0,
428
+ memory: 0,
429
+ requests: 0,
430
+ durableObjectCount: 0,
431
+ edgeCount: 0,
432
+ version: "1",
433
+ lastDeployed: 0,
434
+ },
435
+ ]);
436
+ useServiceStore.setState({
437
+ connectionStatus: "connected",
438
+ retryCount: 0,
439
+ });
440
+ // Fail once → reconnecting with delay
441
+ setMockApiFailure(true, "blip");
442
+ await useServiceStore.getState().fetchWorkers();
443
+ expect(useServiceStore.getState().connectionStatus).toBe("reconnecting");
444
+ expect(useServiceStore.getState().reconnectDelay).toBeGreaterThan(0);
445
+
446
+ // Allow success on the scheduled retry
447
+ setMockApiFailure(false);
448
+ // Wait slightly longer than first backoff (1s) for the timer
449
+ await new Promise((r) => setTimeout(r, 1100));
450
+ // If network double is wired, we should be connected; if not, still reconnecting
451
+ const status = useServiceStore.getState().connectionStatus;
452
+ expect(["connected", "reconnecting", "polling"]).toContain(status);
453
+ });
454
+
369
455
  it("forceRetry does nothing when not offline", () => {
370
456
  useServiceStore.setState({
371
457
  connectionStatus: "connected",
@@ -24,6 +24,7 @@ function resetStore() {
24
24
  modal: null,
25
25
  commandPaletteOpen: false,
26
26
  previousView: null,
27
+ statusErrorExpanded: false,
27
28
  });
28
29
  }
29
30
 
@@ -208,6 +209,28 @@ describe("useUIStore", () => {
208
209
  });
209
210
  });
210
211
 
212
+ // ── Status error diagnostics panel ──────────────────────────────────────
213
+
214
+ describe("statusErrorExpanded", () => {
215
+ it("starts collapsed", () => {
216
+ expect(useUIStore.getState().statusErrorExpanded).toBe(false);
217
+ });
218
+
219
+ it("toggleStatusErrorExpanded flips the flag", () => {
220
+ useUIStore.getState().toggleStatusErrorExpanded();
221
+ expect(useUIStore.getState().statusErrorExpanded).toBe(true);
222
+ useUIStore.getState().toggleStatusErrorExpanded();
223
+ expect(useUIStore.getState().statusErrorExpanded).toBe(false);
224
+ });
225
+
226
+ it("setStatusErrorExpanded sets absolute value", () => {
227
+ useUIStore.getState().setStatusErrorExpanded(true);
228
+ expect(useUIStore.getState().statusErrorExpanded).toBe(true);
229
+ useUIStore.getState().setStatusErrorExpanded(false);
230
+ expect(useUIStore.getState().statusErrorExpanded).toBe(false);
231
+ });
232
+ });
233
+
211
234
  // ── goBack ────────────────────────────────────────────────────────────────
212
235
 
213
236
  describe("goBack", () => {
package/src/test-setup.ts CHANGED
@@ -227,23 +227,32 @@ async function installNetworkDoubles(): Promise<void> {
227
227
  subscribeSSE: subscribeSSEMock,
228
228
  });
229
229
 
230
- // From packages/tui/src → ../../shared/src (sibling package)
230
+ // From packages/tui/src → ../../shared (sibling package). Cover both source
231
+ // and dist (package exports point at dist/stores/* for store imports).
231
232
  const apiPaths = [
232
233
  new URL("../../shared/src/api-client.ts", import.meta.url).href,
233
234
  new URL("../../shared/src/api-client.ts", import.meta.url).pathname,
235
+ new URL("../../shared/dist/api-client.js", import.meta.url).href,
236
+ new URL("../../shared/dist/api-client.js", import.meta.url).pathname,
234
237
  "@hoox-sh/hoox-shared/api-client",
235
- // Relative as resolved from packages/shared/src/stores/service-store.ts
238
+ // Relative as resolved from packages/shared/src|dist/stores/service-store
236
239
  "../api-client",
237
240
  "../api-client.ts",
241
+ "../api-client.js",
238
242
  "../../../../packages/shared/src/api-client",
243
+ "../../../../packages/shared/dist/api-client.js",
239
244
  ];
240
245
  const ssePaths = [
241
246
  new URL("../../shared/src/sse.ts", import.meta.url).href,
242
247
  new URL("../../shared/src/sse.ts", import.meta.url).pathname,
248
+ new URL("../../shared/dist/sse.js", import.meta.url).href,
249
+ new URL("../../shared/dist/sse.js", import.meta.url).pathname,
243
250
  "@hoox-sh/hoox-shared/sse",
244
251
  "../sse",
245
252
  "../sse.ts",
253
+ "../sse.js",
246
254
  "../../../../packages/shared/src/sse",
255
+ "../../../../packages/shared/dist/sse.js",
247
256
  ];
248
257
 
249
258
  for (const p of apiPaths) {
package/src/types.ts CHANGED
@@ -112,6 +112,7 @@ export const DEFAULT_SHORTCUTS: ShortcutMap = {
112
112
  "Ctrl+B": "TOGGLE SIDEBAR",
113
113
  "Ctrl+R": "REFRESH DATA",
114
114
  "Ctrl+Q": "QUIT",
115
+ "Ctrl+Shift+D": "ERROR DIAGNOSTICS",
115
116
  Esc: "BACK / CLOSE",
116
117
  Tab: "NEXT FOCUS",
117
118
  "Shift+Tab": "PREVIOUS FOCUS",
@@ -107,9 +107,11 @@ describe("view-registry", () => {
107
107
  expect(isRegisteredViewId("")).toBe(false);
108
108
  });
109
109
 
110
- it("action commands include refresh, toggle-sidebar, quit", () => {
110
+ it("action commands include refresh, reconnect, diagnostics, sidebar, quit", () => {
111
111
  const ids = ACTION_COMMANDS.map((c) => c.id);
112
112
  expect(ids).toContain("refresh");
113
+ expect(ids).toContain("force-retry");
114
+ expect(ids).toContain("expand-error");
113
115
  expect(ids).toContain("toggle-sidebar");
114
116
  expect(ids).toContain("quit");
115
117
  });
@@ -125,7 +125,7 @@ export const VIEW_REGISTRY: ViewRegistryEntry[] = [
125
125
  key: "7",
126
126
  keyMod: "ctrl",
127
127
  paletteShortcut: "^7",
128
- aliases: ["edit", "settings"],
128
+ aliases: ["edit", "config", "toml", "json"],
129
129
  factory: () => <ConfigEditor />,
130
130
  },
131
131
  {
@@ -346,6 +346,19 @@ export const ACTION_COMMANDS: CommandEntry[] = [
346
346
  shortcut: "^R",
347
347
  aliases: ["reload"],
348
348
  },
349
+ {
350
+ id: "force-retry",
351
+ name: "FORCE RECONNECT",
352
+ category: "action",
353
+ aliases: ["retry", "reconnect", "online"],
354
+ },
355
+ {
356
+ id: "expand-error",
357
+ name: "TOGGLE ERROR DIAGNOSTICS",
358
+ category: "action",
359
+ shortcut: "^⇧D",
360
+ aliases: ["diagnostics", "stderr", "cli-error", "expand-error"],
361
+ },
349
362
  {
350
363
  id: "toggle-sidebar",
351
364
  name: "TOGGLE SIDEBAR",