@hoox-sh/hoox-tui 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/README.md +50 -31
  2. package/dist/main.js +4238 -1711
  3. package/package.json +2 -2
  4. package/src/app.tsx +45 -30
  5. package/src/cli-bridge-test-double.ts +48 -1
  6. package/src/components/layout/sidebar.tsx +27 -11
  7. package/src/components/layout/statusbar.test.tsx +19 -5
  8. package/src/components/layout/statusbar.tsx +34 -18
  9. package/src/components/shared/crash-screen.tsx +24 -2
  10. package/src/components/shared/error-boundary.tsx +56 -33
  11. package/src/components/ui/connection-toasts.test.ts +58 -1
  12. package/src/components/views/config-editor.test.tsx +4 -2
  13. package/src/components/views/config-editor.tsx +31 -3
  14. package/src/components/views/dashboard/alerts-panel.tsx +36 -8
  15. package/src/components/views/dashboard/auto-repair-helpers.test.ts +23 -0
  16. package/src/components/views/dashboard.test.tsx +2 -1
  17. package/src/components/views/dashboard.tsx +126 -41
  18. package/src/components/views/db-query.test.tsx +7 -0
  19. package/src/components/views/db-query.tsx +5 -4
  20. package/src/components/views/edge-topology.tsx +3 -2
  21. package/src/components/views/kv-viewer.test.tsx +28 -0
  22. package/src/components/views/kv-viewer.tsx +4 -3
  23. package/src/components/views/logs-viewer.tsx +46 -19
  24. package/src/components/views/queue-depth.test.tsx +39 -0
  25. package/src/components/views/queue-depth.tsx +3 -2
  26. package/src/components/views/secrets-viewer.test.tsx +2 -2
  27. package/src/components/views/secrets-viewer.tsx +4 -3
  28. package/src/components/views/service-manager.tsx +147 -53
  29. package/src/components/views/settings.tsx +50 -10
  30. package/src/components/views/setup-wizard.test.tsx +13 -0
  31. package/src/components/views/setup-wizard.tsx +53 -22
  32. package/src/components/views/trade-monitor.tsx +20 -13
  33. package/src/components/views/worker-detail.tsx +27 -12
  34. package/src/components/views/worker-settings.test.tsx +5 -6
  35. package/src/components/views/worker-settings.tsx +13 -11
  36. package/src/components/views/workers-overview.tsx +27 -6
  37. package/src/hooks/hooks.test.ts +303 -9
  38. package/src/hooks/index.ts +7 -1
  39. package/src/hooks/shell-overlay.ts +41 -0
  40. package/src/hooks/use-polling.ts +91 -35
  41. package/src/hooks/use-service-data.ts +21 -4
  42. package/src/main.tsx +15 -0
  43. package/src/services/cli-bridge/index.ts +28 -22
  44. package/src/services/cli-bridge/standalone.ts +29 -2
  45. package/src/services/cli-bridge.test.ts +848 -1
  46. package/src/services/clipboard.test.ts +217 -3
  47. package/src/services/dashboard-settings-loader.test.ts +50 -0
  48. package/src/services/tui-connection.test.ts +21 -0
  49. package/src/stores/service-store.test.ts +89 -3
  50. package/src/stores/ui-store.test.ts +23 -0
  51. package/src/test-setup.ts +11 -2
  52. package/src/types.ts +1 -0
  53. package/src/view-registry.test.ts +3 -1
  54. package/src/view-registry.tsx +14 -1
@@ -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>
@@ -22,13 +22,14 @@
22
22
  * Colors from @hoox-sh/hoox-shared design tokens. No CSS, no DOM.
23
23
  */
24
24
  import { useState, useMemo, useRef, useEffect } from "react";
25
- import { useKeyboard } from "@opentui/react";
25
+
26
26
  import { Colors, useServiceStore, useUIStore } from "@hoox-sh/hoox-shared";
27
27
  import type { Trade, TradeSide } from "@hoox-sh/hoox-shared";
28
28
  import { ErrorBoundary } from "../shared/error-boundary";
29
29
  import { Spinner, EmptyState } from "../shared/spinner";
30
30
  import { ViewHeader } from "../shared/view-header";
31
31
  import { Panel } from "../shared/panel";
32
+ import { useViewKeyboard } from "../../hooks/shell-overlay";
32
33
 
33
34
  // ─── Constants ────────────────────────────────────────────────────────────────
34
35
 
@@ -235,19 +236,25 @@ function LiveTradeFeed({
235
236
 
236
237
  // Snapshot frozen trades when paused; keep updating otherwise
237
238
  const frozenRef = useRef<Trade[]>([]);
238
- const wasPausedRef = useRef(false);
239
+ const [frozenStream, setFrozenStream] = useState<Trade[] | null>(null);
239
240
 
240
- // Capture snapshot on pause edge, release on resume
241
- if (!paused && wasPausedRef.current) {
242
- frozenRef.current = [];
243
- }
244
- if (paused && !wasPausedRef.current) {
245
- frozenRef.current = [...tradeStream];
246
- }
247
- wasPausedRef.current = paused;
241
+ // Capture snapshot on pause edge, release on resume (not during render)
242
+ useEffect(() => {
243
+ if (paused) {
244
+ frozenRef.current = [...tradeStream];
245
+ setFrozenStream(frozenRef.current);
246
+ } else {
247
+ frozenRef.current = [];
248
+ setFrozenStream(null);
249
+ }
250
+ // Only react to pause edge — intentionally omit tradeStream from deps
251
+ // when already paused so the frozen snapshot stays stable.
252
+ }, [paused]);
248
253
 
249
254
  // Use frozen snapshot when paused, live stream when not
250
- const effectiveStream = paused ? frozenRef.current : tradeStream;
255
+ const effectiveStream = paused
256
+ ? (frozenStream ?? frozenRef.current)
257
+ : tradeStream;
251
258
 
252
259
  // Newest first, only the render window (store is newest-last)
253
260
  const sortedTrades = useMemo(
@@ -265,7 +272,7 @@ function LiveTradeFeed({
265
272
  const safeIndex = Math.min(selectedIndex, maxIndex);
266
273
 
267
274
  // Keyboard: navigate trade rows only while this view is active
268
- useKeyboard((key) => {
275
+ useViewKeyboard((key) => {
269
276
  if (!isActive) return;
270
277
  if (key.name === "up") {
271
278
  setSelectedIndex((i) => Math.max(0, i - 1));
@@ -687,7 +694,7 @@ export function TradeMonitor() {
687
694
  const offline = connectionStatus === "offline";
688
695
 
689
696
  // Keyboard: space toggles pause/resume only while this view is active
690
- useKeyboard((key) => {
697
+ useViewKeyboard((key) => {
691
698
  if (!isActive) return;
692
699
  if (key.name === "space") {
693
700
  setPaused((p) => !p);
@@ -21,7 +21,7 @@
21
21
  * Colors from @hoox-sh/hoox-shared tokens — no hardcoded hex.
22
22
  */
23
23
  import { useState, useMemo, useEffect, useCallback, useRef } from "react";
24
- import { useKeyboard } from "@opentui/react";
24
+
25
25
  import {
26
26
  Colors,
27
27
  LogLevelColor,
@@ -36,6 +36,7 @@ import {
36
36
  import { ErrorBoundary } from "../shared/error-boundary";
37
37
  import { StatusDot, type StatusDotStatus } from "../shared/status-dot";
38
38
  import { ViewHeader } from "../shared/view-header";
39
+ import { useViewKeyboard } from "../../hooks/shell-overlay";
39
40
 
40
41
  // ── Local type aliases (inferred from store return types) ──────────────────
41
42
 
@@ -97,14 +98,20 @@ export function redactConfigEntries(
97
98
  /**
98
99
  * Breadcrumb header showing back navigation, worker name, and status indicator.
99
100
  */
100
- function BreadcrumbHeader({ worker }: { worker: Worker }) {
101
+ function BreadcrumbHeader({
102
+ worker,
103
+ onBack,
104
+ }: {
105
+ worker: Worker;
106
+ onBack: () => void;
107
+ }) {
101
108
  return (
102
109
  <ViewHeader
103
110
  title={worker.name}
104
111
  showDivider={false}
105
112
  meta={
106
113
  <box flexDirection="row" gap={2}>
107
- <text fg={Colors.muted} dim>
114
+ <text fg={Colors.accent} onMouseUp={onBack}>
108
115
  ← BACK
109
116
  </text>
110
117
  <StatusDot
@@ -207,7 +214,7 @@ function LogsPane({
207
214
  }, [workerLogs.length, paused]);
208
215
 
209
216
  // Keyboard: Space toggles pause when this pane is focused
210
- useKeyboard((key) => {
217
+ useViewKeyboard((key) => {
211
218
  if (!focused) return;
212
219
  if (key.name === "space") {
213
220
  setPaused((p) => !p);
@@ -546,25 +553,32 @@ export function WorkerDetail() {
546
553
  void fetchConfig();
547
554
  }, [fetchConfig]);
548
555
 
549
- // Fetch logs via CLI as fallback when SSE is not connected
556
+ // Fetch logs via CLI when SSE is offline OR when connected but empty for this worker
550
557
  useEffect(() => {
551
558
  if (connectionStatus !== "connected") {
552
559
  void fetchLogs();
560
+ return;
553
561
  }
554
- }, [connectionStatus, fetchLogs]);
562
+ // Connected but no lines yet for this worker — still allow a one-shot CLI seed
563
+ const hasWorkerLogs = useServiceStore
564
+ .getState()
565
+ .logs.some((l) => l.workerId === worker?.id || l.source === worker?.name);
566
+ if (!hasWorkerLogs && worker) {
567
+ void fetchLogs();
568
+ }
569
+ }, [connectionStatus, fetchLogs, worker]);
555
570
 
556
571
  const handleRefresh = useCallback(() => {
557
572
  void fetchConfig();
558
- if (connectionStatus !== "connected") {
559
- void fetchLogs();
560
- }
561
- }, [fetchConfig, fetchLogs, connectionStatus]);
573
+ // Always refresh logs on explicit Ctrl+R — not only when offline
574
+ void fetchLogs();
575
+ }, [fetchConfig, fetchLogs]);
562
576
 
563
577
  const activeView = useUIStore((s) => s.activeView);
564
578
  const isActive = activeView === "worker-detail";
565
579
 
566
580
  // View-local keyboard handling (only when this view is active)
567
- useKeyboard((key) => {
581
+ useViewKeyboard((key) => {
568
582
  if (!isActive) return;
569
583
  switch (key.name) {
570
584
  case "tab":
@@ -586,6 +600,7 @@ export function WorkerDetail() {
586
600
  <ErrorBoundary viewName="Worker Detail">
587
601
  <box flexDirection="column" flexGrow={1} padding={2} gap={1}>
588
602
  <BreadcrumbHeader
603
+ onBack={goBack}
589
604
  worker={{
590
605
  id: selectedWorkerId || "unknown",
591
606
  name: selectedWorkerId || "Unknown Worker",
@@ -631,7 +646,7 @@ export function WorkerDetail() {
631
646
  <ErrorBoundary viewName={`Worker: ${worker.name}`}>
632
647
  <box flexDirection="column" flexGrow={1} padding={1} gap={1}>
633
648
  {/* Breadcrumb header */}
634
- <BreadcrumbHeader worker={worker} />
649
+ <BreadcrumbHeader worker={worker} onBack={goBack} />
635
650
 
636
651
  {/* 2×2 Grid of panes */}
637
652
  <box flexDirection="column" flexGrow={1} gap={1}>
@@ -24,20 +24,19 @@ describe("WorkerSettingsView", () => {
24
24
  expect(display).not.toMatch(/sk-|token|password/i);
25
25
  });
26
26
 
27
- it("dangerous fields require a second confirm before write (contract)", () => {
28
- // Mirrors saveCurrent double-Enter gate for kind === "dangerous"
27
+ it("all non-secret fields require a second confirm before write (contract)", () => {
28
+ // Mirrors saveCurrent double-Enter gate for every non-secret write
29
29
  let pending: string | null = null;
30
- const fieldKey = "risk:kill_switch";
31
- const kind = "dangerous" as const;
30
+ const fieldKey = "risk:max_position_size";
32
31
 
33
32
  // First Enter → arm confirm
34
- if (kind === "dangerous" && pending !== fieldKey) {
33
+ if (pending !== fieldKey) {
35
34
  pending = fieldKey;
36
35
  }
37
36
  expect(pending).toBe(fieldKey);
38
37
 
39
38
  // Second Enter with same key → proceed
40
- const shouldWrite = kind === "dangerous" && pending === fieldKey;
39
+ const shouldWrite = pending === fieldKey;
41
40
  expect(shouldWrite).toBe(true);
42
41
  });
43
42
  });
@@ -16,7 +16,7 @@
16
16
  * `hoox config kv set`. Secret fields are read-only (CLI command shown).
17
17
  */
18
18
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
19
- import { useKeyboard } from "@opentui/react";
19
+
20
20
  import {
21
21
  Colors,
22
22
  AGENT_CONFIG_KV_KEY,
@@ -37,6 +37,7 @@ import { Panel } from "../shared/panel";
37
37
  import { Spinner, EmptyState } from "../shared/spinner";
38
38
  import { cliBridge } from "../../services/cli-bridge";
39
39
  import { loadDashboardSettingsManifests } from "../../services/dashboard-settings-loader";
40
+ import { useViewKeyboard } from "../../hooks/shell-overlay";
40
41
 
41
42
  function parseKvValue(
42
43
  raw: string | null,
@@ -276,15 +277,16 @@ export function WorkerSettingsView() {
276
277
  return;
277
278
  }
278
279
 
279
- // Fail-closed confirm for dangerous fields: require a second Enter.
280
- if (row.field.kind === "dangerous") {
281
- if (pendingDangerousKey !== row.field.key) {
282
- setPendingDangerousKey(row.field.key);
283
- setStatus(
284
- `Confirm dangerous write for "${row.field.label}" — press Enter again to save, Esc to cancel`
285
- );
286
- return;
287
- }
280
+ // Fail-closed confirm for ALL non-secret writes: require a second Enter.
281
+ // Prevents accidental live KV mutations from a single mis-press.
282
+ if (pendingDangerousKey !== row.field.key) {
283
+ setPendingDangerousKey(row.field.key);
284
+ const kindLabel =
285
+ row.field.kind === "dangerous" ? "dangerous write" : "write";
286
+ setStatus(
287
+ `Confirm ${kindLabel} for "${row.field.label}" — press Enter again to save, Esc to cancel`
288
+ );
289
+ return;
288
290
  }
289
291
  setPendingDangerousKey(null);
290
292
 
@@ -350,7 +352,7 @@ export function WorkerSettingsView() {
350
352
  }
351
353
  }, [flatFields, fieldIndex, selected, values, pendingDangerousKey, saving]);
352
354
 
353
- useKeyboard((key) => {
355
+ useViewKeyboard((key) => {
354
356
  if (!isActive) return;
355
357
  if (key.name === "escape") {
356
358
  if (pendingDangerousKey) {
@@ -25,7 +25,7 @@
25
25
  * Follows TUI Patterns 1 (View Composition), 2 (Store Subscription), 8 (ScrollBox).
26
26
  */
27
27
  import { useState, useMemo, useCallback, useEffect, useRef } from "react";
28
- import { useKeyboard } from "@opentui/react";
28
+
29
29
  import { Colors } from "@hoox-sh/hoox-shared";
30
30
  import { useServiceStore } from "@hoox-sh/hoox-shared";
31
31
  import { useUIStore } from "@hoox-sh/hoox-shared";
@@ -39,6 +39,7 @@ import { redactSecretsInText } from "../../services/dev-log";
39
39
  import type { WorkerInfo } from "@hoox-sh/hoox-shared";
40
40
  import { showConfirm } from "../ui/dialog";
41
41
  import type { DialogHandle } from "../ui/dialog";
42
+ import { useViewKeyboard } from "../../hooks/shell-overlay";
42
43
 
43
44
  // ── Props ─────────────────────────────────────────────────────────────────────
44
45
 
@@ -337,7 +338,7 @@ export function WorkersOverview({ dialog }: WorkersOverviewProps = {}) {
337
338
  }, [maxIndex]);
338
339
 
339
340
  // ── View-local keyboard: 2D grid navigation ─────────────────────────────
340
- useKeyboard((key) => {
341
+ useViewKeyboard((key) => {
341
342
  switch (key.name) {
342
343
  case "up":
343
344
  setFocusedIndex((i) => Math.max(0, i - COLS));
@@ -371,25 +372,45 @@ export function WorkersOverview({ dialog }: WorkersOverviewProps = {}) {
371
372
 
372
373
  const handleLogs = useCallback(async (worker: WorkerInfo) => {
373
374
  if (deployingRef.current) return;
375
+ const store = useServiceStore.getState();
376
+ // Select worker and open Logs Viewer — primary operator path
377
+ store.selectWorker(worker.id);
378
+ useUIStore.getState().setView("logs-viewer");
379
+
374
380
  try {
375
381
  const result = await cliBridge.workerLogs(worker.name);
376
382
  if (!mountedRef.current) return;
383
+ if (result.success && result.stdout) {
384
+ // Seed the log ring so Logs Viewer has content immediately
385
+ const lines = result.stdout.split("\n").filter((l) => l.trim());
386
+ const now = Date.now();
387
+ for (const line of lines.slice(-100)) {
388
+ store.pushLog({
389
+ id: `cli-log-${now}-${Math.random().toString(36).slice(2, 8)}`,
390
+ timestamp: now,
391
+ level: "info",
392
+ workerId: worker.id,
393
+ message: redactSecretsInText(line).slice(0, 500),
394
+ source: worker.name,
395
+ });
396
+ }
397
+ }
377
398
  const failDetail = redactSecretsInText(
378
399
  result.stderr || result.stdout || "unknown error"
379
400
  );
380
- useServiceStore.getState().addAlert({
401
+ store.addAlert({
381
402
  id: `logs-${Date.now()}-${worker.name}`,
382
403
  type: "logs",
383
404
  severity: result.success ? "info" : "warning",
384
405
  message: result.success
385
- ? `${worker.name} logs retrieved (${(result.duration / 1000).toFixed(1)}s)`
386
- : `${worker.name} logs failed: ${failDetail}`,
406
+ ? `Opened logs for ${worker.name}`
407
+ : `${worker.name} logs fetch failed: ${failDetail}`,
387
408
  timestamp: Date.now(),
388
409
  acknowledged: false,
389
410
  });
390
411
  } catch (err) {
391
412
  if (!mountedRef.current) return;
392
- useServiceStore.getState().addAlert({
413
+ store.addAlert({
393
414
  id: `logs-err-${Date.now()}`,
394
415
  type: "logs",
395
416
  severity: "warning",