@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
@@ -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 });
@@ -28,12 +28,12 @@ describe("SecretsViewer", () => {
28
28
  });
29
29
 
30
30
  it("documents keyboard search (/) and list navigation (↑↓)", async () => {
31
- // Contract: SecretsViewer wires useKeyboard for / and ↑↓ (parity with KV Viewer).
31
+ // Contract: SecretsViewer wires useViewKeyboard for / and ↑↓ (parity with KV Viewer).
32
32
  // Regression guard against the previous dead SearchBox (onChange discarded).
33
33
  const src = await Bun.file(
34
34
  new URL("./secrets-viewer.tsx", import.meta.url)
35
35
  ).text();
36
- expect(src).toContain("useKeyboard");
36
+ expect(src).toContain("useViewKeyboard");
37
37
  expect(src).toContain('key.name === "slash"');
38
38
  expect(src).toContain("setSearchActive(true)");
39
39
  expect(src).toContain("filteredSecrets");
@@ -42,7 +42,7 @@
42
42
  * - No ability to reveal or copy secret values
43
43
  */
44
44
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
45
- import { useKeyboard } from "@opentui/react";
45
+
46
46
  import { Colors, useUIStore } from "@hoox-sh/hoox-shared";
47
47
  import { ErrorBoundary } from "../shared/error-boundary";
48
48
  import { Spinner, EmptyState } from "../shared/spinner";
@@ -52,6 +52,7 @@ import type {
52
52
  SecretMetadata,
53
53
  SecretsSnapshot,
54
54
  } from "../../services/cli-bridge";
55
+ import { useViewKeyboard } from "../../hooks/shell-overlay";
55
56
 
56
57
  /** Auto-refresh interval in milliseconds. */
57
58
  export const REFRESH_INTERVAL_MS = 5_000;
@@ -144,7 +145,7 @@ function SearchBox({
144
145
  onDeactivate,
145
146
  }: SearchBoxProps) {
146
147
  // When active, capture printable keys for filtering (same pattern as KV Viewer).
147
- useKeyboard((key) => {
148
+ useViewKeyboard((key) => {
148
149
  if (!active) return;
149
150
  if (key.name === "escape") {
150
151
  onDeactivate();
@@ -291,7 +292,7 @@ export function SecretsViewer() {
291
292
  }, [allSecrets, selectedSecretName]);
292
293
 
293
294
  // ── Keyboard: list nav + open search (inactive while search is active) ───
294
- useKeyboard((key) => {
295
+ useViewKeyboard((key) => {
295
296
  if (!isActive) return;
296
297
  if (searchActive) return; // SearchBox owns keys while active
297
298
  if (key.name === "slash" || (key.ctrl && key.name === "f")) {
@@ -26,7 +26,11 @@
26
26
  */
27
27
  import { useState, useCallback, useEffect, useMemo, useRef } from "react";
28
28
  import { useServiceStore } from "@hoox-sh/hoox-shared/stores/service-store";
29
- import { Colors, ConnectionStatusColor } from "@hoox-sh/hoox-shared";
29
+ import {
30
+ Colors,
31
+ ConnectionStatusColor,
32
+ useUIStore,
33
+ } from "@hoox-sh/hoox-shared";
30
34
  import { StatusDot } from "../shared/status-dot";
31
35
  import { ErrorBoundary } from "../shared/error-boundary";
32
36
  import { EmptyState } from "../shared/spinner";
@@ -39,6 +43,7 @@ import { cliBridge } from "../../services/cli-bridge";
39
43
  import type { KillSwitchStatus } from "../../services/cli-bridge";
40
44
  import type { CliResult } from "../../types";
41
45
  import { redactSecretsInText } from "../../services/dev-log";
46
+ import { useViewKeyboard } from "../../hooks/shell-overlay";
42
47
 
43
48
  // ─── Types ──────────────────────────────────────────────────────────────────
44
49
 
@@ -262,6 +267,9 @@ interface WorkerControlListProps {
262
267
  onDeploy: (worker: WorkerInfo) => void;
263
268
  onRestart: (worker: WorkerInfo) => void;
264
269
  deployingWorker?: string | null;
270
+ /** Controlled selection (keyboard nav from parent). */
271
+ selectedIndex: number;
272
+ onSelect: (index: number) => void;
265
273
  }
266
274
 
267
275
  function WorkerControlList({
@@ -269,9 +277,9 @@ function WorkerControlList({
269
277
  onDeploy,
270
278
  onRestart,
271
279
  deployingWorker,
280
+ selectedIndex,
281
+ onSelect,
272
282
  }: WorkerControlListProps) {
273
- const [selectedIndex, setSelectedIndex] = useState(0);
274
-
275
283
  return (
276
284
  <box flexDirection="column" flexGrow={1} gap={0}>
277
285
  {/* List header */}
@@ -317,7 +325,7 @@ function WorkerControlList({
317
325
  worker={worker}
318
326
  index={i}
319
327
  selectedIndex={selectedIndex}
320
- onSelect={setSelectedIndex}
328
+ onSelect={onSelect}
321
329
  onDeploy={onDeploy}
322
330
  onRestart={onRestart}
323
331
  deployingWorker={deployingWorker}
@@ -325,6 +333,11 @@ function WorkerControlList({
325
333
  ))
326
334
  )}
327
335
  </scrollbox>
336
+ <box paddingLeft={1}>
337
+ <text fg={Colors.dim} dim>
338
+ ↑↓ · d/r deploy/restart · D/R all · Enter detail · k/e/u kill-switch
339
+ </text>
340
+ </box>
328
341
  </box>
329
342
  );
330
343
  }
@@ -427,6 +440,8 @@ function KillSwitchSection({ dialog, onAlert }: KillSwitchSectionProps) {
427
440
  const [error, setError] = useState<string | null>(null);
428
441
  const mountedRef = useRef(true);
429
442
  const loadingRef = useRef(false);
443
+ const activeView = useUIStore((s) => s.activeView);
444
+ const isActive = activeView === "service-manager";
430
445
 
431
446
  useEffect(() => {
432
447
  mountedRef.current = true;
@@ -555,64 +570,90 @@ function KillSwitchSection({ dialog, onAlert }: KillSwitchSectionProps) {
555
570
  const engageDisabled = loading || engaged;
556
571
  const releaseDisabled = loading || !engaged;
557
572
 
573
+ // Keyboard: k refresh · e engage · u release (confirm still required)
574
+ useViewKeyboard((key) => {
575
+ if (!isActive) return;
576
+ const name = String(key.name ?? "").toLowerCase();
577
+ if (name === "k") {
578
+ void refresh();
579
+ return;
580
+ }
581
+ if (name === "e" && !engageDisabled) {
582
+ void setEngaged(true);
583
+ return;
584
+ }
585
+ if (name === "u" && !releaseDisabled) {
586
+ void setEngaged(false);
587
+ }
588
+ });
589
+
558
590
  return (
559
591
  <Panel
560
592
  elevated={false}
561
593
  compact
562
594
  borderColor={engaged ? Colors.error : undefined}
563
595
  >
564
- <box
565
- flexDirection="row"
566
- justifyContent="space-between"
567
- alignItems="center"
568
- >
569
- {/* Status side — label, dot, state, timestamp */}
570
- <box flexDirection="row" gap={1} alignItems="center">
571
- <text fg={Colors.accent} bold>
572
- KILL SWITCH
573
- </text>
574
- <StatusDot status={dotStatus} pulse={!engaged} />
575
- <text fg={stateColor} bold>
576
- {stateLabel}
577
- </text>
578
- {status && (
579
- <text fg={Colors.muted} dim>
580
- @ {formatKillSwitchTime(status.timestamp)}
596
+ <box flexDirection="column" gap={0}>
597
+ <box
598
+ flexDirection="row"
599
+ justifyContent="space-between"
600
+ alignItems="center"
601
+ >
602
+ {/* Status side — label, dot, state, timestamp */}
603
+ <box flexDirection="row" gap={1} alignItems="center">
604
+ <text fg={Colors.accent} bold>
605
+ KILL SWITCH
581
606
  </text>
582
- )}
583
- {error && (
584
- <text fg={Colors.error} dim>
585
- ! {error.length > 40 ? error.slice(0, 37) + "…" : error}
607
+ <StatusDot status={dotStatus} pulse={!engaged} />
608
+ <text fg={stateColor} bold>
609
+ {stateLabel}
586
610
  </text>
587
- )}
588
- </box>
611
+ {status && (
612
+ <text fg={Colors.muted} dim>
613
+ @ {formatKillSwitchTime(status.timestamp)}
614
+ </text>
615
+ )}
616
+ {error && (
617
+ <text fg={Colors.error} dim>
618
+ ! {error.length > 40 ? error.slice(0, 37) + "…" : error}
619
+ </text>
620
+ )}
621
+ </box>
589
622
 
590
- {/* Action side — engage / release buttons + refresh */}
591
- <box flexDirection="row" gap={2}>
592
- <text
593
- fg={Colors.muted}
594
- bg={Colors.card}
595
- onMouseUp={loading ? undefined : () => void refresh()}
596
- >
597
- {loading ? " ..." : " [REFRESH] "}
598
- </text>
599
- <text
600
- fg={engageDisabled ? Colors.muted : Colors.error}
601
- bg={engageDisabled ? undefined : Colors.card}
602
- dim={engageDisabled}
603
- onMouseUp={engageDisabled ? undefined : () => void setEngaged(true)}
604
- >
605
- {" ENGAGE "}
606
- </text>
607
- <text
608
- fg={releaseDisabled ? Colors.muted : Colors.success}
609
- bg={releaseDisabled ? undefined : Colors.card}
610
- dim={releaseDisabled}
611
- onMouseUp={
612
- releaseDisabled ? undefined : () => void setEngaged(false)
613
- }
614
- >
615
- {" RELEASE "}
623
+ {/* Action side — engage / release buttons + refresh */}
624
+ <box flexDirection="row" gap={2}>
625
+ <text
626
+ fg={Colors.muted}
627
+ bg={Colors.card}
628
+ onMouseUp={loading ? undefined : () => void refresh()}
629
+ >
630
+ {loading ? " ..." : " [REFRESH] "}
631
+ </text>
632
+ <text
633
+ fg={engageDisabled ? Colors.muted : Colors.error}
634
+ bg={engageDisabled ? undefined : Colors.card}
635
+ dim={engageDisabled}
636
+ onMouseUp={
637
+ engageDisabled ? undefined : () => void setEngaged(true)
638
+ }
639
+ >
640
+ {" ENGAGE "}
641
+ </text>
642
+ <text
643
+ fg={releaseDisabled ? Colors.muted : Colors.success}
644
+ bg={releaseDisabled ? undefined : Colors.card}
645
+ dim={releaseDisabled}
646
+ onMouseUp={
647
+ releaseDisabled ? undefined : () => void setEngaged(false)
648
+ }
649
+ >
650
+ {" RELEASE "}
651
+ </text>
652
+ </box>
653
+ </box>
654
+ <box paddingLeft={1}>
655
+ <text fg={Colors.dim} dim>
656
+ k refresh · e engage · u release
616
657
  </text>
617
658
  </box>
618
659
  </box>
@@ -626,10 +667,13 @@ export function ServiceManager({ dialog }: ServiceManagerProps) {
626
667
  // Subscribe to workers from the service store
627
668
  const workers = useServiceStore((s) => s.workers);
628
669
  const connectionStatus = useServiceStore((s) => s.connectionStatus);
670
+ const activeView = useUIStore((s) => s.activeView);
671
+ const isActive = activeView === "service-manager";
629
672
 
630
673
  // Deploy/restart tracking state
631
674
  const [deployingWorker, setDeployingWorker] = useState<string | null>(null);
632
675
  const [deployProgress, setDeployProgress] = useState("");
676
+ const [selectedIndex, setSelectedIndex] = useState(0);
633
677
  const mountedRef = useRef(true);
634
678
  const deployingRef = useRef(false);
635
679
 
@@ -640,6 +684,13 @@ export function ServiceManager({ dialog }: ServiceManagerProps) {
640
684
  };
641
685
  }, []);
642
686
 
687
+ // Keep selection in range when worker list changes
688
+ useEffect(() => {
689
+ setSelectedIndex((i) =>
690
+ workers.length === 0 ? 0 : Math.min(i, workers.length - 1)
691
+ );
692
+ }, [workers.length]);
693
+
643
694
  // Edge count from worker telemetry only — never invent a marketing "275+".
644
695
  const totalEdgeCount = useMemo(
645
696
  () => workers.reduce((sum, w) => sum + (w.edgeCount ?? 0), 0),
@@ -866,6 +917,47 @@ export function ServiceManager({ dialog }: ServiceManagerProps) {
866
917
  }
867
918
  }, [dialog, workers.length, onProgress, addResultAlert, blockWithoutDialog]);
868
919
 
920
+ // ── Keyboard: list nav + deploy/restart (overlay-safe) ─────────────────
921
+ useViewKeyboard((key) => {
922
+ if (!isActive) return;
923
+ const name = String(key.name ?? "").toLowerCase();
924
+ const selected = workers[selectedIndex];
925
+
926
+ if (name === "up") {
927
+ setSelectedIndex((i) => Math.max(0, i - 1));
928
+ return;
929
+ }
930
+ if (name === "down") {
931
+ setSelectedIndex((i) => Math.min(Math.max(0, workers.length - 1), i + 1));
932
+ return;
933
+ }
934
+ // Enter → worker detail
935
+ if (name === "return" || name === "enter") {
936
+ if (selected) {
937
+ useServiceStore.getState().selectWorker(selected.id);
938
+ useUIStore.getState().setView("worker-detail");
939
+ }
940
+ return;
941
+ }
942
+ // d → deploy selected · r → restart selected (lowercase)
943
+ if (name === "d" && !key.shift) {
944
+ if (selected) void handleDeploy(selected);
945
+ return;
946
+ }
947
+ if (name === "r" && !key.shift) {
948
+ if (selected) void handleRestart(selected);
949
+ return;
950
+ }
951
+ // Shift+D / Shift+R → bulk (OpenTUI may report name "d" with shift)
952
+ if (name === "d" && key.shift) {
953
+ void handleDeployAll();
954
+ return;
955
+ }
956
+ if (name === "r" && key.shift) {
957
+ void handleRestartAll();
958
+ }
959
+ });
960
+
869
961
  // ── Render ──────────────────────────────────────────────────────────────
870
962
  return (
871
963
  <ErrorBoundary viewName="Service Manager">
@@ -909,6 +1001,8 @@ export function ServiceManager({ dialog }: ServiceManagerProps) {
909
1001
  onDeploy={handleDeploy}
910
1002
  onRestart={handleRestart}
911
1003
  deployingWorker={deployingWorker}
1004
+ selectedIndex={selectedIndex}
1005
+ onSelect={setSelectedIndex}
912
1006
  />
913
1007
  </Panel>
914
1008