@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
@@ -105,9 +105,11 @@ describe("ConfigEditor", () => {
105
105
  expect(CONFIG_TREE_BLUEPRINT[0].type).toBe("directory");
106
106
  });
107
107
 
108
- it("contains all 5 top-level entries in config/", async () => {
108
+ it("contains config files without secret-like .env entries", async () => {
109
109
  const children = CONFIG_TREE_BLUEPRINT[0].children!;
110
- expect(children.length).toBe(5); // wrangler.toml, trade.config.json, risk.config.json, strategies, .env
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
- import { useKeyboard } from "@opentui/react";
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
- { name: ".env", path: "config/.env", type: "file" },
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
- useKeyboard((key) => {
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. Scrollable with ↑↓ keys.
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
- // Keyboard: scroll through alerts
60
- useKeyboard((key) => {
61
- if (key.name === "up") {
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
- } else if (key.name === "down") {
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
- ↑↓ scroll · {scrollOffset + 1}/{sortedAlerts.length}
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
- const dashCount = (output.match(/—/g) ?? []).length;
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
- import { useKeyboard } from "@opentui/react";
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
- <text fg={Colors.foreground} bold dim>
243
- SERVICE HEALTH
244
- </text>
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={worker.id}
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
- <StatusDot status={worker.status} />
264
- <text fg={Colors.foreground}>{worker.name}</text>
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
- {/* Empty state when metrics unavailable */}
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
- useKeyboard((key) => {
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
- import { useKeyboard } from "@opentui/react";
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
- useKeyboard((key) => {
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
- useKeyboard((key) => {
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
- useKeyboard((key) => {
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
- import { useKeyboard } from "@opentui/react";
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
- useKeyboard((key) => {
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
- import { useKeyboard } from "@opentui/react";
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
- useKeyboard((key) => {
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
- useKeyboard((key) => {
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")) {