@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.
Files changed (55) hide show
  1. package/README.md +50 -31
  2. package/dist/main.js +4239 -1743
  3. package/package.json +8 -5
  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/ui/toast.tsx +44 -92
  13. package/src/components/views/config-editor.test.tsx +4 -2
  14. package/src/components/views/config-editor.tsx +31 -3
  15. package/src/components/views/dashboard/alerts-panel.tsx +36 -8
  16. package/src/components/views/dashboard/auto-repair-helpers.test.ts +23 -0
  17. package/src/components/views/dashboard.test.tsx +2 -1
  18. package/src/components/views/dashboard.tsx +126 -41
  19. package/src/components/views/db-query.test.tsx +7 -0
  20. package/src/components/views/db-query.tsx +5 -4
  21. package/src/components/views/edge-topology.tsx +3 -2
  22. package/src/components/views/kv-viewer.test.tsx +28 -0
  23. package/src/components/views/kv-viewer.tsx +4 -3
  24. package/src/components/views/logs-viewer.tsx +46 -19
  25. package/src/components/views/queue-depth.test.tsx +39 -0
  26. package/src/components/views/queue-depth.tsx +3 -2
  27. package/src/components/views/secrets-viewer.test.tsx +2 -2
  28. package/src/components/views/secrets-viewer.tsx +4 -3
  29. package/src/components/views/service-manager.tsx +147 -53
  30. package/src/components/views/settings.tsx +50 -10
  31. package/src/components/views/setup-wizard.test.tsx +13 -0
  32. package/src/components/views/setup-wizard.tsx +53 -22
  33. package/src/components/views/trade-monitor.tsx +20 -13
  34. package/src/components/views/worker-detail.tsx +27 -12
  35. package/src/components/views/worker-settings.test.tsx +5 -6
  36. package/src/components/views/worker-settings.tsx +13 -11
  37. package/src/components/views/workers-overview.tsx +27 -6
  38. package/src/hooks/hooks.test.ts +303 -9
  39. package/src/hooks/index.ts +7 -1
  40. package/src/hooks/shell-overlay.ts +41 -0
  41. package/src/hooks/use-polling.ts +91 -35
  42. package/src/hooks/use-service-data.ts +21 -4
  43. package/src/main.tsx +6 -0
  44. package/src/services/cli-bridge/index.ts +28 -22
  45. package/src/services/cli-bridge/standalone.ts +29 -2
  46. package/src/services/cli-bridge.test.ts +848 -1
  47. package/src/services/clipboard.test.ts +217 -3
  48. package/src/services/dashboard-settings-loader.test.ts +50 -0
  49. package/src/services/tui-connection.test.ts +21 -0
  50. package/src/stores/service-store.test.ts +89 -3
  51. package/src/stores/ui-store.test.ts +23 -0
  52. package/src/test-setup.ts +11 -2
  53. package/src/types.ts +1 -0
  54. package/src/view-registry.test.ts +3 -1
  55. package/src/view-registry.tsx +14 -1
@@ -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")) {
@@ -13,7 +13,7 @@
13
13
  * Color-coded by level using Hoox design tokens. Wrapped in ErrorBoundary.
14
14
  */
15
15
  import { useState, useMemo, useCallback, useEffect, useRef } from "react";
16
- import { useKeyboard } from "@opentui/react";
16
+
17
17
  import {
18
18
  Colors,
19
19
  LogLevelColor,
@@ -25,6 +25,7 @@ import { ErrorBoundary } from "../shared/error-boundary";
25
25
  import { cliBridge } from "../../services/cli-bridge";
26
26
  import { ViewHeader } from "../shared/view-header";
27
27
  import { Panel } from "../shared/panel";
28
+ import { useViewKeyboard } from "../../hooks/shell-overlay";
28
29
 
29
30
  // ─── Color Tokens ────────────────────────────────────────────────────────────
30
31
 
@@ -283,6 +284,8 @@ interface ActionBarProps {
283
284
  onTogglePause: () => void;
284
285
  onExport: () => void;
285
286
  onClear: () => void;
287
+ /** True when user must confirm clear a second time. */
288
+ clearArmed?: boolean;
286
289
  sseConnected: boolean;
287
290
  onFetch?: () => void;
288
291
  }
@@ -292,6 +295,7 @@ function ActionBar({
292
295
  onTogglePause,
293
296
  onExport,
294
297
  onClear,
298
+ clearArmed = false,
295
299
  sseConnected,
296
300
  onFetch,
297
301
  }: ActionBarProps) {
@@ -315,8 +319,12 @@ function ActionBar({
315
319
  <text fg={Colors.foreground} onMouseUp={onExport}>
316
320
  [Export]
317
321
  </text>
318
- <text fg={Colors.foreground} onMouseUp={onClear}>
319
- [Clear]
322
+ <text
323
+ fg={clearArmed ? Colors.warning : Colors.foreground}
324
+ bold={clearArmed}
325
+ onMouseUp={onClear}
326
+ >
327
+ {clearArmed ? "[Confirm Clear]" : "[Clear]"}
320
328
  </text>
321
329
  {!sseConnected && onFetch ? (
322
330
  <text fg={Colors.accent} onMouseUp={onFetch}>
@@ -325,7 +333,7 @@ function ActionBar({
325
333
  ) : null}
326
334
  </box>
327
335
  <text dim fg={Colors.muted}>
328
- Space:pause /:search Tab:switch
336
+ Space:pause /:search
329
337
  </text>
330
338
  </box>
331
339
  );
@@ -439,23 +447,41 @@ export function LogsViewer() {
439
447
  }
440
448
  }, []);
441
449
 
450
+ const [clearArmed, setClearArmed] = useState(false);
451
+ const clearArmTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
452
+
442
453
  const handleClear = useCallback(() => {
443
- // Soft confirm via status alert path — clearing is local buffer only.
444
- // (No dialog prop on this view; keep non-destructive.)
454
+ // Two-step confirm (local buffer only — no dialog on this view).
455
+ if (!clearArmed) {
456
+ setClearArmed(true);
457
+ if (clearArmTimer.current) clearTimeout(clearArmTimer.current);
458
+ clearArmTimer.current = setTimeout(() => setClearArmed(false), 3000);
459
+ useServiceStore.getState().addAlert({
460
+ id: `clear-confirm-${Date.now()}`,
461
+ type: "info",
462
+ severity: "warning",
463
+ message: "Press CLEAR again within 3s to wipe the log buffer",
464
+ timestamp: Date.now(),
465
+ acknowledged: false,
466
+ });
467
+ return;
468
+ }
469
+ setClearArmed(false);
470
+ if (clearArmTimer.current) {
471
+ clearTimeout(clearArmTimer.current);
472
+ clearArmTimer.current = null;
473
+ }
445
474
  const ts = Date.now();
446
- useServiceStore.setState((state) => {
447
- state.logs = [];
448
- });
449
- const alert: Alert = {
475
+ useServiceStore.getState().clearLogs();
476
+ useServiceStore.getState().addAlert({
450
477
  id: `clear-${ts}`,
451
478
  type: "info",
452
479
  severity: "info",
453
480
  message: "Log buffer cleared",
454
481
  timestamp: ts,
455
482
  acknowledged: false,
456
- };
457
- useServiceStore.getState().addAlert(alert);
458
- }, []);
483
+ });
484
+ }, [clearArmed]);
459
485
 
460
486
  const handleFetch = useCallback(async () => {
461
487
  const ts = Date.now();
@@ -526,16 +552,17 @@ export function LogsViewer() {
526
552
  }, [paused, frozen, displayed]);
527
553
 
528
554
  // ── Keyboard (active view only) ─────────────────────────────────────────
529
- useKeyboard((key: { name: string; sequence?: string; ctrl?: boolean }) => {
555
+ useViewKeyboard((key) => {
530
556
  if (!isActive) return;
557
+ const name = String(key.name ?? "");
531
558
 
532
559
  // Search mode: type to filter, Esc / Enter to leave
533
560
  if (searchActive) {
534
- if (key.name === "escape" || key.name === "return") {
561
+ if (name === "escape" || name === "return") {
535
562
  setSearchActive(false);
536
563
  return;
537
564
  }
538
- if (key.name === "backspace" || key.name === "delete") {
565
+ if (name === "backspace" || name === "delete") {
539
566
  setSearchText((t) => t.slice(0, -1));
540
567
  return;
541
568
  }
@@ -545,13 +572,12 @@ export function LogsViewer() {
545
572
  return;
546
573
  }
547
574
 
548
- if (key.name === "space") {
575
+ if (name === "space") {
549
576
  setPaused((p) => !p);
550
577
  return;
551
578
  }
552
- if (key.name === "slash" || key.name === "/") {
579
+ if (name === "slash" || name === "/") {
553
580
  setSearchActive(true);
554
- return;
555
581
  }
556
582
  });
557
583
 
@@ -607,6 +633,7 @@ export function LogsViewer() {
607
633
  onTogglePause={() => setPaused((p) => !p)}
608
634
  onExport={handleExport}
609
635
  onClear={handleClear}
636
+ clearArmed={clearArmed}
610
637
  sseConnected={sseConnected}
611
638
  onFetch={handleFetch}
612
639
  />
@@ -172,6 +172,45 @@ describe("QueueDepthView", () => {
172
172
  ]);
173
173
  });
174
174
 
175
+ it("sortQueuesByPressure ties break by name and handles unknown status", () => {
176
+ const ts = new Date().toISOString();
177
+ const queues = [
178
+ {
179
+ queueName: "b",
180
+ depth: 0,
181
+ max: 1000,
182
+ status: "healthy" as const,
183
+ producers: 0,
184
+ consumers: 0,
185
+ paused: false,
186
+ timestamp: ts,
187
+ },
188
+ {
189
+ queueName: "a",
190
+ depth: 0,
191
+ max: 1000,
192
+ status: "healthy" as const,
193
+ producers: 0,
194
+ consumers: 0,
195
+ paused: false,
196
+ timestamp: ts,
197
+ },
198
+ {
199
+ queueName: "z-unknown",
200
+ depth: 0,
201
+ max: 1000,
202
+ status: "unknown" as const,
203
+ producers: 0,
204
+ consumers: 0,
205
+ paused: false,
206
+ timestamp: ts,
207
+ },
208
+ ];
209
+ // unknown ranks above healthy (3 < 4), ties break by name
210
+ const sorted = sortQueuesByPressure(queues);
211
+ expect(sorted.map((q) => q.queueName)).toEqual(["z-unknown", "a", "b"]);
212
+ });
213
+
175
214
  // ── Pattern contract for subsequent views ──────────────────────────────
176
215
  // Document the architectural pattern this view establishes so the
177
216
  // views added in subtasks 04, 05, 06, 08 can be audited against it.
@@ -35,7 +35,7 @@
35
35
  * - unknown → Colors.muted (gray)
36
36
  */
37
37
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
38
- import { useKeyboard } from "@opentui/react";
38
+
39
39
  import { Colors, useUIStore } from "@hoox-sh/hoox-shared";
40
40
  import { ErrorBoundary } from "../shared/error-boundary";
41
41
  import { Spinner, EmptyState } from "../shared/spinner";
@@ -43,6 +43,7 @@ import { ViewHeader } from "../shared/view-header";
43
43
  import { Panel } from "../shared/panel";
44
44
  import { cliBridge } from "../../services/cli-bridge";
45
45
  import type { QueueDepth, QueueDepthStatus } from "../../services/cli-bridge";
46
+ import { useViewKeyboard } from "../../hooks/shell-overlay";
46
47
 
47
48
  /** Auto-refresh interval in milliseconds. */
48
49
  export const REFRESH_INTERVAL_MS = 5_000;
@@ -265,7 +266,7 @@ export function QueueDepthView() {
265
266
  }, [isActive, refresh]);
266
267
 
267
268
  // Keyboard: r / F5 manual refresh while active
268
- useKeyboard((key) => {
269
+ useViewKeyboard((key) => {
269
270
  if (!isActive) return;
270
271
  if (key.name === "r" || key.name === "f5") {
271
272
  void refresh({ soft: false });