@hoox-sh/hoox-tui 0.2.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 (104) hide show
  1. package/README.md +247 -0
  2. package/bin/hoox-tui.js +35 -0
  3. package/dist/main.js +17594 -0
  4. package/package.json +72 -0
  5. package/src/app.tsx +696 -0
  6. package/src/cli-bridge-test-double.ts +212 -0
  7. package/src/components/layout/layout.test.tsx +205 -0
  8. package/src/components/layout/sidebar.tsx +69 -0
  9. package/src/components/layout/statusbar.test.tsx +423 -0
  10. package/src/components/layout/statusbar.tsx +348 -0
  11. package/src/components/shared/animated-border.tsx +58 -0
  12. package/src/components/shared/chrome.test.tsx +29 -0
  13. package/src/components/shared/command-palette.tsx +361 -0
  14. package/src/components/shared/cool-brackets.tsx +75 -0
  15. package/src/components/shared/crash-screen.tsx +167 -0
  16. package/src/components/shared/error-boundary.tsx +99 -0
  17. package/src/components/shared/keybinding-hint.tsx +37 -0
  18. package/src/components/shared/panel.tsx +61 -0
  19. package/src/components/shared/quit-modal.tsx +65 -0
  20. package/src/components/shared/shared.test.tsx +538 -0
  21. package/src/components/shared/spinner.tsx +85 -0
  22. package/src/components/shared/status-dot.tsx +54 -0
  23. package/src/components/shared/view-header.tsx +53 -0
  24. package/src/components/ui/connection-toasts.test.ts +50 -0
  25. package/src/components/ui/connection-toasts.ts +215 -0
  26. package/src/components/ui/dialog.tsx +281 -0
  27. package/src/components/ui/select.tsx +197 -0
  28. package/src/components/ui/toast.tsx +185 -0
  29. package/src/components/views/ai-chat.test.tsx +81 -0
  30. package/src/components/views/ai-chat.tsx +576 -0
  31. package/src/components/views/config-editor/action-bar.tsx +102 -0
  32. package/src/components/views/config-editor/code-editor.tsx +597 -0
  33. package/src/components/views/config-editor/file-tree.tsx +113 -0
  34. package/src/components/views/config-editor/types.ts +37 -0
  35. package/src/components/views/config-editor.test.tsx +465 -0
  36. package/src/components/views/config-editor.tsx +709 -0
  37. package/src/components/views/dashboard/alerts-panel.tsx +154 -0
  38. package/src/components/views/dashboard/auto-repair-panel.tsx +229 -0
  39. package/src/components/views/dashboard/model-health-section.tsx +276 -0
  40. package/src/components/views/dashboard/pyne-health-section.tsx +112 -0
  41. package/src/components/views/dashboard.test.tsx +347 -0
  42. package/src/components/views/dashboard.tsx +647 -0
  43. package/src/components/views/db-query.test.tsx +115 -0
  44. package/src/components/views/db-query.tsx +849 -0
  45. package/src/components/views/edge-topology.test.tsx +198 -0
  46. package/src/components/views/edge-topology.tsx +572 -0
  47. package/src/components/views/kv-viewer.test.tsx +241 -0
  48. package/src/components/views/kv-viewer.tsx +717 -0
  49. package/src/components/views/logs-viewer.test.tsx +273 -0
  50. package/src/components/views/logs-viewer.tsx +616 -0
  51. package/src/components/views/queue-depth.test.tsx +221 -0
  52. package/src/components/views/queue-depth.tsx +376 -0
  53. package/src/components/views/secrets-viewer.test.tsx +65 -0
  54. package/src/components/views/secrets-viewer.tsx +548 -0
  55. package/src/components/views/service-manager.test.tsx +358 -0
  56. package/src/components/views/service-manager.tsx +946 -0
  57. package/src/components/views/settings.test.tsx +648 -0
  58. package/src/components/views/settings.tsx +1497 -0
  59. package/src/components/views/setup-wizard.test.tsx +297 -0
  60. package/src/components/views/setup-wizard.tsx +1321 -0
  61. package/src/components/views/trade-monitor.test.tsx +592 -0
  62. package/src/components/views/trade-monitor.tsx +745 -0
  63. package/src/components/views/worker-detail.test.tsx +289 -0
  64. package/src/components/views/worker-detail.tsx +676 -0
  65. package/src/components/views/worker-settings.test.tsx +43 -0
  66. package/src/components/views/worker-settings.tsx +563 -0
  67. package/src/components/views/workers-overview.test.tsx +260 -0
  68. package/src/components/views/workers-overview.tsx +631 -0
  69. package/src/hooks/hooks.test.ts +72 -0
  70. package/src/hooks/index.ts +11 -0
  71. package/src/hooks/renderer-ref.ts +22 -0
  72. package/src/hooks/use-keyboard.ts +130 -0
  73. package/src/hooks/use-polling.ts +73 -0
  74. package/src/hooks/use-service-data.ts +18 -0
  75. package/src/main.tsx +117 -0
  76. package/src/network-test-double.ts +133 -0
  77. package/src/opentui-augment.d.ts +45 -0
  78. package/src/opentui-jsx.d.ts +70 -0
  79. package/src/services/cli-bridge/index.ts +1662 -0
  80. package/src/services/cli-bridge/standalone.ts +315 -0
  81. package/src/services/cli-bridge/types.ts +307 -0
  82. package/src/services/cli-bridge.test.ts +406 -0
  83. package/src/services/clipboard.test.ts +57 -0
  84. package/src/services/clipboard.ts +180 -0
  85. package/src/services/dashboard-settings-loader.ts +31 -0
  86. package/src/services/dev-log.test.ts +168 -0
  87. package/src/services/dev-log.ts +194 -0
  88. package/src/services/hoox-path-service.test.ts +183 -0
  89. package/src/services/hoox-path-service.ts +185 -0
  90. package/src/services/tui-connection.test.ts +212 -0
  91. package/src/services/tui-connection.ts +236 -0
  92. package/src/services/tui-storage.test.ts +92 -0
  93. package/src/services/tui-storage.ts +86 -0
  94. package/src/stores/config-store.test.ts +360 -0
  95. package/src/stores/service-store.test.ts +1026 -0
  96. package/src/stores/session-restore.test.ts +134 -0
  97. package/src/stores/ui-store.test.ts +276 -0
  98. package/src/test-setup.ts +265 -0
  99. package/src/test-utils.ts +179 -0
  100. package/src/types.ts +153 -0
  101. package/src/utils/colors.test.ts +326 -0
  102. package/src/utils/formatters.test.ts +236 -0
  103. package/src/view-registry.test.ts +132 -0
  104. package/src/view-registry.tsx +368 -0
package/src/app.tsx ADDED
@@ -0,0 +1,696 @@
1
+ /**
2
+ * Copyright (c) 2026 HOOX · HOOX · jango-blockchained (hoox-sh)
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /** @jsxImportSource @opentui/react */
7
+ /**
8
+ * App Root — Main entry point for the Hoox TUI dashboard.
9
+ *
10
+ * Responsibilities:
11
+ * 1. Restore session state from $HOME/.hoox/.tui-state/session.json on startup
12
+ * 2. Initialize ToasterRenderable for toast notifications
13
+ * 3. Render the layout shell (sidebar, tab bar, status bar, active view)
14
+ * 4. Register global keyboard shortcuts
15
+ * 5. Catch unhandled errors → crash screen
16
+ * 6. Save session state on clean shutdown
17
+ *
18
+ * Follows TUI Pattern 1 (FrameBuffer full-screen root) and Pattern 4 (Keyboard).
19
+ * Colors from design tokens via @hoox-sh/hoox-shared. No CSS, no DOM.
20
+ */
21
+ import { useState, useEffect, useCallback } from "react";
22
+ import { useKeyboard } from "@opentui/react";
23
+ import { DialogProvider, useDialog } from "@opentui-ui/dialog/react";
24
+ import { useServiceStore, useUIStore, Colors } from "@hoox-sh/hoox-shared";
25
+ import { restoreSession, saveSession } from "@hoox-sh/hoox-shared";
26
+ import type { ViewId } from "@hoox-sh/hoox-shared";
27
+
28
+ import { cliBridge } from "./services/cli-bridge";
29
+ import { resolveTuiStatePath } from "./services/hoox-path-service";
30
+ import { redactSecretsInText, tuiDevLog } from "./services/dev-log";
31
+ import {
32
+ classifyConnectionError,
33
+ resolveTuiConnectionEnv,
34
+ } from "./services/tui-connection";
35
+ import {
36
+ toastAuthMissingRemote,
37
+ toastAuthRequiredMode,
38
+ toastConnectedMode,
39
+ toastConnectionLostMode,
40
+ toastOfflineStartup,
41
+ toastRateLimited,
42
+ toastReconnectedMode,
43
+ } from "./components/ui/connection-toasts";
44
+ import { getRendererRef } from "./hooks";
45
+ import type { DialogHandle } from "./components/ui/dialog";
46
+ import {
47
+ getViewFactory,
48
+ getViewShortcutMap,
49
+ getCtrlAltViewMap,
50
+ isRegisteredViewId,
51
+ ALL_PALETTE_COMMANDS,
52
+ } from "./view-registry";
53
+ import {
54
+ CrashScreen,
55
+ type CrashAction,
56
+ } from "./components/shared/crash-screen";
57
+ import { CommandPalette } from "./components/shared/command-palette";
58
+ import { QuitModal } from "./components/shared/quit-modal";
59
+ import { StatusBar } from "./components/layout/statusbar";
60
+ import { Sidebar } from "./components/layout/sidebar";
61
+
62
+ // ─── View keyboard shortcuts (derived from view-registry) ────────────────────
63
+
64
+ const VIEW_SHORTCUTS = getViewShortcutMap();
65
+ const CTRL_ALT_VIEWS = getCtrlAltViewMap();
66
+
67
+ // ─── Main App ────────────────────────────────────────────────────────────────
68
+
69
+ /**
70
+ * AppRoot — the top-level component.
71
+ *
72
+ * Renders the full layout: Sidebar | (TabBar + View) with StatusBar at bottom.
73
+ * Wraps the entire app in a try/catch-free zone; unhandled errors are caught
74
+ * by the platform-level crash handler (registered in the entry script).
75
+ *
76
+ * State flow:
77
+ * 1. On mount: restore session → set activeView and sidebarExpanded
78
+ * 2. On unmount (cleanup): save session to $HOME/.hoox/.tui-state/session.json
79
+ * 3. Crash: CrashScreen rendered with [Restart] [Safe Mode] [Report Bug]
80
+ */
81
+ /** Cleanly shut down the TUI: destroy renderer (session save runs on destroy) then exit. */
82
+ function quitApp(): void {
83
+ try {
84
+ const renderer = getRendererRef();
85
+ renderer?.destroy();
86
+ } catch {
87
+ // destroy may throw if already torn down
88
+ }
89
+ process.exit(0);
90
+ }
91
+
92
+ /**
93
+ * AppRoot — wraps the shell in DialogProvider so views can call
94
+ * `showConfirm` / `useDialog` for destructive actions.
95
+ */
96
+ export function AppRoot({ safeMode = false }: { safeMode?: boolean }) {
97
+ return (
98
+ <DialogProvider
99
+ size="medium"
100
+ backdropColor={Colors.backdrop}
101
+ backdropOpacity={0.35}
102
+ >
103
+ <AppRootInner safeMode={safeMode} />
104
+ </DialogProvider>
105
+ );
106
+ }
107
+
108
+ function AppRootInner({ safeMode = false }: { safeMode?: boolean }) {
109
+ // DialogProvider is required for useDialog; cast to our thin DialogHandle.
110
+ const dialog = useDialog() as unknown as DialogHandle;
111
+ const [restoring, setRestoring] = useState(true);
112
+ const activeView = useUIStore((s) => s.activeView);
113
+ const commandPaletteOpen = useUIStore((s) => s.commandPaletteOpen);
114
+ const modal = useUIStore((s) => s.modal);
115
+ const setView = useUIStore((s) => s.setView);
116
+ const toggleSidebar = useUIStore((s) => s.toggleSidebar);
117
+ const openPalette = useUIStore((s) => s.openPalette);
118
+ const closePalette = useUIStore((s) => s.closePalette);
119
+ const showModal = useUIStore((s) => s.showModal);
120
+ const dismissModal = useUIStore((s) => s.dismissModal);
121
+
122
+ const requestQuit = useCallback(() => {
123
+ closePalette();
124
+ showModal({
125
+ type: "confirm",
126
+ title: "Quit HOOX?",
127
+ message: "Exit the terminal operations center.",
128
+ onConfirm: quitApp,
129
+ onCancel: () => dismissModal(),
130
+ });
131
+ }, [closePalette, showModal, dismissModal]);
132
+
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.
137
+ useEffect(() => {
138
+ let cancelled = false;
139
+ (async () => {
140
+ const session = await restoreSession();
141
+ if (cancelled) return;
142
+
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
+ }
164
+
165
+ if (cancelled) return;
166
+ if (isRegisteredViewId(view)) {
167
+ setView(view);
168
+ }
169
+ // Align sidebar with saved state (toggle only when mismatched)
170
+ if (expanded !== useUIStore.getState().sidebarExpanded) {
171
+ toggleSidebar();
172
+ }
173
+ setRestoring(false);
174
+ })();
175
+ return () => {
176
+ cancelled = true;
177
+ };
178
+ }, [setView, toggleSidebar]);
179
+
180
+ // ── Startup data load: HTTP → (local only) CLI fallback ────────────────
181
+ // After session restore, try to fetch worker data. HTTP is tried first;
182
+ // if the local dev server is unreachable, fall back to the `hoox` CLI.
183
+ // REMOTE mode never uses CLI fallback — gateway HTTP is the source of truth.
184
+ // Also kick off SSE trade/log streams when the API is available.
185
+ // Safe mode skips all network/CLI/SSE to keep the shell usable after a crash.
186
+ useEffect(() => {
187
+ if (restoring) return;
188
+ if (safeMode) {
189
+ void tuiDevLog.info(
190
+ "connection",
191
+ "safe mode — skipping startup data load and SSE"
192
+ );
193
+ return;
194
+ }
195
+ let cancelled = false;
196
+
197
+ (async () => {
198
+ const store = useServiceStore.getState();
199
+ const conn = resolveTuiConnectionEnv();
200
+ await tuiDevLog.info("connection", "startup data load begin", {
201
+ mode: conn.mode,
202
+ apiUrl: conn.apiUrl,
203
+ hasToken: conn.hasToken,
204
+ hasAuth: conn.hasAuth,
205
+ allowCliFallback: conn.allowCliFallback,
206
+ });
207
+
208
+ // Fail-closed when remote has neither Bearer nor Access service-token pair
209
+ if (conn.mode === "remote" && !conn.hasAuth) {
210
+ toastAuthMissingRemote(conn.apiHost);
211
+ await tuiDevLog.warn("connection", "remote without API credentials", {
212
+ host: conn.apiHost,
213
+ });
214
+ }
215
+
216
+ // fetchWorkers swallows network errors into store state — inspect status.
217
+ await store.fetchWorkers();
218
+ if (cancelled) return;
219
+ const afterHttp = useServiceStore.getState();
220
+ const httpOk = afterHttp.connectionStatus === "connected";
221
+ if (httpOk) {
222
+ toastConnectedMode(conn.mode, conn.apiHost);
223
+ await tuiDevLog.info("connection", "HTTP fetchWorkers succeeded", {
224
+ mode: conn.mode,
225
+ apiUrl: conn.apiUrl,
226
+ workerCount: afterHttp.workers.length,
227
+ });
228
+ } else {
229
+ const kind = classifyConnectionError(afterHttp.lastError);
230
+ if (kind === "auth") {
231
+ toastAuthRequiredMode(conn.mode, conn.apiHost);
232
+ } else if (kind === "rate-limit") {
233
+ toastRateLimited();
234
+ } else {
235
+ toastOfflineStartup(conn.mode, conn.apiHost, kind);
236
+ }
237
+ await tuiDevLog.warn("connection", "HTTP fetchWorkers failed", {
238
+ mode: conn.mode,
239
+ apiUrl: conn.apiUrl,
240
+ connectionStatus: afterHttp.connectionStatus,
241
+ lastError: afterHttp.lastError,
242
+ errorKind: kind,
243
+ });
244
+ }
245
+
246
+ // CLI fallback: local only (never mark REMOTE connected via local CLI)
247
+ if (!httpOk && conn.allowCliFallback) {
248
+ try {
249
+ await tuiDevLog.debug("connection", "CLI monitorStatus fallback");
250
+ const result = await cliBridge.monitorStatus();
251
+ if (cancelled) return;
252
+ if (result.success && result.data) {
253
+ const raw = result.data as Record<string, unknown>;
254
+ const rawWorkers = (raw.workers ?? raw.status ?? raw) as
255
+ | unknown[]
256
+ | Record<string, unknown>;
257
+ const parsed = Array.isArray(rawWorkers)
258
+ ? rawWorkers
259
+ : typeof rawWorkers === "object" && rawWorkers !== null
260
+ ? Object.values(rawWorkers)
261
+ : [];
262
+ if (parsed.length > 0) {
263
+ const workerInfo = (parsed as Record<string, unknown>[]).map(
264
+ (w, i) => {
265
+ const cliStatus = String(w.status ?? "healthy");
266
+ const status =
267
+ cliStatus === "healthy"
268
+ ? "operational"
269
+ : cliStatus === "degraded"
270
+ ? "degraded"
271
+ : "down";
272
+ return {
273
+ id: String(w.id ?? w.worker ?? `worker-${i}`),
274
+ name: String(w.worker ?? `worker-${i}`),
275
+ status: status as "operational" | "degraded" | "down",
276
+ uptime: Number(w.uptime ?? 0) || 0,
277
+ cpu: Number(w.cpu ?? 0) || 0,
278
+ memory: Number(w.memory ?? 0) || 0,
279
+ requests: Number(w.requests ?? 0) || 0,
280
+ durableObjectCount: Number(w.durableObjectCount ?? 0) || 0,
281
+ edgeCount: Number(w.edgeCount ?? 0) || 0,
282
+ version: String(w.version ?? ""),
283
+ lastDeployed: Number(w.lastDeployed ?? 0) || 0,
284
+ };
285
+ }
286
+ );
287
+ store.setWorkers(workerInfo);
288
+ store.setMetrics({
289
+ totalWorkers: workerInfo.length,
290
+ onlineWorkers: workerInfo.filter(
291
+ (x) => x.status === "operational"
292
+ ).length,
293
+ totalPnl: 0,
294
+ activeStrategies: 0,
295
+ dailyTrades: 0,
296
+ aiCalls: 0,
297
+ uptime: 0,
298
+ lastUpdated: Date.now(),
299
+ });
300
+ store.handleConnectionSuccess();
301
+ toastConnectedMode(conn.mode, conn.apiHost);
302
+ await tuiDevLog.info("connection", "CLI fallback succeeded", {
303
+ workerCount: workerInfo.length,
304
+ });
305
+ } else {
306
+ await tuiDevLog.warn(
307
+ "connection",
308
+ "CLI fallback returned no workers",
309
+ {
310
+ success: result.success,
311
+ stderr: result.stderr || null,
312
+ }
313
+ );
314
+ }
315
+ } else {
316
+ await tuiDevLog.warn("connection", "CLI fallback failed", {
317
+ success: result.success,
318
+ stderr: result.stderr || null,
319
+ errorType: result.errorType ?? null,
320
+ });
321
+ }
322
+ } catch (err) {
323
+ await tuiDevLog.error(
324
+ "connection",
325
+ "HTTP and CLI unavailable — staying offline",
326
+ {
327
+ error: err instanceof Error ? err.message : String(err),
328
+ }
329
+ );
330
+ }
331
+ } else if (!httpOk && !conn.allowCliFallback) {
332
+ await tuiDevLog.info(
333
+ "connection",
334
+ "skipping CLI fallback in remote mode"
335
+ );
336
+ }
337
+
338
+ if (cancelled) return;
339
+
340
+ // Long-lived SSE streams (no-op when API offline; store handles errors)
341
+ await tuiDevLog.debug("connection", "starting SSE trade/log streams");
342
+ void store.streamTrades();
343
+ void store.streamLogs();
344
+ })();
345
+
346
+ return () => {
347
+ cancelled = true;
348
+ // Tear down long-lived SSE so remounts / safe-mode don't pile up callbacks
349
+ useServiceStore.getState().stopStreams();
350
+ };
351
+ }, [restoring, safeMode]);
352
+
353
+ // ── Connection status → mode-aware toasts ───────────────────────────────
354
+ useEffect(() => {
355
+ if (restoring) return;
356
+ const conn = resolveTuiConnectionEnv();
357
+ let prev = useServiceStore.getState().connectionStatus;
358
+ let prevDisconnectedAt = useServiceStore.getState().disconnectedAt;
359
+
360
+ const unsub = useServiceStore.subscribe((state) => {
361
+ const next = state.connectionStatus;
362
+ if (next === prev) return;
363
+
364
+ if (
365
+ next === "connected" &&
366
+ (prev === "reconnecting" || prev === "offline" || prev === "polling")
367
+ ) {
368
+ if (prevDisconnectedAt != null && prevDisconnectedAt > 0) {
369
+ toastReconnectedMode(conn.mode, conn.apiHost, prevDisconnectedAt);
370
+ }
371
+ } else if (next === "offline" && prev !== "offline") {
372
+ const kind = classifyConnectionError(state.lastError);
373
+ if (kind === "auth") {
374
+ toastAuthRequiredMode(conn.mode, conn.apiHost);
375
+ } else if (kind === "rate-limit") {
376
+ toastRateLimited();
377
+ } else {
378
+ toastConnectionLostMode(conn.mode, conn.apiHost);
379
+ }
380
+ }
381
+
382
+ prev = next;
383
+ prevDisconnectedAt = state.disconnectedAt;
384
+ });
385
+
386
+ return unsub;
387
+ }, [restoring]);
388
+
389
+ // ── Session save on unmount ─────────────────────────────────────────────
390
+ useEffect(() => {
391
+ return () => {
392
+ const lastUpdated = useServiceStore.getState().lastUpdated;
393
+ saveSession(
394
+ useUIStore.getState().activeView,
395
+ useUIStore.getState().sidebarExpanded,
396
+ { cols: 80, rows: 24 }, // window size detected elsewhere
397
+ lastUpdated
398
+ ).catch(() => {
399
+ // Non-fatal: session save failures are logged silently
400
+ });
401
+ };
402
+ }, []);
403
+
404
+ // ── Global CLI bridge error sink ────────────────────────────────────────
405
+ // Registers a single error sink at the app root so every `cliBridge.*`
406
+ // call (deploy, kill-switch, health check, etc.) — regardless of which
407
+ // view triggered it — propagates structured `CliErrorDetails` to the
408
+ // service store. The status bar subscribes to `lastErrorDetails` and
409
+ // surfaces the real diagnostic context (command, exit code, stderr)
410
+ // instead of a generic OFFLINE pill.
411
+ useEffect(() => {
412
+ const unsubscribe = cliBridge.onError((details) => {
413
+ useServiceStore.getState().setLastErrorDetails(details);
414
+ });
415
+ return unsubscribe;
416
+ }, []);
417
+
418
+ // ── Global keyboard shortcuts ───────────────────────────────────────────
419
+ // useCallback keeps handler identity stable across re-renders so OpenTUI
420
+ // does not thrash key listener registration.
421
+ const onGlobalKey = useCallback(
422
+ (key: { name?: string; ctrl?: boolean; alt?: boolean; meta?: boolean }) => {
423
+ const name = String(key.name ?? "").toLowerCase();
424
+
425
+ // Quit confirmation modal takes priority over other shortcuts
426
+ if (modal?.type === "confirm" && modal.title === "Quit HOOX?") {
427
+ if (name === "return" || name === "enter" || name === "y") {
428
+ dismissModal();
429
+ quitApp();
430
+ return;
431
+ }
432
+ if (name === "escape" || name === "n") {
433
+ dismissModal();
434
+ return;
435
+ }
436
+ return; // swallow other keys while confirming quit
437
+ }
438
+
439
+ // While palette is open, leave typing/nav to CommandPalette;
440
+ // still allow Esc + a few global chords (view switch closes palette).
441
+ if (commandPaletteOpen) {
442
+ if (name === "escape") {
443
+ closePalette();
444
+ return;
445
+ }
446
+ // Allow Ctrl+digit / Ctrl+Alt view jumps (setView closes palette)
447
+ if (key.ctrl && !key.alt && name && VIEW_SHORTCUTS[name]) {
448
+ setView(VIEW_SHORTCUTS[name]);
449
+ return;
450
+ }
451
+ if (key.ctrl && key.alt && name && CTRL_ALT_VIEWS[name]) {
452
+ setView(CTRL_ALT_VIEWS[name]);
453
+ return;
454
+ }
455
+ return;
456
+ }
457
+
458
+ // Ctrl+1-9 / Ctrl+0: switch views (not while alt is held)
459
+ if (key.ctrl && !key.alt && name && VIEW_SHORTCUTS[name]) {
460
+ setView(VIEW_SHORTCUTS[name]);
461
+ return;
462
+ }
463
+
464
+ // Ctrl+Alt+letter: switch to letter-chord views (kv, secrets, etc.)
465
+ if (key.ctrl && key.alt && name && CTRL_ALT_VIEWS[name]) {
466
+ setView(CTRL_ALT_VIEWS[name]);
467
+ return;
468
+ }
469
+
470
+ // Ctrl+B: toggle sidebar
471
+ if (key.ctrl && !key.alt && name === "b") {
472
+ toggleSidebar();
473
+ return;
474
+ }
475
+
476
+ // Ctrl+P: command palette
477
+ if (key.ctrl && !key.alt && name === "p") {
478
+ openPalette();
479
+ return;
480
+ }
481
+
482
+ // Ctrl+R: refresh worker data (no-op in safe mode network sense — still allowed)
483
+ if (key.ctrl && !key.alt && name === "r") {
484
+ if (!safeMode) {
485
+ void useServiceStore.getState().fetchWorkers();
486
+ }
487
+ return;
488
+ }
489
+
490
+ // Ctrl+Q: quit with confirmation (Ctrl+Alt+Q is db-query above)
491
+ if (key.ctrl && !key.alt && name === "q") {
492
+ requestQuit();
493
+ return;
494
+ }
495
+
496
+ // Escape: dismiss modal, then palette
497
+ if (name === "escape") {
498
+ if (modal) {
499
+ dismissModal();
500
+ return;
501
+ }
502
+ closePalette();
503
+ }
504
+ },
505
+ [
506
+ modal,
507
+ commandPaletteOpen,
508
+ setView,
509
+ toggleSidebar,
510
+ openPalette,
511
+ closePalette,
512
+ dismissModal,
513
+ requestQuit,
514
+ safeMode,
515
+ ]
516
+ );
517
+
518
+ useKeyboard(onGlobalKey);
519
+
520
+ // ── Loading state during session restore ────────────────────────────────
521
+ if (restoring) {
522
+ return (
523
+ <box
524
+ flexDirection="column"
525
+ width="100%"
526
+ height="100%"
527
+ justifyContent="center"
528
+ alignItems="center"
529
+ backgroundColor={Colors.background}
530
+ >
531
+ <text fg={Colors.highlight} bold>
532
+ ┌ HOOX ┐
533
+ </text>
534
+ <text fg={Colors.muted} dim>
535
+ Restoring session…
536
+ </text>
537
+ </box>
538
+ );
539
+ }
540
+
541
+ // ── Active view component ───────────────────────────────────────────────
542
+ const renderView = getViewFactory(activeView);
543
+
544
+ return (
545
+ <box
546
+ flexDirection="column"
547
+ width="100%"
548
+ height="100%"
549
+ backgroundColor={Colors.background}
550
+ >
551
+ {/* Main area: Sidebar + Content */}
552
+ <box flexDirection="row" flexGrow={1}>
553
+ {/* Sidebar (left) */}
554
+ <Sidebar />
555
+
556
+ {/* Content area: View (fills remaining space) */}
557
+ <box flexDirection="column" flexGrow={1} padding={1}>
558
+ {renderView(dialog)}
559
+ </box>
560
+ </box>
561
+
562
+ {/* StatusBar (bottom, always visible) */}
563
+ <StatusBar />
564
+
565
+ {/* Command Palette overlay */}
566
+ {commandPaletteOpen && (
567
+ <CommandPalette
568
+ visible={commandPaletteOpen}
569
+ commands={ALL_PALETTE_COMMANDS}
570
+ onSelect={(selection) => {
571
+ if (selection.action === "setView" && selection.command.id) {
572
+ setView(selection.command.id as ViewId);
573
+ } else if (selection.command.id === "refresh") {
574
+ void useServiceStore.getState().fetchWorkers();
575
+ } else if (selection.command.id === "toggle-sidebar") {
576
+ toggleSidebar();
577
+ } else if (selection.command.id === "quit") {
578
+ closePalette();
579
+ requestQuit();
580
+ return;
581
+ }
582
+ closePalette();
583
+ }}
584
+ onDismiss={() => closePalette()}
585
+ />
586
+ )}
587
+
588
+ {/* Quit confirmation overlay */}
589
+ {modal?.type === "confirm" && modal.title === "Quit HOOX?" && (
590
+ <QuitModal
591
+ title={modal.title}
592
+ message={modal.message ?? "Exit the terminal operations center."}
593
+ onConfirm={() => {
594
+ dismissModal();
595
+ quitApp();
596
+ }}
597
+ onCancel={() => dismissModal()}
598
+ />
599
+ )}
600
+ </box>
601
+ );
602
+ }
603
+
604
+ // ─── Crash Recovery Wrapper ──────────────────────────────────────────────────
605
+
606
+ /**
607
+ * CrashRecoveryApp — wraps AppRoot with a crash boundary.
608
+ *
609
+ * When an unhandled error escapes the React tree:
610
+ * 1. The error is caught
611
+ * 2. CrashScreen is rendered with action buttons
612
+ * 3. [Restart] → re-mount AppRoot (clears React error state)
613
+ * 4. [Safe Mode] → re-mount with crashScreen.safeMode=true
614
+ * 5. [Report Bug] → write error details to $HOME/.hoox/.tui-state/crash.log
615
+ */
616
+ export function CrashRecoveryApp() {
617
+ const [crash, setCrash] = useState<Error | null>(null);
618
+ const [safeMode, setSafeMode] = useState(false);
619
+
620
+ // React error boundary equivalent: catch errors in a wrapper
621
+ const handleCrashAction = useCallback(
622
+ (action: CrashAction) => {
623
+ switch (action) {
624
+ case "restart":
625
+ // Clear crash state → re-mount AppRoot
626
+ setCrash(null);
627
+ setSafeMode(false);
628
+ break;
629
+
630
+ case "safe-mode":
631
+ // Clear crash, enable safe mode (no network / SSE)
632
+ setCrash(null);
633
+ setSafeMode(true);
634
+ break;
635
+
636
+ case "report-bug":
637
+ // Write redacted crash details to $HOME/.hoox/.tui-state/crash.log
638
+ if (crash) {
639
+ const crashLog = [
640
+ `=== Hoox Crash Report ===`,
641
+ `Time: ${new Date().toISOString()}`,
642
+ `Error: ${redactSecretsInText(crash.message)}`,
643
+ `Stack: ${crash.stack ? redactSecretsInText(crash.stack) : "N/A"}`,
644
+ `Safe Mode: ${safeMode}`,
645
+ ``,
646
+ ].join("\n");
647
+ // Best-effort write (non-blocking) to $HOME/.hoox/.tui-state/crash.log
648
+ try {
649
+ Bun.write(resolveTuiStatePath("crash.log"), crashLog).catch(
650
+ () => {}
651
+ );
652
+ } catch {
653
+ // Silent — write failed
654
+ }
655
+ }
656
+ break;
657
+ }
658
+ },
659
+ [crash, safeMode]
660
+ );
661
+
662
+ // Register unhandled error handlers — remove only *our* listeners on cleanup
663
+ // (never process.removeAllListeners, which would strip other libraries).
664
+ useEffect(() => {
665
+ if (typeof process === "undefined") return;
666
+
667
+ const onUncaught = (error: Error) => {
668
+ setCrash(error);
669
+ };
670
+ const onRejection = (reason: unknown) => {
671
+ setCrash(reason instanceof Error ? reason : new Error(String(reason)));
672
+ };
673
+
674
+ process.on("uncaughtException", onUncaught);
675
+ process.on("unhandledRejection", onRejection);
676
+
677
+ return () => {
678
+ process.off("uncaughtException", onUncaught);
679
+ process.off("unhandledRejection", onRejection);
680
+ };
681
+ }, []);
682
+
683
+ // ── Crash screen ────────────────────────────────────────────────────────
684
+ if (crash) {
685
+ return (
686
+ <CrashScreen
687
+ error={crash}
688
+ safeMode={safeMode}
689
+ onAction={handleCrashAction}
690
+ />
691
+ );
692
+ }
693
+
694
+ // ── Normal / safe mode ──────────────────────────────────────────────────
695
+ return <AppRoot safeMode={safeMode} />;
696
+ }