@hoox-sh/hoox-tui 0.3.2 → 0.4.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.
- package/dist/main.js +1069 -2734
- package/package.json +3 -3
- package/src/app.tsx +15 -11
- package/src/components/shared/shared.test.tsx +2 -2
- package/src/components/ui/connection-toasts.ts +6 -0
- package/src/components/ui/dialog-state.ts +2 -0
- package/src/components/ui/dialog.tsx +0 -13
- package/src/components/views/ai-chat.tsx +6 -1
- package/src/components/views/config-editor/code-editor.tsx +8 -6
- package/src/components/views/config-editor.test.tsx +20 -17
- package/src/components/views/config-editor.tsx +8 -6
- package/src/components/views/dashboard/model-health-section.tsx +38 -46
- package/src/components/views/dashboard/pyne-health-section.tsx +36 -41
- package/src/components/views/dashboard.tsx +25 -28
- package/src/components/views/kv-viewer.test.tsx +4 -16
- package/src/components/views/kv-viewer.tsx +9 -13
- package/src/components/views/logs-viewer.test.tsx +4 -4
- package/src/components/views/queue-depth.test.tsx +5 -7
- package/src/components/views/queue-depth.tsx +10 -14
- package/src/components/views/secrets-viewer.test.tsx +1 -1
- package/src/components/views/secrets-viewer.tsx +11 -15
- package/src/components/views/service-manager.tsx +1 -0
- package/src/components/views/settings.test.tsx +2 -7
- package/src/components/views/settings.tsx +43 -60
- package/src/components/views/setup-wizard.test.tsx +12 -14
- package/src/components/views/setup-wizard.tsx +61 -59
- package/src/components/views/worker-detail.test.tsx +2 -2
- package/src/components/views/worker-settings.tsx +6 -6
- package/src/components/views/workers-overview.tsx +1 -1
- package/src/hooks/hooks.test.ts +10 -174
- package/src/hooks/index.ts +1 -3
- package/src/hooks/renderer-ref.ts +1 -0
- package/src/hooks/use-polling.ts +12 -0
- package/src/services/cli-bridge/index.ts +56 -12
- package/src/services/cli-bridge/standalone.ts +4 -1
- package/src/services/cli-bridge/types.ts +6 -9
- package/src/services/cli-bridge.test.ts +20 -4
- package/src/services/clipboard.ts +2 -2
- package/src/stores/service-store.test.ts +29 -26
- package/src/stores/session-restore.test.ts +2 -0
- package/src/test-setup.ts +5 -13
- package/src/test-utils.ts +4 -24
- package/src/types.ts +9 -47
- package/src/utils/colors.test.ts +8 -3
- package/src/view-registry.test.ts +2 -19
- package/src/view-registry.tsx +7 -20
- package/src/components/shared/animated-border.tsx +0 -58
- package/src/components/shared/keybinding-hint.tsx +0 -37
- package/src/components/ui/select.tsx +0 -49
- package/src/hooks/use-keyboard.ts +0 -130
- package/src/hooks/use-service-data.ts +0 -35
|
@@ -60,7 +60,7 @@ async function spawnVersion(
|
|
|
60
60
|
|
|
61
61
|
function extractSemver(raw: string): string {
|
|
62
62
|
const m = raw.match(/(\d+\.\d+\.\d+)/);
|
|
63
|
-
return m
|
|
63
|
+
return m?.[1] ?? raw;
|
|
64
64
|
}
|
|
65
65
|
|
|
66
66
|
async function checkBun(): Promise<PrereqCheck> {
|
|
@@ -95,7 +95,7 @@ async function checkGit(): Promise<PrereqCheck> {
|
|
|
95
95
|
hint: "apt install git",
|
|
96
96
|
};
|
|
97
97
|
const m = raw.match(/(\d+\.\d+)/);
|
|
98
|
-
const ver = m
|
|
98
|
+
const ver = m?.[1] ?? "unknown";
|
|
99
99
|
const [major, minor] = ver.split(".").map(Number);
|
|
100
100
|
const ok = (major ?? 0) >= 2 && (minor ?? 0) >= 40;
|
|
101
101
|
return {
|
|
@@ -189,7 +189,7 @@ async function checkCfAccountId(): Promise<PrereqCheck> {
|
|
|
189
189
|
const m = text.match(
|
|
190
190
|
/(?:account_id|accountId)\s*["']?\s*:\s*["']([^"']+)["']/
|
|
191
191
|
);
|
|
192
|
-
if (m)
|
|
192
|
+
if (m?.[1])
|
|
193
193
|
return {
|
|
194
194
|
name: "Cloudflare Account",
|
|
195
195
|
status: "pass",
|
|
@@ -298,10 +298,9 @@ type ValidationState = null | boolean;
|
|
|
298
298
|
// ─── Default Form Data ──────────────────────────────────────────────────────
|
|
299
299
|
|
|
300
300
|
const defaultFormData = (): WizardFormData => ({
|
|
301
|
+
// Single unified exchange API key pair (venue selected via Active Exchanges)
|
|
301
302
|
apiKeys: {
|
|
302
|
-
|
|
303
|
-
bybit: { key: "", secret: "" },
|
|
304
|
-
mexc: { key: "", secret: "" },
|
|
303
|
+
exchange: { key: "", secret: "" },
|
|
305
304
|
},
|
|
306
305
|
exchanges: { binance: false, bybit: false, mexc: false },
|
|
307
306
|
ai: { providerUrl: "", apiKey: "", model: "default" },
|
|
@@ -447,12 +446,12 @@ export function SetupWizard({ dialog }: SetupWizardProps) {
|
|
|
447
446
|
}, []);
|
|
448
447
|
|
|
449
448
|
useEffect(() => {
|
|
450
|
-
runChecks();
|
|
449
|
+
void runChecks();
|
|
451
450
|
}, [runChecks]);
|
|
452
451
|
|
|
453
452
|
// ── Detect Cloudflare account ID from wrangler.jsonc ────────────────────
|
|
454
453
|
useEffect(() => {
|
|
455
|
-
(async () => {
|
|
454
|
+
void (async () => {
|
|
456
455
|
try {
|
|
457
456
|
const file = Bun.file("wrangler.jsonc");
|
|
458
457
|
if (await file.exists()) {
|
|
@@ -460,7 +459,7 @@ export function SetupWizard({ dialog }: SetupWizardProps) {
|
|
|
460
459
|
const m = text.match(
|
|
461
460
|
/(?:account_id|accountId)\s*["']?\s*:\s*["']([^"']+)["']/
|
|
462
461
|
);
|
|
463
|
-
if (m) setCfAccountId(m[1]);
|
|
462
|
+
if (m?.[1]) setCfAccountId(m[1]);
|
|
464
463
|
}
|
|
465
464
|
const cfgFile = Bun.file(
|
|
466
465
|
`${process.env.HOME ?? "~"}/.hoox/config.json`
|
|
@@ -482,7 +481,7 @@ export function SetupWizard({ dialog }: SetupWizardProps) {
|
|
|
482
481
|
|
|
483
482
|
// ── Restore wizard session from disk on mount ───────────────────────────
|
|
484
483
|
useEffect(() => {
|
|
485
|
-
(async () => {
|
|
484
|
+
void (async () => {
|
|
486
485
|
try {
|
|
487
486
|
const f = Bun.file(WIZARD_SESSION_PATH);
|
|
488
487
|
if (!(await f.exists())) return;
|
|
@@ -534,12 +533,18 @@ export function SetupWizard({ dialog }: SetupWizardProps) {
|
|
|
534
533
|
>;
|
|
535
534
|
for (let i = 0; i < keys.length - 1; i++) {
|
|
536
535
|
const key = keys[i];
|
|
537
|
-
if (
|
|
536
|
+
if (
|
|
537
|
+
key === undefined ||
|
|
538
|
+
key === "__proto__" ||
|
|
539
|
+
key === "constructor" ||
|
|
540
|
+
key === "prototype"
|
|
541
|
+
)
|
|
538
542
|
return prev;
|
|
539
543
|
obj = obj[key] as Record<string, unknown>;
|
|
540
544
|
}
|
|
541
545
|
const lastKey = keys[keys.length - 1];
|
|
542
546
|
if (
|
|
547
|
+
lastKey === undefined ||
|
|
543
548
|
lastKey === "__proto__" ||
|
|
544
549
|
lastKey === "constructor" ||
|
|
545
550
|
lastKey === "prototype"
|
|
@@ -561,20 +566,17 @@ export function SetupWizard({ dialog }: SetupWizardProps) {
|
|
|
561
566
|
let allValid = true;
|
|
562
567
|
|
|
563
568
|
if (step === 1) {
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
if (
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
if (
|
|
575
|
-
results[secretPath] = validateApiKey(secret);
|
|
576
|
-
if (!results[secretPath]) allValid = false;
|
|
577
|
-
}
|
|
569
|
+
const keyPath = "apiKeys.exchange.key";
|
|
570
|
+
const secretPath = "apiKeys.exchange.secret";
|
|
571
|
+
const key = data.apiKeys.exchange?.key ?? "";
|
|
572
|
+
const secret = data.apiKeys.exchange?.secret ?? "";
|
|
573
|
+
if (key) {
|
|
574
|
+
results[keyPath] = validateApiKey(key);
|
|
575
|
+
if (!results[keyPath]) allValid = false;
|
|
576
|
+
}
|
|
577
|
+
if (secret) {
|
|
578
|
+
results[secretPath] = validateApiKey(secret);
|
|
579
|
+
if (!results[secretPath]) allValid = false;
|
|
578
580
|
}
|
|
579
581
|
} else if (step === 3) {
|
|
580
582
|
// AI providers: if a URL is provided it must be valid; key optional but format-checked when set.
|
|
@@ -740,7 +742,7 @@ export function SetupWizard({ dialog }: SetupWizardProps) {
|
|
|
740
742
|
if (key.name === "left") handleBack();
|
|
741
743
|
if (key.name === "escape") handleBack();
|
|
742
744
|
if (key.name === "tab" && key.shift) handleBack();
|
|
743
|
-
if (key.name === "r" && step === 0) runChecks();
|
|
745
|
+
if (key.name === "r" && step === 0) void runChecks();
|
|
744
746
|
});
|
|
745
747
|
|
|
746
748
|
// ── Validation indicator for a field ─────────────────────────────────────
|
|
@@ -916,34 +918,33 @@ export function SetupWizard({ dialog }: SetupWizardProps) {
|
|
|
916
918
|
Exchange API Keys
|
|
917
919
|
</text>
|
|
918
920
|
<text dim fg={Colors.muted}>
|
|
919
|
-
Secrets are not entered in the TUI
|
|
921
|
+
One key pair for all venues. Secrets are not entered in the TUI — set
|
|
922
|
+
via CLI, then continue. Active venues are chosen in the next step.
|
|
920
923
|
</text>
|
|
921
924
|
<text dim fg={Colors.dim}>
|
|
922
|
-
hoox
|
|
925
|
+
hoox secrets set trade-worker EXCHANGE_KEY_BINDING
|
|
926
|
+
</text>
|
|
927
|
+
<text dim fg={Colors.dim}>
|
|
928
|
+
hoox secrets set trade-worker EXCHANGE_SECRET_BINDING
|
|
923
929
|
</text>
|
|
924
930
|
<box flexDirection="column" gap={1} paddingTop={1}>
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
cliHint={`hoox config secrets set ${name.toUpperCase()}_API_SECRET`}
|
|
943
|
-
/>
|
|
944
|
-
</box>
|
|
945
|
-
);
|
|
946
|
-
})}
|
|
931
|
+
<box flexDirection="column" gap={0}>
|
|
932
|
+
<text bold fg={Colors.foreground}>
|
|
933
|
+
Unified exchange credentials
|
|
934
|
+
</text>
|
|
935
|
+
<SecretField
|
|
936
|
+
label=" API Key"
|
|
937
|
+
value={data.apiKeys.exchange?.key ?? ""}
|
|
938
|
+
path="apiKeys.exchange.key"
|
|
939
|
+
cliHint="hoox secrets set trade-worker EXCHANGE_KEY_BINDING"
|
|
940
|
+
/>
|
|
941
|
+
<SecretField
|
|
942
|
+
label=" API Secret"
|
|
943
|
+
value={data.apiKeys.exchange?.secret ?? ""}
|
|
944
|
+
path="apiKeys.exchange.secret"
|
|
945
|
+
cliHint="hoox secrets set trade-worker EXCHANGE_SECRET_BINDING"
|
|
946
|
+
/>
|
|
947
|
+
</box>
|
|
947
948
|
</box>
|
|
948
949
|
</box>
|
|
949
950
|
);
|
|
@@ -961,7 +962,7 @@ export function SetupWizard({ dialog }: SetupWizardProps) {
|
|
|
961
962
|
{EXCHANGES.map((name) => (
|
|
962
963
|
<Checkbox
|
|
963
964
|
label={name}
|
|
964
|
-
checked={data.exchanges[name.toLowerCase()]}
|
|
965
|
+
checked={data.exchanges[name.toLowerCase()] ?? false}
|
|
965
966
|
path={`exchanges.${name.toLowerCase()}`}
|
|
966
967
|
/>
|
|
967
968
|
))}
|
|
@@ -1126,9 +1127,8 @@ export function SetupWizard({ dialog }: SetupWizardProps) {
|
|
|
1126
1127
|
/** Step 6: Deploy — Summary view */
|
|
1127
1128
|
const StepDeploy = () => {
|
|
1128
1129
|
const activeCount = Object.values(data.exchanges).filter(Boolean).length;
|
|
1129
|
-
const apiKeysConfigured =
|
|
1130
|
-
|
|
1131
|
-
).length;
|
|
1130
|
+
const apiKeysConfigured =
|
|
1131
|
+
data.apiKeys.exchange?.key || data.apiKeys.exchange?.secret ? 1 : 0;
|
|
1132
1132
|
const notifCount = [
|
|
1133
1133
|
data.notifications.email.enabled,
|
|
1134
1134
|
data.notifications.telegram.enabled,
|
|
@@ -1185,7 +1185,7 @@ export function SetupWizard({ dialog }: SetupWizardProps) {
|
|
|
1185
1185
|
bold
|
|
1186
1186
|
onMouseUp={() => {
|
|
1187
1187
|
setDeployLog("");
|
|
1188
|
-
handleDeploy();
|
|
1188
|
+
void handleDeploy();
|
|
1189
1189
|
}}
|
|
1190
1190
|
>
|
|
1191
1191
|
{" [ Retry Deploy ] "}
|
|
@@ -1209,7 +1209,7 @@ export function SetupWizard({ dialog }: SetupWizardProps) {
|
|
|
1209
1209
|
{activeCount === 0 ? "none" : ""})
|
|
1210
1210
|
</text>
|
|
1211
1211
|
<text fg={Colors.foreground}>
|
|
1212
|
-
API Keys: {apiKeysConfigured
|
|
1212
|
+
API Keys: {apiKeysConfigured ? "configured" : "not set"}
|
|
1213
1213
|
</text>
|
|
1214
1214
|
<text fg={Colors.foreground}>
|
|
1215
1215
|
AI Provider:{" "}
|
|
@@ -1226,10 +1226,12 @@ export function SetupWizard({ dialog }: SetupWizardProps) {
|
|
|
1226
1226
|
</box>
|
|
1227
1227
|
<box paddingTop={2} justifyContent="center">
|
|
1228
1228
|
<text
|
|
1229
|
-
fg={
|
|
1230
|
-
bg={
|
|
1229
|
+
fg={Colors.accent}
|
|
1230
|
+
bg={Colors.card}
|
|
1231
1231
|
bold
|
|
1232
|
-
onMouseUp={
|
|
1232
|
+
onMouseUp={() => {
|
|
1233
|
+
void handleDeploy();
|
|
1234
|
+
}}
|
|
1233
1235
|
>
|
|
1234
1236
|
{" [ Deploy Now ] "}
|
|
1235
1237
|
</text>
|
|
@@ -192,8 +192,8 @@ describe("WorkerDetail", () => {
|
|
|
192
192
|
// Only logs for worker "w1" should be relevant
|
|
193
193
|
const workerLogs = state.logs.filter((l) => l.workerId === "w1");
|
|
194
194
|
expect(workerLogs).toHaveLength(2);
|
|
195
|
-
expect(workerLogs[0]
|
|
196
|
-
expect(workerLogs[1]
|
|
195
|
+
expect(workerLogs[0]?.level).toBe("info");
|
|
196
|
+
expect(workerLogs[1]?.level).toBe("warn");
|
|
197
197
|
|
|
198
198
|
// The log for worker "w2" should be filtered out
|
|
199
199
|
const otherLogs = state.logs.filter((l) => l.workerId === "w2");
|
|
@@ -298,13 +298,13 @@ export function WorkerSettingsView() {
|
|
|
298
298
|
|
|
299
299
|
if (isAgentConfigEmbeddedField(row.field.key)) {
|
|
300
300
|
const cfgResult = await cliBridge.configKvGet(AGENT_CONFIG_KV_KEY);
|
|
301
|
-
if (!cfgResult.success
|
|
302
|
-
|
|
303
|
-
|
|
301
|
+
if (!cfgResult.success) {
|
|
302
|
+
setStatus(
|
|
303
|
+
`Save blocked: could not read ${AGENT_CONFIG_KV_KEY} (${cfgResult.stderr || cfgResult.stdout || "unknown error"})`
|
|
304
|
+
);
|
|
305
|
+
return;
|
|
304
306
|
}
|
|
305
|
-
const current = parseAgentConfigJson(
|
|
306
|
-
cfgResult.success ? cfgResult.data : null
|
|
307
|
-
);
|
|
307
|
+
const current = parseAgentConfigJson(cfgResult.data);
|
|
308
308
|
const next = applyAgentConfigFieldUpdates(current, {
|
|
309
309
|
[row.field.key]: value,
|
|
310
310
|
});
|
package/src/hooks/hooks.test.ts
CHANGED
|
@@ -4,18 +4,17 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
|
-
* Hook unit tests — renderer ref
|
|
7
|
+
* Hook unit tests — renderer ref and polling helpers.
|
|
8
8
|
*/
|
|
9
9
|
import { describe, it, expect, beforeEach, afterEach, jest } from "bun:test";
|
|
10
10
|
import {
|
|
11
11
|
setRendererRef,
|
|
12
12
|
getRendererRef,
|
|
13
|
-
registerGlobalHandler,
|
|
14
13
|
computePollingBackoff,
|
|
15
14
|
createPollingController,
|
|
15
|
+
formatPollIntervalLabel,
|
|
16
16
|
POLLING_MAX_BACKOFF_MS,
|
|
17
17
|
} from "./index";
|
|
18
|
-
import type { KeyEvent } from "./use-keyboard";
|
|
19
18
|
|
|
20
19
|
describe("renderer-ref", () => {
|
|
21
20
|
afterEach(() => {
|
|
@@ -37,139 +36,6 @@ describe("renderer-ref", () => {
|
|
|
37
36
|
});
|
|
38
37
|
});
|
|
39
38
|
|
|
40
|
-
describe("registerGlobalHandler", () => {
|
|
41
|
-
beforeEach(() => {
|
|
42
|
-
// Ensure no renderer → attach is a no-op; handlers still register
|
|
43
|
-
setRendererRef(null);
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
afterEach(() => {
|
|
47
|
-
setRendererRef(null);
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
it("registers and unregisters without throwing", () => {
|
|
51
|
-
const calls: string[] = [];
|
|
52
|
-
const unsub = registerGlobalHandler((key: KeyEvent) => {
|
|
53
|
-
calls.push(key.name);
|
|
54
|
-
}, 10);
|
|
55
|
-
expect(typeof unsub).toBe("function");
|
|
56
|
-
unsub();
|
|
57
|
-
expect(calls).toEqual([]);
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
it("multiple handlers can register and all unsubscribe cleanly", () => {
|
|
61
|
-
const a = registerGlobalHandler(() => {}, 0);
|
|
62
|
-
const b = registerGlobalHandler(() => {}, 50);
|
|
63
|
-
const c = registerGlobalHandler(() => {}, 100);
|
|
64
|
-
a();
|
|
65
|
-
b();
|
|
66
|
-
c();
|
|
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
|
-
});
|
|
171
|
-
});
|
|
172
|
-
|
|
173
39
|
describe("computePollingBackoff", () => {
|
|
174
40
|
it("caps exponential backoff at 16s", () => {
|
|
175
41
|
const base = 1000;
|
|
@@ -192,6 +58,14 @@ describe("computePollingBackoff", () => {
|
|
|
192
58
|
});
|
|
193
59
|
});
|
|
194
60
|
|
|
61
|
+
describe("formatPollIntervalLabel", () => {
|
|
62
|
+
it("formats whole seconds, fractional seconds, and milliseconds", () => {
|
|
63
|
+
expect(formatPollIntervalLabel(5_000)).toBe("5s auto");
|
|
64
|
+
expect(formatPollIntervalLabel(1500)).toBe("1.5s auto");
|
|
65
|
+
expect(formatPollIntervalLabel(500)).toBe("500ms auto");
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
195
69
|
describe("createPollingController", () => {
|
|
196
70
|
/** Flush microtasks after advancing fake timers (Bun has no advanceTimersByTimeAsync). */
|
|
197
71
|
async function advance(ms: number): Promise<void> {
|
|
@@ -326,41 +200,3 @@ describe("createPollingController", () => {
|
|
|
326
200
|
expect(calls).toBe(1);
|
|
327
201
|
});
|
|
328
202
|
});
|
|
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
|
@@ -3,15 +3,13 @@
|
|
|
3
3
|
* SPDX-License-Identifier: Apache-2.0
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
export { useKeyboard, registerGlobalHandler } from "./use-keyboard";
|
|
7
|
-
export type { KeyEvent, UseKeyboardOptions } from "./use-keyboard";
|
|
8
6
|
export {
|
|
9
7
|
usePolling,
|
|
10
8
|
computePollingBackoff,
|
|
11
9
|
createPollingController,
|
|
10
|
+
formatPollIntervalLabel,
|
|
12
11
|
POLLING_MAX_BACKOFF_MS,
|
|
13
12
|
} from "./use-polling";
|
|
14
13
|
export type { UsePollingOptions } from "./use-polling";
|
|
15
|
-
export { useServiceData } from "./use-service-data";
|
|
16
14
|
export { setRendererRef, getRendererRef } from "./renderer-ref";
|
|
17
15
|
export { isShellOverlayOpen, useViewKeyboard } from "./shell-overlay";
|
|
@@ -17,6 +17,7 @@ export function setRendererRef(renderer: CliRenderer | null): void {
|
|
|
17
17
|
_renderer = renderer;
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
/** Current CLI renderer, or `null` before `setRendererRef` / after teardown. */
|
|
20
21
|
export function getRendererRef(): CliRenderer | null {
|
|
21
22
|
return _renderer;
|
|
22
23
|
}
|
package/src/hooks/use-polling.ts
CHANGED
|
@@ -101,6 +101,18 @@ export function createPollingController(options: {
|
|
|
101
101
|
};
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
+
/** Compact HUD label for the configured poll interval. */
|
|
105
|
+
export function formatPollIntervalLabel(ms: number): string {
|
|
106
|
+
if (!Number.isFinite(ms) || ms <= 0) return "auto";
|
|
107
|
+
if (ms >= 1000 && ms % 1000 === 0) return `${ms / 1000}s auto`;
|
|
108
|
+
if (ms >= 1000) return `${(ms / 1000).toFixed(1)}s auto`;
|
|
109
|
+
return `${Math.round(ms)}ms auto`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Poll `callback` on the Settings refresh interval while `enabled`.
|
|
114
|
+
* Backs off exponentially after thrown failures; resets after success.
|
|
115
|
+
*/
|
|
104
116
|
export function usePolling(options: UsePollingOptions): void {
|
|
105
117
|
const { callback, enabled = true, immediate = true } = options;
|
|
106
118
|
const intervalMs = useConfigStore((s) => s.refreshIntervalMs);
|