@hoox-sh/hoox-tui 0.2.0 → 0.3.1

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 (55) hide show
  1. package/README.md +50 -31
  2. package/dist/main.js +4239 -1743
  3. package/package.json +8 -5
  4. package/src/app.tsx +45 -30
  5. package/src/cli-bridge-test-double.ts +48 -1
  6. package/src/components/layout/sidebar.tsx +27 -11
  7. package/src/components/layout/statusbar.test.tsx +19 -5
  8. package/src/components/layout/statusbar.tsx +34 -18
  9. package/src/components/shared/crash-screen.tsx +24 -2
  10. package/src/components/shared/error-boundary.tsx +56 -33
  11. package/src/components/ui/connection-toasts.test.ts +58 -1
  12. package/src/components/ui/toast.tsx +44 -92
  13. package/src/components/views/config-editor.test.tsx +4 -2
  14. package/src/components/views/config-editor.tsx +31 -3
  15. package/src/components/views/dashboard/alerts-panel.tsx +36 -8
  16. package/src/components/views/dashboard/auto-repair-helpers.test.ts +23 -0
  17. package/src/components/views/dashboard.test.tsx +2 -1
  18. package/src/components/views/dashboard.tsx +126 -41
  19. package/src/components/views/db-query.test.tsx +7 -0
  20. package/src/components/views/db-query.tsx +5 -4
  21. package/src/components/views/edge-topology.tsx +3 -2
  22. package/src/components/views/kv-viewer.test.tsx +28 -0
  23. package/src/components/views/kv-viewer.tsx +4 -3
  24. package/src/components/views/logs-viewer.tsx +46 -19
  25. package/src/components/views/queue-depth.test.tsx +39 -0
  26. package/src/components/views/queue-depth.tsx +3 -2
  27. package/src/components/views/secrets-viewer.test.tsx +2 -2
  28. package/src/components/views/secrets-viewer.tsx +4 -3
  29. package/src/components/views/service-manager.tsx +147 -53
  30. package/src/components/views/settings.tsx +50 -10
  31. package/src/components/views/setup-wizard.test.tsx +13 -0
  32. package/src/components/views/setup-wizard.tsx +53 -22
  33. package/src/components/views/trade-monitor.tsx +20 -13
  34. package/src/components/views/worker-detail.tsx +27 -12
  35. package/src/components/views/worker-settings.test.tsx +5 -6
  36. package/src/components/views/worker-settings.tsx +13 -11
  37. package/src/components/views/workers-overview.tsx +27 -6
  38. package/src/hooks/hooks.test.ts +303 -9
  39. package/src/hooks/index.ts +7 -1
  40. package/src/hooks/shell-overlay.ts +41 -0
  41. package/src/hooks/use-polling.ts +91 -35
  42. package/src/hooks/use-service-data.ts +21 -4
  43. package/src/main.tsx +6 -0
  44. package/src/services/cli-bridge/index.ts +28 -22
  45. package/src/services/cli-bridge/standalone.ts +29 -2
  46. package/src/services/cli-bridge.test.ts +848 -1
  47. package/src/services/clipboard.test.ts +217 -3
  48. package/src/services/dashboard-settings-loader.test.ts +50 -0
  49. package/src/services/tui-connection.test.ts +21 -0
  50. package/src/stores/service-store.test.ts +89 -3
  51. package/src/stores/ui-store.test.ts +23 -0
  52. package/src/test-setup.ts +11 -2
  53. package/src/types.ts +1 -0
  54. package/src/view-registry.test.ts +3 -1
  55. package/src/view-registry.tsx +14 -1
@@ -28,12 +28,12 @@ describe("SecretsViewer", () => {
28
28
  });
29
29
 
30
30
  it("documents keyboard search (/) and list navigation (↑↓)", async () => {
31
- // Contract: SecretsViewer wires useKeyboard for / and ↑↓ (parity with KV Viewer).
31
+ // Contract: SecretsViewer wires useViewKeyboard for / and ↑↓ (parity with KV Viewer).
32
32
  // Regression guard against the previous dead SearchBox (onChange discarded).
33
33
  const src = await Bun.file(
34
34
  new URL("./secrets-viewer.tsx", import.meta.url)
35
35
  ).text();
36
- expect(src).toContain("useKeyboard");
36
+ expect(src).toContain("useViewKeyboard");
37
37
  expect(src).toContain('key.name === "slash"');
38
38
  expect(src).toContain("setSearchActive(true)");
39
39
  expect(src).toContain("filteredSecrets");
@@ -42,7 +42,7 @@
42
42
  * - No ability to reveal or copy secret values
43
43
  */
44
44
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
45
- import { useKeyboard } from "@opentui/react";
45
+
46
46
  import { Colors, useUIStore } from "@hoox-sh/hoox-shared";
47
47
  import { ErrorBoundary } from "../shared/error-boundary";
48
48
  import { Spinner, EmptyState } from "../shared/spinner";
@@ -52,6 +52,7 @@ import type {
52
52
  SecretMetadata,
53
53
  SecretsSnapshot,
54
54
  } from "../../services/cli-bridge";
55
+ import { useViewKeyboard } from "../../hooks/shell-overlay";
55
56
 
56
57
  /** Auto-refresh interval in milliseconds. */
57
58
  export const REFRESH_INTERVAL_MS = 5_000;
@@ -144,7 +145,7 @@ function SearchBox({
144
145
  onDeactivate,
145
146
  }: SearchBoxProps) {
146
147
  // When active, capture printable keys for filtering (same pattern as KV Viewer).
147
- useKeyboard((key) => {
148
+ useViewKeyboard((key) => {
148
149
  if (!active) return;
149
150
  if (key.name === "escape") {
150
151
  onDeactivate();
@@ -291,7 +292,7 @@ export function SecretsViewer() {
291
292
  }, [allSecrets, selectedSecretName]);
292
293
 
293
294
  // ── Keyboard: list nav + open search (inactive while search is active) ───
294
- useKeyboard((key) => {
295
+ useViewKeyboard((key) => {
295
296
  if (!isActive) return;
296
297
  if (searchActive) return; // SearchBox owns keys while active
297
298
  if (key.name === "slash" || (key.ctrl && key.name === "f")) {
@@ -26,7 +26,11 @@
26
26
  */
27
27
  import { useState, useCallback, useEffect, useMemo, useRef } from "react";
28
28
  import { useServiceStore } from "@hoox-sh/hoox-shared/stores/service-store";
29
- import { Colors, ConnectionStatusColor } from "@hoox-sh/hoox-shared";
29
+ import {
30
+ Colors,
31
+ ConnectionStatusColor,
32
+ useUIStore,
33
+ } from "@hoox-sh/hoox-shared";
30
34
  import { StatusDot } from "../shared/status-dot";
31
35
  import { ErrorBoundary } from "../shared/error-boundary";
32
36
  import { EmptyState } from "../shared/spinner";
@@ -39,6 +43,7 @@ import { cliBridge } from "../../services/cli-bridge";
39
43
  import type { KillSwitchStatus } from "../../services/cli-bridge";
40
44
  import type { CliResult } from "../../types";
41
45
  import { redactSecretsInText } from "../../services/dev-log";
46
+ import { useViewKeyboard } from "../../hooks/shell-overlay";
42
47
 
43
48
  // ─── Types ──────────────────────────────────────────────────────────────────
44
49
 
@@ -262,6 +267,9 @@ interface WorkerControlListProps {
262
267
  onDeploy: (worker: WorkerInfo) => void;
263
268
  onRestart: (worker: WorkerInfo) => void;
264
269
  deployingWorker?: string | null;
270
+ /** Controlled selection (keyboard nav from parent). */
271
+ selectedIndex: number;
272
+ onSelect: (index: number) => void;
265
273
  }
266
274
 
267
275
  function WorkerControlList({
@@ -269,9 +277,9 @@ function WorkerControlList({
269
277
  onDeploy,
270
278
  onRestart,
271
279
  deployingWorker,
280
+ selectedIndex,
281
+ onSelect,
272
282
  }: WorkerControlListProps) {
273
- const [selectedIndex, setSelectedIndex] = useState(0);
274
-
275
283
  return (
276
284
  <box flexDirection="column" flexGrow={1} gap={0}>
277
285
  {/* List header */}
@@ -317,7 +325,7 @@ function WorkerControlList({
317
325
  worker={worker}
318
326
  index={i}
319
327
  selectedIndex={selectedIndex}
320
- onSelect={setSelectedIndex}
328
+ onSelect={onSelect}
321
329
  onDeploy={onDeploy}
322
330
  onRestart={onRestart}
323
331
  deployingWorker={deployingWorker}
@@ -325,6 +333,11 @@ function WorkerControlList({
325
333
  ))
326
334
  )}
327
335
  </scrollbox>
336
+ <box paddingLeft={1}>
337
+ <text fg={Colors.dim} dim>
338
+ ↑↓ · d/r deploy/restart · D/R all · Enter detail · k/e/u kill-switch
339
+ </text>
340
+ </box>
328
341
  </box>
329
342
  );
330
343
  }
@@ -427,6 +440,8 @@ function KillSwitchSection({ dialog, onAlert }: KillSwitchSectionProps) {
427
440
  const [error, setError] = useState<string | null>(null);
428
441
  const mountedRef = useRef(true);
429
442
  const loadingRef = useRef(false);
443
+ const activeView = useUIStore((s) => s.activeView);
444
+ const isActive = activeView === "service-manager";
430
445
 
431
446
  useEffect(() => {
432
447
  mountedRef.current = true;
@@ -555,64 +570,90 @@ function KillSwitchSection({ dialog, onAlert }: KillSwitchSectionProps) {
555
570
  const engageDisabled = loading || engaged;
556
571
  const releaseDisabled = loading || !engaged;
557
572
 
573
+ // Keyboard: k refresh · e engage · u release (confirm still required)
574
+ useViewKeyboard((key) => {
575
+ if (!isActive) return;
576
+ const name = String(key.name ?? "").toLowerCase();
577
+ if (name === "k") {
578
+ void refresh();
579
+ return;
580
+ }
581
+ if (name === "e" && !engageDisabled) {
582
+ void setEngaged(true);
583
+ return;
584
+ }
585
+ if (name === "u" && !releaseDisabled) {
586
+ void setEngaged(false);
587
+ }
588
+ });
589
+
558
590
  return (
559
591
  <Panel
560
592
  elevated={false}
561
593
  compact
562
594
  borderColor={engaged ? Colors.error : undefined}
563
595
  >
564
- <box
565
- flexDirection="row"
566
- justifyContent="space-between"
567
- alignItems="center"
568
- >
569
- {/* Status side — label, dot, state, timestamp */}
570
- <box flexDirection="row" gap={1} alignItems="center">
571
- <text fg={Colors.accent} bold>
572
- KILL SWITCH
573
- </text>
574
- <StatusDot status={dotStatus} pulse={!engaged} />
575
- <text fg={stateColor} bold>
576
- {stateLabel}
577
- </text>
578
- {status && (
579
- <text fg={Colors.muted} dim>
580
- @ {formatKillSwitchTime(status.timestamp)}
596
+ <box flexDirection="column" gap={0}>
597
+ <box
598
+ flexDirection="row"
599
+ justifyContent="space-between"
600
+ alignItems="center"
601
+ >
602
+ {/* Status side — label, dot, state, timestamp */}
603
+ <box flexDirection="row" gap={1} alignItems="center">
604
+ <text fg={Colors.accent} bold>
605
+ KILL SWITCH
581
606
  </text>
582
- )}
583
- {error && (
584
- <text fg={Colors.error} dim>
585
- ! {error.length > 40 ? error.slice(0, 37) + "…" : error}
607
+ <StatusDot status={dotStatus} pulse={!engaged} />
608
+ <text fg={stateColor} bold>
609
+ {stateLabel}
586
610
  </text>
587
- )}
588
- </box>
611
+ {status && (
612
+ <text fg={Colors.muted} dim>
613
+ @ {formatKillSwitchTime(status.timestamp)}
614
+ </text>
615
+ )}
616
+ {error && (
617
+ <text fg={Colors.error} dim>
618
+ ! {error.length > 40 ? error.slice(0, 37) + "…" : error}
619
+ </text>
620
+ )}
621
+ </box>
589
622
 
590
- {/* Action side — engage / release buttons + refresh */}
591
- <box flexDirection="row" gap={2}>
592
- <text
593
- fg={Colors.muted}
594
- bg={Colors.card}
595
- onMouseUp={loading ? undefined : () => void refresh()}
596
- >
597
- {loading ? " ..." : " [REFRESH] "}
598
- </text>
599
- <text
600
- fg={engageDisabled ? Colors.muted : Colors.error}
601
- bg={engageDisabled ? undefined : Colors.card}
602
- dim={engageDisabled}
603
- onMouseUp={engageDisabled ? undefined : () => void setEngaged(true)}
604
- >
605
- {" ENGAGE "}
606
- </text>
607
- <text
608
- fg={releaseDisabled ? Colors.muted : Colors.success}
609
- bg={releaseDisabled ? undefined : Colors.card}
610
- dim={releaseDisabled}
611
- onMouseUp={
612
- releaseDisabled ? undefined : () => void setEngaged(false)
613
- }
614
- >
615
- {" RELEASE "}
623
+ {/* Action side — engage / release buttons + refresh */}
624
+ <box flexDirection="row" gap={2}>
625
+ <text
626
+ fg={Colors.muted}
627
+ bg={Colors.card}
628
+ onMouseUp={loading ? undefined : () => void refresh()}
629
+ >
630
+ {loading ? " ..." : " [REFRESH] "}
631
+ </text>
632
+ <text
633
+ fg={engageDisabled ? Colors.muted : Colors.error}
634
+ bg={engageDisabled ? undefined : Colors.card}
635
+ dim={engageDisabled}
636
+ onMouseUp={
637
+ engageDisabled ? undefined : () => void setEngaged(true)
638
+ }
639
+ >
640
+ {" ENGAGE "}
641
+ </text>
642
+ <text
643
+ fg={releaseDisabled ? Colors.muted : Colors.success}
644
+ bg={releaseDisabled ? undefined : Colors.card}
645
+ dim={releaseDisabled}
646
+ onMouseUp={
647
+ releaseDisabled ? undefined : () => void setEngaged(false)
648
+ }
649
+ >
650
+ {" RELEASE "}
651
+ </text>
652
+ </box>
653
+ </box>
654
+ <box paddingLeft={1}>
655
+ <text fg={Colors.dim} dim>
656
+ k refresh · e engage · u release
616
657
  </text>
617
658
  </box>
618
659
  </box>
@@ -626,10 +667,13 @@ export function ServiceManager({ dialog }: ServiceManagerProps) {
626
667
  // Subscribe to workers from the service store
627
668
  const workers = useServiceStore((s) => s.workers);
628
669
  const connectionStatus = useServiceStore((s) => s.connectionStatus);
670
+ const activeView = useUIStore((s) => s.activeView);
671
+ const isActive = activeView === "service-manager";
629
672
 
630
673
  // Deploy/restart tracking state
631
674
  const [deployingWorker, setDeployingWorker] = useState<string | null>(null);
632
675
  const [deployProgress, setDeployProgress] = useState("");
676
+ const [selectedIndex, setSelectedIndex] = useState(0);
633
677
  const mountedRef = useRef(true);
634
678
  const deployingRef = useRef(false);
635
679
 
@@ -640,6 +684,13 @@ export function ServiceManager({ dialog }: ServiceManagerProps) {
640
684
  };
641
685
  }, []);
642
686
 
687
+ // Keep selection in range when worker list changes
688
+ useEffect(() => {
689
+ setSelectedIndex((i) =>
690
+ workers.length === 0 ? 0 : Math.min(i, workers.length - 1)
691
+ );
692
+ }, [workers.length]);
693
+
643
694
  // Edge count from worker telemetry only — never invent a marketing "275+".
644
695
  const totalEdgeCount = useMemo(
645
696
  () => workers.reduce((sum, w) => sum + (w.edgeCount ?? 0), 0),
@@ -866,6 +917,47 @@ export function ServiceManager({ dialog }: ServiceManagerProps) {
866
917
  }
867
918
  }, [dialog, workers.length, onProgress, addResultAlert, blockWithoutDialog]);
868
919
 
920
+ // ── Keyboard: list nav + deploy/restart (overlay-safe) ─────────────────
921
+ useViewKeyboard((key) => {
922
+ if (!isActive) return;
923
+ const name = String(key.name ?? "").toLowerCase();
924
+ const selected = workers[selectedIndex];
925
+
926
+ if (name === "up") {
927
+ setSelectedIndex((i) => Math.max(0, i - 1));
928
+ return;
929
+ }
930
+ if (name === "down") {
931
+ setSelectedIndex((i) => Math.min(Math.max(0, workers.length - 1), i + 1));
932
+ return;
933
+ }
934
+ // Enter → worker detail
935
+ if (name === "return" || name === "enter") {
936
+ if (selected) {
937
+ useServiceStore.getState().selectWorker(selected.id);
938
+ useUIStore.getState().setView("worker-detail");
939
+ }
940
+ return;
941
+ }
942
+ // d → deploy selected · r → restart selected (lowercase)
943
+ if (name === "d" && !key.shift) {
944
+ if (selected) void handleDeploy(selected);
945
+ return;
946
+ }
947
+ if (name === "r" && !key.shift) {
948
+ if (selected) void handleRestart(selected);
949
+ return;
950
+ }
951
+ // Shift+D / Shift+R → bulk (OpenTUI may report name "d" with shift)
952
+ if (name === "d" && key.shift) {
953
+ void handleDeployAll();
954
+ return;
955
+ }
956
+ if (name === "r" && key.shift) {
957
+ void handleRestartAll();
958
+ }
959
+ });
960
+
869
961
  // ── Render ──────────────────────────────────────────────────────────────
870
962
  return (
871
963
  <ErrorBoundary viewName="Service Manager">
@@ -909,6 +1001,8 @@ export function ServiceManager({ dialog }: ServiceManagerProps) {
909
1001
  onDeploy={handleDeploy}
910
1002
  onRestart={handleRestart}
911
1003
  deployingWorker={deployingWorker}
1004
+ selectedIndex={selectedIndex}
1005
+ onSelect={setSelectedIndex}
912
1006
  />
913
1007
  </Panel>
914
1008
 
@@ -20,7 +20,7 @@
20
20
  * Follows Pattern 1 (View Composition) and Pattern 2 (Store Subscription).
21
21
  */
22
22
  import { useMemo, useState, useCallback, useEffect } from "react";
23
- import { useKeyboard } from "@opentui/react";
23
+
24
24
  import {
25
25
  Colors,
26
26
  useServiceStore,
@@ -38,6 +38,7 @@ import { getSettingsConnectionSnapshot } from "../../services/tui-connection";
38
38
  import * as path from "path";
39
39
  import * as os from "os";
40
40
  import type { ViewId, NotificationPreferences } from "@hoox-sh/hoox-shared";
41
+ import { useViewKeyboard } from "../../hooks/shell-overlay";
41
42
 
42
43
  /**
43
44
  * Prefer the process-wide CLI bridge test double when test-setup installed it
@@ -234,14 +235,16 @@ interface ShortcutEntry {
234
235
  }
235
236
 
236
237
  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
238
  // Keep action labels short — Keyboard panel is ~34 cols; long text
240
239
  // overflows into adjacent panels and corrupts the character frame.
241
- { key: "Ctrl+Alt+W", action: "Worker Settings" },
240
+ // Order matters: first rows stay visible in the 4-column layout.
241
+ { key: "Ctrl+1..9", action: "Switch to view 1-9" },
242
+ { key: "Ctrl+0", action: "Switch to view 10" },
242
243
  { key: "Ctrl+P", action: "Command Palette" },
243
244
  { key: "Ctrl+B", action: "Toggle Sidebar" },
244
245
  { key: "Ctrl+Q", action: "Quit" },
246
+ // Compact summary of Ctrl+Alt chords (sidebar shows per-view keys)
247
+ { key: "Ctrl+Alt+*", action: "KV/Secrets/DB/AI/…" },
245
248
  { key: "Esc", action: "Back / Close" },
246
249
  { key: "Tab", action: "Next panel / field" },
247
250
  { key: "/", action: "Focus search" },
@@ -975,6 +978,31 @@ export function SettingsView({ dialog }: SettingsViewProps = {}) {
975
978
 
976
979
  const handleImportConfirm = useCallback(async () => {
977
980
  if (!importPath.trim()) return;
981
+
982
+ // Fail-closed: require confirm before clobbering local prefs
983
+ if (!dialog) {
984
+ useServiceStore.getState().addAlert({
985
+ id: `import-noconfirm-${Date.now()}`,
986
+ type: "config",
987
+ severity: "warning",
988
+ message: "Import blocked: confirmation dialog unavailable",
989
+ timestamp: Date.now(),
990
+ acknowledged: false,
991
+ });
992
+ setImporting(false);
993
+ return;
994
+ }
995
+ const confirmed = await showConfirm(dialog, {
996
+ title: "Import preferences?",
997
+ message: `Apply settings from ${importPath.trim()}? This overwrites local TUI preferences (not operator secrets).`,
998
+ confirmLabel: "Import",
999
+ cancelLabel: "Cancel",
1000
+ });
1001
+ if (!confirmed) {
1002
+ setImporting(false);
1003
+ return;
1004
+ }
1005
+
978
1006
  try {
979
1007
  const f = Bun.file(importPath.trim());
980
1008
  const exists = await f.exists();
@@ -1006,18 +1034,30 @@ export function SettingsView({ dialog }: SettingsViewProps = {}) {
1006
1034
  if (typeof config.refreshIntervalMs === "number")
1007
1035
  updateConfig({ refreshIntervalMs: config.refreshIntervalMs });
1008
1036
  if (typeof config.defaultView === "string")
1009
- updateConfig({ defaultView: config.defaultView });
1037
+ updateConfig({ defaultView: config.defaultView as ViewId });
1010
1038
  if (typeof config.soundEnabled === "boolean")
1011
1039
  updateConfig({ soundEnabled: config.soundEnabled });
1040
+ // Absolute set — never toggleNotification (would invert vs current state)
1012
1041
  if (
1013
1042
  typeof config.notifications === "object" &&
1014
1043
  config.notifications !== null
1015
1044
  ) {
1016
- for (const key of Object.keys(config.notifications)) {
1017
- if (typeof config.notifications[key] === "boolean") {
1018
- toggleNotification(key as keyof NotificationPreferences);
1045
+ const current = useConfigStore.getState().notifications;
1046
+ const next: NotificationPreferences = { ...current };
1047
+ for (const key of Object.keys(
1048
+ config.notifications
1049
+ ) as (keyof NotificationPreferences)[]) {
1050
+ if (
1051
+ key in next &&
1052
+ typeof (config.notifications as Record<string, unknown>)[key] ===
1053
+ "boolean"
1054
+ ) {
1055
+ next[key] = Boolean(
1056
+ (config.notifications as Record<string, unknown>)[key]
1057
+ );
1019
1058
  }
1020
1059
  }
1060
+ updateConfig({ notifications: next });
1021
1061
  }
1022
1062
  if (config.activeExchanges)
1023
1063
  updateConfig({ activeExchanges: config.activeExchanges });
@@ -1042,7 +1082,7 @@ export function SettingsView({ dialog }: SettingsViewProps = {}) {
1042
1082
  }
1043
1083
  setImporting(false);
1044
1084
  setImportPath("");
1045
- }, [importPath, updateConfig, toggleNotification]);
1085
+ }, [importPath, updateConfig, dialog]);
1046
1086
 
1047
1087
  const handleCheckSetup = useCallback(async () => {
1048
1088
  setSetupCheck({ kind: "running" });
@@ -1248,7 +1288,7 @@ export function SettingsView({ dialog }: SettingsViewProps = {}) {
1248
1288
 
1249
1289
  // ── Keyboard handling (only when Settings is the active view) ───────────────
1250
1290
 
1251
- useKeyboard((key) => {
1291
+ useViewKeyboard((key) => {
1252
1292
  if (!isActive) return;
1253
1293
  // Import mode overrides
1254
1294
  if (importing) {
@@ -181,6 +181,19 @@ describe("Validation helpers", () => {
181
181
  it("rejects non-URL strings", () => {
182
182
  expect(validateUrl("not-a-url")).toBe(false);
183
183
  expect(validateUrl("")).toBe(false);
184
+ expect(validateUrl("ftp://x.com")).toBe(false);
185
+ });
186
+ });
187
+
188
+ describe("maskSecret edge cases", () => {
189
+ it("masks short secrets entirely", () => {
190
+ expect(maskSecret("")).toBe("••••");
191
+ expect(maskSecret("ab")).toBe("••");
192
+ expect(maskSecret("abcdefg")).toBe("•••••••");
193
+ });
194
+
195
+ it("shows first/last 4 for longer secrets", () => {
196
+ expect(maskSecret("abcdefghij")).toBe("abcd••••ghij");
184
197
  });
185
198
  });
186
199
  });
@@ -14,7 +14,7 @@
14
14
  * status indicators matching the landing page's HUD-style status display.
15
15
  */
16
16
  import { useState, useEffect, useCallback, useRef } from "react";
17
- import { useKeyboard } from "@opentui/react";
17
+
18
18
  import { Colors, useServiceStore } from "@hoox-sh/hoox-shared";
19
19
  import { useConfigStore } from "@hoox-sh/hoox-shared";
20
20
  import { useUIStore } from "@hoox-sh/hoox-shared";
@@ -24,6 +24,7 @@ import { showConfirm } from "../ui/dialog";
24
24
  import type { DialogHandle } from "../ui/dialog";
25
25
  import { cliBridge } from "../../services/cli-bridge";
26
26
  import { resolveTuiStatePath } from "../../services/hoox-path-service";
27
+ import { useViewKeyboard } from "../../hooks/shell-overlay";
27
28
 
28
29
  // ─── Prerequisite Check Types ───────────────────────────────────────────────
29
30
 
@@ -660,7 +661,10 @@ export function SetupWizard({ dialog }: SetupWizardProps) {
660
661
  }
661
662
  };
662
663
 
663
- /** Deploy: write config, run hoox deploy all, navigate on success */
664
+ /**
665
+ * Deploy: persist exchange flags + run `hoox deploy all`.
666
+ * Secrets/API keys are NOT collected here — operators set them via CLI first.
667
+ */
664
668
  const handleDeploy = async () => {
665
669
  try {
666
670
  const activeExchanges = Object.entries(data.exchanges)
@@ -684,7 +688,8 @@ export function SetupWizard({ dialog }: SetupWizardProps) {
684
688
  }
685
689
  const confirmed = await showConfirm(dialog, {
686
690
  title: "Deploy Configuration",
687
- message: "Save setup and apply configuration to Cloudflare?",
691
+ message:
692
+ "Deploy with current exchange flags? Secrets must already be set via CLI (hoox config secrets set …).",
688
693
  confirmLabel: "Deploy",
689
694
  cancelLabel: "Cancel",
690
695
  });
@@ -725,10 +730,14 @@ export function SetupWizard({ dialog }: SetupWizardProps) {
725
730
  };
726
731
 
727
732
  // ── Keyboard navigation (only when Setup Wizard is active) ──────────────
728
- useKeyboard((key) => {
733
+ // Note: Ctrl+P is reserved for the global command palette — do not steal it.
734
+ useViewKeyboard((key) => {
729
735
  if (!isActive) return;
736
+ if (useUIStore.getState().commandPaletteOpen || useUIStore.getState().modal)
737
+ return;
730
738
  if (key.name === "right" || (key.ctrl && key.name === "n")) handleNext();
731
- if (key.name === "left" || (key.ctrl && key.name === "p")) handleBack();
739
+ // Back: ← only (Ctrl+P opens the palette globally)
740
+ if (key.name === "left") handleBack();
732
741
  if (key.name === "escape") handleBack();
733
742
  if (key.name === "tab" && key.shift) handleBack();
734
743
  if (key.name === "r" && step === 0) runChecks();
@@ -745,26 +754,35 @@ export function SetupWizard({ dialog }: SetupWizardProps) {
745
754
  );
746
755
  };
747
756
 
748
- // ── Masked input for secrets ────────────────────────────────────────────
757
+ // ── Secret fields — CLI-only (TUI does not collect secrets interactively)
749
758
  const SecretField = ({
750
759
  label,
751
760
  value,
752
- path,
761
+ path: _path,
762
+ cliHint,
753
763
  width,
754
764
  }: {
755
765
  label: string;
756
766
  value: string;
757
767
  path: string;
768
+ /** Shown when empty so operators know how to set the secret. */
769
+ cliHint?: string;
758
770
  width?: number;
759
771
  }) => (
760
- <box flexDirection="row" gap={1}>
761
- <text fg={Colors.muted} width={16}>
762
- {label}
763
- </text>
764
- <text fg={value ? Colors.muted : Colors.dim} width={width ?? 24}>
765
- {value ? maskSecret(value) : "(not set)"}
766
- </text>
767
- <ValidationIcon fieldPath={path} />
772
+ <box flexDirection="column" gap={0}>
773
+ <box flexDirection="row" gap={1}>
774
+ <text fg={Colors.muted} width={16}>
775
+ {label}
776
+ </text>
777
+ <text fg={value ? Colors.muted : Colors.dim} width={width ?? 24}>
778
+ {value ? maskSecret(value) : "(set via CLI)"}
779
+ </text>
780
+ </box>
781
+ {!value && cliHint ? (
782
+ <text fg={Colors.dim} dim>
783
+ {` → ${cliHint}`}
784
+ </text>
785
+ ) : null}
768
786
  </box>
769
787
  );
770
788
 
@@ -891,14 +909,17 @@ export function SetupWizard({ dialog }: SetupWizardProps) {
891
909
 
892
910
  // ── Step Content Renderers (shifted: old step N is now step N+1) ────────
893
911
 
894
- /** Step 1: API Keys */
912
+ /** Step 1: API Keys — CLI-only; TUI never prompts for secret material */
895
913
  const StepApiKeys = () => (
896
914
  <box flexDirection="column" gap={0}>
897
915
  <text bold fg={Colors.accent}>
898
916
  Exchange API Keys
899
917
  </text>
900
918
  <text dim fg={Colors.muted}>
901
- Enter your exchange API credentials. Secrets are stored encrypted.
919
+ Secrets are not entered in the TUI. Set them via CLI, then continue.
920
+ </text>
921
+ <text dim fg={Colors.dim}>
922
+ hoox config secrets set BINANCE_API_KEY · etc.
902
923
  </text>
903
924
  <box flexDirection="column" gap={1} paddingTop={1}>
904
925
  {EXCHANGES.map((name) => {
@@ -912,11 +933,13 @@ export function SetupWizard({ dialog }: SetupWizardProps) {
912
933
  label=" API Key"
913
934
  value={data.apiKeys[e].key}
914
935
  path={`apiKeys.${e}.key`}
936
+ cliHint={`hoox config secrets set ${name.toUpperCase()}_API_KEY`}
915
937
  />
916
938
  <SecretField
917
939
  label=" API Secret"
918
940
  value={data.apiKeys[e].secret}
919
941
  path={`apiKeys.${e}.secret`}
942
+ cliHint={`hoox config secrets set ${name.toUpperCase()}_API_SECRET`}
920
943
  />
921
944
  </box>
922
945
  );
@@ -946,14 +969,17 @@ export function SetupWizard({ dialog }: SetupWizardProps) {
946
969
  </box>
947
970
  );
948
971
 
949
- /** Step 3: AI Providers */
972
+ /** Step 3: AI Providers — URL/key via CLI/env; deploy only applies exchange flags */
950
973
  const StepAiProviders = () => (
951
974
  <box flexDirection="column" gap={0}>
952
975
  <text bold fg={Colors.accent}>
953
976
  AI Provider
954
977
  </text>
955
978
  <text dim fg={Colors.muted}>
956
- Configure the AI endpoint for strategy generation.
979
+ Configure AI credentials via CLI/env (not typed in this wizard).
980
+ </text>
981
+ <text dim fg={Colors.dim}>
982
+ HOOX_API_TOKEN · hoox config secrets set OPENAI_API_KEY
957
983
  </text>
958
984
  <box flexDirection="column" gap={1} paddingTop={1}>
959
985
  <box flexDirection="row" gap={1}>
@@ -961,15 +987,20 @@ export function SetupWizard({ dialog }: SetupWizardProps) {
961
987
  Provider URL
962
988
  </text>
963
989
  <text fg={data.ai.providerUrl ? Colors.foreground : Colors.dim}>
964
- {data.ai.providerUrl || "(not set)"}
990
+ {data.ai.providerUrl || "(set via CLI/env)"}
965
991
  </text>
966
992
  </box>
967
- <SecretField label="API Key" value={data.ai.apiKey} path="ai.apiKey" />
993
+ <SecretField
994
+ label="API Key"
995
+ value={data.ai.apiKey}
996
+ path="ai.apiKey"
997
+ cliHint="hoox config secrets set OPENAI_API_KEY"
998
+ />
968
999
  <box flexDirection="row" gap={1}>
969
1000
  <text fg={Colors.muted} width={12}>
970
1001
  Model
971
1002
  </text>
972
- <text fg={Colors.foreground}>{data.ai.model}</text>
1003
+ <text fg={Colors.foreground}>{data.ai.model || "default"}</text>
973
1004
  </box>
974
1005
  </box>
975
1006
  </box>