@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
|
@@ -105,9 +105,11 @@ describe("ConfigEditor", () => {
|
|
|
105
105
|
expect(CONFIG_TREE_BLUEPRINT[0].type).toBe("directory");
|
|
106
106
|
});
|
|
107
107
|
|
|
108
|
-
it("contains
|
|
108
|
+
it("contains config files without secret-like .env entries", async () => {
|
|
109
109
|
const children = CONFIG_TREE_BLUEPRINT[0].children!;
|
|
110
|
-
|
|
110
|
+
// wrangler.toml, trade.config.json, risk.config.json, strategies (no .env)
|
|
111
|
+
expect(children.length).toBe(4);
|
|
112
|
+
expect(children.some((c) => c.name === ".env")).toBe(false);
|
|
111
113
|
});
|
|
112
114
|
|
|
113
115
|
it("strategies directory has 3 config files", async () => {
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
import { existsSync } from "node:fs";
|
|
34
34
|
import { isAbsolute, relative, resolve as pathResolve } from "node:path";
|
|
35
35
|
import { useState, useCallback, useMemo, useRef, useEffect } from "react";
|
|
36
|
-
|
|
36
|
+
|
|
37
37
|
import {
|
|
38
38
|
Colors,
|
|
39
39
|
getHooxRepoPath,
|
|
@@ -60,6 +60,7 @@ import {
|
|
|
60
60
|
formatContent,
|
|
61
61
|
} from "./config-editor/code-editor";
|
|
62
62
|
import { ActionBar } from "./config-editor/action-bar";
|
|
63
|
+
import { useViewKeyboard } from "../../hooks/shell-overlay";
|
|
63
64
|
|
|
64
65
|
// Re-export extracted types and functions for backward compatibility
|
|
65
66
|
export type {
|
|
@@ -134,7 +135,8 @@ export const CONFIG_TREE_BLUEPRINT: FileNode[] = [
|
|
|
134
135
|
},
|
|
135
136
|
],
|
|
136
137
|
},
|
|
137
|
-
|
|
138
|
+
// .env intentionally omitted — secret material must not be shown in-pane.
|
|
139
|
+
// Operators edit env files externally (or via `hoox config secrets …`).
|
|
138
140
|
],
|
|
139
141
|
},
|
|
140
142
|
];
|
|
@@ -272,7 +274,28 @@ export function resolveConfigFilePath(relativePath: string): string {
|
|
|
272
274
|
* Read a config file from disk.
|
|
273
275
|
* Enforces path safety + max size. Returns empty string if missing.
|
|
274
276
|
*/
|
|
277
|
+
/** True for secret-like paths that must never be rendered in the TUI. */
|
|
278
|
+
export function isSecretConfigPath(relativePath: string): boolean {
|
|
279
|
+
const base = relativePath.replace(/\\/g, "/").split("/").pop() ?? "";
|
|
280
|
+
const lower = base.toLowerCase();
|
|
281
|
+
return (
|
|
282
|
+
lower === ".env" ||
|
|
283
|
+
lower.startsWith(".env.") ||
|
|
284
|
+
lower.endsWith(".pem") ||
|
|
285
|
+
lower.endsWith(".key") ||
|
|
286
|
+
lower.includes("secret") ||
|
|
287
|
+
lower.includes("credentials")
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
|
|
275
291
|
async function loadFileContent(relativePath: string): Promise<string> {
|
|
292
|
+
if (isSecretConfigPath(relativePath)) {
|
|
293
|
+
return (
|
|
294
|
+
`# ${relativePath}\n` +
|
|
295
|
+
`# BLOCKED: secret-like files are not displayed in the TUI.\n` +
|
|
296
|
+
`# Edit externally or use: hoox config secrets set <NAME>\n`
|
|
297
|
+
);
|
|
298
|
+
}
|
|
276
299
|
let fullPath: string;
|
|
277
300
|
try {
|
|
278
301
|
fullPath = resolveConfigFilePath(relativePath);
|
|
@@ -311,6 +334,11 @@ async function saveFileContent(
|
|
|
311
334
|
relativePath: string,
|
|
312
335
|
content: string
|
|
313
336
|
): Promise<void> {
|
|
337
|
+
if (isSecretConfigPath(relativePath)) {
|
|
338
|
+
throw new Error(
|
|
339
|
+
`Refuse to write secret-like path from TUI: ${relativePath}`
|
|
340
|
+
);
|
|
341
|
+
}
|
|
314
342
|
if (content.length > MAX_CONFIG_FILE_BYTES) {
|
|
315
343
|
throw new Error(
|
|
316
344
|
`Content exceeds max size (${MAX_CONFIG_FILE_BYTES} bytes); refuse to write`
|
|
@@ -548,7 +576,7 @@ export function ConfigEditor() {
|
|
|
548
576
|
|
|
549
577
|
// ── Keyboard (only when this view is active) ───────────────────────────
|
|
550
578
|
|
|
551
|
-
|
|
579
|
+
useViewKeyboard((key) => {
|
|
552
580
|
if (!isActive) return;
|
|
553
581
|
if (key.ctrl && key.name === "z") {
|
|
554
582
|
handleUndo();
|
|
@@ -4,13 +4,14 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { useState, useMemo, useEffect } from "react";
|
|
7
|
-
import { useKeyboard } from "@opentui/react";
|
|
8
7
|
import {
|
|
9
8
|
Colors,
|
|
10
9
|
AlertSeverityColor,
|
|
11
10
|
useServiceStore,
|
|
11
|
+
useUIStore,
|
|
12
12
|
} from "@hoox-sh/hoox-shared";
|
|
13
13
|
import type { AlertSeverity } from "@hoox-sh/hoox-shared";
|
|
14
|
+
import { useViewKeyboard } from "../../../hooks/shell-overlay";
|
|
14
15
|
|
|
15
16
|
/** Severity label prefix */
|
|
16
17
|
const SEVERITY_LABEL: Record<AlertSeverity, string> = {
|
|
@@ -38,10 +39,17 @@ function formatTime(ts: number): string {
|
|
|
38
39
|
/**
|
|
39
40
|
* AlertsPanel — scrollable list of recent alerts, newest first.
|
|
40
41
|
* Each alert row shows: [SEV] HH:MM:SS — message
|
|
41
|
-
* Color-coded by severity.
|
|
42
|
+
* Color-coded by severity.
|
|
43
|
+
*
|
|
44
|
+
* Keyboard (when Dashboard is active):
|
|
45
|
+
* ↑↓ — move selection
|
|
46
|
+
* Enter / Space — acknowledge selected
|
|
47
|
+
* x / Delete — dismiss (remove) selected
|
|
42
48
|
*/
|
|
43
49
|
function AlertsPanel() {
|
|
44
50
|
const alerts = useServiceStore((s) => s.alerts);
|
|
51
|
+
const activeView = useUIStore((s) => s.activeView);
|
|
52
|
+
const isActive = activeView === "dashboard";
|
|
45
53
|
const [scrollOffset, setScrollOffset] = useState(0);
|
|
46
54
|
|
|
47
55
|
// Newest first, limited
|
|
@@ -56,14 +64,33 @@ function AlertsPanel() {
|
|
|
56
64
|
setScrollOffset((o) => Math.min(o, Math.max(0, sortedAlerts.length - 1)));
|
|
57
65
|
}, [sortedAlerts.length]);
|
|
58
66
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
67
|
+
useViewKeyboard((key) => {
|
|
68
|
+
if (!isActive) return;
|
|
69
|
+
const name = String(key.name ?? "").toLowerCase();
|
|
70
|
+
|
|
71
|
+
if (name === "up") {
|
|
62
72
|
setScrollOffset((o) => Math.max(0, o - 1));
|
|
63
|
-
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
if (name === "down") {
|
|
64
76
|
setScrollOffset((o) =>
|
|
65
77
|
Math.min(Math.max(0, sortedAlerts.length - 1), o + 1)
|
|
66
78
|
);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const selected = sortedAlerts[scrollOffset];
|
|
83
|
+
if (!selected) return;
|
|
84
|
+
|
|
85
|
+
if (name === "return" || name === "enter" || name === "space") {
|
|
86
|
+
useServiceStore.getState().acknowledgeAlert(selected.id);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
if (name === "x" || name === "delete" || name === "backspace") {
|
|
90
|
+
useServiceStore.getState().dismissAlert(selected.id);
|
|
91
|
+
setScrollOffset((o) =>
|
|
92
|
+
Math.max(0, o - (o > 0 && o >= sortedAlerts.length - 1 ? 1 : 0))
|
|
93
|
+
);
|
|
67
94
|
}
|
|
68
95
|
});
|
|
69
96
|
|
|
@@ -139,11 +166,12 @@ function AlertsPanel() {
|
|
|
139
166
|
</scrollbox>
|
|
140
167
|
)}
|
|
141
168
|
|
|
142
|
-
{/* Scroll hint */}
|
|
169
|
+
{/* Scroll / action hint */}
|
|
143
170
|
{sortedAlerts.length > 0 && (
|
|
144
171
|
<box>
|
|
145
172
|
<text fg={Colors.dim} dim>
|
|
146
|
-
↑↓
|
|
173
|
+
↑↓ · Enter:ack · x:dismiss · {scrollOffset + 1}/
|
|
174
|
+
{sortedAlerts.length}
|
|
147
175
|
</text>
|
|
148
176
|
</box>
|
|
149
177
|
)}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2026 HOOX · HOOX · jango-blockchained (hoox-sh)
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, it, expect } from "bun:test";
|
|
7
|
+
import { formatRepairTime } from "./auto-repair-panel";
|
|
8
|
+
|
|
9
|
+
describe("formatRepairTime", () => {
|
|
10
|
+
it("formats as HH:MM:SS with zero padding", () => {
|
|
11
|
+
// Use local Date so test is timezone-stable for the chosen components
|
|
12
|
+
const d = new Date(2026, 0, 1, 9, 5, 7);
|
|
13
|
+
const out = formatRepairTime(d.getTime());
|
|
14
|
+
expect(out).toMatch(/^\d{2}:\d{2}:\d{2}$/);
|
|
15
|
+
expect(out).toBe(
|
|
16
|
+
[
|
|
17
|
+
d.getHours().toString().padStart(2, "0"),
|
|
18
|
+
d.getMinutes().toString().padStart(2, "0"),
|
|
19
|
+
d.getSeconds().toString().padStart(2, "0"),
|
|
20
|
+
].join(":")
|
|
21
|
+
);
|
|
22
|
+
});
|
|
23
|
+
});
|
|
@@ -145,7 +145,8 @@ describe("DashboardView", () => {
|
|
|
145
145
|
});
|
|
146
146
|
const output = await renderDashboard();
|
|
147
147
|
expect(output).toContain("only-worker");
|
|
148
|
-
|
|
148
|
+
// Empty slots render single-dash placeholders
|
|
149
|
+
const dashCount = (output.match(/(?:^|\s)-\s/g) ?? []).length;
|
|
149
150
|
expect(dashCount).toBeGreaterThan(0);
|
|
150
151
|
});
|
|
151
152
|
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
* Colors from design tokens via @hoox-sh/hoox-shared. No CSS, no DOM.
|
|
19
19
|
*/
|
|
20
20
|
import { useEffect, useMemo, useState, useCallback, useRef } from "react";
|
|
21
|
-
|
|
21
|
+
|
|
22
22
|
import { Colors, useServiceStore } from "@hoox-sh/hoox-shared";
|
|
23
23
|
import { ErrorBoundary } from "../shared/error-boundary";
|
|
24
24
|
import { StatusDot } from "../shared/status-dot";
|
|
@@ -36,6 +36,7 @@ import { ModelHealthSection } from "./dashboard/model-health-section";
|
|
|
36
36
|
import { PyneHealthSection } from "./dashboard/pyne-health-section";
|
|
37
37
|
import { AlertsPanel } from "./dashboard/alerts-panel";
|
|
38
38
|
import { useUIStore } from "@hoox-sh/hoox-shared";
|
|
39
|
+
import { useViewKeyboard } from "../../hooks/shell-overlay";
|
|
39
40
|
|
|
40
41
|
// ─── Props ────────────────────────────────────────────────────────────────────
|
|
41
42
|
|
|
@@ -73,11 +74,13 @@ function formatStatNumber(value: number, isPnl = false): string {
|
|
|
73
74
|
* header. Auto-refreshes on mount and shows the engaged/released state with a
|
|
74
75
|
* color-coded status dot. Errors degrade gracefully (badge just shows UNKNOWN).
|
|
75
76
|
*/
|
|
77
|
+
/** How often the dashboard kill-switch badge re-probes (ms). */
|
|
78
|
+
const KILL_SWITCH_POLL_MS = 15_000;
|
|
79
|
+
|
|
76
80
|
function KillSwitchStatusBadge() {
|
|
77
81
|
const [state, setState] = useState<"engaged" | "released" | "unknown">(
|
|
78
82
|
"unknown"
|
|
79
83
|
);
|
|
80
|
-
|
|
81
84
|
useEffect(() => {
|
|
82
85
|
let cancelled = false;
|
|
83
86
|
const refresh = async () => {
|
|
@@ -94,8 +97,13 @@ function KillSwitchStatusBadge() {
|
|
|
94
97
|
}
|
|
95
98
|
};
|
|
96
99
|
void refresh();
|
|
100
|
+
// Re-probe periodically so engage/release in Service Manager is visible here
|
|
101
|
+
const timer = setInterval(() => {
|
|
102
|
+
void refresh();
|
|
103
|
+
}, KILL_SWITCH_POLL_MS);
|
|
97
104
|
return () => {
|
|
98
105
|
cancelled = true;
|
|
106
|
+
clearInterval(timer);
|
|
99
107
|
};
|
|
100
108
|
}, []);
|
|
101
109
|
|
|
@@ -200,10 +208,18 @@ function DashboardHeader({
|
|
|
200
208
|
* ServiceHealthGrid — displays all workers as a grid of status cards.
|
|
201
209
|
* Each card shows: [█ ▌ ░] WORKER_NAME
|
|
202
210
|
* Limited to first 10 workers (fits the dashboard layout).
|
|
211
|
+
*
|
|
212
|
+
* Keyboard (dashboard active, when not handling alerts):
|
|
213
|
+
* ←→↑↓ / hjkl — move focus among worker cards
|
|
214
|
+
* Enter — open worker detail
|
|
215
|
+
* Mouse: click card → detail
|
|
203
216
|
*/
|
|
204
217
|
function ServiceHealthGrid() {
|
|
205
218
|
const workers = useServiceStore((s) => s.workers);
|
|
206
219
|
const connectionStatus = useServiceStore((s) => s.connectionStatus);
|
|
220
|
+
const activeView = useUIStore((s) => s.activeView);
|
|
221
|
+
const isActive = activeView === "dashboard";
|
|
222
|
+
const [focusIndex, setFocusIndex] = useState(0);
|
|
207
223
|
|
|
208
224
|
// Show first 10 workers (dashboard is an overview)
|
|
209
225
|
const visibleWorkers = useMemo(() => workers.slice(0, 10), [workers]);
|
|
@@ -215,6 +231,51 @@ function ServiceHealthGrid() {
|
|
|
215
231
|
[visibleWorkers.length]
|
|
216
232
|
);
|
|
217
233
|
|
|
234
|
+
useEffect(() => {
|
|
235
|
+
setFocusIndex((i) =>
|
|
236
|
+
visibleWorkers.length === 0 ? 0 : Math.min(i, visibleWorkers.length - 1)
|
|
237
|
+
);
|
|
238
|
+
}, [visibleWorkers.length]);
|
|
239
|
+
|
|
240
|
+
const openWorker = useCallback((workerId: string) => {
|
|
241
|
+
useServiceStore.getState().selectWorker(workerId);
|
|
242
|
+
useUIStore.getState().setView("worker-detail");
|
|
243
|
+
}, []);
|
|
244
|
+
|
|
245
|
+
// 5-column grid navigation
|
|
246
|
+
const COLS = 5;
|
|
247
|
+
useViewKeyboard((key) => {
|
|
248
|
+
if (!isActive || visibleWorkers.length === 0) return;
|
|
249
|
+
const name = String(key.name ?? "").toLowerCase();
|
|
250
|
+
const n = visibleWorkers.length;
|
|
251
|
+
if (name === "left" || name === "h") {
|
|
252
|
+
setFocusIndex((i) => Math.max(0, i - 1));
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
if (name === "right" || name === "l") {
|
|
256
|
+
setFocusIndex((i) => Math.min(n - 1, i + 1));
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
if (name === "up" || name === "k") {
|
|
260
|
+
// Prefer alerts panel owning ↑↓ when alerts exist — grid uses only
|
|
261
|
+
// left/right by default; vertical jumps by COLS when pressed with Alt.
|
|
262
|
+
if (key.alt) {
|
|
263
|
+
setFocusIndex((i) => Math.max(0, i - COLS));
|
|
264
|
+
}
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
if (name === "down" || name === "j") {
|
|
268
|
+
if (key.alt) {
|
|
269
|
+
setFocusIndex((i) => Math.min(n - 1, i + COLS));
|
|
270
|
+
}
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
if (name === "return" || name === "enter") {
|
|
274
|
+
const w = visibleWorkers[focusIndex];
|
|
275
|
+
if (w) openWorker(w.id);
|
|
276
|
+
}
|
|
277
|
+
});
|
|
278
|
+
|
|
218
279
|
if (workers.length === 0) {
|
|
219
280
|
const offline =
|
|
220
281
|
connectionStatus === "offline" || connectionStatus === "reconnecting";
|
|
@@ -238,10 +299,16 @@ function ServiceHealthGrid() {
|
|
|
238
299
|
|
|
239
300
|
return (
|
|
240
301
|
<box flexDirection="column" gap={0}>
|
|
241
|
-
{/* Section label
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
302
|
+
{/* Section label + compact keyboard affordance (avoid extra footer row —
|
|
303
|
+
OpenTUI flexWrap was overlapping a sibling hint into later cards). */}
|
|
304
|
+
<box flexDirection="row" gap={2}>
|
|
305
|
+
<text fg={Colors.foreground} bold dim>
|
|
306
|
+
SERVICE HEALTH
|
|
307
|
+
</text>
|
|
308
|
+
<text fg={Colors.dim} dim>
|
|
309
|
+
←→ Enter
|
|
310
|
+
</text>
|
|
311
|
+
</box>
|
|
245
312
|
|
|
246
313
|
{/* 2 rows × 5 columns grid */}
|
|
247
314
|
<box
|
|
@@ -251,43 +318,51 @@ function ServiceHealthGrid() {
|
|
|
251
318
|
paddingTop={1}
|
|
252
319
|
paddingBottom={1}
|
|
253
320
|
>
|
|
254
|
-
{visibleWorkers.map((worker) =>
|
|
321
|
+
{visibleWorkers.map((worker, i) => {
|
|
322
|
+
const focused = i === focusIndex;
|
|
323
|
+
return (
|
|
324
|
+
<box
|
|
325
|
+
key={worker.id}
|
|
326
|
+
flexDirection="row"
|
|
327
|
+
width={28}
|
|
328
|
+
gap={1}
|
|
329
|
+
paddingLeft={1}
|
|
330
|
+
paddingRight={1}
|
|
331
|
+
backgroundColor={focused ? Colors.card : undefined}
|
|
332
|
+
onMouseUp={() => {
|
|
333
|
+
setFocusIndex(i);
|
|
334
|
+
openWorker(worker.id);
|
|
335
|
+
}}
|
|
336
|
+
>
|
|
337
|
+
<StatusDot status={worker.status} />
|
|
338
|
+
<text
|
|
339
|
+
fg={focused ? Colors.accent : Colors.foreground}
|
|
340
|
+
bold={focused}
|
|
341
|
+
>
|
|
342
|
+
{worker.name}
|
|
343
|
+
</text>
|
|
344
|
+
</box>
|
|
345
|
+
);
|
|
346
|
+
})}
|
|
347
|
+
{/* Fill empty slots in the same wrap container */}
|
|
348
|
+
{emptySlots.map((i) => (
|
|
255
349
|
<box
|
|
256
|
-
key={
|
|
350
|
+
key={`empty-${i}`}
|
|
257
351
|
flexDirection="row"
|
|
258
352
|
width={28}
|
|
259
353
|
gap={1}
|
|
260
354
|
paddingLeft={1}
|
|
261
355
|
paddingRight={1}
|
|
262
356
|
>
|
|
263
|
-
<
|
|
264
|
-
|
|
357
|
+
<text fg={Colors.dim} dim>
|
|
358
|
+
-
|
|
359
|
+
</text>
|
|
360
|
+
<text fg={Colors.dim} dim>
|
|
361
|
+
-
|
|
362
|
+
</text>
|
|
265
363
|
</box>
|
|
266
364
|
))}
|
|
267
365
|
</box>
|
|
268
|
-
|
|
269
|
-
{/* Fill empty slots in grid to maintain 10-card layout */}
|
|
270
|
-
{emptySlots.length > 0 && (
|
|
271
|
-
<box flexDirection="row" flexWrap="wrap" gap={1}>
|
|
272
|
-
{emptySlots.map((i) => (
|
|
273
|
-
<box
|
|
274
|
-
key={`empty-${i}`}
|
|
275
|
-
flexDirection="row"
|
|
276
|
-
width={28}
|
|
277
|
-
gap={1}
|
|
278
|
-
paddingLeft={1}
|
|
279
|
-
paddingRight={1}
|
|
280
|
-
>
|
|
281
|
-
<text fg={Colors.dim} dim>
|
|
282
|
-
—
|
|
283
|
-
</text>
|
|
284
|
-
<text fg={Colors.dim} dim>
|
|
285
|
-
—
|
|
286
|
-
</text>
|
|
287
|
-
</box>
|
|
288
|
-
))}
|
|
289
|
-
</box>
|
|
290
|
-
)}
|
|
291
366
|
</box>
|
|
292
367
|
);
|
|
293
368
|
}
|
|
@@ -301,11 +376,14 @@ function MetricCard({
|
|
|
301
376
|
value,
|
|
302
377
|
color,
|
|
303
378
|
isPnl = false,
|
|
379
|
+
loading = false,
|
|
304
380
|
}: {
|
|
305
381
|
label: string;
|
|
306
382
|
value: number;
|
|
307
383
|
color: string;
|
|
308
384
|
isPnl?: boolean;
|
|
385
|
+
/** When true, show placeholder instead of inventing zeros. */
|
|
386
|
+
loading?: boolean;
|
|
309
387
|
}) {
|
|
310
388
|
return (
|
|
311
389
|
<box
|
|
@@ -325,10 +403,10 @@ function MetricCard({
|
|
|
325
403
|
</text>
|
|
326
404
|
</box>
|
|
327
405
|
|
|
328
|
-
{/* Value (large, colored) */}
|
|
406
|
+
{/* Value (large, colored) — never invent zeros while loading */}
|
|
329
407
|
<box>
|
|
330
|
-
<text fg={color} bold>
|
|
331
|
-
{formatStatNumber(value, isPnl)}
|
|
408
|
+
<text fg={loading ? Colors.dim : color} bold={!loading}>
|
|
409
|
+
{loading ? "..." : formatStatNumber(value, isPnl)}
|
|
332
410
|
</text>
|
|
333
411
|
</box>
|
|
334
412
|
</box>
|
|
@@ -341,8 +419,8 @@ function MetricCard({
|
|
|
341
419
|
*/
|
|
342
420
|
function QuickStatsRow() {
|
|
343
421
|
const metrics = useServiceStore((s) => s.metrics);
|
|
422
|
+
const loading = metrics === null;
|
|
344
423
|
|
|
345
|
-
// Default values when metrics is null/unavailable
|
|
346
424
|
const totalPnl = metrics?.totalPnl ?? 0;
|
|
347
425
|
const activeStrategies = metrics?.activeStrategies ?? 0;
|
|
348
426
|
const dailyTrades = metrics?.dailyTrades ?? 0;
|
|
@@ -360,22 +438,29 @@ function QuickStatsRow() {
|
|
|
360
438
|
value={totalPnl}
|
|
361
439
|
color={totalPnl >= 0 ? Colors.success : Colors.error}
|
|
362
440
|
isPnl
|
|
441
|
+
loading={loading}
|
|
363
442
|
/>
|
|
364
443
|
<MetricCard
|
|
365
444
|
label="ACTIVE STRATEGIES"
|
|
366
445
|
value={activeStrategies}
|
|
367
446
|
color={Colors.accent}
|
|
447
|
+
loading={loading}
|
|
368
448
|
/>
|
|
369
449
|
<MetricCard
|
|
370
450
|
label="DAILY TRADES"
|
|
371
451
|
value={dailyTrades}
|
|
372
452
|
color={Colors.info}
|
|
453
|
+
loading={loading}
|
|
454
|
+
/>
|
|
455
|
+
<MetricCard
|
|
456
|
+
label="AI CALLS"
|
|
457
|
+
value={aiCalls}
|
|
458
|
+
color={Colors.accent}
|
|
459
|
+
loading={loading}
|
|
373
460
|
/>
|
|
374
|
-
<MetricCard label="AI CALLS" value={aiCalls} color={Colors.accent} />
|
|
375
461
|
</box>
|
|
376
462
|
|
|
377
|
-
{
|
|
378
|
-
{metrics === null && (
|
|
463
|
+
{loading && (
|
|
379
464
|
<box paddingTop={1} alignItems="center">
|
|
380
465
|
<Spinner label="Waiting for metrics data..." />
|
|
381
466
|
</box>
|
|
@@ -554,7 +639,7 @@ export function DashboardView({ dialog }: DashboardViewProps = {}) {
|
|
|
554
639
|
}, [handleRunAutoRepair]);
|
|
555
640
|
|
|
556
641
|
// Dismiss repair panel on ESC key when visible
|
|
557
|
-
|
|
642
|
+
useViewKeyboard((key) => {
|
|
558
643
|
if (repairState.kind !== "idle" && key.name === "escape") {
|
|
559
644
|
dismissRepairPanel();
|
|
560
645
|
}
|
|
@@ -45,18 +45,25 @@ describe("DbQueryView", () => {
|
|
|
45
45
|
expect(formatCell(null, 20)).toBe("NULL");
|
|
46
46
|
expect(formatCell(undefined, 20)).toBe("NULL");
|
|
47
47
|
expect(formatCell(42, 20)).toBe("42");
|
|
48
|
+
expect(formatCell(true, 20)).toBe("true");
|
|
49
|
+
expect(formatCell(false, 20)).toBe("false");
|
|
50
|
+
expect(formatCell(Number.POSITIVE_INFINITY, 20)).toBe("Infinity");
|
|
48
51
|
});
|
|
49
52
|
|
|
50
53
|
it("sanitizes cell text and collapses newlines", () => {
|
|
51
54
|
expect(sanitizeCellText("a\x1bb")).toBe("ab");
|
|
52
55
|
expect(formatCell("line1\nline2", 40)).toBe("line1 line2");
|
|
56
|
+
expect(formatCell("x".repeat(30), 10)).toBe("xxxxxxxxx…");
|
|
53
57
|
});
|
|
54
58
|
|
|
55
59
|
it("sorts nulls last and numbers numerically", () => {
|
|
56
60
|
expect(compareCells(null, 1, "asc")).toBe(1);
|
|
57
61
|
expect(compareCells(1, null, "asc")).toBe(-1);
|
|
62
|
+
expect(compareCells(null, null, "asc")).toBe(0);
|
|
58
63
|
expect(compareCells(2, 10, "asc")).toBeLessThan(0);
|
|
59
64
|
expect(compareCells(2, 10, "desc")).toBeGreaterThan(0);
|
|
65
|
+
expect(compareCells("b", "a", "asc")).toBeGreaterThan(0);
|
|
66
|
+
expect(compareCells("b", "a", "desc")).toBeLessThan(0);
|
|
60
67
|
});
|
|
61
68
|
|
|
62
69
|
it("rejects write SQL before any CLI call (defence in depth)", () => {
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
* - Renders an explicit empty/error state instead of throwing
|
|
38
38
|
*/
|
|
39
39
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
40
|
-
|
|
40
|
+
|
|
41
41
|
import { Colors, useUIStore } from "@hoox-sh/hoox-shared";
|
|
42
42
|
import { ErrorBoundary } from "../shared/error-boundary";
|
|
43
43
|
import { Spinner, EmptyState } from "../shared/spinner";
|
|
@@ -53,6 +53,7 @@ import {
|
|
|
53
53
|
TuiStateFiles,
|
|
54
54
|
writeJsonState,
|
|
55
55
|
} from "../../services/tui-storage";
|
|
56
|
+
import { useViewKeyboard } from "../../hooks/shell-overlay";
|
|
56
57
|
|
|
57
58
|
// ─── Constants ────────────────────────────────────────────────────────────────
|
|
58
59
|
|
|
@@ -270,7 +271,7 @@ interface HistoryOverlayProps {
|
|
|
270
271
|
|
|
271
272
|
function HistoryOverlay({ history, onSelect, onClose }: HistoryOverlayProps) {
|
|
272
273
|
// Keyboard: Escape closes, Up/Down navigate, Enter selects
|
|
273
|
-
|
|
274
|
+
useViewKeyboard((key) => {
|
|
274
275
|
if (key.name === "escape") {
|
|
275
276
|
onClose();
|
|
276
277
|
return;
|
|
@@ -369,7 +370,7 @@ function SqlInputRow({
|
|
|
369
370
|
onChange,
|
|
370
371
|
}: SqlInputRowProps) {
|
|
371
372
|
// Capture keyboard when active.
|
|
372
|
-
|
|
373
|
+
useViewKeyboard((key) => {
|
|
373
374
|
if (!inputActive) return;
|
|
374
375
|
if (key.name === "escape") {
|
|
375
376
|
onDeactivate();
|
|
@@ -587,7 +588,7 @@ export function DbQueryView() {
|
|
|
587
588
|
}, [historyIdx, history]);
|
|
588
589
|
|
|
589
590
|
// ── Global keyboard shortcuts (active view only) ───────────────────────────
|
|
590
|
-
|
|
591
|
+
useViewKeyboard((key) => {
|
|
591
592
|
if (!isActive) return;
|
|
592
593
|
if (showHistory) return; // History overlay handles its own keys
|
|
593
594
|
|
|
@@ -11,12 +11,13 @@ import {
|
|
|
11
11
|
resolveHooxRuntimeRoot,
|
|
12
12
|
useUIStore,
|
|
13
13
|
} from "@hoox-sh/hoox-shared";
|
|
14
|
-
|
|
14
|
+
|
|
15
15
|
import * as fs from "fs";
|
|
16
16
|
import * as path from "path";
|
|
17
17
|
import { ErrorBoundary } from "../shared/error-boundary";
|
|
18
18
|
import { ViewHeader } from "../shared/view-header";
|
|
19
19
|
import { EmptyState } from "../shared/spinner";
|
|
20
|
+
import { useViewKeyboard } from "../../hooks/shell-overlay";
|
|
20
21
|
|
|
21
22
|
/** Cap default flow list so huge graphs stay responsive. */
|
|
22
23
|
export const MAX_FLOWS_PREVIEW = 15;
|
|
@@ -201,7 +202,7 @@ function EdgeTopologyInner() {
|
|
|
201
202
|
);
|
|
202
203
|
}, [selectableItems.length]);
|
|
203
204
|
|
|
204
|
-
|
|
205
|
+
useViewKeyboard((key) => {
|
|
205
206
|
if (!isActive) return;
|
|
206
207
|
if (key.name === "escape") {
|
|
207
208
|
setSelectedNode(null);
|
|
@@ -151,6 +151,34 @@ describe("KvViewer", () => {
|
|
|
151
151
|
|
|
152
152
|
it("sanitizes revealed KV values (no ESC injection)", () => {
|
|
153
153
|
expect(sanitizeKvValue("secret\x1b[0mvalue")).toBe("secret[0mvalue");
|
|
154
|
+
expect(sanitizeKvValue("a\u0000b\u0007c")).toBe("abc");
|
|
155
|
+
const long = "x".repeat(5000);
|
|
156
|
+
expect(sanitizeKvValue(long).length).toBeLessThanOrEqual(4001);
|
|
157
|
+
expect(
|
|
158
|
+
sanitizeKvValue(long).endsWith("…") ||
|
|
159
|
+
sanitizeKvValue(long).endsWith("\u2026")
|
|
160
|
+
).toBe(true);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("filterKvKeys is case-insensitive and trims", () => {
|
|
164
|
+
const keys = [
|
|
165
|
+
{
|
|
166
|
+
name: "Alpha",
|
|
167
|
+
valueSize: 1,
|
|
168
|
+
lastModified: null,
|
|
169
|
+
isSecret: false,
|
|
170
|
+
manifestType: null as null,
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
name: "beta",
|
|
174
|
+
valueSize: 1,
|
|
175
|
+
lastModified: null,
|
|
176
|
+
isSecret: false,
|
|
177
|
+
manifestType: null as null,
|
|
178
|
+
},
|
|
179
|
+
];
|
|
180
|
+
expect(filterKvKeys(keys, "ALP")).toHaveLength(1);
|
|
181
|
+
expect(filterKvKeys(keys, "\t")).toHaveLength(2);
|
|
154
182
|
});
|
|
155
183
|
|
|
156
184
|
it("caps visible key rows", () => {
|
|
@@ -48,13 +48,14 @@
|
|
|
48
48
|
* while the table shows the filtered count.
|
|
49
49
|
*/
|
|
50
50
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
51
|
-
|
|
51
|
+
|
|
52
52
|
import { Colors, useUIStore } from "@hoox-sh/hoox-shared";
|
|
53
53
|
import { ErrorBoundary } from "../shared/error-boundary";
|
|
54
54
|
import { Spinner, EmptyState } from "../shared/spinner";
|
|
55
55
|
import { ViewHeader } from "../shared/view-header";
|
|
56
56
|
import { cliBridge } from "../../services/cli-bridge";
|
|
57
57
|
import type { KvKey, KvKeySnapshot } from "../../services/cli-bridge";
|
|
58
|
+
import { useViewKeyboard } from "../../hooks/shell-overlay";
|
|
58
59
|
|
|
59
60
|
/** Auto-refresh interval in milliseconds. */
|
|
60
61
|
export const REFRESH_INTERVAL_MS = 5_000;
|
|
@@ -307,7 +308,7 @@ function SearchBox({
|
|
|
307
308
|
onActivate,
|
|
308
309
|
onDeactivate,
|
|
309
310
|
}: SearchBoxProps) {
|
|
310
|
-
|
|
311
|
+
useViewKeyboard((key) => {
|
|
311
312
|
if (!active) return;
|
|
312
313
|
if (key.name === "escape") {
|
|
313
314
|
onDeactivate();
|
|
@@ -494,7 +495,7 @@ export function KvViewer() {
|
|
|
494
495
|
}, []);
|
|
495
496
|
|
|
496
497
|
// ── Global keyboard handling (active only when this view is on top) ──
|
|
497
|
-
|
|
498
|
+
useViewKeyboard((key) => {
|
|
498
499
|
if (!isActive) return;
|
|
499
500
|
if (searchActive) return; // SearchBox handles its own keys.
|
|
500
501
|
if (key.name === "slash" || (key.ctrl && key.name === "f")) {
|