@hoox-sh/hoox-tui 0.3.2 → 0.4.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 (51) hide show
  1. package/dist/main.js +1069 -2734
  2. package/package.json +3 -3
  3. package/src/app.tsx +15 -11
  4. package/src/components/shared/shared.test.tsx +2 -2
  5. package/src/components/ui/connection-toasts.ts +6 -0
  6. package/src/components/ui/dialog-state.ts +2 -0
  7. package/src/components/ui/dialog.tsx +0 -13
  8. package/src/components/views/ai-chat.tsx +6 -1
  9. package/src/components/views/config-editor/code-editor.tsx +8 -6
  10. package/src/components/views/config-editor.test.tsx +20 -17
  11. package/src/components/views/config-editor.tsx +8 -6
  12. package/src/components/views/dashboard/model-health-section.tsx +38 -46
  13. package/src/components/views/dashboard/pyne-health-section.tsx +36 -41
  14. package/src/components/views/dashboard.tsx +25 -28
  15. package/src/components/views/kv-viewer.test.tsx +4 -16
  16. package/src/components/views/kv-viewer.tsx +9 -13
  17. package/src/components/views/logs-viewer.test.tsx +4 -4
  18. package/src/components/views/queue-depth.test.tsx +5 -7
  19. package/src/components/views/queue-depth.tsx +10 -14
  20. package/src/components/views/secrets-viewer.test.tsx +1 -1
  21. package/src/components/views/secrets-viewer.tsx +11 -15
  22. package/src/components/views/service-manager.tsx +1 -0
  23. package/src/components/views/settings.test.tsx +2 -7
  24. package/src/components/views/settings.tsx +43 -60
  25. package/src/components/views/setup-wizard.test.tsx +12 -14
  26. package/src/components/views/setup-wizard.tsx +61 -59
  27. package/src/components/views/worker-detail.test.tsx +2 -2
  28. package/src/components/views/worker-settings.tsx +6 -6
  29. package/src/components/views/workers-overview.tsx +1 -1
  30. package/src/hooks/hooks.test.ts +10 -174
  31. package/src/hooks/index.ts +1 -3
  32. package/src/hooks/renderer-ref.ts +1 -0
  33. package/src/hooks/use-polling.ts +12 -0
  34. package/src/services/cli-bridge/index.ts +56 -12
  35. package/src/services/cli-bridge/standalone.ts +4 -1
  36. package/src/services/cli-bridge/types.ts +6 -9
  37. package/src/services/cli-bridge.test.ts +20 -4
  38. package/src/services/clipboard.ts +2 -2
  39. package/src/stores/service-store.test.ts +29 -26
  40. package/src/stores/session-restore.test.ts +2 -0
  41. package/src/test-setup.ts +5 -13
  42. package/src/test-utils.ts +4 -24
  43. package/src/types.ts +9 -47
  44. package/src/utils/colors.test.ts +8 -3
  45. package/src/view-registry.test.ts +2 -19
  46. package/src/view-registry.tsx +7 -20
  47. package/src/components/shared/animated-border.tsx +0 -58
  48. package/src/components/shared/keybinding-hint.tsx +0 -37
  49. package/src/components/ui/select.tsx +0 -49
  50. package/src/hooks/use-keyboard.ts +0 -130
  51. package/src/hooks/use-service-data.ts +0 -35
@@ -37,6 +37,7 @@ import { PyneHealthSection } from "./dashboard/pyne-health-section";
37
37
  import { AlertsPanel } from "./dashboard/alerts-panel";
38
38
  import { useUIStore } from "@hoox-sh/hoox-shared";
39
39
  import { useViewKeyboard } from "../../hooks/shell-overlay";
40
+ import { usePolling } from "../../hooks/use-polling";
40
41
 
41
42
  // ─── Props ────────────────────────────────────────────────────────────────────
42
43
 
@@ -71,42 +72,38 @@ function formatStatNumber(value: number, isPnl = false): string {
71
72
 
72
73
  /**
73
74
  * KillSwitchStatusBadge — Compact trade kill-switch indicator for the dashboard
74
- * header. Auto-refreshes on mount and shows the engaged/released state with a
75
- * color-coded status dot. Errors degrade gracefully (badge just shows UNKNOWN).
75
+ * header. Auto-refreshes on the Settings interval and shows the engaged/released
76
+ * state with a color-coded status dot. Errors degrade to UNKNOWN.
76
77
  */
77
- /** How often the dashboard kill-switch badge re-probes (ms). */
78
- const KILL_SWITCH_POLL_MS = 15_000;
79
-
80
78
  function KillSwitchStatusBadge() {
81
79
  const [state, setState] = useState<"engaged" | "released" | "unknown">(
82
80
  "unknown"
83
81
  );
84
- useEffect(() => {
85
- let cancelled = false;
86
- const refresh = async () => {
87
- try {
88
- const result = await cliBridge.monitorKillSwitch("show");
89
- if (cancelled) return;
90
- if (result.success && result.data) {
91
- setState(result.data.engaged ? "engaged" : "released");
92
- } else {
93
- setState("unknown");
94
- }
95
- } catch {
96
- if (!cancelled) setState("unknown");
82
+ const genRef = useRef(0);
83
+
84
+ const refresh = useCallback(async () => {
85
+ const gen = ++genRef.current;
86
+ try {
87
+ const result = await cliBridge.monitorKillSwitch("show");
88
+ if (gen !== genRef.current) return;
89
+ if (result.success && result.data) {
90
+ setState(result.data.engaged ? "engaged" : "released");
91
+ } else {
92
+ setState("unknown");
97
93
  }
98
- };
99
- void refresh();
100
- // Re-probe periodically so engage/release in Service Manager is visible here
101
- const timer = setInterval(() => {
102
- void refresh();
103
- }, KILL_SWITCH_POLL_MS);
104
- return () => {
105
- cancelled = true;
106
- clearInterval(timer);
107
- };
94
+ } catch {
95
+ if (gen === genRef.current) setState("unknown");
96
+ }
108
97
  }, []);
109
98
 
99
+ useEffect(
100
+ () => () => {
101
+ genRef.current += 1;
102
+ },
103
+ []
104
+ );
105
+ usePolling({ callback: refresh, immediate: true });
106
+
110
107
  const label =
111
108
  state === "engaged"
112
109
  ? "KILL SWITCH ENGAGED"
@@ -23,9 +23,10 @@ import {
23
23
  filterKvKeys,
24
24
  sanitizeKvValue,
25
25
  MAX_VISIBLE_KEYS,
26
- REFRESH_INTERVAL_MS,
27
26
  } from "./kv-viewer";
27
+ import { formatPollIntervalLabel } from "../../hooks/use-polling";
28
28
  import { Colors, type ViewId } from "@hoox-sh/hoox-shared";
29
+ import { ALL_VIEWS } from "../../types";
29
30
  import { useUIStore } from "@hoox-sh/hoox-shared/stores/ui-store";
30
31
  import {
31
32
  cliBridgeDouble,
@@ -124,8 +125,8 @@ describe("KvViewer", () => {
124
125
 
125
126
  // ── Pattern contract (mirrors queue-depth subtask 02) ──────────────────
126
127
 
127
- it("auto-refreshes every 5 seconds while active", () => {
128
- expect(REFRESH_INTERVAL_MS).toBe(5_000);
128
+ it("labels configured poll intervals", () => {
129
+ expect(formatPollIntervalLabel(5_000)).toBe("5s auto");
129
130
  });
130
131
 
131
132
  it("filters keys case-insensitively by name", () => {
@@ -235,19 +236,6 @@ describe("KvViewer", () => {
235
236
  });
236
237
 
237
238
  it("is included in the local ALL_VIEWS list (tui/src/types.ts)", () => {
238
- const ALL_VIEWS: string[] = [
239
- "dashboard",
240
- "workers",
241
- "worker-detail",
242
- "trade-monitor",
243
- "logs-viewer",
244
- "service-manager",
245
- "config-editor",
246
- "setup-wizard",
247
- "settings",
248
- "queue-depth",
249
- "kv-viewer",
250
- ];
251
239
  expect(ALL_VIEWS).toContain("kv-viewer");
252
240
  // ViewId is derived from ALL_VIEWS so the local type must include it
253
241
  // as well. The TypeScript type-check is the real assertion; this
@@ -49,16 +49,14 @@
49
49
  */
50
50
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
51
51
 
52
- import { Colors, useUIStore } from "@hoox-sh/hoox-shared";
52
+ import { Colors, useUIStore, useConfigStore } from "@hoox-sh/hoox-shared";
53
53
  import { ErrorBoundary } from "../shared/error-boundary";
54
54
  import { Spinner, EmptyState } from "../shared/spinner";
55
55
  import { ViewHeader } from "../shared/view-header";
56
56
  import { cliBridge } from "../../services/cli-bridge";
57
57
  import type { KvKey, KvKeySnapshot } from "../../services/cli-bridge";
58
58
  import { useViewKeyboard } from "../../hooks/shell-overlay";
59
-
60
- /** Auto-refresh interval in milliseconds. */
61
- export const REFRESH_INTERVAL_MS = 5_000;
59
+ import { usePolling, formatPollIntervalLabel } from "../../hooks/use-polling";
62
60
 
63
61
  /** Cap rendered rows so large namespaces stay responsive. */
64
62
  export const MAX_VISIBLE_KEYS = 200;
@@ -427,14 +425,12 @@ export function KvViewer() {
427
425
  };
428
426
  }, [refresh]);
429
427
 
430
- // Auto-refresh every 5s while the view is the active view (soft = no flicker).
431
- useEffect(() => {
432
- if (!isActive) return;
433
- const handle = setInterval(() => {
434
- void refresh({ soft: true });
435
- }, REFRESH_INTERVAL_MS);
436
- return () => clearInterval(handle);
437
- }, [isActive, refresh]);
428
+ const refreshIntervalMs = useConfigStore((s) => s.refreshIntervalMs);
429
+ usePolling({
430
+ callback: () => refresh({ soft: true }),
431
+ enabled: isActive,
432
+ immediate: false,
433
+ });
438
434
 
439
435
  // ── Derived data ─────────────────────────────────────────────────────
440
436
  const allKeys = snapshot?.keys ?? [];
@@ -558,7 +554,7 @@ export function KvViewer() {
558
554
  ⚠ read-only
559
555
  </text>
560
556
  <text fg={Colors.info} dim>
561
- {`◉ ${REFRESH_INTERVAL_MS / 1000}s auto`}
557
+ {`◉ ${formatPollIntervalLabel(refreshIntervalMs)}`}
562
558
  </text>
563
559
  </box>
564
560
  }
@@ -113,7 +113,7 @@ describe("LogsViewer filter logic", () => {
113
113
  });
114
114
  // Entries with workerId=w1 (alpha-worker) + entries with no workerId
115
115
  expect(result).toHaveLength(1); // only id=5 has workerId=w1
116
- expect(result[0].id).toBe("5");
116
+ expect(result[0]?.id).toBe("5");
117
117
  });
118
118
 
119
119
  it("filters by text search (case-insensitive)", () => {
@@ -125,7 +125,7 @@ describe("LogsViewer filter logic", () => {
125
125
  workers,
126
126
  });
127
127
  expect(result).toHaveLength(1);
128
- expect(result[0].message).toBe("Trade executed");
128
+ expect(result[0]?.message).toBe("Trade executed");
129
129
  });
130
130
 
131
131
  it("combines filters with AND logic", () => {
@@ -138,7 +138,7 @@ describe("LogsViewer filter logic", () => {
138
138
  });
139
139
  // Must be error OR info, alpha-worker, contains "auth"
140
140
  expect(result).toHaveLength(1);
141
- expect(result[0].id).toBe("5"); // error + w1 + "Auth failure"
141
+ expect(result[0]?.id).toBe("5"); // error + w1 + "Auth failure"
142
142
  });
143
143
 
144
144
  it("handles empty text search as pass-through", () => {
@@ -263,7 +263,7 @@ describe("LogsViewer sanitization & caps", () => {
263
263
  workers: [],
264
264
  });
265
265
  expect(result).toHaveLength(1);
266
- expect(result[0].id).toBe("1");
266
+ expect(result[0]?.id).toBe("1");
267
267
  });
268
268
 
269
269
  it("caps visible render window below store ring buffer (1000)", () => {
@@ -17,11 +17,8 @@
17
17
  import { describe, it, expect, beforeEach, afterEach } from "bun:test";
18
18
  import { createRoot } from "@opentui/react";
19
19
  import { createCliRenderer } from "@opentui/core";
20
- import {
21
- QueueDepthView,
22
- sortQueuesByPressure,
23
- REFRESH_INTERVAL_MS,
24
- } from "./queue-depth";
20
+ import { QueueDepthView, sortQueuesByPressure } from "./queue-depth";
21
+ import { formatPollIntervalLabel } from "../../hooks/use-polling";
25
22
  import { Colors } from "@hoox-sh/hoox-shared";
26
23
  import { useUIStore } from "@hoox-sh/hoox-shared/stores/ui-store";
27
24
  import {
@@ -126,8 +123,9 @@ describe("QueueDepthView", () => {
126
123
 
127
124
  // ── Refresh interval contract ─────────────────────────────────────────
128
125
 
129
- it("auto-refreshes every 5 seconds while active", () => {
130
- expect(REFRESH_INTERVAL_MS).toBe(5_000);
126
+ it("labels configured poll intervals", () => {
127
+ expect(formatPollIntervalLabel(5_000)).toBe("5s auto");
128
+ expect(formatPollIntervalLabel(500)).toBe("500ms auto");
131
129
  });
132
130
 
133
131
  it("sorts queues by pressure: critical → backlogged → paused → healthy", () => {
@@ -23,7 +23,7 @@
23
23
  * Pattern established for the TUI feature-parity batch:
24
24
  * - Pure function component, no props required
25
25
  * - Subscribes to useUIStore (so auto-refresh can pause when not active)
26
- * - Calls cliBridge.monitorQueueDepth() on mount + every 5s while active
26
+ * - Calls cliBridge.monitorQueueDepth() on mount + config refreshIntervalMs while active
27
27
  * - Wraps content in <ErrorBoundary viewName="Queue Depth">
28
28
  * - Renders an empty/error state without throwing
29
29
  *
@@ -44,9 +44,8 @@ import { Panel } from "../shared/panel";
44
44
  import { cliBridge } from "../../services/cli-bridge";
45
45
  import type { QueueDepth, QueueDepthStatus } from "../../services/cli-bridge";
46
46
  import { useViewKeyboard } from "../../hooks/shell-overlay";
47
-
48
- /** Auto-refresh interval in milliseconds. */
49
- export const REFRESH_INTERVAL_MS = 5_000;
47
+ import { usePolling, formatPollIntervalLabel } from "../../hooks/use-polling";
48
+ import { useConfigStore } from "@hoox-sh/hoox-shared";
50
49
 
51
50
  /** Status severity for sort order (critical first). */
52
51
  const STATUS_RANK: Record<QueueDepthStatus, number> = {
@@ -255,15 +254,12 @@ export function QueueDepthView() {
255
254
  };
256
255
  }, [refresh]);
257
256
 
258
- // Auto-refresh every 5s while the view is the active view.
259
- // Soft poll avoids flicker; interval stops when user navigates away.
260
- useEffect(() => {
261
- if (!isActive) return;
262
- const handle = setInterval(() => {
263
- void refresh({ soft: true });
264
- }, REFRESH_INTERVAL_MS);
265
- return () => clearInterval(handle);
266
- }, [isActive, refresh]);
257
+ const refreshIntervalMs = useConfigStore((s) => s.refreshIntervalMs);
258
+ usePolling({
259
+ callback: () => refresh({ soft: true }),
260
+ enabled: isActive,
261
+ immediate: false,
262
+ });
267
263
 
268
264
  // Keyboard: r / F5 manual refresh while active
269
265
  useViewKeyboard((key) => {
@@ -291,7 +287,7 @@ export function QueueDepthView() {
291
287
  {queues.length} queue{queues.length === 1 ? "" : "s"}
292
288
  </text>
293
289
  <text fg={Colors.info} dim>
294
- ◉ {REFRESH_INTERVAL_MS / 1000}s auto
290
+ ◉ {formatPollIntervalLabel(refreshIntervalMs)}
295
291
  </text>
296
292
  </box>
297
293
  }
@@ -53,7 +53,7 @@ describe("SecretsViewer", () => {
53
53
  it("filters by name only (values never considered)", () => {
54
54
  const secrets: SecretMetadata[] = [
55
55
  { name: "OPENAI_API_KEY", type: "api_key", source: "config" },
56
- { name: "BINANCE_KEY_BINDING", type: "api_key", source: "Cloudflare" },
56
+ { name: "EXCHANGE_KEY_BINDING", type: "api_key", source: "Cloudflare" },
57
57
  ];
58
58
  expect(filterSecrets(secrets, "openai")).toHaveLength(1);
59
59
  expect(filterSecrets(secrets, "")).toHaveLength(2);
@@ -17,7 +17,7 @@
17
17
  * │ [search…] Last sampled 12:34:56 │
18
18
  * │───────────────────────────────────────────────────────────────
19
19
  * │ SECRET NAME TYPE SOURCE STATUS │
20
- * │ BINANCE_KEY_BINDING api_key config OK │
20
+ * │ EXCHANGE_KEY_BINDING api_key config OK │
21
21
  * │ OPENAI_API_KEY api_key config OK │
22
22
  * │ TELEGRAM_BOT_TOKEN token config OK │
23
23
  * │ … │
@@ -43,7 +43,7 @@
43
43
  */
44
44
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
45
45
 
46
- import { Colors, useUIStore } from "@hoox-sh/hoox-shared";
46
+ import { Colors, useUIStore, useConfigStore } from "@hoox-sh/hoox-shared";
47
47
  import { ErrorBoundary } from "../shared/error-boundary";
48
48
  import { Spinner, EmptyState } from "../shared/spinner";
49
49
  import { ViewHeader } from "../shared/view-header";
@@ -53,9 +53,7 @@ import type {
53
53
  SecretsSnapshot,
54
54
  } from "../../services/cli-bridge";
55
55
  import { useViewKeyboard } from "../../hooks/shell-overlay";
56
-
57
- /** Auto-refresh interval in milliseconds. */
58
- export const REFRESH_INTERVAL_MS = 5_000;
56
+ import { usePolling, formatPollIntervalLabel } from "../../hooks/use-polling";
59
57
 
60
58
  /** Cap rendered secret rows for large worker graphs. */
61
59
  export const MAX_VISIBLE_SECRETS = 200;
@@ -117,7 +115,7 @@ function SecretRow({ secret, isSelected, onSelect }: SecretRowProps) {
117
115
  onMouseUp={() => onSelect(secret.name)}
118
116
  >
119
117
  <text fg={fg} bold={isSelected}>
120
- {secret.name}
118
+ {secret.worker ? `${secret.worker}/${secret.name}` : secret.name}
121
119
  </text>
122
120
  <text fg={Colors.muted}>{formatType(secret.type).padStart(8, " ")}</text>
123
121
  <text fg={sourceColor(secret.source)} dim>
@@ -266,14 +264,12 @@ export function SecretsViewer() {
266
264
  };
267
265
  }, [refresh]);
268
266
 
269
- // Auto-refresh every 5s while the view is the active view (soft = no flicker).
270
- useEffect(() => {
271
- if (!isActive) return;
272
- const handle = setInterval(() => {
273
- void refresh({ soft: true });
274
- }, REFRESH_INTERVAL_MS);
275
- return () => clearInterval(handle);
276
- }, [isActive, refresh]);
267
+ const refreshIntervalMs = useConfigStore((s) => s.refreshIntervalMs);
268
+ usePolling({
269
+ callback: () => refresh({ soft: true }),
270
+ enabled: isActive,
271
+ immediate: false,
272
+ });
277
273
 
278
274
  // ── Derived data ───────────────────────────────────────────────────────────
279
275
  const allSecrets = snapshot?.secrets ?? [];
@@ -349,7 +345,7 @@ export function SecretsViewer() {
349
345
  ⚠ READ-ONLY
350
346
  </text>
351
347
  <text fg={Colors.info} dim>
352
- {`◉ ${REFRESH_INTERVAL_MS / 1000}s auto`}
348
+ {`◉ ${formatPollIntervalLabel(refreshIntervalMs)}`}
353
349
  </text>
354
350
  </box>
355
351
  }
@@ -939,6 +939,7 @@ export function ServiceManager({ dialog }: ServiceManagerProps) {
939
939
  }
940
940
  return;
941
941
  }
942
+ if (key.ctrl || key.alt || key.meta) return;
942
943
  // d → deploy selected · r → restart selected (lowercase)
943
944
  if (name === "d" && !key.shift) {
944
945
  if (selected) void handleDeploy(selected);
@@ -171,13 +171,9 @@ async function renderSettingsWithCheck(
171
171
  width = 140,
172
172
  height = 60
173
173
  ): Promise<string> {
174
- // Tell the view to auto-run the check on mount via a useEffect.
175
- (
176
- globalThis as unknown as { __hooxTestAutoCheckSetup?: boolean }
177
- ).__hooxTestAutoCheckSetup = true;
178
174
  try {
179
175
  const { captureCharFrame, renderOnce } = await testRender(
180
- <SettingsView />,
176
+ <SettingsView autoCheckSetup />,
181
177
  {
182
178
  width,
183
179
  height,
@@ -209,8 +205,7 @@ async function renderSettingsWithCheck(
209
205
  // Fallback - return whatever we have
210
206
  return captureCharFrame();
211
207
  } finally {
212
- delete (globalThis as unknown as { __hooxTestAutoCheckSetup?: boolean })
213
- .__hooxTestAutoCheckSetup;
208
+ /* autoCheckSetup is a render prop; nothing to clean up */
214
209
  }
215
210
  }
216
211
 
@@ -37,24 +37,22 @@ import { cliBridge as realCliBridge } from "../../services/cli-bridge";
37
37
  import { getSettingsConnectionSnapshot } from "../../services/tui-connection";
38
38
  import * as path from "path";
39
39
  import * as os from "os";
40
- import type { ViewId, NotificationPreferences } from "@hoox-sh/hoox-shared";
40
+ import {
41
+ VIEW_IDS,
42
+ type ViewId,
43
+ type NotificationPreferences,
44
+ } from "@hoox-sh/hoox-shared";
41
45
  import { useViewKeyboard } from "../../hooks/shell-overlay";
42
46
 
43
- /**
44
- * Prefer the process-wide CLI bridge test double when test-setup installed it
45
- * (`globalThis.__hooxCliBridgeDouble`). Resolved at call time so the double is
46
- * picked up even if this module evaluated before the preload finished.
47
- */
48
47
  function cliBridge(): typeof realCliBridge {
49
- const g = globalThis as unknown as {
50
- __hooxCliBridgeDouble?: typeof realCliBridge;
51
- };
52
- return g.__hooxCliBridgeDouble ?? realCliBridge;
48
+ return realCliBridge;
53
49
  }
54
50
 
55
51
  export interface SettingsViewProps {
56
52
  /** Dialog handle for destructive action confirmation. */
57
53
  dialog?: DialogHandle;
54
+ /** Test-only: run Check Setup once on mount. */
55
+ autoCheckSetup?: boolean;
58
56
  }
59
57
 
60
58
  // ─── Check Setup Types ────────────────────────────────────────────────────────
@@ -172,26 +170,6 @@ function formatRefreshRate(ms: number): string {
172
170
  return `${ms}ms`;
173
171
  }
174
172
 
175
- /** ViewId options for the default view selector */
176
- const VIEW_OPTIONS: ViewId[] = [
177
- "dashboard",
178
- "workers",
179
- "worker-detail",
180
- "trade-monitor",
181
- "logs-viewer",
182
- "service-manager",
183
- "config-editor",
184
- "setup-wizard",
185
- "settings",
186
- "queue-depth",
187
- "kv-viewer",
188
- "secrets-viewer",
189
- "db-query",
190
- "ai-chat",
191
- "edge-topology",
192
- "worker-settings",
193
- ];
194
-
195
173
  /** Human-readable labels for ViewId values */
196
174
  const VIEW_LABELS: Record<ViewId, string> = {
197
175
  dashboard: "Dashboard",
@@ -846,7 +824,10 @@ function CheckSetupResultsPanel({
846
824
  *
847
825
  * Wrapped in ErrorBoundary for crash recovery.
848
826
  */
849
- export function SettingsView({ dialog }: SettingsViewProps = {}) {
827
+ export function SettingsView({
828
+ dialog,
829
+ autoCheckSetup = false,
830
+ }: SettingsViewProps = {}) {
850
831
  const activeView = useUIStore((s) => s.activeView);
851
832
  const isActive = activeView === "settings";
852
833
 
@@ -909,7 +890,18 @@ export function SettingsView({ dialog }: SettingsViewProps = {}) {
909
890
  try {
910
891
  const cachePath = path.join(os.homedir(), ".hoox", "cache");
911
892
  const proc = Bun.spawn(["rm", "-rf", cachePath]);
912
- await proc.exited;
893
+ const code = await proc.exited;
894
+ if (code !== 0) {
895
+ useServiceStore.getState().addAlert({
896
+ id: `cache-err-${Date.now()}`,
897
+ type: "config",
898
+ severity: "error",
899
+ message: `Cache clear failed (exit ${code})`,
900
+ timestamp: Date.now(),
901
+ acknowledged: false,
902
+ });
903
+ return;
904
+ }
913
905
  useServiceStore.getState().addAlert({
914
906
  id: `cache-${Date.now()}`,
915
907
  type: "config",
@@ -1259,22 +1251,12 @@ export function SettingsView({ dialog }: SettingsViewProps = {}) {
1259
1251
  });
1260
1252
  }, [confirmAction, resetDefaults]);
1261
1253
 
1262
- /**
1263
- * Test hook: when `globalThis.__hooxTestAutoCheckSetup` is set, the
1264
- * Check Setup check runs automatically once on mount. This lets the
1265
- * test suite populate the results panel without simulating mouse/keyboard
1266
- * events. Production code never sets this flag.
1267
- */
1268
1254
  useEffect(() => {
1269
- const g = globalThis as unknown as {
1270
- __hooxTestAutoCheckSetup?: boolean;
1271
- };
1272
- if (g.__hooxTestAutoCheckSetup) {
1255
+ if (autoCheckSetup) {
1273
1256
  void handleCheckSetup();
1274
1257
  }
1275
- // Intentionally mount-only — tests set the flag then render, so
1276
- // re-running on state changes would cause duplicate checks.
1277
- }, []);
1258
+ // Mount-only: tests pass autoCheckSetup then render once.
1259
+ }, [autoCheckSetup]);
1278
1260
 
1279
1261
  /** Max items per panel */
1280
1262
  const maxItemsPerPanel = useMemo((): Record<number, number> => {
@@ -1293,7 +1275,7 @@ export function SettingsView({ dialog }: SettingsViewProps = {}) {
1293
1275
  // Import mode overrides
1294
1276
  if (importing) {
1295
1277
  if (key.name === "return" || key.name === "enter") {
1296
- handleImportConfirm();
1278
+ void handleImportConfirm();
1297
1279
  return;
1298
1280
  }
1299
1281
  if (key.name === "escape") {
@@ -1310,9 +1292,11 @@ export function SettingsView({ dialog }: SettingsViewProps = {}) {
1310
1292
  return;
1311
1293
  }
1312
1294
 
1313
- // Tab: cycle panels
1295
+ // Tab / Shift+Tab: cycle panels
1314
1296
  if (key.name === "tab") {
1315
- setActivePanel((p) => (p + 1) % PANEL_COUNT);
1297
+ setActivePanel((p) =>
1298
+ key.shift ? (p - 1 + PANEL_COUNT) % PANEL_COUNT : (p + 1) % PANEL_COUNT
1299
+ );
1316
1300
  setActiveItem(0);
1317
1301
  return;
1318
1302
  }
@@ -1351,14 +1335,13 @@ export function SettingsView({ dialog }: SettingsViewProps = {}) {
1351
1335
  }
1352
1336
  case 1: {
1353
1337
  // Default view
1354
- const currentIdx = VIEW_OPTIONS.indexOf(defaultView);
1338
+ const currentIdx = VIEW_IDS.indexOf(defaultView);
1355
1339
  if (key.name === "left") {
1356
- const prev =
1357
- (currentIdx - 1 + VIEW_OPTIONS.length) % VIEW_OPTIONS.length;
1358
- updateConfig({ defaultView: VIEW_OPTIONS[prev] });
1340
+ const prev = (currentIdx - 1 + VIEW_IDS.length) % VIEW_IDS.length;
1341
+ updateConfig({ defaultView: VIEW_IDS[prev] });
1359
1342
  } else if (key.name === "right") {
1360
- const next = (currentIdx + 1) % VIEW_OPTIONS.length;
1361
- updateConfig({ defaultView: VIEW_OPTIONS[next] });
1343
+ const next = (currentIdx + 1) % VIEW_IDS.length;
1344
+ updateConfig({ defaultView: VIEW_IDS[next] });
1362
1345
  }
1363
1346
  break;
1364
1347
  }
@@ -1375,7 +1358,7 @@ export function SettingsView({ dialog }: SettingsViewProps = {}) {
1375
1358
  if (activeItem >= 0 && activeItem < 4) {
1376
1359
  if (key.name === "space") {
1377
1360
  const channel = NOTIFICATION_CHANNELS[activeItem];
1378
- toggleNotification(channel);
1361
+ if (channel !== undefined) toggleNotification(channel);
1379
1362
  }
1380
1363
  } else if (activeItem === 4) {
1381
1364
  if (key.name === "space")
@@ -1394,19 +1377,19 @@ export function SettingsView({ dialog }: SettingsViewProps = {}) {
1394
1377
  if (key.name === "space" || key.name === "return") {
1395
1378
  switch (activeItem) {
1396
1379
  case 0:
1397
- handleClearCache();
1380
+ void handleClearCache();
1398
1381
  break;
1399
1382
  case 1:
1400
- handleExportData();
1383
+ void handleExportData();
1401
1384
  break;
1402
1385
  case 2:
1403
- handleImportData();
1386
+ void handleImportData();
1404
1387
  break;
1405
1388
  case 3:
1406
- handleCheckSetup();
1389
+ void handleCheckSetup();
1407
1390
  break;
1408
1391
  case 4:
1409
- handleRunAutoRepair();
1392
+ void handleRunAutoRepair();
1410
1393
  break;
1411
1394
  }
1412
1395
  }
@@ -32,9 +32,10 @@ describe("redactWizardSecrets", () => {
32
32
  it("clears exchange keys, AI key, telegram token, and discord webhook", () => {
33
33
  const redacted = redactWizardSecrets({
34
34
  apiKeys: {
35
- binance: { key: "binance-key-secret-value", secret: "binance-sec" },
36
- bybit: { key: "bybit-key", secret: "bybit-sec" },
37
- mexc: { key: "mexc-key", secret: "mexc-sec" },
35
+ exchange: {
36
+ key: "exchange-key-secret-value",
37
+ secret: "exchange-sec",
38
+ },
38
39
  },
39
40
  exchanges: { binance: true, bybit: false, mexc: false },
40
41
  ai: {
@@ -57,9 +58,7 @@ describe("redactWizardSecrets", () => {
57
58
  },
58
59
  });
59
60
 
60
- expect(redacted.apiKeys.binance).toEqual({ key: "", secret: "" });
61
- expect(redacted.apiKeys.bybit).toEqual({ key: "", secret: "" });
62
- expect(redacted.apiKeys.mexc).toEqual({ key: "", secret: "" });
61
+ expect(redacted.apiKeys.exchange).toEqual({ key: "", secret: "" });
63
62
  expect(redacted.ai.apiKey).toBe("");
64
63
  expect(redacted.ai.providerUrl).toBe("https://api.example.com");
65
64
  expect(redacted.ai.model).toBe("gpt-4");
@@ -74,9 +73,7 @@ describe("redactWizardSecrets", () => {
74
73
  it("does not mutate the input object", () => {
75
74
  const original = {
76
75
  apiKeys: {
77
- binance: { key: "keep-me", secret: "keep-me-too" },
78
- bybit: { key: "", secret: "" },
79
- mexc: { key: "", secret: "" },
76
+ exchange: { key: "keep-me", secret: "keep-me-too" },
80
77
  },
81
78
  exchanges: { binance: true, bybit: false, mexc: false },
82
79
  ai: { providerUrl: "", apiKey: "secret", model: "default" },
@@ -88,7 +85,7 @@ describe("redactWizardSecrets", () => {
88
85
  },
89
86
  };
90
87
  redactWizardSecrets(original);
91
- expect(original.apiKeys.binance.key).toBe("keep-me");
88
+ expect(original.apiKeys.exchange.key).toBe("keep-me");
92
89
  expect(original.ai.apiKey).toBe("secret");
93
90
  expect(original.notifications.telegram.botToken).toBe("tok");
94
91
  });
@@ -96,9 +93,10 @@ describe("redactWizardSecrets", () => {
96
93
  it("session payload is safe to JSON.stringify (no secrets)", () => {
97
94
  const redacted = redactWizardSecrets({
98
95
  apiKeys: {
99
- binance: { key: "binance-key-secret-value", secret: "binance-sec" },
100
- bybit: { key: "", secret: "" },
101
- mexc: { key: "", secret: "" },
96
+ exchange: {
97
+ key: "exchange-key-secret-value",
98
+ secret: "exchange-sec",
99
+ },
102
100
  },
103
101
  exchanges: { binance: true, bybit: false, mexc: false },
104
102
  ai: {
@@ -118,7 +116,7 @@ describe("redactWizardSecrets", () => {
118
116
  });
119
117
  const json = JSON.stringify({ step: 2, data: redacted });
120
118
  expect(json).not.toContain("sk-live");
121
- expect(json).not.toContain("binance-key");
119
+ expect(json).not.toContain("exchange-key");
122
120
  expect(json).not.toContain("123:ABC");
123
121
  expect(json).not.toContain("webhooks/x");
124
122
  });