@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.
Files changed (51) hide show
  1. package/dist/main.js +1069 -2734
  2. package/package.json +3 -3
  3. package/src/app.tsx +15 -11
  4. package/src/components/shared/shared.test.tsx +2 -2
  5. package/src/components/ui/connection-toasts.ts +6 -0
  6. package/src/components/ui/dialog-state.ts +2 -0
  7. package/src/components/ui/dialog.tsx +0 -13
  8. package/src/components/views/ai-chat.tsx +6 -1
  9. package/src/components/views/config-editor/code-editor.tsx +8 -6
  10. package/src/components/views/config-editor.test.tsx +20 -17
  11. package/src/components/views/config-editor.tsx +8 -6
  12. package/src/components/views/dashboard/model-health-section.tsx +38 -46
  13. package/src/components/views/dashboard/pyne-health-section.tsx +36 -41
  14. package/src/components/views/dashboard.tsx +25 -28
  15. package/src/components/views/kv-viewer.test.tsx +4 -16
  16. package/src/components/views/kv-viewer.tsx +9 -13
  17. package/src/components/views/logs-viewer.test.tsx +4 -4
  18. package/src/components/views/queue-depth.test.tsx +5 -7
  19. package/src/components/views/queue-depth.tsx +10 -14
  20. package/src/components/views/secrets-viewer.test.tsx +1 -1
  21. package/src/components/views/secrets-viewer.tsx +11 -15
  22. package/src/components/views/service-manager.tsx +1 -0
  23. package/src/components/views/settings.test.tsx +2 -7
  24. package/src/components/views/settings.tsx +43 -60
  25. package/src/components/views/setup-wizard.test.tsx +12 -14
  26. package/src/components/views/setup-wizard.tsx +61 -59
  27. package/src/components/views/worker-detail.test.tsx +2 -2
  28. package/src/components/views/worker-settings.tsx +6 -6
  29. package/src/components/views/workers-overview.tsx +1 -1
  30. package/src/hooks/hooks.test.ts +10 -174
  31. package/src/hooks/index.ts +1 -3
  32. package/src/hooks/renderer-ref.ts +1 -0
  33. package/src/hooks/use-polling.ts +12 -0
  34. package/src/services/cli-bridge/index.ts +56 -12
  35. package/src/services/cli-bridge/standalone.ts +4 -1
  36. package/src/services/cli-bridge/types.ts +6 -9
  37. package/src/services/cli-bridge.test.ts +20 -4
  38. package/src/services/clipboard.ts +2 -2
  39. package/src/stores/service-store.test.ts +29 -26
  40. package/src/stores/session-restore.test.ts +2 -0
  41. package/src/test-setup.ts +5 -13
  42. package/src/test-utils.ts +4 -24
  43. package/src/types.ts +9 -47
  44. package/src/utils/colors.test.ts +8 -3
  45. package/src/view-registry.test.ts +2 -19
  46. package/src/view-registry.tsx +7 -20
  47. package/src/components/shared/animated-border.tsx +0 -58
  48. package/src/components/shared/keybinding-hint.tsx +0 -37
  49. package/src/components/ui/select.tsx +0 -49
  50. package/src/hooks/use-keyboard.ts +0 -130
  51. 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 ? m[1] : raw;
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 ? m[1] : "unknown";
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
- binance: { key: "", secret: "" },
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 (key === "__proto__" || key === "constructor" || key === "prototype")
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
- for (const exchange of EXCHANGES) {
565
- const e = exchange.toLowerCase();
566
- const keyPath = `apiKeys.${e}.key`;
567
- const secretPath = `apiKeys.${e}.secret`;
568
- const key = data.apiKeys[e].key;
569
- const secret = data.apiKeys[e].secret;
570
- if (key) {
571
- results[keyPath] = validateApiKey(key);
572
- if (!results[keyPath]) allValid = false;
573
- }
574
- if (secret) {
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. Set them via CLI, then continue.
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 config secrets set BINANCE_API_KEY · etc.
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
- {EXCHANGES.map((name) => {
926
- const e = name.toLowerCase();
927
- return (
928
- <box flexDirection="column" gap={0}>
929
- <text bold fg={Colors.foreground}>
930
- {name}
931
- </text>
932
- <SecretField
933
- label=" API Key"
934
- value={data.apiKeys[e].key}
935
- path={`apiKeys.${e}.key`}
936
- cliHint={`hoox config secrets set ${name.toUpperCase()}_API_KEY`}
937
- />
938
- <SecretField
939
- label=" API Secret"
940
- value={data.apiKeys[e].secret}
941
- path={`apiKeys.${e}.secret`}
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 = Object.values(data.apiKeys).filter(
1130
- (e) => e.key || e.secret
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}/3 configured
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={deploying ? Colors.dim : Colors.accent}
1230
- bg={deploying ? Colors.border : Colors.card}
1229
+ fg={Colors.accent}
1230
+ bg={Colors.card}
1231
1231
  bold
1232
- onMouseUp={deploying ? undefined : handleDeploy}
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].level).toBe("info");
196
- expect(workerLogs[1].level).toBe("warn");
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 && cfgResult.data == null) {
302
- // Empty/missing agent:config is fine (starts from {}); only hard-fail on I/O errors
303
- // when stderr indicates a real failure and data is unusable.
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
  });
@@ -237,7 +237,7 @@ export function WorkersOverview({ dialog }: WorkersOverviewProps = {}) {
237
237
 
238
238
  let cancelled = false;
239
239
 
240
- (async () => {
240
+ void (async () => {
241
241
  try {
242
242
  const result = await cliBridge.monitorStatus();
243
243
  if (cancelled) return;
@@ -4,18 +4,17 @@
4
4
  */
5
5
 
6
6
  /**
7
- * Hook unit tests — renderer ref, global keyboard registration, polling helpers.
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
- });
@@ -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
  }
@@ -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);