@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.
- package/README.md +50 -31
- package/dist/main.js +4238 -1711
- package/package.json +2 -2
- 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/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 +15 -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
|
@@ -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>
|
|
@@ -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
|
-
|
|
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
|
|
239
|
+
const [frozenStream, setFrozenStream] = useState<Trade[] | null>(null);
|
|
239
240
|
|
|
240
|
-
// Capture snapshot on pause edge, release on resume
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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({
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
559
|
-
|
|
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
|
-
|
|
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("
|
|
28
|
-
// Mirrors saveCurrent double-Enter gate for
|
|
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:
|
|
31
|
-
const kind = "dangerous" as const;
|
|
30
|
+
const fieldKey = "risk:max_position_size";
|
|
32
31
|
|
|
33
32
|
// First Enter → arm confirm
|
|
34
|
-
if (
|
|
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 =
|
|
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
|
-
|
|
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
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
?
|
|
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
|
-
|
|
413
|
+
store.addAlert({
|
|
393
414
|
id: `logs-err-${Date.now()}`,
|
|
394
415
|
type: "logs",
|
|
395
416
|
severity: "warning",
|