@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
@@ -0,0 +1,1497 @@
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
+ * Settings View — 4-column layout for user preferences.
9
+ *
10
+ * Columns:
11
+ * 1. ThemePanel — dark-only note, refresh rate, default view, reset + connection readout
12
+ * 2. NotificationsPanel — alert/trade/debug/system checkboxes, sound toggle
13
+ * 3. KeyboardPanel — read-only shortcut reference table
14
+ * 4. DataPanel — cache/export/import actions + About info
15
+ *
16
+ * Reads/writes the Zustand config store (@hoox-sh/hoox-shared/stores/config-store).
17
+ * Keyboard: Tab cycles panels, ↑↓ navigates items within panel,
18
+ * Space toggles, ←→ cycles selects, Enter activates buttons.
19
+ *
20
+ * Follows Pattern 1 (View Composition) and Pattern 2 (Store Subscription).
21
+ */
22
+ import { useMemo, useState, useCallback, useEffect } from "react";
23
+ import { useKeyboard } from "@opentui/react";
24
+ import {
25
+ Colors,
26
+ useServiceStore,
27
+ useUIStore,
28
+ useConfigStore,
29
+ readConfigSync,
30
+ } from "@hoox-sh/hoox-shared";
31
+ import { ErrorBoundary } from "../shared/error-boundary";
32
+ import { ViewHeader } from "../shared/view-header";
33
+ import { Panel } from "../shared/panel";
34
+ import { showConfirm } from "../ui/dialog";
35
+ import type { DialogHandle } from "../ui/dialog";
36
+ import { cliBridge as realCliBridge } from "../../services/cli-bridge";
37
+ import { getSettingsConnectionSnapshot } from "../../services/tui-connection";
38
+ import * as path from "path";
39
+ import * as os from "os";
40
+ import type { ViewId, NotificationPreferences } from "@hoox-sh/hoox-shared";
41
+
42
+ /**
43
+ * Prefer the process-wide CLI bridge test double when test-setup installed it
44
+ * (`globalThis.__hooxCliBridgeDouble`). Resolved at call time so the double is
45
+ * picked up even if this module evaluated before the preload finished.
46
+ */
47
+ function cliBridge(): typeof realCliBridge {
48
+ const g = globalThis as unknown as {
49
+ __hooxCliBridgeDouble?: typeof realCliBridge;
50
+ };
51
+ return g.__hooxCliBridgeDouble ?? realCliBridge;
52
+ }
53
+
54
+ export interface SettingsViewProps {
55
+ /** Dialog handle for destructive action confirmation. */
56
+ dialog?: DialogHandle;
57
+ }
58
+
59
+ // ─── Check Setup Types ────────────────────────────────────────────────────────
60
+
61
+ /**
62
+ * Local mirror of the CLI's CheckReport shape (`packages/cli/src/commands/check/types.ts`).
63
+ * We re-declare the shape here because that type isn't exported from the
64
+ * shared package — the CLI's CheckReport flows over the wire as JSON, and
65
+ * `cliBridge.checkSetup()` returns it as `unknown`. Validating at the boundary
66
+ * keeps the view decoupled from the CLI's internal type module.
67
+ */
68
+ interface CheckItem {
69
+ /** Human-readable check name (e.g. "D1 Database", "Wrangler Configs"). */
70
+ name: string;
71
+ /** Whether this check passed (no errors). */
72
+ success: boolean;
73
+ /** Failing conditions — must be addressed before deployment. */
74
+ errors: string[];
75
+ /** Non-blocking issues — should be reviewed. */
76
+ warnings: string[];
77
+ }
78
+
79
+ interface CheckCategoryResult {
80
+ /** Category label displayed in the report. */
81
+ name: string;
82
+ /** Per-check results within this category. */
83
+ checks: CheckItem[];
84
+ }
85
+
86
+ interface CheckReport {
87
+ /** True when every check in every category passed (zero errors). */
88
+ success: boolean;
89
+ /** Grouped category results. */
90
+ categories: CheckCategoryResult[];
91
+ /** Aggregated counts across all checks. */
92
+ summary: {
93
+ total: number;
94
+ passed: number;
95
+ failed: number;
96
+ warnings: number;
97
+ };
98
+ }
99
+
100
+ /** UI state for the Check Setup results panel. */
101
+ type CheckSetupState =
102
+ | { kind: "idle" }
103
+ | { kind: "running" }
104
+ | { kind: "error"; message: string }
105
+ | { kind: "report"; report: CheckReport; durationMs: number };
106
+
107
+ /**
108
+ * Human-readable explanation + suggested fix for each known check.
109
+ * Used by the results panel to give operators actionable guidance when
110
+ * a check fails. Keys must match the `name` field of CLI's CheckResult.
111
+ */
112
+ const SUGGESTED_FIXES: Record<string, { what: string; fix: string }> = {
113
+ "wrangler.jsonc validation": {
114
+ what: "Your wrangler.jsonc has invalid syntax or is missing required fields.",
115
+ fix: "Run `hoox init` to regenerate the config, or fix syntax errors manually.",
116
+ },
117
+ "Global config": {
118
+ what: "Required global fields (cloudflare_account_id, etc.) are missing.",
119
+ fix:
120
+ "Set `global.cloudflare_account_id` in wrangler.jsonc, and " +
121
+ "`global.cloudflare_api_token` via `wrangler secret put`.",
122
+ },
123
+ "Worker paths": {
124
+ what: "One or more workers have no `path` field in the config.",
125
+ fix: "Add a `path` field to each worker entry, or run `hoox init` to repair.",
126
+ },
127
+ "D1 Database": {
128
+ what: "Required D1 databases are missing from Cloudflare.",
129
+ fix: "Run `hoox infra provision` to create them automatically.",
130
+ },
131
+ "KV Namespaces": {
132
+ what: "Required KV namespaces are missing from Cloudflare.",
133
+ fix: "Run `hoox infra provision` to create them automatically.",
134
+ },
135
+ "R2 Buckets": {
136
+ what: "Required R2 buckets are missing from Cloudflare.",
137
+ fix: "Run `hoox infra provision` to create them automatically.",
138
+ },
139
+ Queues: {
140
+ what: "Required Cloudflare Queues are missing.",
141
+ fix: "Run `hoox infra provision` to create them automatically.",
142
+ },
143
+ "Secrets (local)": {
144
+ what: "Local .dev.vars files are missing required secrets.",
145
+ fix: "Run `hoox check fix` to create placeholder files, then fill in real values.",
146
+ },
147
+ "Secrets (remote)": {
148
+ what: "Remote Cloudflare secrets are missing for one or more workers.",
149
+ fix: "Run `hoox secrets update-cf` to push local secrets to Cloudflare.",
150
+ },
151
+ Database: {
152
+ what: "Required D1 database tables or schemas are missing.",
153
+ fix: "Run `hoox infra provision` and apply D1 migrations.",
154
+ },
155
+ };
156
+
157
+ /** Fallback explanation when a check name is not in the SUGGESTED_FIXES map. */
158
+ const GENERIC_FAIL_HELP = {
159
+ what: "This check reported one or more errors.",
160
+ fix: "Review the error details below, then re-run `hoox check setup` after fixing.",
161
+ };
162
+
163
+ // ─── Constants ────────────────────────────────────────────────────────────────
164
+
165
+ /** Refresh rate options in milliseconds */
166
+ const REFRESH_RATES = [250, 500, 1000, 2000, 5000] as const;
167
+
168
+ /** Refresh rate display labels */
169
+ function formatRefreshRate(ms: number): string {
170
+ if (ms >= 1000) return `${ms / 1000}s`;
171
+ return `${ms}ms`;
172
+ }
173
+
174
+ /** ViewId options for the default view selector */
175
+ const VIEW_OPTIONS: ViewId[] = [
176
+ "dashboard",
177
+ "workers",
178
+ "worker-detail",
179
+ "trade-monitor",
180
+ "logs-viewer",
181
+ "service-manager",
182
+ "config-editor",
183
+ "setup-wizard",
184
+ "settings",
185
+ "queue-depth",
186
+ "kv-viewer",
187
+ "secrets-viewer",
188
+ "db-query",
189
+ "ai-chat",
190
+ "edge-topology",
191
+ "worker-settings",
192
+ ];
193
+
194
+ /** Human-readable labels for ViewId values */
195
+ const VIEW_LABELS: Record<ViewId, string> = {
196
+ dashboard: "Dashboard",
197
+ workers: "Workers Overview",
198
+ "worker-detail": "Worker Detail",
199
+ "trade-monitor": "Trade Monitor",
200
+ "logs-viewer": "Logs Viewer",
201
+ "service-manager": "Service Manager",
202
+ "config-editor": "Config Editor",
203
+ "setup-wizard": "Setup Wizard",
204
+ settings: "Settings",
205
+ "queue-depth": "Queue Depth",
206
+ "kv-viewer": "KV Viewer",
207
+ "secrets-viewer": "Secrets Viewer",
208
+ "db-query": "DB Query",
209
+ "ai-chat": "AI Chat",
210
+ "edge-topology": "Edge Topology",
211
+ "worker-settings": "Worker Settings",
212
+ };
213
+
214
+ /** Notification channel keys in display order */
215
+ const NOTIFICATION_CHANNELS: (keyof NotificationPreferences)[] = [
216
+ "alerts",
217
+ "trades",
218
+ "debug",
219
+ "system",
220
+ ];
221
+
222
+ /** Human-readable labels for notification channels */
223
+ const NOTIFICATION_LABELS: Record<keyof NotificationPreferences, string> = {
224
+ alerts: "ALERTS",
225
+ trades: "TRADES",
226
+ debug: "DEBUG",
227
+ system: "SYSTEM",
228
+ };
229
+
230
+ /** System-level keyboard shortcuts shown in the reference panel */
231
+ interface ShortcutEntry {
232
+ key: string;
233
+ action: string;
234
+ }
235
+
236
+ const SYSTEM_SHORTCUTS: ShortcutEntry[] = [
237
+ { key: "Ctrl+1..9", action: "Switch to view 1-9" },
238
+ { key: "Ctrl+0", action: "Switch to view 10" },
239
+ // Keep action labels short — Keyboard panel is ~34 cols; long text
240
+ // overflows into adjacent panels and corrupts the character frame.
241
+ { key: "Ctrl+Alt+W", action: "Worker Settings" },
242
+ { key: "Ctrl+P", action: "Command Palette" },
243
+ { key: "Ctrl+B", action: "Toggle Sidebar" },
244
+ { key: "Ctrl+Q", action: "Quit" },
245
+ { key: "Esc", action: "Back / Close" },
246
+ { key: "Tab", action: "Next panel / field" },
247
+ { key: "/", action: "Focus search" },
248
+ { key: "Space", action: "Toggle checkbox" },
249
+ ];
250
+
251
+ // ─── Panel Count ──────────────────────────────────────────────────────────────
252
+
253
+ const PANEL_COUNT = 4;
254
+
255
+ // ─── Sub-Components ────────────────────────────────────────────────────────────
256
+
257
+ /**
258
+ * Toggle checkbox: "[x]" for checked, "[ ]" for unchecked.
259
+ * Responds to Space and mouse click.
260
+ */
261
+ function Checkbox({
262
+ label,
263
+ checked,
264
+ onToggle,
265
+ active,
266
+ }: {
267
+ label: string;
268
+ checked: boolean;
269
+ onToggle: () => void;
270
+ active: boolean;
271
+ }) {
272
+ const marker = checked ? "x" : " ";
273
+ return (
274
+ <text
275
+ fg={checked ? Colors.success : Colors.muted}
276
+ bg={active ? Colors.background : undefined}
277
+ bold={checked}
278
+ dim={!checked}
279
+ onMouseUp={onToggle}
280
+ >
281
+ [{marker}] {label}
282
+ </text>
283
+ );
284
+ }
285
+
286
+ // ─── Column 1: ThemePanel ─────────────────────────────────────────────────────
287
+
288
+ /**
289
+ * ThemePanel — dark-only theme note, refresh rate, default view, reset,
290
+ * plus a read-only connection/transport snapshot.
291
+ *
292
+ * Local item indices for keyboard navigation:
293
+ * 0 = refresh rate, 1 = default view, 2 = reset button
294
+ * (theme is display-only; connection is read-only)
295
+ */
296
+ const THEME_ITEM_COUNT = 3;
297
+
298
+ function ThemePanel({
299
+ active,
300
+ activeItem,
301
+ onResetDefaults,
302
+ }: {
303
+ active: boolean;
304
+ activeItem: number;
305
+ onResetDefaults: () => void;
306
+ }) {
307
+ const refreshIntervalMs = useConfigStore((s) => s.refreshIntervalMs);
308
+ const defaultView = useConfigStore((s) => s.defaultView);
309
+
310
+ const connection = useMemo(() => {
311
+ let configFallback: {
312
+ transport?: string;
313
+ apiUrl?: string;
314
+ apiToken?: string;
315
+ } = {};
316
+ try {
317
+ const cfg = readConfigSync();
318
+ // Presence only — never pass cleartext tokens into view/snapshot objects.
319
+ // A non-empty placeholder is enough for authSummary (Boolean check).
320
+ configFallback = {
321
+ transport: cfg.transport,
322
+ apiUrl: cfg.apiUrl,
323
+ apiToken: cfg.apiToken ? "1" : undefined,
324
+ };
325
+ } catch {
326
+ // config.json may be missing — env-only snapshot is fine
327
+ }
328
+ return getSettingsConnectionSnapshot(process.env, configFallback);
329
+ }, []);
330
+
331
+ return (
332
+ <box flexDirection="column" gap={0} paddingX={1}>
333
+ {/* Dark-only theme (panel title is THEME; light not implemented) */}
334
+ <text fg={Colors.accent} bold>
335
+ (•) DARK
336
+ </text>
337
+ <text fg={Colors.muted} dim>
338
+ Light not implemented
339
+ </text>
340
+
341
+ {/* Refresh rate selector */}
342
+ <text fg={Colors.foreground} bold dim>
343
+ REFRESH
344
+ </text>
345
+ <text
346
+ fg={Colors.accent}
347
+ bg={active && activeItem === 0 ? Colors.background : undefined}
348
+ bold
349
+ >
350
+ {"◀ "}
351
+ {formatRefreshRate(refreshIntervalMs)}
352
+ {" ▶"}
353
+ </text>
354
+
355
+ {/* Default view dropdown */}
356
+ <text fg={Colors.foreground} bold dim>
357
+ DEFAULT VIEW
358
+ </text>
359
+ <text
360
+ fg={Colors.accent}
361
+ bg={active && activeItem === 1 ? Colors.background : undefined}
362
+ bold
363
+ >
364
+ {"◀ "}
365
+ {VIEW_LABELS[defaultView] ?? defaultView}
366
+ {" ▶"}
367
+ </text>
368
+
369
+ {/* Reset to Defaults */}
370
+ <text
371
+ fg={Colors.warning}
372
+ bg={active && activeItem === 2 ? Colors.background : undefined}
373
+ bold
374
+ onMouseUp={onResetDefaults}
375
+ >
376
+ {"[ RESET TO DEFAULTS ]"}
377
+ </text>
378
+
379
+ {/* Connection / transport (read-only; secrets never shown) */}
380
+ <text fg={Colors.foreground} bold dim>
381
+ CONNECTION
382
+ </text>
383
+ <text
384
+ fg={
385
+ connection.mode === "remote"
386
+ ? Colors.info
387
+ : Colors["muted-foreground"]
388
+ }
389
+ bold
390
+ >
391
+ {connection.mode.toUpperCase()} · {connection.apiHost}
392
+ </text>
393
+ <text fg={Colors.accent}>
394
+ {connection.transport} · Auth {connection.authSummary}
395
+ </text>
396
+ <text fg={Colors.dim} dim>
397
+ {connection.hint}
398
+ </text>
399
+ </box>
400
+ );
401
+ }
402
+
403
+ // ─── Column 2: NotificationsPanel ─────────────────────────────────────────────
404
+
405
+ /**
406
+ * NotificationsPanel — toggle checkboxes for alert/trade/debug/system
407
+ * notification channels plus a master sound toggle.
408
+ *
409
+ * Local item indices:
410
+ * 0-3 = notification channels, 4 = sound toggle
411
+ */
412
+ const NOTIF_ITEM_COUNT = 5;
413
+
414
+ function NotificationsPanel({
415
+ active,
416
+ activeItem,
417
+ }: {
418
+ active: boolean;
419
+ activeItem: number;
420
+ }) {
421
+ const notifications = useConfigStore((s) => s.notifications);
422
+ const soundEnabled = useConfigStore((s) => s.soundEnabled);
423
+ const toggleNotification = useConfigStore((s) => s.toggleNotification);
424
+ const updateConfig = useConfigStore((s) => s.updateConfig);
425
+
426
+ return (
427
+ <box flexDirection="column" gap={0}>
428
+ {/* Notification checkboxes */}
429
+ <text fg={Colors.foreground} bold dim>
430
+ CHANNELS
431
+ </text>
432
+ {NOTIFICATION_CHANNELS.map((channel, i) => (
433
+ <Checkbox
434
+ key={channel}
435
+ label={NOTIFICATION_LABELS[channel]}
436
+ checked={notifications[channel]}
437
+ onToggle={() => toggleNotification(channel)}
438
+ active={active && activeItem === i}
439
+ />
440
+ ))}
441
+
442
+ {/* Sound toggle */}
443
+ <box paddingTop={1}>
444
+ <Checkbox
445
+ label="SOUND"
446
+ checked={soundEnabled}
447
+ onToggle={() => updateConfig({ soundEnabled: !soundEnabled })}
448
+ active={active && activeItem === 4}
449
+ />
450
+ </box>
451
+ </box>
452
+ );
453
+ }
454
+
455
+ // ─── Column 3: KeyboardPanel ──────────────────────────────────────────────────
456
+
457
+ /**
458
+ * KeyboardPanel — read-only reference table showing system-level
459
+ * keyboard shortcuts (key → action).
460
+ */
461
+ function KeyboardPanel() {
462
+ return (
463
+ <box flexDirection="column" gap={0}>
464
+ <text fg={Colors.foreground} bold dim>
465
+ KEY
466
+ {" "}
467
+ ACTION
468
+ </text>
469
+
470
+ {SYSTEM_SHORTCUTS.map((entry) => (
471
+ <box key={entry.key} flexDirection="row" gap={1}>
472
+ <text fg={Colors.accent} bold>
473
+ {entry.key.padEnd(14)}
474
+ </text>
475
+ <text fg={Colors.muted} dim>
476
+ {entry.action}
477
+ </text>
478
+ </box>
479
+ ))}
480
+
481
+ {/* Divider + hint */}
482
+ <box paddingTop={1}>
483
+ <text fg={Colors.dim} dim>
484
+ These shortcuts are global and cannot be changed.
485
+ </text>
486
+ </box>
487
+ </box>
488
+ );
489
+ }
490
+
491
+ // ─── Column 4: DataPanel ──────────────────────────────────────────────────────
492
+
493
+ /**
494
+ * DataPanel — data management actions (Clear Cache, Export, Import, Check Setup, Auto-Repair)
495
+ * plus an About section with version and tech info.
496
+ *
497
+ * Local item indices:
498
+ * 0 = Clear Cache, 1 = Export Data, 2 = Import Data, 3 = Check Setup, 4 = Run Auto-Repair
499
+ */
500
+ const DATA_ITEM_COUNT = 5;
501
+
502
+ function DataPanel({
503
+ active,
504
+ activeItem,
505
+ onClearCache,
506
+ onExportData,
507
+ onImportData,
508
+ onCheckSetup,
509
+ onRunAutoRepair,
510
+ }: {
511
+ active: boolean;
512
+ activeItem: number;
513
+ onClearCache: () => void;
514
+ onExportData: () => void;
515
+ onImportData: () => void;
516
+ onCheckSetup: () => void;
517
+ onRunAutoRepair: () => void;
518
+ }) {
519
+ return (
520
+ <box flexDirection="column" gap={0} flexGrow={1}>
521
+ {/* Data management header */}
522
+ <text fg={Colors.foreground} bold dim>
523
+ DATA MANAGEMENT
524
+ </text>
525
+
526
+ {/* Action buttons */}
527
+ <box flexDirection="column" gap={0} paddingLeft={1}>
528
+ <text
529
+ fg={Colors.accent}
530
+ bg={active && activeItem === 0 ? Colors.background : undefined}
531
+ bold
532
+ onMouseUp={onClearCache}
533
+ >
534
+ [ CLEAR CACHE ]
535
+ </text>
536
+ <text
537
+ fg={Colors.accent}
538
+ bg={active && activeItem === 1 ? Colors.background : undefined}
539
+ bold
540
+ onMouseUp={onExportData}
541
+ >
542
+ [ EXPORT DATA ]
543
+ </text>
544
+ <text
545
+ fg={Colors.accent}
546
+ bg={active && activeItem === 2 ? Colors.background : undefined}
547
+ bold
548
+ onMouseUp={onImportData}
549
+ >
550
+ [ IMPORT DATA ]
551
+ </text>
552
+ <text
553
+ fg={Colors.warning}
554
+ bg={active && activeItem === 3 ? Colors.background : undefined}
555
+ bold
556
+ onMouseUp={onCheckSetup}
557
+ >
558
+ [ CHECK SETUP ]
559
+ </text>
560
+ <text
561
+ fg={Colors.info}
562
+ bg={active && activeItem === 4 ? Colors.background : undefined}
563
+ bold
564
+ onMouseUp={onRunAutoRepair}
565
+ >
566
+ [ AUTO-REPAIR ]
567
+ </text>
568
+ </box>
569
+
570
+ {/* About section */}
571
+ <box paddingTop={1}>
572
+ <text fg={Colors.foreground} bold dim>
573
+ ABOUT
574
+ </text>
575
+ </box>
576
+ <box flexDirection="column" gap={0} paddingLeft={1}>
577
+ <text fg={Colors.foreground} bold>
578
+ HOOX CLI v1.0.0
579
+ </text>
580
+ <text fg={Colors.muted} dim>
581
+ OpenTUI + Bun
582
+ </text>
583
+ <text fg={Colors.info} dim>
584
+ github.com/hoox/hoox-cli
585
+ </text>
586
+ </box>
587
+ </box>
588
+ );
589
+ }
590
+
591
+ // ─── Check Setup Results Panel ────────────────────────────────────────────────
592
+
593
+ /**
594
+ * Status badge for a single check: PASS, FAIL, or WARN.
595
+ * Renders as a colored bracketed tag so it's easy to scan in a long list.
596
+ */
597
+ function CheckStatusBadge({ status }: { status: "pass" | "fail" | "warn" }) {
598
+ if (status === "pass") {
599
+ return (
600
+ <text fg={Colors.success} bold>
601
+ {"[PASS]"}
602
+ </text>
603
+ );
604
+ }
605
+ if (status === "fail") {
606
+ return (
607
+ <text fg={Colors.error} bold>
608
+ {"[FAIL]"}
609
+ </text>
610
+ );
611
+ }
612
+ return (
613
+ <text fg={Colors.warning} bold>
614
+ {"[WARN]"}
615
+ </text>
616
+ );
617
+ }
618
+
619
+ /**
620
+ * Status icon for a check row. Mirrors CheckStatusBadge but renders a
621
+ * single character — used in compact lists where width is tight.
622
+ */
623
+ function checkStatusIcon(check: CheckItem): "pass" | "fail" | "warn" {
624
+ if (!check.success) return "fail";
625
+ if (check.warnings.length > 0) return "warn";
626
+ return "pass";
627
+ }
628
+
629
+ /**
630
+ * Renders a single failed check's "what it means" + "suggested fix" section.
631
+ * Only shown for failed checks; passes/warnings are listed without the
632
+ * extra block to keep the panel scannable.
633
+ */
634
+ function CheckFixHelp({ check }: { check: CheckItem }) {
635
+ const help = SUGGESTED_FIXES[check.name] ?? GENERIC_FAIL_HELP;
636
+ return (
637
+ <box flexDirection="column" gap={0} paddingLeft={6}>
638
+ <box>
639
+ <text fg={Colors.foreground} dim>
640
+ {" what: "}
641
+ {help.what}
642
+ </text>
643
+ </box>
644
+ <box>
645
+ <text fg={Colors.info} dim>
646
+ {" fix: "}
647
+ {help.fix}
648
+ </text>
649
+ </box>
650
+ </box>
651
+ );
652
+ }
653
+
654
+ /**
655
+ * CheckSetupResultsPanel — renders a full CheckReport below the 4-column
656
+ * settings layout. Shows a summary header, per-category sections, and a
657
+ * per-check row with PASS/FAIL/WARN status. Failed checks include a
658
+ * "what it means / suggested fix" block.
659
+ *
660
+ * Persists until the user dismisses with ESC or the [DISMISS] button.
661
+ */
662
+ function CheckSetupResultsPanel({
663
+ state,
664
+ onDismiss,
665
+ onRerun,
666
+ }: {
667
+ state: CheckSetupState;
668
+ onDismiss: () => void;
669
+ onRerun: () => void;
670
+ }) {
671
+ return (
672
+ <box
673
+ flexDirection="column"
674
+ border={true}
675
+ borderStyle="single"
676
+ borderColor={Colors.accent}
677
+ backgroundColor={Colors.card}
678
+ paddingX={1}
679
+ paddingY={0}
680
+ gap={0}
681
+ >
682
+ {/* Header row: title + summary counts + dismiss button */}
683
+ <box flexDirection="row" gap={2}>
684
+ <text fg={Colors.accent} bold>
685
+ SETUP CHECK RESULTS
686
+ </text>
687
+ {state.kind === "report" && (
688
+ <>
689
+ <text fg={Colors.success} bold>
690
+ {state.report.summary.passed} passed
691
+ </text>
692
+ <text
693
+ fg={state.report.summary.failed > 0 ? Colors.error : Colors.muted}
694
+ bold={state.report.summary.failed > 0}
695
+ >
696
+ {state.report.summary.failed} failed
697
+ </text>
698
+ <text
699
+ fg={
700
+ state.report.summary.warnings > 0
701
+ ? Colors.warning
702
+ : Colors.muted
703
+ }
704
+ bold={state.report.summary.warnings > 0}
705
+ >
706
+ {state.report.summary.warnings} warnings
707
+ </text>
708
+ <text fg={Colors.muted} dim>
709
+ {`(${(state.durationMs / 1000).toFixed(1)}s)`}
710
+ </text>
711
+ </>
712
+ )}
713
+ {state.kind === "running" && (
714
+ <text fg={Colors.info} bold>
715
+ running...
716
+ </text>
717
+ )}
718
+ <text fg={Colors.muted}>{" "}</text>
719
+ <text fg={Colors.accent} bold onMouseUp={onRerun}>
720
+ [ RE-RUN ]
721
+ </text>
722
+ <text fg={Colors.warning} bold onMouseUp={onDismiss}>
723
+ [ DISMISS ]
724
+ </text>
725
+ </box>
726
+
727
+ {/* Divider */}
728
+ <box>
729
+ <text fg={Colors.border} dim>
730
+ {"─".repeat(80)}
731
+ </text>
732
+ </box>
733
+
734
+ {/* Error state */}
735
+ {state.kind === "error" && (
736
+ <box flexDirection="column" gap={0} paddingTop={1}>
737
+ <box>
738
+ <text fg={Colors.error} bold>
739
+ Setup check failed to run:
740
+ </text>
741
+ </box>
742
+ <box>
743
+ <text fg={Colors.foreground}>{state.message}</text>
744
+ </box>
745
+ </box>
746
+ )}
747
+
748
+ {/* Running state */}
749
+ {state.kind === "running" && (
750
+ <box flexDirection="column" gap={0} paddingTop={1}>
751
+ <text fg={Colors.muted} dim>
752
+ Running `hoox check setup` — this may take up to 20 seconds.
753
+ </text>
754
+ </box>
755
+ )}
756
+
757
+ {/* Report state — show all categories and checks */}
758
+ {state.kind === "report" && (
759
+ <box flexDirection="column" gap={0} paddingTop={1}>
760
+ {state.report.categories.length === 0 ? (
761
+ <box>
762
+ <text fg={Colors.muted} dim>
763
+ No checks were run.
764
+ </text>
765
+ </box>
766
+ ) : (
767
+ state.report.categories.map((cat) => (
768
+ <box key={cat.name} flexDirection="column" gap={0} paddingTop={1}>
769
+ {/* Category header */}
770
+ <text fg={Colors.foreground} bold dim>
771
+ {cat.name.toUpperCase()}
772
+ </text>
773
+ {/* Each check in the category */}
774
+ {cat.checks.map((check, idx) => {
775
+ const status = checkStatusIcon(check);
776
+ return (
777
+ <box
778
+ key={`${cat.name}-${idx}-${check.name}`}
779
+ flexDirection="column"
780
+ gap={0}
781
+ >
782
+ <box flexDirection="row" gap={1} paddingLeft={1}>
783
+ <box width={8}>
784
+ <CheckStatusBadge status={status} />
785
+ </box>
786
+ <text
787
+ fg={
788
+ status === "fail"
789
+ ? Colors.foreground
790
+ : Colors.foreground
791
+ }
792
+ bold={status === "fail"}
793
+ >
794
+ {check.name}
795
+ </text>
796
+ </box>
797
+ {/* Error details (one line each) */}
798
+ {check.errors.map((err, i) => (
799
+ <box key={`err-${idx}-${i}`} paddingLeft={6}>
800
+ <text fg={Colors.error}>
801
+ {" - "}
802
+ {err}
803
+ </text>
804
+ </box>
805
+ ))}
806
+ {/* Warning details */}
807
+ {check.warnings.map((warn, i) => (
808
+ <box key={`warn-${idx}-${i}`} paddingLeft={6}>
809
+ <text fg={Colors.warning}>
810
+ {" ~ "}
811
+ {warn}
812
+ </text>
813
+ </box>
814
+ ))}
815
+ {/* What it means + suggested fix (failures only) */}
816
+ {status === "fail" && <CheckFixHelp check={check} />}
817
+ </box>
818
+ );
819
+ })}
820
+ </box>
821
+ ))
822
+ )}
823
+ </box>
824
+ )}
825
+ </box>
826
+ );
827
+ }
828
+
829
+ // ─── Main View ─────────────────────────────────────────────────────────────────
830
+
831
+ /**
832
+ * SettingsView — 4-column user preferences view.
833
+ *
834
+ * Keyboard model:
835
+ * Tab / Shift+Tab → cycle active panel (0-3)
836
+ * ↑↓ → navigate items within active panel
837
+ * Space → toggle checkbox / select radio
838
+ * ←→ → cycle select values (refresh rate, default view)
839
+ * Enter → activate button / toggle
840
+ *
841
+ * When the Check Setup results panel is visible, ESC dismisses it
842
+ * (overrides the import-mode ESC handler).
843
+ *
844
+ * Wrapped in ErrorBoundary for crash recovery.
845
+ */
846
+ export function SettingsView({ dialog }: SettingsViewProps = {}) {
847
+ const activeView = useUIStore((s) => s.activeView);
848
+ const isActive = activeView === "settings";
849
+
850
+ const [activePanel, setActivePanel] = useState(0);
851
+ const [activeItem, setActiveItem] = useState(0);
852
+ const [importing, setImporting] = useState(false);
853
+ const [importPath, setImportPath] = useState("");
854
+ const [setupCheck, setSetupCheck] = useState<CheckSetupState>({
855
+ kind: "idle",
856
+ });
857
+
858
+ const updateConfig = useConfigStore((s) => s.updateConfig);
859
+ const theme = useConfigStore((s) => s.theme);
860
+ const refreshIntervalMs = useConfigStore((s) => s.refreshIntervalMs);
861
+ const defaultView = useConfigStore((s) => s.defaultView);
862
+ const resetDefaults = useConfigStore((s) => s.resetDefaults);
863
+ const toggleNotification = useConfigStore((s) => s.toggleNotification);
864
+ const soundEnabled = useConfigStore((s) => s.soundEnabled);
865
+
866
+ // Light theme is not implemented — coerce any stored "light" to dark.
867
+ useEffect(() => {
868
+ if (theme === "light") {
869
+ updateConfig({ theme: "dark" });
870
+ }
871
+ }, [theme, updateConfig]);
872
+
873
+ /** Fail-closed confirm helper for destructive settings actions. */
874
+ const confirmAction = useCallback(
875
+ async (title: string, message: string, confirmLabel: string) => {
876
+ if (!dialog) {
877
+ useServiceStore.getState().addAlert({
878
+ id: `settings-noconfirm-${Date.now()}`,
879
+ type: "config",
880
+ severity: "warning",
881
+ message: `${title} blocked: confirmation dialog unavailable`,
882
+ timestamp: Date.now(),
883
+ acknowledged: false,
884
+ });
885
+ return false;
886
+ }
887
+ return showConfirm(dialog, {
888
+ title,
889
+ message,
890
+ confirmLabel,
891
+ cancelLabel: "Cancel",
892
+ });
893
+ },
894
+ [dialog]
895
+ );
896
+
897
+ // ── Data panel handlers ────────────────────────────────────────────────────
898
+
899
+ const handleClearCache = useCallback(async () => {
900
+ const ok = await confirmAction(
901
+ "Clear Cache?",
902
+ "Delete ~/.hoox/cache? This cannot be undone.",
903
+ "Clear Cache"
904
+ );
905
+ if (!ok) return;
906
+ try {
907
+ const cachePath = path.join(os.homedir(), ".hoox", "cache");
908
+ const proc = Bun.spawn(["rm", "-rf", cachePath]);
909
+ await proc.exited;
910
+ useServiceStore.getState().addAlert({
911
+ id: `cache-${Date.now()}`,
912
+ type: "config",
913
+ severity: "info",
914
+ message: "Cache cleared successfully",
915
+ timestamp: Date.now(),
916
+ acknowledged: false,
917
+ });
918
+ } catch (err) {
919
+ useServiceStore.getState().addAlert({
920
+ id: `cache-err-${Date.now()}`,
921
+ type: "config",
922
+ severity: "error",
923
+ message: `Cache clear failed: ${err instanceof Error ? err.message : String(err)}`,
924
+ timestamp: Date.now(),
925
+ acknowledged: false,
926
+ });
927
+ }
928
+ }, [confirmAction]);
929
+
930
+ const handleExportData = useCallback(async () => {
931
+ try {
932
+ const result = await cliBridge().configShow();
933
+ if (!result.success) {
934
+ useServiceStore.getState().addAlert({
935
+ id: `export-err-${Date.now()}`,
936
+ type: "config",
937
+ severity: "error",
938
+ message: `Export failed: ${result.stderr || result.stdout || "unknown error"}`,
939
+ timestamp: Date.now(),
940
+ acknowledged: false,
941
+ });
942
+ return;
943
+ }
944
+ const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
945
+ const filePath = path.join(
946
+ os.homedir(),
947
+ ".hoox",
948
+ `config-export-${timestamp}.json`
949
+ );
950
+ await Bun.write(filePath, JSON.stringify(result.data, null, 2));
951
+ useServiceStore.getState().addAlert({
952
+ id: `export-${Date.now()}`,
953
+ type: "config",
954
+ severity: "info",
955
+ message: `Config exported to ${filePath}`,
956
+ timestamp: Date.now(),
957
+ acknowledged: false,
958
+ });
959
+ } catch (err) {
960
+ useServiceStore.getState().addAlert({
961
+ id: `export-err-${Date.now()}`,
962
+ type: "config",
963
+ severity: "error",
964
+ message: `Export error: ${err instanceof Error ? err.message : String(err)}`,
965
+ timestamp: Date.now(),
966
+ acknowledged: false,
967
+ });
968
+ }
969
+ }, []);
970
+
971
+ const handleImportData = useCallback(async () => {
972
+ setImporting(true);
973
+ setImportPath("");
974
+ }, []);
975
+
976
+ const handleImportConfirm = useCallback(async () => {
977
+ if (!importPath.trim()) return;
978
+ try {
979
+ const f = Bun.file(importPath.trim());
980
+ const exists = await f.exists();
981
+ if (!exists) {
982
+ useServiceStore.getState().addAlert({
983
+ id: `import-err-${Date.now()}`,
984
+ type: "config",
985
+ severity: "error",
986
+ message: `File not found: ${importPath.trim()}`,
987
+ timestamp: Date.now(),
988
+ acknowledged: false,
989
+ });
990
+ setImporting(false);
991
+ return;
992
+ }
993
+ const text = await f.text();
994
+ const config = JSON.parse(text);
995
+
996
+ // Validate it has expected shape
997
+ if (typeof config !== "object" || config === null) {
998
+ throw new Error("Config must be a JSON object");
999
+ }
1000
+
1001
+ // Apply config values if they match known keys (light theme coerced to dark)
1002
+ if (typeof config.theme === "string")
1003
+ updateConfig({
1004
+ theme: config.theme === "light" ? "dark" : (config.theme as "dark"),
1005
+ });
1006
+ if (typeof config.refreshIntervalMs === "number")
1007
+ updateConfig({ refreshIntervalMs: config.refreshIntervalMs });
1008
+ if (typeof config.defaultView === "string")
1009
+ updateConfig({ defaultView: config.defaultView });
1010
+ if (typeof config.soundEnabled === "boolean")
1011
+ updateConfig({ soundEnabled: config.soundEnabled });
1012
+ if (
1013
+ typeof config.notifications === "object" &&
1014
+ config.notifications !== null
1015
+ ) {
1016
+ for (const key of Object.keys(config.notifications)) {
1017
+ if (typeof config.notifications[key] === "boolean") {
1018
+ toggleNotification(key as keyof NotificationPreferences);
1019
+ }
1020
+ }
1021
+ }
1022
+ if (config.activeExchanges)
1023
+ updateConfig({ activeExchanges: config.activeExchanges });
1024
+
1025
+ useServiceStore.getState().addAlert({
1026
+ id: `import-${Date.now()}`,
1027
+ type: "config",
1028
+ severity: "info",
1029
+ message: `Config imported from ${importPath.trim()}`,
1030
+ timestamp: Date.now(),
1031
+ acknowledged: false,
1032
+ });
1033
+ } catch (err) {
1034
+ useServiceStore.getState().addAlert({
1035
+ id: `import-err-${Date.now()}`,
1036
+ type: "config",
1037
+ severity: "error",
1038
+ message: `Import failed: ${err instanceof Error ? err.message : String(err)}`,
1039
+ timestamp: Date.now(),
1040
+ acknowledged: false,
1041
+ });
1042
+ }
1043
+ setImporting(false);
1044
+ setImportPath("");
1045
+ }, [importPath, updateConfig, toggleNotification]);
1046
+
1047
+ const handleCheckSetup = useCallback(async () => {
1048
+ setSetupCheck({ kind: "running" });
1049
+ try {
1050
+ const result = await cliBridge().checkSetup();
1051
+ if (!result.success) {
1052
+ // Process-level failure (CLI exited non-zero, or binary not found).
1053
+ setSetupCheck({
1054
+ kind: "error",
1055
+ message: result.stderr || result.stdout || "unknown error from CLI",
1056
+ });
1057
+ useServiceStore.getState().addAlert({
1058
+ id: `setup-${Date.now()}`,
1059
+ type: "config",
1060
+ severity: "warning",
1061
+ message: `Setup check failed: ${result.stderr || result.stdout || "unknown error"}`,
1062
+ timestamp: Date.now(),
1063
+ acknowledged: false,
1064
+ });
1065
+ return;
1066
+ }
1067
+ // CLI succeeded — `data` should be a CheckReport JSON object.
1068
+ const data = result.data as Partial<CheckReport> | null;
1069
+ if (
1070
+ !data ||
1071
+ !Array.isArray(data.categories) ||
1072
+ typeof data.summary !== "object" ||
1073
+ data.summary === null
1074
+ ) {
1075
+ setSetupCheck({
1076
+ kind: "error",
1077
+ message:
1078
+ "CLI did not return a valid CheckReport (missing categories/summary).",
1079
+ });
1080
+ return;
1081
+ }
1082
+ // At this point we trust the shape — cast to CheckReport.
1083
+ const report: CheckReport = {
1084
+ success: Boolean(data.success),
1085
+ categories: data.categories as CheckCategoryResult[],
1086
+ summary: {
1087
+ total: Number(data.summary.total ?? 0),
1088
+ passed: Number(data.summary.passed ?? 0),
1089
+ failed: Number(data.summary.failed ?? 0),
1090
+ warnings: Number(data.summary.warnings ?? 0),
1091
+ },
1092
+ };
1093
+ setSetupCheck({
1094
+ kind: "report",
1095
+ report,
1096
+ durationMs: result.duration,
1097
+ });
1098
+ useServiceStore.getState().addAlert({
1099
+ id: `setup-${Date.now()}`,
1100
+ type: "config",
1101
+ severity: report.success ? "info" : "warning",
1102
+ message: report.success
1103
+ ? `Setup check passed (${(result.duration / 1000).toFixed(1)}s)`
1104
+ : `Setup check found ${report.summary.failed} issue(s)`,
1105
+ timestamp: Date.now(),
1106
+ acknowledged: false,
1107
+ });
1108
+ } catch (err) {
1109
+ const message = err instanceof Error ? err.message : String(err);
1110
+ setSetupCheck({ kind: "error", message });
1111
+ useServiceStore.getState().addAlert({
1112
+ id: `setup-err-${Date.now()}`,
1113
+ type: "config",
1114
+ severity: "error",
1115
+ message: `Setup check error: ${message}`,
1116
+ timestamp: Date.now(),
1117
+ acknowledged: false,
1118
+ });
1119
+ }
1120
+ }, []);
1121
+
1122
+ /** Dismiss the Check Setup results panel. */
1123
+ const dismissCheckSetup = useCallback(() => {
1124
+ setSetupCheck({ kind: "idle" });
1125
+ }, []);
1126
+
1127
+ /** Run auto-repair via `hoox check fix`. */
1128
+ const handleRunAutoRepair = useCallback(async () => {
1129
+ const ok = await confirmAction(
1130
+ "Run Auto-Repair?",
1131
+ "Apply non-destructive config fixes (placeholders, flags, worker configs). Continue?",
1132
+ "Run Repair"
1133
+ );
1134
+ if (!ok) return;
1135
+ try {
1136
+ const result = await cliBridge().checkFix();
1137
+ if (!result.success) {
1138
+ useServiceStore.getState().addAlert({
1139
+ id: `auto-repair-${Date.now()}`,
1140
+ type: "config",
1141
+ severity: "warning",
1142
+ message: `Auto-repair failed: ${result.stderr || result.stdout || "unknown error"}`,
1143
+ timestamp: Date.now(),
1144
+ acknowledged: false,
1145
+ });
1146
+ return;
1147
+ }
1148
+
1149
+ // Parse FixReport
1150
+ const data = result.data as {
1151
+ actions?: Array<{
1152
+ description: string;
1153
+ type: string;
1154
+ target: string;
1155
+ applied: boolean;
1156
+ error?: string;
1157
+ }>;
1158
+ summary?: {
1159
+ total: number;
1160
+ applied: number;
1161
+ skipped: number;
1162
+ failed: number;
1163
+ };
1164
+ } | null;
1165
+
1166
+ if (!data || !Array.isArray(data.actions)) {
1167
+ useServiceStore.getState().addAlert({
1168
+ id: `auto-repair-${Date.now()}`,
1169
+ type: "config",
1170
+ severity: "warning",
1171
+ message: "Auto-repair returned invalid result",
1172
+ timestamp: Date.now(),
1173
+ acknowledged: false,
1174
+ });
1175
+ return;
1176
+ }
1177
+
1178
+ const applied = data.actions.filter((a) => a.applied).length;
1179
+ const failed = data.actions.filter((a) => a.error).length;
1180
+ useServiceStore.getState().addAlert({
1181
+ id: `auto-repair-${Date.now()}`,
1182
+ type: "config",
1183
+ severity: failed > 0 ? "warning" : "info",
1184
+ message:
1185
+ failed > 0
1186
+ ? `Auto-repair: ${applied} applied, ${failed} failed`
1187
+ : `Auto-repair: ${applied} fix(es) applied`,
1188
+ timestamp: Date.now(),
1189
+ acknowledged: false,
1190
+ });
1191
+ } catch (err) {
1192
+ const message = err instanceof Error ? err.message : String(err);
1193
+ useServiceStore.getState().addAlert({
1194
+ id: `auto-repair-err-${Date.now()}`,
1195
+ type: "config",
1196
+ severity: "error",
1197
+ message: `Auto-repair error: ${message}`,
1198
+ timestamp: Date.now(),
1199
+ acknowledged: false,
1200
+ });
1201
+ }
1202
+ }, [confirmAction]);
1203
+
1204
+ const handleResetDefaults = useCallback(async () => {
1205
+ const ok = await confirmAction(
1206
+ "Reset Defaults?",
1207
+ "Reset theme, refresh rate, default view, and notification preferences to factory defaults?",
1208
+ "Reset"
1209
+ );
1210
+ if (!ok) return;
1211
+ resetDefaults();
1212
+ useServiceStore.getState().addAlert({
1213
+ id: `reset-${Date.now()}`,
1214
+ type: "config",
1215
+ severity: "info",
1216
+ message: "Settings reset to defaults",
1217
+ timestamp: Date.now(),
1218
+ acknowledged: false,
1219
+ });
1220
+ }, [confirmAction, resetDefaults]);
1221
+
1222
+ /**
1223
+ * Test hook: when `globalThis.__hooxTestAutoCheckSetup` is set, the
1224
+ * Check Setup check runs automatically once on mount. This lets the
1225
+ * test suite populate the results panel without simulating mouse/keyboard
1226
+ * events. Production code never sets this flag.
1227
+ */
1228
+ useEffect(() => {
1229
+ const g = globalThis as unknown as {
1230
+ __hooxTestAutoCheckSetup?: boolean;
1231
+ };
1232
+ if (g.__hooxTestAutoCheckSetup) {
1233
+ void handleCheckSetup();
1234
+ }
1235
+ // Intentionally mount-only — tests set the flag then render, so
1236
+ // re-running on state changes would cause duplicate checks.
1237
+ }, []);
1238
+
1239
+ /** Max items per panel */
1240
+ const maxItemsPerPanel = useMemo((): Record<number, number> => {
1241
+ return {
1242
+ 0: THEME_ITEM_COUNT,
1243
+ 1: NOTIF_ITEM_COUNT,
1244
+ 2: 1, // Keyboard panel has no interactive items (read-only)
1245
+ 3: DATA_ITEM_COUNT,
1246
+ };
1247
+ }, []);
1248
+
1249
+ // ── Keyboard handling (only when Settings is the active view) ───────────────
1250
+
1251
+ useKeyboard((key) => {
1252
+ if (!isActive) return;
1253
+ // Import mode overrides
1254
+ if (importing) {
1255
+ if (key.name === "return" || key.name === "enter") {
1256
+ handleImportConfirm();
1257
+ return;
1258
+ }
1259
+ if (key.name === "escape") {
1260
+ setImporting(false);
1261
+ setImportPath("");
1262
+ return;
1263
+ }
1264
+ return; // block other keys during import
1265
+ }
1266
+
1267
+ // Check Setup results panel: ESC dismisses (overrides panel navigation)
1268
+ if (setupCheck.kind !== "idle" && key.name === "escape") {
1269
+ dismissCheckSetup();
1270
+ return;
1271
+ }
1272
+
1273
+ // Tab: cycle panels
1274
+ if (key.name === "tab") {
1275
+ setActivePanel((p) => (p + 1) % PANEL_COUNT);
1276
+ setActiveItem(0);
1277
+ return;
1278
+ }
1279
+
1280
+ // Up/Down: navigate items within active panel
1281
+ if (key.name === "up") {
1282
+ setActiveItem((i) => Math.max(0, i - 1));
1283
+ return;
1284
+ }
1285
+ if (key.name === "down") {
1286
+ setActiveItem((i) =>
1287
+ Math.min((maxItemsPerPanel[activePanel] ?? 0) - 1, i + 1)
1288
+ );
1289
+ return;
1290
+ }
1291
+
1292
+ // Panel-specific actions
1293
+ switch (activePanel) {
1294
+ case 0: {
1295
+ // Theme panel — items: 0 refresh, 1 default view, 2 reset
1296
+ switch (activeItem) {
1297
+ case 0: {
1298
+ // Refresh rate
1299
+ const currentIdx = REFRESH_RATES.indexOf(
1300
+ refreshIntervalMs as (typeof REFRESH_RATES)[number]
1301
+ );
1302
+ if (key.name === "left") {
1303
+ const prev =
1304
+ (currentIdx - 1 + REFRESH_RATES.length) % REFRESH_RATES.length;
1305
+ updateConfig({ refreshIntervalMs: REFRESH_RATES[prev] });
1306
+ } else if (key.name === "right") {
1307
+ const next = (currentIdx + 1) % REFRESH_RATES.length;
1308
+ updateConfig({ refreshIntervalMs: REFRESH_RATES[next] });
1309
+ }
1310
+ break;
1311
+ }
1312
+ case 1: {
1313
+ // Default view
1314
+ const currentIdx = VIEW_OPTIONS.indexOf(defaultView);
1315
+ if (key.name === "left") {
1316
+ const prev =
1317
+ (currentIdx - 1 + VIEW_OPTIONS.length) % VIEW_OPTIONS.length;
1318
+ updateConfig({ defaultView: VIEW_OPTIONS[prev] });
1319
+ } else if (key.name === "right") {
1320
+ const next = (currentIdx + 1) % VIEW_OPTIONS.length;
1321
+ updateConfig({ defaultView: VIEW_OPTIONS[next] });
1322
+ }
1323
+ break;
1324
+ }
1325
+ case 2: // Reset defaults
1326
+ if (key.name === "space" || key.name === "return")
1327
+ void handleResetDefaults();
1328
+ break;
1329
+ }
1330
+ break;
1331
+ }
1332
+
1333
+ case 1: {
1334
+ // Notifications panel
1335
+ if (activeItem >= 0 && activeItem < 4) {
1336
+ if (key.name === "space") {
1337
+ const channel = NOTIFICATION_CHANNELS[activeItem];
1338
+ toggleNotification(channel);
1339
+ }
1340
+ } else if (activeItem === 4) {
1341
+ if (key.name === "space")
1342
+ updateConfig({ soundEnabled: !soundEnabled });
1343
+ }
1344
+ break;
1345
+ }
1346
+
1347
+ case 2: {
1348
+ // Keyboard panel — read-only, nothing to toggle
1349
+ break;
1350
+ }
1351
+
1352
+ case 3: {
1353
+ // Data panel — buttons are activated on Enter/Space
1354
+ if (key.name === "space" || key.name === "return") {
1355
+ switch (activeItem) {
1356
+ case 0:
1357
+ handleClearCache();
1358
+ break;
1359
+ case 1:
1360
+ handleExportData();
1361
+ break;
1362
+ case 2:
1363
+ handleImportData();
1364
+ break;
1365
+ case 3:
1366
+ handleCheckSetup();
1367
+ break;
1368
+ case 4:
1369
+ handleRunAutoRepair();
1370
+ break;
1371
+ }
1372
+ }
1373
+ break;
1374
+ }
1375
+ }
1376
+ });
1377
+
1378
+ // ── Render ──────────────────────────────────────────────────────────────────
1379
+
1380
+ return (
1381
+ <ErrorBoundary viewName="Settings">
1382
+ <box flexDirection="column" flexGrow={1} padding={1} gap={1}>
1383
+ <ViewHeader
1384
+ title="SETTINGS"
1385
+ meta={
1386
+ <text fg={Colors.muted} dim>
1387
+ Tab to switch panels · ↑↓ to navigate · ←→ to change · Space to
1388
+ toggle
1389
+ </text>
1390
+ }
1391
+ />
1392
+
1393
+ {/* 4-column layout */}
1394
+ <box flexDirection="row" flexGrow={1} gap={1}>
1395
+ <Panel
1396
+ title="THEME"
1397
+ focused={activePanel === 0}
1398
+ width={28}
1399
+ flexGrow={1}
1400
+ compact
1401
+ >
1402
+ <ThemePanel
1403
+ active={activePanel === 0}
1404
+ activeItem={activeItem}
1405
+ onResetDefaults={() => void handleResetDefaults()}
1406
+ />
1407
+ </Panel>
1408
+
1409
+ <Panel
1410
+ title="NOTIFICATIONS"
1411
+ focused={activePanel === 1}
1412
+ width={28}
1413
+ flexGrow={1}
1414
+ compact
1415
+ >
1416
+ <NotificationsPanel
1417
+ active={activePanel === 1}
1418
+ activeItem={activeItem}
1419
+ />
1420
+ </Panel>
1421
+
1422
+ <Panel
1423
+ title="KEYBOARD"
1424
+ focused={activePanel === 2}
1425
+ width={34}
1426
+ flexGrow={1}
1427
+ compact
1428
+ >
1429
+ <KeyboardPanel />
1430
+ </Panel>
1431
+
1432
+ <Panel
1433
+ title="DATA"
1434
+ focused={activePanel === 3}
1435
+ width={24}
1436
+ flexGrow={1}
1437
+ compact
1438
+ >
1439
+ <DataPanel
1440
+ active={activePanel === 3}
1441
+ activeItem={activeItem}
1442
+ onClearCache={handleClearCache}
1443
+ onExportData={handleExportData}
1444
+ onImportData={handleImportData}
1445
+ onCheckSetup={handleCheckSetup}
1446
+ onRunAutoRepair={handleRunAutoRepair}
1447
+ />
1448
+ </Panel>
1449
+ </box>
1450
+
1451
+ {/* Check Setup results panel — persists until dismissed or view change */}
1452
+ {setupCheck.kind !== "idle" && (
1453
+ <CheckSetupResultsPanel
1454
+ state={setupCheck}
1455
+ onDismiss={dismissCheckSetup}
1456
+ onRerun={handleCheckSetup}
1457
+ />
1458
+ )}
1459
+
1460
+ {/* Import path input overlay */}
1461
+ {importing && (
1462
+ <box
1463
+ flexDirection="row"
1464
+ gap={1}
1465
+ paddingTop={1}
1466
+ paddingLeft={1}
1467
+ paddingRight={1}
1468
+ backgroundColor={Colors.card}
1469
+ >
1470
+ <text fg={Colors.accent} bold>
1471
+ Import path:
1472
+ </text>
1473
+ <input
1474
+ id="import-path"
1475
+ placeholder="~/.hoox/config.json"
1476
+ width={40}
1477
+ textColor={Colors.foreground}
1478
+ cursorColor={Colors.accent}
1479
+ onInput={(v: string) => setImportPath(v)}
1480
+ value={importPath}
1481
+ />
1482
+ <text
1483
+ fg={importPath.trim() ? Colors.accent : Colors.muted}
1484
+ bold={!!importPath.trim()}
1485
+ onMouseUp={importPath.trim() ? handleImportConfirm : undefined}
1486
+ >
1487
+ [Import]
1488
+ </text>
1489
+ <text fg={Colors.muted} onMouseUp={() => setImporting(false)}>
1490
+ [Cancel]
1491
+ </text>
1492
+ </box>
1493
+ )}
1494
+ </box>
1495
+ </ErrorBoundary>
1496
+ );
1497
+ }