@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.
- package/README.md +50 -31
- package/dist/main.js +4239 -1743
- package/package.json +8 -5
- package/src/app.tsx +45 -30
- package/src/cli-bridge-test-double.ts +48 -1
- package/src/components/layout/sidebar.tsx +27 -11
- package/src/components/layout/statusbar.test.tsx +19 -5
- package/src/components/layout/statusbar.tsx +34 -18
- package/src/components/shared/crash-screen.tsx +24 -2
- package/src/components/shared/error-boundary.tsx +56 -33
- package/src/components/ui/connection-toasts.test.ts +58 -1
- package/src/components/ui/toast.tsx +44 -92
- package/src/components/views/config-editor.test.tsx +4 -2
- package/src/components/views/config-editor.tsx +31 -3
- package/src/components/views/dashboard/alerts-panel.tsx +36 -8
- package/src/components/views/dashboard/auto-repair-helpers.test.ts +23 -0
- package/src/components/views/dashboard.test.tsx +2 -1
- package/src/components/views/dashboard.tsx +126 -41
- package/src/components/views/db-query.test.tsx +7 -0
- package/src/components/views/db-query.tsx +5 -4
- package/src/components/views/edge-topology.tsx +3 -2
- package/src/components/views/kv-viewer.test.tsx +28 -0
- package/src/components/views/kv-viewer.tsx +4 -3
- package/src/components/views/logs-viewer.tsx +46 -19
- package/src/components/views/queue-depth.test.tsx +39 -0
- package/src/components/views/queue-depth.tsx +3 -2
- package/src/components/views/secrets-viewer.test.tsx +2 -2
- package/src/components/views/secrets-viewer.tsx +4 -3
- package/src/components/views/service-manager.tsx +147 -53
- package/src/components/views/settings.tsx +50 -10
- package/src/components/views/setup-wizard.test.tsx +13 -0
- package/src/components/views/setup-wizard.tsx +53 -22
- package/src/components/views/trade-monitor.tsx +20 -13
- package/src/components/views/worker-detail.tsx +27 -12
- package/src/components/views/worker-settings.test.tsx +5 -6
- package/src/components/views/worker-settings.tsx +13 -11
- package/src/components/views/workers-overview.tsx +27 -6
- package/src/hooks/hooks.test.ts +303 -9
- package/src/hooks/index.ts +7 -1
- package/src/hooks/shell-overlay.ts +41 -0
- package/src/hooks/use-polling.ts +91 -35
- package/src/hooks/use-service-data.ts +21 -4
- package/src/main.tsx +6 -0
- package/src/services/cli-bridge/index.ts +28 -22
- package/src/services/cli-bridge/standalone.ts +29 -2
- package/src/services/cli-bridge.test.ts +848 -1
- package/src/services/clipboard.test.ts +217 -3
- package/src/services/dashboard-settings-loader.test.ts +50 -0
- package/src/services/tui-connection.test.ts +21 -0
- package/src/stores/service-store.test.ts +89 -3
- package/src/stores/ui-store.test.ts +23 -0
- package/src/test-setup.ts +11 -2
- package/src/types.ts +1 -0
- package/src/view-registry.test.ts +3 -1
- 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
|
|
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("
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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={
|
|
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
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
<
|
|
572
|
-
|
|
573
|
-
|
|
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
|
-
|
|
584
|
-
|
|
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
|
-
|
|
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
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
releaseDisabled
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
// ──
|
|
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="
|
|
761
|
-
<
|
|
762
|
-
{
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
{value ?
|
|
766
|
-
|
|
767
|
-
|
|
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
|
-
|
|
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
|
|
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 || "(
|
|
990
|
+
{data.ai.providerUrl || "(set via CLI/env)"}
|
|
965
991
|
</text>
|
|
966
992
|
</box>
|
|
967
|
-
<SecretField
|
|
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>
|