@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hoox-sh/hoox-tui",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Hoox algorithmic trading terminal operations center — OpenTUI dashboard",
5
5
  "type": "module",
6
6
  "main": "./dist/main.js",
@@ -32,7 +32,7 @@
32
32
  "prepublishOnly": "bun run typecheck && bun run build"
33
33
  },
34
34
  "dependencies": {
35
- "@hoox-sh/hoox-shared": "^1.2.0",
35
+ "@hoox-sh/hoox-shared": "^1.3.0",
36
36
  "@opentui-ui/dialog": "^0.1.2",
37
37
  "@opentui-ui/toast": "^0.0.5",
38
38
  "@opentui/core": "^0.5.1",
package/src/app.tsx CHANGED
@@ -131,38 +131,16 @@ function AppRootInner({ safeMode = false }: { safeMode?: boolean }) {
131
131
  }, [closePalette, showModal, dismissModal]);
132
132
 
133
133
  // ── Session restore on mount ────────────────────────────────────────────
134
- // Shared restoreSession currently only accepts the original 9 ViewIds.
135
- // Re-read session.json and accept any registry ViewId so newer views
136
- // (queues, kv, secrets, …) actually restore after restart.
134
+ // Shared restoreSession validates all 16 ViewIds (unknown → dashboard).
137
135
  useEffect(() => {
138
136
  let cancelled = false;
139
137
  (async () => {
140
138
  const session = await restoreSession();
141
139
  if (cancelled) return;
142
140
 
143
- let view: ViewId = session.activeView;
144
- let expanded = session.sidebarExpanded;
145
-
146
- try {
147
- const rawPath = resolveTuiStatePath("session.json");
148
- const file = Bun.file(rawPath);
149
- if (await file.exists()) {
150
- const raw = (await file.json()) as {
151
- activeView?: unknown;
152
- sidebarExpanded?: unknown;
153
- };
154
- if (isRegisteredViewId(raw.activeView)) {
155
- view = raw.activeView;
156
- }
157
- if (typeof raw.sidebarExpanded === "boolean") {
158
- expanded = raw.sidebarExpanded;
159
- }
160
- }
161
- } catch {
162
- // Corrupt raw session — keep shared restore result
163
- }
141
+ const view: ViewId = session.activeView;
142
+ const expanded = session.sidebarExpanded;
164
143
 
165
- if (cancelled) return;
166
144
  if (isRegisteredViewId(view)) {
167
145
  setView(view);
168
146
  }
@@ -479,10 +457,15 @@ function AppRootInner({ safeMode = false }: { safeMode?: boolean }) {
479
457
  return;
480
458
  }
481
459
 
482
- // Ctrl+R: refresh worker data (no-op in safe mode network sense — still allowed)
460
+ // Ctrl+R: refresh worker data + force reconnect from offline
483
461
  if (key.ctrl && !key.alt && name === "r") {
484
462
  if (!safeMode) {
485
- void useServiceStore.getState().fetchWorkers();
463
+ const store = useServiceStore.getState();
464
+ if (store.connectionStatus === "offline") {
465
+ store.forceRetry();
466
+ } else {
467
+ void store.fetchWorkers();
468
+ }
486
469
  }
487
470
  return;
488
471
  }
@@ -493,13 +476,20 @@ function AppRootInner({ safeMode = false }: { safeMode?: boolean }) {
493
476
  return;
494
477
  }
495
478
 
496
- // Escape: dismiss modal, then palette
479
+ // Escape: modal → palette → goBack (when previousView set)
497
480
  if (name === "escape") {
498
481
  if (modal) {
499
482
  dismissModal();
500
483
  return;
501
484
  }
502
- closePalette();
485
+ if (commandPaletteOpen) {
486
+ closePalette();
487
+ return;
488
+ }
489
+ const prev = useUIStore.getState().previousView;
490
+ if (prev) {
491
+ useUIStore.getState().goBack();
492
+ }
503
493
  }
504
494
  },
505
495
  [
@@ -555,6 +545,22 @@ function AppRootInner({ safeMode = false }: { safeMode?: boolean }) {
555
545
 
556
546
  {/* Content area: View (fills remaining space) */}
557
547
  <box flexDirection="column" flexGrow={1} padding={1}>
548
+ {safeMode ? (
549
+ <box
550
+ flexDirection="row"
551
+ paddingLeft={1}
552
+ paddingRight={1}
553
+ backgroundColor={Colors.card}
554
+ >
555
+ <text fg={Colors.warning} bold>
556
+ SAFE MODE
557
+ </text>
558
+ <text fg={Colors.muted} dim>
559
+ {" "}
560
+ — network / CLI / SSE disabled · restart for full ops
561
+ </text>
562
+ </box>
563
+ ) : null}
558
564
  {renderView(dialog)}
559
565
  </box>
560
566
  </box>
@@ -571,7 +577,16 @@ function AppRootInner({ safeMode = false }: { safeMode?: boolean }) {
571
577
  if (selection.action === "setView" && selection.command.id) {
572
578
  setView(selection.command.id as ViewId);
573
579
  } else if (selection.command.id === "refresh") {
574
- void useServiceStore.getState().fetchWorkers();
580
+ const store = useServiceStore.getState();
581
+ if (store.connectionStatus === "offline") {
582
+ store.forceRetry();
583
+ } else {
584
+ void store.fetchWorkers();
585
+ }
586
+ } else if (selection.command.id === "force-retry") {
587
+ useServiceStore.getState().forceRetry();
588
+ } else if (selection.command.id === "expand-error") {
589
+ useUIStore.getState().toggleStatusErrorExpanded();
575
590
  } else if (selection.command.id === "toggle-sidebar") {
576
591
  toggleSidebar();
577
592
  } else if (selection.command.id === "quit") {
@@ -188,13 +188,48 @@ export function resetCliBridgeDouble(): void {
188
188
  applyDefaults();
189
189
  }
190
190
 
191
+ /**
192
+ * Real CliBridgeImpl singleton loaded before mock.module installs the double.
193
+ * Used by method-level unit tests that stub `exec` without spawning CLI.
194
+ */
195
+ export let realCliBridge: {
196
+ exec: (...args: unknown[]) => Promise<unknown>;
197
+ dbQuery: (sql: string) => Promise<unknown>;
198
+ monitorQueueDepth: () => Promise<unknown>;
199
+ monitorKillSwitch: (action: string) => Promise<unknown>;
200
+ configKvList: () => Promise<unknown>;
201
+ configKvGet: (key: string) => Promise<unknown>;
202
+ configKvSet: (key: string, value: string) => Promise<unknown>;
203
+ configSecretsList: () => Promise<unknown>;
204
+ agentHealthCheck: () => Promise<unknown>;
205
+ pyneHealthCheck: () => Promise<unknown>;
206
+ deployAll: (...args: unknown[]) => Promise<unknown>;
207
+ deployWorker: (...args: unknown[]) => Promise<unknown>;
208
+ checkHealth: () => Promise<unknown>;
209
+ checkHealthFix: () => Promise<unknown>;
210
+ checkFix: () => Promise<unknown>;
211
+ workerLogs: (name: string) => Promise<unknown>;
212
+ configShow: () => Promise<unknown>;
213
+ configValidate: () => Promise<unknown>;
214
+ monitorStatus: () => Promise<unknown>;
215
+ rebuild: (...args: unknown[]) => Promise<unknown>;
216
+ repairWorker: (...args: unknown[]) => Promise<unknown>;
217
+ checkSetup: () => Promise<unknown>;
218
+ resolveBinary: () => Promise<string>;
219
+ invalidateCache: () => void;
220
+ onError: (sink: (details: unknown) => void) => () => void;
221
+ abort: (tag: string) => void;
222
+ dispose: () => void;
223
+ } | null = null;
224
+
191
225
  /** Factory for mock.module — re-exports real standalone + index helpers. */
192
226
  export async function createCliBridgeModuleMock(): Promise<
193
227
  Record<string, unknown>
194
228
  > {
195
229
  // Load real module exports *before* mock.module is installed so tests that
196
- // import tryParseJsonLoose / sanitizeCliArgsForLog still get the real fns.
230
+ // import pure parsers / sanitize helpers still get the real fns.
197
231
  const realBridge = await import("./services/cli-bridge/index");
232
+ realCliBridge = realBridge.cliBridge as typeof realCliBridge;
198
233
  const standalone = await import("./services/cli-bridge/standalone");
199
234
  const types = await import("./services/cli-bridge/types");
200
235
  return {
@@ -204,6 +239,18 @@ export async function createCliBridgeModuleMock(): Promise<
204
239
  AI_MODEL_OPTIONS: standalone.AI_MODEL_OPTIONS,
205
240
  tryParseJsonLoose: realBridge.tryParseJsonLoose,
206
241
  sanitizeCliArgsForLog: realBridge.sanitizeCliArgsForLog,
242
+ parseDbQueryResult: realBridge.parseDbQueryResult,
243
+ deriveQueueDepthStatus: realBridge.deriveQueueDepthStatus,
244
+ parseQueueDepths: realBridge.parseQueueDepths,
245
+ parseSecretsList: realBridge.parseSecretsList,
246
+ inferSecretType: realBridge.inferSecretType,
247
+ parseKvList: realBridge.parseKvList,
248
+ parseKvManifest: realBridge.parseKvManifest,
249
+ stripAnsi: realBridge.stripAnsi,
250
+ parseKillSwitchStatus: realBridge.parseKillSwitchStatus,
251
+ parsePyneHealth: realBridge.parsePyneHealth,
252
+ parseAgentHealth: realBridge.parseAgentHealth,
253
+ extractNamespaceId: realBridge.extractNamespaceId,
207
254
  // Type re-exports are erased at runtime; keep values if any exist
208
255
  ...Object.fromEntries(
209
256
  Object.entries(types).filter(([, v]) => typeof v !== "undefined")
@@ -42,27 +42,43 @@ export function Sidebar() {
42
42
  </CoolBrackets>
43
43
  <text fg={Colors.dim}>─────────────────</text>
44
44
 
45
- {/* Navigation items (registry order) */}
45
+ {/* Navigation items (registry order) — shortcut from registry */}
46
46
  {SIDEBAR_ITEMS.map((item) => {
47
47
  const isActive = item.id === activeView;
48
+ // Pad label so shortcut sits right-aligned in the fixed-width rail
49
+ const shortcutHint = item.shortcut;
48
50
  return (
49
- <box flexDirection="row" gap={1} key={item.id}>
50
- {isActive ? <CoolGlyph char="▸" /> : <text fg={Colors.dim}> </text>}
51
- <text
52
- fg={isActive ? Colors.accent : Colors.muted}
53
- bold={isActive}
54
- onMouseUp={() => setView(item.id)}
55
- >
56
- {item.label}
51
+ <box
52
+ flexDirection="row"
53
+ gap={1}
54
+ key={item.id}
55
+ justifyContent="space-between"
56
+ >
57
+ <box flexDirection="row" gap={1} flexGrow={1}>
58
+ {isActive ? (
59
+ <CoolGlyph char="▸" />
60
+ ) : (
61
+ <text fg={Colors.dim}> </text>
62
+ )}
63
+ <text
64
+ fg={isActive ? Colors.accent : Colors.muted}
65
+ bold={isActive}
66
+ onMouseUp={() => setView(item.id)}
67
+ >
68
+ {item.label}
69
+ </text>
70
+ </box>
71
+ <text fg={Colors.dim} dim>
72
+ {shortcutHint}
57
73
  </text>
58
74
  </box>
59
75
  );
60
76
  })}
61
77
 
62
- {/* Shortcut hints — digits are Ctrl; letter chords are Ctrl+Alt */}
78
+ {/* Shortcut legend — digits are Ctrl; ^X chords are Ctrl+Alt */}
63
79
  <box flexGrow={1} />
64
80
  <text fg={Colors.dim} dim>
65
- Ctrl+0-9 · Ctrl+Alt+…
81
+ 1-9 Ctrl · ^X Ctrl+Alt
66
82
  </text>
67
83
  </box>
68
84
  );
@@ -197,7 +197,7 @@ describe("StatusBar", () => {
197
197
  });
198
198
  const output = await renderStatusBar();
199
199
  // The expand hint is rendered when pillInteractive is true
200
- expect(output).toContain("click for details");
200
+ expect(output).toContain("details");
201
201
  });
202
202
 
203
203
  it("does not show the expand hint when no error details are present", async () => {
@@ -224,11 +224,12 @@ describe("StatusBar", () => {
224
224
  // ── Keyboard hints ─────────────────────────────────────────────────────
225
225
 
226
226
  describe("keyboard hints", () => {
227
- it("shows ^P PALETTE · ^B SIDEBAR · ^Q QUIT hint", async () => {
227
+ it("shows global shortcut hints including diagnostics chord", async () => {
228
228
  const output = await renderStatusBar();
229
- expect(output).toContain("PALETTE");
230
- expect(output).toContain("SIDEBAR");
231
- expect(output).toContain("QUIT");
229
+ expect(output).toContain("^P");
230
+ expect(output).toContain("^B");
231
+ expect(output).toContain("^Q");
232
+ expect(output).toContain("^⇧D");
232
233
  });
233
234
  });
234
235
 
@@ -278,10 +279,23 @@ describe("StatusBar", () => {
278
279
  process.env.HOOX_TUI_MODE = "remote";
279
280
  process.env.HOOX_API_URL = "https://gw.test";
280
281
  delete process.env.HOOX_API_TOKEN;
282
+ delete process.env.CF_ACCESS_CLIENT_ID;
283
+ delete process.env.CF_ACCESS_CLIENT_SECRET;
281
284
  const output = await renderStatusBar();
282
285
  expect(output).toContain("AUTH?");
283
286
  expect(output).toContain("gw.test");
284
287
  });
288
+
289
+ it("does not show AUTH? for Access-only remote credentials", async () => {
290
+ process.env.HOOX_TUI_MODE = "remote";
291
+ process.env.HOOX_API_URL = "https://gw.test";
292
+ delete process.env.HOOX_API_TOKEN;
293
+ process.env.CF_ACCESS_CLIENT_ID = "access-id";
294
+ process.env.CF_ACCESS_CLIENT_SECRET = "access-secret";
295
+ const output = await renderStatusBar();
296
+ expect(output).not.toContain("AUTH?");
297
+ expect(output).toContain("gw.test");
298
+ });
285
299
  });
286
300
  });
287
301
 
@@ -20,22 +20,24 @@
20
20
  * known error types so the user gets a suggested next step, not just
21
21
  * the raw error.
22
22
  */
23
- import { useState, useCallback, memo } from "react";
23
+ import { useCallback, memo } from "react";
24
+ import { useKeyboard } from "@opentui/react";
24
25
  import {
25
26
  Colors,
26
27
  ConnectionStatusColor,
27
28
  useServiceStore,
29
+ useUIStore,
28
30
  formatRelativeTimeFromTime as formatRelativeTime,
29
31
  type CliErrorType,
30
32
  type CliErrorDetails,
31
33
  } from "@hoox-sh/hoox-shared";
32
34
  import {
33
35
  classifyConnectionError,
34
- hasApiToken,
35
36
  resolveTuiConnectionEnv,
36
37
  } from "../../services/tui-connection";
37
38
  import { redactSecretsInText } from "../../services/dev-log";
38
39
  import { CoolGlyph } from "../shared/cool-brackets";
40
+ import { isShellOverlayOpen } from "../../hooks/shell-overlay";
39
41
 
40
42
  /** Map each CliErrorType to a short, human-readable label. */
41
43
  const ERROR_TYPE_LABELS: Record<CliErrorType, string> = {
@@ -231,26 +233,40 @@ export function StatusBar() {
231
233
  const lastErrorDetails = useServiceStore((s) => s.lastErrorDetails);
232
234
  const retryCount = useServiceStore((s) => s.retryCount);
233
235
  const reconnectDelay = useServiceStore((s) => s.reconnectDelay);
236
+ const errorExpanded = useUIStore((s) => s.statusErrorExpanded);
237
+ const toggleStatusErrorExpanded = useUIStore(
238
+ (s) => s.toggleStatusErrorExpanded
239
+ );
234
240
 
235
241
  const conn = resolveTuiConnectionEnv();
236
242
  const tuiMode = conn.mode;
237
243
  const apiHost = conn.apiHost;
238
- const tokenPresent = hasApiToken();
244
+ // Fail-closed readiness: Bearer and/or Access pair (not token alone)
245
+ const authReady = conn.hasAuth;
239
246
  const errorKind = classifyConnectionError(lastError);
240
- // Compact AUTH cue: missing token (remote) or auth error — keep short for host room
247
+ // Compact AUTH cue: missing any remote credential, or auth error
241
248
  const showAuthHint =
242
249
  tuiMode === "remote" &&
243
- (!tokenPresent ||
244
- errorKind === "auth" ||
245
- Boolean(lastError?.includes("401")));
246
-
247
- // Local UI state — expansion is purely a presentation concern, so it
248
- // lives in component state rather than the global store.
249
- const [errorExpanded, setErrorExpanded] = useState<boolean>(false);
250
+ (!authReady || errorKind === "auth" || Boolean(lastError?.includes("401")));
250
251
 
251
252
  const toggleError = useCallback(() => {
252
- setErrorExpanded((prev) => !prev);
253
- }, []);
253
+ toggleStatusErrorExpanded();
254
+ }, [toggleStatusErrorExpanded]);
255
+
256
+ // Keyboard expand diagnostics: Ctrl+Shift+D (does not steal view chords)
257
+ useKeyboard((key) => {
258
+ if (isShellOverlayOpen()) return;
259
+ const name = String(key.name ?? "").toLowerCase();
260
+ if (key.ctrl && key.shift && name === "d") {
261
+ const details = useServiceStore.getState().lastErrorDetails;
262
+ const status = useServiceStore.getState().connectionStatus;
263
+ const canExpand =
264
+ details !== null && (status === "offline" || status === "reconnecting");
265
+ if (canExpand) {
266
+ useUIStore.getState().toggleStatusErrorExpanded();
267
+ }
268
+ }
269
+ });
254
270
 
255
271
  const statusLabel: Record<string, string> = {
256
272
  connected: "CONNECTED",
@@ -285,7 +301,7 @@ export function StatusBar() {
285
301
  );
286
302
 
287
303
  if (showAuthHint) {
288
- parts.push(!tokenPresent ? "| AUTH?" : "| AUTH!");
304
+ parts.push(!authReady ? "| AUTH?" : "| AUTH!");
289
305
  }
290
306
 
291
307
  if (lastError && isErrorState && errorKind !== "auth") {
@@ -294,11 +310,11 @@ export function StatusBar() {
294
310
  }
295
311
 
296
312
  // The expand hint appears whenever a clickable error is available so
297
- // users know they can drill in.
313
+ // users know they can drill in (mouse or Ctrl+Shift+D).
298
314
  const expandHint = pillInteractive
299
315
  ? errorExpanded
300
- ? "▾ click to collapse"
301
- : "▸ click for details"
316
+ ? "▾ collapse (^⇧D)"
317
+ : "▸ details (^⇧D)"
302
318
  : "";
303
319
 
304
320
  return (
@@ -335,7 +351,7 @@ export function StatusBar() {
335
351
  <CoolGlyph char="┐" />
336
352
  </box>
337
353
  <text fg={Colors.dim} dim>
338
- ^P PALETTE · ^B SIDEBAR · ^Q QUIT
354
+ ^P · ^B · ^Q · ^⇧D err
339
355
  </text>
340
356
  </box>
341
357
 
@@ -11,11 +11,11 @@
11
11
  * - "Something went wrong" banner
12
12
  * - Error message (first line)
13
13
  * - Three action buttons: [Restart] [Safe Mode] [Report Bug]
14
+ * - Keyboard: R restart · S safe mode · B report bug
14
15
  *
15
- * This component is a plain function (not a class) because it's meant to be
16
- * rendered in a degraded state — no hooks, no stores, no external state.
17
16
  * Colors use Hoox design tokens via @hoox-sh/hoox-shared.
18
17
  */
18
+ import { useKeyboard } from "@opentui/react";
19
19
  import { Colors } from "@hoox-sh/hoox-shared";
20
20
  import { redactSecretsInText } from "../../services/dev-log";
21
21
 
@@ -56,6 +56,22 @@ export function CrashScreen({
56
56
  ? redactSecretsInText(secondRaw).slice(0, 80)
57
57
  : undefined;
58
58
 
59
+ // Keyboard recovery (mouse optional / TUI_MOUSE=0)
60
+ useKeyboard((key) => {
61
+ const name = String(key.name ?? "").toLowerCase();
62
+ if (name === "r" || name === "1") {
63
+ onAction("restart");
64
+ return;
65
+ }
66
+ if (!safeMode && (name === "s" || name === "2")) {
67
+ onAction("safe-mode");
68
+ return;
69
+ }
70
+ if (name === "b" || name === "3") {
71
+ onAction("report-bug");
72
+ }
73
+ });
74
+
59
75
  return (
60
76
  <box
61
77
  flexDirection="column"
@@ -162,6 +178,12 @@ export function CrashScreen({
162
178
  </text>
163
179
  </box>
164
180
  </box>
181
+
182
+ <text fg={Colors.dim} dim>
183
+ {safeMode
184
+ ? "Keys: R restart · B report bug"
185
+ : "Keys: R restart · S safe mode · B report bug"}
186
+ </text>
165
187
  </box>
166
188
  );
167
189
  }
@@ -12,6 +12,7 @@
12
12
  * Colors use Hoox design tokens — no hardcoded hex.
13
13
  */
14
14
  import { Component, type ReactNode } from "react";
15
+ import { useKeyboard } from "@opentui/react";
15
16
  import { Colors } from "@hoox-sh/hoox-shared";
16
17
  import { redactSecretsInText } from "../../services/dev-log";
17
18
 
@@ -37,6 +38,56 @@ export function formatBoundaryErrorMessage(error: Error, maxLen = 160): string {
37
38
  : scrubbed;
38
39
  }
39
40
 
41
+ /** Keyboard + mouse retry chrome (hooks require a function component). */
42
+ function ErrorRecoveryPanel({
43
+ viewName,
44
+ message,
45
+ onRetry,
46
+ }: {
47
+ viewName: string;
48
+ message: string;
49
+ onRetry: () => void;
50
+ }) {
51
+ useKeyboard((key) => {
52
+ const name = String(key.name ?? "").toLowerCase();
53
+ if (name === "return" || name === "enter" || name === "r") {
54
+ onRetry();
55
+ }
56
+ });
57
+
58
+ return (
59
+ <box
60
+ flexDirection="column"
61
+ padding={2}
62
+ gap={1}
63
+ border={true}
64
+ borderStyle="single"
65
+ borderColor={Colors.border}
66
+ backgroundColor={Colors.card}
67
+ >
68
+ <box flexDirection="row" gap={1}>
69
+ <text fg={Colors.error} bold>
70
+ ⚠
71
+ </text>
72
+ <text fg={Colors.error} bold>
73
+ Failed to load {viewName}
74
+ </text>
75
+ </box>
76
+
77
+ <text fg={Colors.muted}>{message}</text>
78
+
79
+ <box paddingTop={1} flexDirection="row" gap={2}>
80
+ <text fg={Colors.accent} bg={Colors.card} onMouseUp={onRetry}>
81
+ {" [Retry] "}
82
+ </text>
83
+ <text fg={Colors.dim} dim>
84
+ Enter / R
85
+ </text>
86
+ </box>
87
+ </box>
88
+ );
89
+ }
90
+
40
91
  // ── Component ──────────────────────────────────────────────────────────────
41
92
 
42
93
  export class ErrorBoundary extends Component<
@@ -58,39 +109,11 @@ export class ErrorBoundary extends Component<
58
109
  if (this.state.error) {
59
110
  const message = formatBoundaryErrorMessage(this.state.error);
60
111
  return (
61
- <box
62
- flexDirection="column"
63
- padding={2}
64
- gap={1}
65
- border={true}
66
- borderStyle="single"
67
- borderColor={Colors.border}
68
- backgroundColor={Colors.card}
69
- >
70
- {/* Header: view name + error indicator */}
71
- <box flexDirection="row" gap={1}>
72
- <text fg={Colors.error} bold>
73
- ⚠
74
- </text>
75
- <text fg={Colors.error} bold>
76
- Failed to load {this.props.viewName}
77
- </text>
78
- </box>
79
-
80
- {/* Error message (redacted, first line only) */}
81
- <text fg={Colors.muted}>{message}</text>
82
-
83
- {/* Retry action */}
84
- <box paddingTop={1}>
85
- <text
86
- fg={Colors.accent}
87
- bg={Colors.card}
88
- onMouseUp={this.handleRetry}
89
- >
90
- {" [Retry] "}
91
- </text>
92
- </box>
93
- </box>
112
+ <ErrorRecoveryPanel
113
+ viewName={this.props.viewName}
114
+ message={message}
115
+ onRetry={this.handleRetry}
116
+ />
94
117
  );
95
118
  }
96
119
 
@@ -11,6 +11,19 @@ import {
11
11
  messageConnectionLost,
12
12
  messageOfflineStartup,
13
13
  messageReconnected,
14
+ toastReconnected,
15
+ toastReconnectedMode,
16
+ toastRateLimited,
17
+ toastConfigSaved,
18
+ toastDeployStarted,
19
+ toastDeployCompleted,
20
+ toastDeployFailed,
21
+ toastConnectionLost,
22
+ toastConnectionLostMode,
23
+ toastConnectedMode,
24
+ toastAuthRequiredMode,
25
+ toastAuthMissingRemote,
26
+ toastOfflineStartup,
14
27
  } from "./connection-toasts";
15
28
 
16
29
  describe("connection-toasts messages", () => {
@@ -36,15 +49,59 @@ describe("connection-toasts messages", () => {
36
49
  expect(msg).toContain("downtime");
37
50
  });
38
51
 
52
+ it("reconnected clamps negative downtime to zero", () => {
53
+ const msg = messageReconnected("local", "h", 10_000, 1_000);
54
+ expect(msg).toContain("downtime");
55
+ });
56
+
39
57
  it("auth messages are explicit", () => {
40
58
  expect(messageAuthRequired("remote", "gw")).toContain("Auth failed");
59
+ expect(messageAuthRequired("local", "localhost")).toContain("LOCAL");
41
60
  expect(messageAuthMissing("gw")).toContain("No API credentials");
42
61
  });
43
62
 
44
- it("offline startup distinguishes auth", () => {
63
+ it("offline startup distinguishes kinds", () => {
45
64
  expect(messageOfflineStartup("remote", "gw", "auth")).toContain("(auth)");
46
65
  expect(
47
66
  messageOfflineStartup("local", "localhost:8787", "network")
48
67
  ).not.toContain("(auth)");
68
+ expect(messageOfflineStartup("remote", "gw", "rate-limit")).toContain(
69
+ "(rate limited)"
70
+ );
71
+ expect(messageOfflineStartup("local", "h", "unknown")).toBe(
72
+ "Could not connect · LOCAL · h"
73
+ );
74
+ });
75
+ });
76
+
77
+ describe("connection-toasts factory functions", () => {
78
+ // Toast singleton may no-op without a mounted Toaster; factories must not throw.
79
+ it("toast factories are callable without a mounted toaster", () => {
80
+ expect(() => toastReconnected(Date.now() - 5_000)).not.toThrow();
81
+ expect(() =>
82
+ toastReconnectedMode("remote", "gw.test", Date.now() - 2_000)
83
+ ).not.toThrow();
84
+ expect(() => toastRateLimited()).not.toThrow();
85
+ expect(() => toastConfigSaved()).not.toThrow();
86
+ expect(() => toastDeployStarted("trade-worker")).not.toThrow();
87
+ expect(() => toastDeployCompleted("trade-worker")).not.toThrow();
88
+ expect(() => toastDeployFailed("trade-worker", "timeout")).not.toThrow();
89
+ expect(() => toastConnectionLost()).not.toThrow();
90
+ expect(() =>
91
+ toastConnectionLostMode("local", "localhost:8787")
92
+ ).not.toThrow();
93
+ expect(() => toastConnectionLostMode("remote", "gw")).not.toThrow();
94
+ expect(() => toastConnectedMode("local", "localhost:8787")).not.toThrow();
95
+ expect(() => toastAuthRequiredMode("remote", "gw")).not.toThrow();
96
+ expect(() => toastAuthRequiredMode("local", "localhost")).not.toThrow();
97
+ expect(() => toastAuthMissingRemote("gw")).not.toThrow();
98
+ expect(() => toastOfflineStartup("remote", "gw", "auth")).not.toThrow();
99
+ expect(() => toastOfflineStartup("remote", "gw", "network")).not.toThrow();
100
+ expect(() =>
101
+ toastOfflineStartup("local", "localhost", "rate-limit")
102
+ ).not.toThrow();
103
+ expect(() =>
104
+ toastOfflineStartup("local", "localhost", "unknown")
105
+ ).not.toThrow();
49
106
  });
50
107
  });