@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
@@ -22,13 +22,14 @@
22
22
  * Colors from @hoox-sh/hoox-shared design tokens. No CSS, no DOM.
23
23
  */
24
24
  import { useState, useMemo, useRef, useEffect } from "react";
25
- import { useKeyboard } from "@opentui/react";
25
+
26
26
  import { Colors, useServiceStore, useUIStore } from "@hoox-sh/hoox-shared";
27
27
  import type { Trade, TradeSide } from "@hoox-sh/hoox-shared";
28
28
  import { ErrorBoundary } from "../shared/error-boundary";
29
29
  import { Spinner, EmptyState } from "../shared/spinner";
30
30
  import { ViewHeader } from "../shared/view-header";
31
31
  import { Panel } from "../shared/panel";
32
+ import { useViewKeyboard } from "../../hooks/shell-overlay";
32
33
 
33
34
  // ─── Constants ────────────────────────────────────────────────────────────────
34
35
 
@@ -235,19 +236,25 @@ function LiveTradeFeed({
235
236
 
236
237
  // Snapshot frozen trades when paused; keep updating otherwise
237
238
  const frozenRef = useRef<Trade[]>([]);
238
- const wasPausedRef = useRef(false);
239
+ const [frozenStream, setFrozenStream] = useState<Trade[] | null>(null);
239
240
 
240
- // Capture snapshot on pause edge, release on resume
241
- if (!paused && wasPausedRef.current) {
242
- frozenRef.current = [];
243
- }
244
- if (paused && !wasPausedRef.current) {
245
- frozenRef.current = [...tradeStream];
246
- }
247
- wasPausedRef.current = paused;
241
+ // Capture snapshot on pause edge, release on resume (not during render)
242
+ useEffect(() => {
243
+ if (paused) {
244
+ frozenRef.current = [...tradeStream];
245
+ setFrozenStream(frozenRef.current);
246
+ } else {
247
+ frozenRef.current = [];
248
+ setFrozenStream(null);
249
+ }
250
+ // Only react to pause edge — intentionally omit tradeStream from deps
251
+ // when already paused so the frozen snapshot stays stable.
252
+ }, [paused]);
248
253
 
249
254
  // Use frozen snapshot when paused, live stream when not
250
- const effectiveStream = paused ? frozenRef.current : tradeStream;
255
+ const effectiveStream = paused
256
+ ? (frozenStream ?? frozenRef.current)
257
+ : tradeStream;
251
258
 
252
259
  // Newest first, only the render window (store is newest-last)
253
260
  const sortedTrades = useMemo(
@@ -265,7 +272,7 @@ function LiveTradeFeed({
265
272
  const safeIndex = Math.min(selectedIndex, maxIndex);
266
273
 
267
274
  // Keyboard: navigate trade rows only while this view is active
268
- useKeyboard((key) => {
275
+ useViewKeyboard((key) => {
269
276
  if (!isActive) return;
270
277
  if (key.name === "up") {
271
278
  setSelectedIndex((i) => Math.max(0, i - 1));
@@ -687,7 +694,7 @@ export function TradeMonitor() {
687
694
  const offline = connectionStatus === "offline";
688
695
 
689
696
  // Keyboard: space toggles pause/resume only while this view is active
690
- useKeyboard((key) => {
697
+ useViewKeyboard((key) => {
691
698
  if (!isActive) return;
692
699
  if (key.name === "space") {
693
700
  setPaused((p) => !p);
@@ -21,7 +21,7 @@
21
21
  * Colors from @hoox-sh/hoox-shared tokens — no hardcoded hex.
22
22
  */
23
23
  import { useState, useMemo, useEffect, useCallback, useRef } from "react";
24
- import { useKeyboard } from "@opentui/react";
24
+
25
25
  import {
26
26
  Colors,
27
27
  LogLevelColor,
@@ -36,6 +36,7 @@ import {
36
36
  import { ErrorBoundary } from "../shared/error-boundary";
37
37
  import { StatusDot, type StatusDotStatus } from "../shared/status-dot";
38
38
  import { ViewHeader } from "../shared/view-header";
39
+ import { useViewKeyboard } from "../../hooks/shell-overlay";
39
40
 
40
41
  // ── Local type aliases (inferred from store return types) ──────────────────
41
42
 
@@ -97,14 +98,20 @@ export function redactConfigEntries(
97
98
  /**
98
99
  * Breadcrumb header showing back navigation, worker name, and status indicator.
99
100
  */
100
- function BreadcrumbHeader({ worker }: { worker: Worker }) {
101
+ function BreadcrumbHeader({
102
+ worker,
103
+ onBack,
104
+ }: {
105
+ worker: Worker;
106
+ onBack: () => void;
107
+ }) {
101
108
  return (
102
109
  <ViewHeader
103
110
  title={worker.name}
104
111
  showDivider={false}
105
112
  meta={
106
113
  <box flexDirection="row" gap={2}>
107
- <text fg={Colors.muted} dim>
114
+ <text fg={Colors.accent} onMouseUp={onBack}>
108
115
  ← BACK
109
116
  </text>
110
117
  <StatusDot
@@ -207,7 +214,7 @@ function LogsPane({
207
214
  }, [workerLogs.length, paused]);
208
215
 
209
216
  // Keyboard: Space toggles pause when this pane is focused
210
- useKeyboard((key) => {
217
+ useViewKeyboard((key) => {
211
218
  if (!focused) return;
212
219
  if (key.name === "space") {
213
220
  setPaused((p) => !p);
@@ -546,25 +553,32 @@ export function WorkerDetail() {
546
553
  void fetchConfig();
547
554
  }, [fetchConfig]);
548
555
 
549
- // Fetch logs via CLI as fallback when SSE is not connected
556
+ // Fetch logs via CLI when SSE is offline OR when connected but empty for this worker
550
557
  useEffect(() => {
551
558
  if (connectionStatus !== "connected") {
552
559
  void fetchLogs();
560
+ return;
553
561
  }
554
- }, [connectionStatus, fetchLogs]);
562
+ // Connected but no lines yet for this worker — still allow a one-shot CLI seed
563
+ const hasWorkerLogs = useServiceStore
564
+ .getState()
565
+ .logs.some((l) => l.workerId === worker?.id || l.source === worker?.name);
566
+ if (!hasWorkerLogs && worker) {
567
+ void fetchLogs();
568
+ }
569
+ }, [connectionStatus, fetchLogs, worker]);
555
570
 
556
571
  const handleRefresh = useCallback(() => {
557
572
  void fetchConfig();
558
- if (connectionStatus !== "connected") {
559
- void fetchLogs();
560
- }
561
- }, [fetchConfig, fetchLogs, connectionStatus]);
573
+ // Always refresh logs on explicit Ctrl+R — not only when offline
574
+ void fetchLogs();
575
+ }, [fetchConfig, fetchLogs]);
562
576
 
563
577
  const activeView = useUIStore((s) => s.activeView);
564
578
  const isActive = activeView === "worker-detail";
565
579
 
566
580
  // View-local keyboard handling (only when this view is active)
567
- useKeyboard((key) => {
581
+ useViewKeyboard((key) => {
568
582
  if (!isActive) return;
569
583
  switch (key.name) {
570
584
  case "tab":
@@ -586,6 +600,7 @@ export function WorkerDetail() {
586
600
  <ErrorBoundary viewName="Worker Detail">
587
601
  <box flexDirection="column" flexGrow={1} padding={2} gap={1}>
588
602
  <BreadcrumbHeader
603
+ onBack={goBack}
589
604
  worker={{
590
605
  id: selectedWorkerId || "unknown",
591
606
  name: selectedWorkerId || "Unknown Worker",
@@ -631,7 +646,7 @@ export function WorkerDetail() {
631
646
  <ErrorBoundary viewName={`Worker: ${worker.name}`}>
632
647
  <box flexDirection="column" flexGrow={1} padding={1} gap={1}>
633
648
  {/* Breadcrumb header */}
634
- <BreadcrumbHeader worker={worker} />
649
+ <BreadcrumbHeader worker={worker} onBack={goBack} />
635
650
 
636
651
  {/* 2×2 Grid of panes */}
637
652
  <box flexDirection="column" flexGrow={1} gap={1}>
@@ -24,20 +24,19 @@ describe("WorkerSettingsView", () => {
24
24
  expect(display).not.toMatch(/sk-|token|password/i);
25
25
  });
26
26
 
27
- it("dangerous fields require a second confirm before write (contract)", () => {
28
- // Mirrors saveCurrent double-Enter gate for kind === "dangerous"
27
+ it("all non-secret fields require a second confirm before write (contract)", () => {
28
+ // Mirrors saveCurrent double-Enter gate for every non-secret write
29
29
  let pending: string | null = null;
30
- const fieldKey = "risk:kill_switch";
31
- const kind = "dangerous" as const;
30
+ const fieldKey = "risk:max_position_size";
32
31
 
33
32
  // First Enter → arm confirm
34
- if (kind === "dangerous" && pending !== fieldKey) {
33
+ if (pending !== fieldKey) {
35
34
  pending = fieldKey;
36
35
  }
37
36
  expect(pending).toBe(fieldKey);
38
37
 
39
38
  // Second Enter with same key → proceed
40
- const shouldWrite = kind === "dangerous" && pending === fieldKey;
39
+ const shouldWrite = pending === fieldKey;
41
40
  expect(shouldWrite).toBe(true);
42
41
  });
43
42
  });
@@ -16,7 +16,7 @@
16
16
  * `hoox config kv set`. Secret fields are read-only (CLI command shown).
17
17
  */
18
18
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
19
- import { useKeyboard } from "@opentui/react";
19
+
20
20
  import {
21
21
  Colors,
22
22
  AGENT_CONFIG_KV_KEY,
@@ -37,6 +37,7 @@ import { Panel } from "../shared/panel";
37
37
  import { Spinner, EmptyState } from "../shared/spinner";
38
38
  import { cliBridge } from "../../services/cli-bridge";
39
39
  import { loadDashboardSettingsManifests } from "../../services/dashboard-settings-loader";
40
+ import { useViewKeyboard } from "../../hooks/shell-overlay";
40
41
 
41
42
  function parseKvValue(
42
43
  raw: string | null,
@@ -276,15 +277,16 @@ export function WorkerSettingsView() {
276
277
  return;
277
278
  }
278
279
 
279
- // Fail-closed confirm for dangerous fields: require a second Enter.
280
- if (row.field.kind === "dangerous") {
281
- if (pendingDangerousKey !== row.field.key) {
282
- setPendingDangerousKey(row.field.key);
283
- setStatus(
284
- `Confirm dangerous write for "${row.field.label}" — press Enter again to save, Esc to cancel`
285
- );
286
- return;
287
- }
280
+ // Fail-closed confirm for ALL non-secret writes: require a second Enter.
281
+ // Prevents accidental live KV mutations from a single mis-press.
282
+ if (pendingDangerousKey !== row.field.key) {
283
+ setPendingDangerousKey(row.field.key);
284
+ const kindLabel =
285
+ row.field.kind === "dangerous" ? "dangerous write" : "write";
286
+ setStatus(
287
+ `Confirm ${kindLabel} for "${row.field.label}" — press Enter again to save, Esc to cancel`
288
+ );
289
+ return;
288
290
  }
289
291
  setPendingDangerousKey(null);
290
292
 
@@ -350,7 +352,7 @@ export function WorkerSettingsView() {
350
352
  }
351
353
  }, [flatFields, fieldIndex, selected, values, pendingDangerousKey, saving]);
352
354
 
353
- useKeyboard((key) => {
355
+ useViewKeyboard((key) => {
354
356
  if (!isActive) return;
355
357
  if (key.name === "escape") {
356
358
  if (pendingDangerousKey) {
@@ -25,7 +25,7 @@
25
25
  * Follows TUI Patterns 1 (View Composition), 2 (Store Subscription), 8 (ScrollBox).
26
26
  */
27
27
  import { useState, useMemo, useCallback, useEffect, useRef } from "react";
28
- import { useKeyboard } from "@opentui/react";
28
+
29
29
  import { Colors } from "@hoox-sh/hoox-shared";
30
30
  import { useServiceStore } from "@hoox-sh/hoox-shared";
31
31
  import { useUIStore } from "@hoox-sh/hoox-shared";
@@ -39,6 +39,7 @@ import { redactSecretsInText } from "../../services/dev-log";
39
39
  import type { WorkerInfo } from "@hoox-sh/hoox-shared";
40
40
  import { showConfirm } from "../ui/dialog";
41
41
  import type { DialogHandle } from "../ui/dialog";
42
+ import { useViewKeyboard } from "../../hooks/shell-overlay";
42
43
 
43
44
  // ── Props ─────────────────────────────────────────────────────────────────────
44
45
 
@@ -337,7 +338,7 @@ export function WorkersOverview({ dialog }: WorkersOverviewProps = {}) {
337
338
  }, [maxIndex]);
338
339
 
339
340
  // ── View-local keyboard: 2D grid navigation ─────────────────────────────
340
- useKeyboard((key) => {
341
+ useViewKeyboard((key) => {
341
342
  switch (key.name) {
342
343
  case "up":
343
344
  setFocusedIndex((i) => Math.max(0, i - COLS));
@@ -371,25 +372,45 @@ export function WorkersOverview({ dialog }: WorkersOverviewProps = {}) {
371
372
 
372
373
  const handleLogs = useCallback(async (worker: WorkerInfo) => {
373
374
  if (deployingRef.current) return;
375
+ const store = useServiceStore.getState();
376
+ // Select worker and open Logs Viewer — primary operator path
377
+ store.selectWorker(worker.id);
378
+ useUIStore.getState().setView("logs-viewer");
379
+
374
380
  try {
375
381
  const result = await cliBridge.workerLogs(worker.name);
376
382
  if (!mountedRef.current) return;
383
+ if (result.success && result.stdout) {
384
+ // Seed the log ring so Logs Viewer has content immediately
385
+ const lines = result.stdout.split("\n").filter((l) => l.trim());
386
+ const now = Date.now();
387
+ for (const line of lines.slice(-100)) {
388
+ store.pushLog({
389
+ id: `cli-log-${now}-${Math.random().toString(36).slice(2, 8)}`,
390
+ timestamp: now,
391
+ level: "info",
392
+ workerId: worker.id,
393
+ message: redactSecretsInText(line).slice(0, 500),
394
+ source: worker.name,
395
+ });
396
+ }
397
+ }
377
398
  const failDetail = redactSecretsInText(
378
399
  result.stderr || result.stdout || "unknown error"
379
400
  );
380
- useServiceStore.getState().addAlert({
401
+ store.addAlert({
381
402
  id: `logs-${Date.now()}-${worker.name}`,
382
403
  type: "logs",
383
404
  severity: result.success ? "info" : "warning",
384
405
  message: result.success
385
- ? `${worker.name} logs retrieved (${(result.duration / 1000).toFixed(1)}s)`
386
- : `${worker.name} logs failed: ${failDetail}`,
406
+ ? `Opened logs for ${worker.name}`
407
+ : `${worker.name} logs fetch failed: ${failDetail}`,
387
408
  timestamp: Date.now(),
388
409
  acknowledged: false,
389
410
  });
390
411
  } catch (err) {
391
412
  if (!mountedRef.current) return;
392
- useServiceStore.getState().addAlert({
413
+ store.addAlert({
393
414
  id: `logs-err-${Date.now()}`,
394
415
  type: "logs",
395
416
  severity: "warning",
@@ -6,8 +6,15 @@
6
6
  /**
7
7
  * Hook unit tests — renderer ref, global keyboard registration, polling helpers.
8
8
  */
9
- import { describe, it, expect, beforeEach, afterEach, mock } from "bun:test";
10
- import { setRendererRef, getRendererRef, registerGlobalHandler } from "./index";
9
+ import { describe, it, expect, beforeEach, afterEach, jest } from "bun:test";
10
+ import {
11
+ setRendererRef,
12
+ getRendererRef,
13
+ registerGlobalHandler,
14
+ computePollingBackoff,
15
+ createPollingController,
16
+ POLLING_MAX_BACKOFF_MS,
17
+ } from "./index";
11
18
  import type { KeyEvent } from "./use-keyboard";
12
19
 
13
20
  describe("renderer-ref", () => {
@@ -36,6 +43,10 @@ describe("registerGlobalHandler", () => {
36
43
  setRendererRef(null);
37
44
  });
38
45
 
46
+ afterEach(() => {
47
+ setRendererRef(null);
48
+ });
49
+
39
50
  it("registers and unregisters without throwing", () => {
40
51
  const calls: string[] = [];
41
52
  const unsub = registerGlobalHandler((key: KeyEvent) => {
@@ -54,19 +65,302 @@ describe("registerGlobalHandler", () => {
54
65
  b();
55
66
  c();
56
67
  });
68
+
69
+ it("dispatches keypresses in priority order when renderer is attached", () => {
70
+ const order: number[] = [];
71
+ const listeners = new Map<string, Set<(...args: unknown[]) => void>>();
72
+ const keyInput = {
73
+ on: (event: string, fn: (...args: unknown[]) => void) => {
74
+ if (!listeners.has(event)) listeners.set(event, new Set());
75
+ listeners.get(event)!.add(fn);
76
+ return () => listeners.get(event)?.delete(fn);
77
+ },
78
+ off: (event: string, fn: (...args: unknown[]) => void) => {
79
+ listeners.get(event)?.delete(fn);
80
+ },
81
+ };
82
+ setRendererRef({
83
+ keyInput,
84
+ } as unknown as import("@opentui/core").CliRenderer);
85
+
86
+ const unsubHigh = registerGlobalHandler(() => {
87
+ order.push(0);
88
+ }, 0);
89
+ const unsubMid = registerGlobalHandler(() => {
90
+ order.push(10);
91
+ }, 10);
92
+ const unsubLow = registerGlobalHandler(() => {
93
+ order.push(50);
94
+ }, 50);
95
+
96
+ const handlers = listeners.get("keypress");
97
+ expect(handlers?.size).toBe(1);
98
+ for (const h of handlers ?? []) {
99
+ h({
100
+ name: "a",
101
+ ctrl: false,
102
+ shift: false,
103
+ alt: false,
104
+ meta: false,
105
+ sequence: "a",
106
+ });
107
+ }
108
+ expect(order).toEqual([0, 10, 50]);
109
+
110
+ unsubHigh();
111
+ unsubMid();
112
+ unsubLow();
113
+ // Last unsubscribe detaches key input
114
+ expect(listeners.get("keypress")?.size ?? 0).toBe(0);
115
+ });
116
+
117
+ it("snapshot protects against unsubscribe mid-dispatch", () => {
118
+ const seen: string[] = [];
119
+ let unsubB: (() => void) | null = null;
120
+ const listeners = new Map<string, Set<(...args: unknown[]) => void>>();
121
+ const keyInput = {
122
+ on: (event: string, fn: (...args: unknown[]) => void) => {
123
+ if (!listeners.has(event)) listeners.set(event, new Set());
124
+ listeners.get(event)!.add(fn);
125
+ return () => listeners.get(event)?.delete(fn);
126
+ },
127
+ off: () => {},
128
+ };
129
+ setRendererRef({
130
+ keyInput,
131
+ } as unknown as import("@opentui/core").CliRenderer);
132
+
133
+ const unsubA = registerGlobalHandler(() => {
134
+ seen.push("a");
135
+ unsubB?.();
136
+ }, 0);
137
+ unsubB = registerGlobalHandler(() => {
138
+ seen.push("b");
139
+ }, 1);
140
+
141
+ for (const h of listeners.get("keypress") ?? []) {
142
+ h({
143
+ name: "x",
144
+ ctrl: false,
145
+ shift: false,
146
+ alt: false,
147
+ meta: false,
148
+ sequence: "x",
149
+ });
150
+ }
151
+ // Snapshot ensures b still runs even though unsub mid-dispatch
152
+ expect(seen).toEqual(["a", "b"]);
153
+ unsubA();
154
+ });
155
+
156
+ it("attaches when on() returns non-function (off fallback path)", () => {
157
+ let offCalled = false;
158
+ const keyInput = {
159
+ on: () => undefined,
160
+ off: () => {
161
+ offCalled = true;
162
+ },
163
+ };
164
+ setRendererRef({
165
+ keyInput,
166
+ } as unknown as import("@opentui/core").CliRenderer);
167
+ const unsub = registerGlobalHandler(() => {}, 5);
168
+ unsub();
169
+ expect(offCalled).toBe(true);
170
+ });
57
171
  });
58
172
 
59
- describe("usePolling backoff math", () => {
173
+ describe("computePollingBackoff", () => {
60
174
  it("caps exponential backoff at 16s", () => {
61
175
  const base = 1000;
62
- const MAX = 16_000;
63
176
  for (let retry = 0; retry < 10; retry++) {
64
- const backoff = Math.min(base * Math.pow(2, retry), MAX);
65
- expect(backoff).toBeLessThanOrEqual(MAX);
177
+ const backoff = computePollingBackoff(base, retry);
178
+ expect(backoff).toBeLessThanOrEqual(POLLING_MAX_BACKOFF_MS);
66
179
  }
67
- expect(Math.min(base * Math.pow(2, 10), MAX)).toBe(MAX);
180
+ expect(computePollingBackoff(base, 10)).toBe(POLLING_MAX_BACKOFF_MS);
181
+ });
182
+
183
+ it("doubles per retry until cap", () => {
184
+ expect(computePollingBackoff(500, 0)).toBe(500);
185
+ expect(computePollingBackoff(500, 1)).toBe(1000);
186
+ expect(computePollingBackoff(500, 2)).toBe(2000);
187
+ expect(computePollingBackoff(500, 3)).toBe(4000);
188
+ });
189
+
190
+ it("respects custom max backoff", () => {
191
+ expect(computePollingBackoff(1000, 5, 3000)).toBe(3000);
68
192
  });
69
193
  });
70
194
 
71
- // Silence unused import if mock is tree-shaken away in some runners
72
- void mock;
195
+ describe("createPollingController", () => {
196
+ /** Flush microtasks after advancing fake timers (Bun has no advanceTimersByTimeAsync). */
197
+ async function advance(ms: number): Promise<void> {
198
+ jest.advanceTimersByTime(ms);
199
+ // Drain promise chains scheduled by timer callbacks
200
+ await Promise.resolve();
201
+ await Promise.resolve();
202
+ }
203
+
204
+ beforeEach(() => {
205
+ jest.useFakeTimers();
206
+ });
207
+
208
+ afterEach(() => {
209
+ jest.useRealTimers();
210
+ });
211
+
212
+ it("invokes callback immediately when immediate=true", async () => {
213
+ let calls = 0;
214
+ const ctrl = createPollingController({
215
+ getCallback: () => async () => {
216
+ calls++;
217
+ },
218
+ getIntervalMs: () => 1000,
219
+ immediate: true,
220
+ });
221
+ ctrl.start();
222
+ // Let the microtask from the immediate poll resolve
223
+ await Promise.resolve();
224
+ expect(calls).toBe(1);
225
+ ctrl.stop();
226
+ });
227
+
228
+ it("defers first poll when immediate=false", async () => {
229
+ let calls = 0;
230
+ const ctrl = createPollingController({
231
+ getCallback: () => async () => {
232
+ calls++;
233
+ },
234
+ getIntervalMs: () => 500,
235
+ immediate: false,
236
+ });
237
+ ctrl.start();
238
+ await Promise.resolve();
239
+ expect(calls).toBe(0);
240
+ await advance(500);
241
+ expect(calls).toBe(1);
242
+ ctrl.stop();
243
+ });
244
+
245
+ it("increments retry count on failure and resets on success", async () => {
246
+ let shouldFail = true;
247
+ const ctrl = createPollingController({
248
+ getCallback: () => async () => {
249
+ if (shouldFail) throw new Error("boom");
250
+ },
251
+ getIntervalMs: () => 100,
252
+ immediate: true,
253
+ });
254
+ ctrl.start();
255
+ await Promise.resolve();
256
+ expect(ctrl.getRetryCount()).toBe(1);
257
+
258
+ // Next scheduled poll after backoff 100 * 2^1 = 200
259
+ shouldFail = false;
260
+ await advance(200);
261
+ expect(ctrl.getRetryCount()).toBe(0);
262
+ ctrl.stop();
263
+ });
264
+
265
+ it("stop prevents further polls after pending timeout", async () => {
266
+ let calls = 0;
267
+ const ctrl = createPollingController({
268
+ getCallback: () => async () => {
269
+ calls++;
270
+ },
271
+ getIntervalMs: () => 1000,
272
+ immediate: true,
273
+ });
274
+ ctrl.start();
275
+ await Promise.resolve();
276
+ expect(calls).toBe(1);
277
+ ctrl.stop();
278
+ await advance(10_000);
279
+ expect(calls).toBe(1);
280
+ });
281
+
282
+ it("reads fresh interval on each iteration", async () => {
283
+ let interval = 100;
284
+ let calls = 0;
285
+ const ctrl = createPollingController({
286
+ getCallback: () => async () => {
287
+ calls++;
288
+ },
289
+ getIntervalMs: () => interval,
290
+ immediate: true,
291
+ });
292
+ ctrl.start();
293
+ await Promise.resolve();
294
+ expect(calls).toBe(1);
295
+ interval = 250;
296
+ // Next poll was scheduled at previous interval (100); after it runs,
297
+ // subsequent delay uses the new interval.
298
+ await advance(100);
299
+ expect(calls).toBe(2);
300
+ await advance(249);
301
+ expect(calls).toBe(2);
302
+ await advance(1);
303
+ expect(calls).toBe(3);
304
+ ctrl.stop();
305
+ });
306
+
307
+ it("stop during in-flight callback does not schedule next poll", async () => {
308
+ let resolveCb!: () => void;
309
+ let calls = 0;
310
+ const ctrl = createPollingController({
311
+ getCallback: () => () =>
312
+ new Promise<void>((resolve) => {
313
+ calls++;
314
+ resolveCb = resolve;
315
+ }),
316
+ getIntervalMs: () => 100,
317
+ immediate: true,
318
+ });
319
+ ctrl.start();
320
+ await Promise.resolve();
321
+ expect(calls).toBe(1);
322
+ ctrl.stop();
323
+ resolveCb();
324
+ await Promise.resolve();
325
+ await advance(1000);
326
+ expect(calls).toBe(1);
327
+ });
328
+ });
329
+
330
+ describe("useServiceData", () => {
331
+ afterEach(async () => {
332
+ const { __setServiceStoreHookForTests } =
333
+ await import("./use-service-data");
334
+ __setServiceStoreHookForTests(null);
335
+ });
336
+
337
+ it("applies the selector via the store hook", async () => {
338
+ const { useServiceData, __setServiceStoreHookForTests } =
339
+ await import("./use-service-data");
340
+ const { useServiceStore } = await import("@hoox-sh/hoox-shared");
341
+ const snapshot = useServiceStore.getState();
342
+
343
+ __setServiceStoreHookForTests((selector) => selector(snapshot));
344
+
345
+ const status = useServiceData((s) => s.connectionStatus);
346
+ expect(status).toBe(snapshot.connectionStatus);
347
+
348
+ const workers = useServiceData((s) => s.workers);
349
+ expect(workers).toBe(snapshot.workers);
350
+ });
351
+
352
+ it("restores real store hook after null reset", async () => {
353
+ const { useServiceData, __setServiceStoreHookForTests } =
354
+ await import("./use-service-data");
355
+ __setServiceStoreHookForTests((() => "injected") as never);
356
+ expect(useServiceData((() => "x") as never) as unknown).toBe("injected");
357
+ __setServiceStoreHookForTests(null);
358
+ // Real hook throws outside React — proves restore happened
359
+ expect(() => useServiceData((s) => s.connectionStatus)).toThrow();
360
+ });
361
+
362
+ it("is re-exported from hooks index", async () => {
363
+ const mod = await import("./index");
364
+ expect(typeof mod.useServiceData).toBe("function");
365
+ });
366
+ });
@@ -5,7 +5,13 @@
5
5
 
6
6
  export { useKeyboard, registerGlobalHandler } from "./use-keyboard";
7
7
  export type { KeyEvent, UseKeyboardOptions } from "./use-keyboard";
8
- export { usePolling } from "./use-polling";
8
+ export {
9
+ usePolling,
10
+ computePollingBackoff,
11
+ createPollingController,
12
+ POLLING_MAX_BACKOFF_MS,
13
+ } from "./use-polling";
9
14
  export type { UsePollingOptions } from "./use-polling";
10
15
  export { useServiceData } from "./use-service-data";
11
16
  export { setRendererRef, getRendererRef } from "./renderer-ref";
17
+ export { isShellOverlayOpen, useViewKeyboard } from "./shell-overlay";