@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.
- package/README.md +50 -31
- package/dist/main.js +4239 -1743
- package/package.json +8 -5
- package/src/app.tsx +45 -30
- package/src/cli-bridge-test-double.ts +48 -1
- package/src/components/layout/sidebar.tsx +27 -11
- package/src/components/layout/statusbar.test.tsx +19 -5
- package/src/components/layout/statusbar.tsx +34 -18
- package/src/components/shared/crash-screen.tsx +24 -2
- package/src/components/shared/error-boundary.tsx +56 -33
- package/src/components/ui/connection-toasts.test.ts +58 -1
- package/src/components/ui/toast.tsx +44 -92
- package/src/components/views/config-editor.test.tsx +4 -2
- package/src/components/views/config-editor.tsx +31 -3
- package/src/components/views/dashboard/alerts-panel.tsx +36 -8
- package/src/components/views/dashboard/auto-repair-helpers.test.ts +23 -0
- package/src/components/views/dashboard.test.tsx +2 -1
- package/src/components/views/dashboard.tsx +126 -41
- package/src/components/views/db-query.test.tsx +7 -0
- package/src/components/views/db-query.tsx +5 -4
- package/src/components/views/edge-topology.tsx +3 -2
- package/src/components/views/kv-viewer.test.tsx +28 -0
- package/src/components/views/kv-viewer.tsx +4 -3
- package/src/components/views/logs-viewer.tsx +46 -19
- package/src/components/views/queue-depth.test.tsx +39 -0
- package/src/components/views/queue-depth.tsx +3 -2
- package/src/components/views/secrets-viewer.test.tsx +2 -2
- package/src/components/views/secrets-viewer.tsx +4 -3
- package/src/components/views/service-manager.tsx +147 -53
- package/src/components/views/settings.tsx +50 -10
- package/src/components/views/setup-wizard.test.tsx +13 -0
- package/src/components/views/setup-wizard.tsx +53 -22
- package/src/components/views/trade-monitor.tsx +20 -13
- package/src/components/views/worker-detail.tsx +27 -12
- package/src/components/views/worker-settings.test.tsx +5 -6
- package/src/components/views/worker-settings.tsx +13 -11
- package/src/components/views/workers-overview.tsx +27 -6
- package/src/hooks/hooks.test.ts +303 -9
- package/src/hooks/index.ts +7 -1
- package/src/hooks/shell-overlay.ts +41 -0
- package/src/hooks/use-polling.ts +91 -35
- package/src/hooks/use-service-data.ts +21 -4
- package/src/main.tsx +6 -0
- package/src/services/cli-bridge/index.ts +28 -22
- package/src/services/cli-bridge/standalone.ts +29 -2
- package/src/services/cli-bridge.test.ts +848 -1
- package/src/services/clipboard.test.ts +217 -3
- package/src/services/dashboard-settings-loader.test.ts +50 -0
- package/src/services/tui-connection.test.ts +21 -0
- package/src/stores/service-store.test.ts +89 -3
- package/src/stores/ui-store.test.ts +23 -0
- package/src/test-setup.ts +11 -2
- package/src/types.ts +1 -0
- package/src/view-registry.test.ts +3 -1
- package/src/view-registry.tsx +14 -1
|
@@ -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
|
-
|
|
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
|
|
239
|
+
const [frozenStream, setFrozenStream] = useState<Trade[] | null>(null);
|
|
239
240
|
|
|
240
|
-
// Capture snapshot on pause edge, release on resume
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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({
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
559
|
-
|
|
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
|
-
|
|
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("
|
|
28
|
-
// Mirrors saveCurrent double-Enter gate for
|
|
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:
|
|
31
|
-
const kind = "dangerous" as const;
|
|
30
|
+
const fieldKey = "risk:max_position_size";
|
|
32
31
|
|
|
33
32
|
// First Enter → arm confirm
|
|
34
|
-
if (
|
|
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 =
|
|
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
|
-
|
|
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
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
?
|
|
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
|
-
|
|
413
|
+
store.addAlert({
|
|
393
414
|
id: `logs-err-${Date.now()}`,
|
|
394
415
|
type: "logs",
|
|
395
416
|
severity: "warning",
|
package/src/hooks/hooks.test.ts
CHANGED
|
@@ -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,
|
|
10
|
-
import {
|
|
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("
|
|
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 =
|
|
65
|
-
expect(backoff).toBeLessThanOrEqual(
|
|
177
|
+
const backoff = computePollingBackoff(base, retry);
|
|
178
|
+
expect(backoff).toBeLessThanOrEqual(POLLING_MAX_BACKOFF_MS);
|
|
66
179
|
}
|
|
67
|
-
expect(
|
|
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
|
-
|
|
72
|
-
|
|
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
|
+
});
|
package/src/hooks/index.ts
CHANGED
|
@@ -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 {
|
|
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";
|