@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
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Copyright (c) 2026 HOOX · HOOX · jango-blockchained (hoox-sh)
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Shell overlay helpers — command palette / quit modal ownership of keys.
8
+ * View-local keyboard handlers should no-op while an overlay is open so
9
+ * typing in the palette (or confirming quit) does not fire view shortcuts.
10
+ */
11
+ import { useKeyboard } from "@opentui/react";
12
+ import { useUIStore } from "@hoox-sh/hoox-shared";
13
+
14
+ /** True when palette or a UI-store modal owns the keyboard. */
15
+ export function isShellOverlayOpen(): boolean {
16
+ const s = useUIStore.getState();
17
+ return Boolean(s.commandPaletteOpen || s.modal);
18
+ }
19
+
20
+ /** Key shape from OpenTUI; fields optional at the type boundary. */
21
+ export type ViewKeyEvent = {
22
+ name?: string;
23
+ ctrl?: boolean;
24
+ alt?: boolean;
25
+ meta?: boolean;
26
+ shift?: boolean;
27
+ sequence?: string;
28
+ /** Some OpenTUI builds expose the raw character as `raw`. */
29
+ raw?: string;
30
+ };
31
+
32
+ /**
33
+ * View-scoped keyboard hook: skips the handler while shell overlays are open.
34
+ * Drop-in for `@opentui/react` `useKeyboard` in primary views.
35
+ */
36
+ export function useViewKeyboard(handler: (key: ViewKeyEvent) => void): void {
37
+ useKeyboard((key) => {
38
+ if (isShellOverlayOpen()) return;
39
+ handler(key as ViewKeyEvent);
40
+ });
41
+ }
@@ -18,6 +18,89 @@ export interface UsePollingOptions {
18
18
  immediate?: boolean;
19
19
  }
20
20
 
21
+ /** Max poll delay after repeated failures (ms). Exported for unit tests. */
22
+ export const POLLING_MAX_BACKOFF_MS = 16_000;
23
+
24
+ /**
25
+ * Exponential backoff for poll failures, capped at {@link POLLING_MAX_BACKOFF_MS}.
26
+ * Exported for unit tests (pure).
27
+ */
28
+ export function computePollingBackoff(
29
+ baseIntervalMs: number,
30
+ retryCount: number,
31
+ maxBackoffMs: number = POLLING_MAX_BACKOFF_MS
32
+ ): number {
33
+ return Math.min(baseIntervalMs * Math.pow(2, retryCount), maxBackoffMs);
34
+ }
35
+
36
+ /**
37
+ * Non-React poll scheduler used by {@link usePolling}.
38
+ * Exported so unit tests can cover success/failure/cancel without a renderer.
39
+ */
40
+ export function createPollingController(options: {
41
+ getCallback: () => () => Promise<void>;
42
+ getIntervalMs: () => number;
43
+ immediate?: boolean;
44
+ maxBackoffMs?: number;
45
+ /** Mutable retry counter (shared with the React ref when used from the hook). */
46
+ retryCountRef?: { current: number };
47
+ }): { start: () => void; stop: () => void; getRetryCount: () => number } {
48
+ const {
49
+ getCallback,
50
+ getIntervalMs,
51
+ immediate = true,
52
+ maxBackoffMs = POLLING_MAX_BACKOFF_MS,
53
+ retryCountRef = { current: 0 },
54
+ } = options;
55
+
56
+ let timeoutId: ReturnType<typeof setTimeout> | undefined;
57
+ let cancelled = false;
58
+
59
+ const poll = async () => {
60
+ if (cancelled) return;
61
+ try {
62
+ await getCallback()();
63
+ if (cancelled) return;
64
+ retryCountRef.current = 0;
65
+ } catch {
66
+ if (cancelled) return;
67
+ retryCountRef.current++;
68
+ }
69
+ // cancelled is already checked in both branches above (no await between
70
+ // those checks and here), so schedule the next tick directly.
71
+ // Read fresh interval on every iteration so config changes take effect.
72
+ const currentInterval = getIntervalMs();
73
+ const backoff = computePollingBackoff(
74
+ currentInterval,
75
+ retryCountRef.current,
76
+ maxBackoffMs
77
+ );
78
+ timeoutId = setTimeout(() => {
79
+ void poll();
80
+ }, backoff);
81
+ };
82
+
83
+ return {
84
+ getRetryCount: () => retryCountRef.current,
85
+ start: () => {
86
+ cancelled = false;
87
+ if (immediate) {
88
+ void poll();
89
+ } else {
90
+ const initialInterval = getIntervalMs();
91
+ timeoutId = setTimeout(() => {
92
+ void poll();
93
+ }, initialInterval);
94
+ }
95
+ },
96
+ stop: () => {
97
+ cancelled = true;
98
+ if (timeoutId !== undefined) clearTimeout(timeoutId);
99
+ timeoutId = undefined;
100
+ },
101
+ };
102
+ }
103
+
21
104
  export function usePolling(options: UsePollingOptions): void {
22
105
  const { callback, enabled = true, immediate = true } = options;
23
106
  const intervalMs = useConfigStore((s) => s.refreshIntervalMs);
@@ -32,42 +115,15 @@ export function usePolling(options: UsePollingOptions): void {
32
115
  return;
33
116
  }
34
117
 
35
- let timeoutId: ReturnType<typeof setTimeout> | undefined;
36
- let cancelled = false;
37
- const MAX_BACKOFF = 16_000;
38
-
39
- const poll = async () => {
40
- if (cancelled) return;
41
- try {
42
- await callbackRef.current();
43
- if (cancelled) return;
44
- retryCount.current = 0;
45
- } catch {
46
- if (cancelled) return;
47
- retryCount.current++;
48
- }
49
- if (cancelled) return;
50
- // Read fresh from store on every iteration so config changes take effect
51
- const currentInterval = useConfigStore.getState().refreshIntervalMs;
52
- const backoff = Math.min(
53
- currentInterval * Math.pow(2, retryCount.current),
54
- MAX_BACKOFF
55
- );
56
- timeoutId = setTimeout(poll, backoff);
57
- };
58
-
59
- if (immediate) {
60
- void poll();
61
- } else {
62
- const initialInterval = useConfigStore.getState().refreshIntervalMs;
63
- timeoutId = setTimeout(() => {
64
- void poll();
65
- }, initialInterval);
66
- }
67
-
118
+ const controller = createPollingController({
119
+ getCallback: () => callbackRef.current,
120
+ getIntervalMs: () => useConfigStore.getState().refreshIntervalMs,
121
+ immediate,
122
+ retryCountRef: retryCount,
123
+ });
124
+ controller.start();
68
125
  return () => {
69
- cancelled = true;
70
- if (timeoutId !== undefined) clearTimeout(timeoutId);
126
+ controller.stop();
71
127
  };
72
128
  }, [intervalMs, enabled, immediate]);
73
129
  }
@@ -11,8 +11,25 @@
11
11
  */
12
12
  import { useServiceStore } from "@hoox-sh/hoox-shared";
13
13
 
14
- export function useServiceData<T>(
15
- selector: (state: ReturnType<typeof useServiceStore.getState>) => T
16
- ): T {
17
- return useServiceStore(selector);
14
+ export type ServiceStoreState = ReturnType<typeof useServiceStore.getState>;
15
+ export type ServiceStoreSelector<T> = (state: ServiceStoreState) => T;
16
+
17
+ /**
18
+ * Store hook used by {@link useServiceData}. Production points at
19
+ * `useServiceStore`; unit tests may swap this for a pure selector runner
20
+ * via {@link __setServiceStoreHookForTests}.
21
+ */
22
+ let storeHook: <T>(selector: ServiceStoreSelector<T>) => T =
23
+ useServiceStore as <T>(selector: ServiceStoreSelector<T>) => T;
24
+
25
+ /** @internal Test-only seam — pass `null` to restore the real Zustand hook. */
26
+ export function __setServiceStoreHookForTests(
27
+ hook: (<T>(selector: ServiceStoreSelector<T>) => T) | null
28
+ ): void {
29
+ storeHook =
30
+ hook ?? (useServiceStore as <T>(selector: ServiceStoreSelector<T>) => T);
31
+ }
32
+
33
+ export function useServiceData<T>(selector: ServiceStoreSelector<T>): T {
34
+ return storeHook(selector);
18
35
  }
package/src/main.tsx CHANGED
@@ -102,6 +102,12 @@ async function main() {
102
102
  // Set renderer ref so hooks + components can access it via getRendererRef()
103
103
  setRendererRef(renderer);
104
104
 
105
+ // Note: @opentui-ui/toast ToasterRenderable is intentionally NOT mounted.
106
+ // toast@0.0.5 peers on ancient @opentui and nests a second core that
107
+ // re-registers OPENTUI_FORCE_WCWIDTH (crash on global install). Connection
108
+ // feedback still surfaces via the status bar + service-store alerts.
109
+ // Toast helpers in components/ui/toast.tsx are fail-closed no-ops.
110
+
105
111
  // Mouse drag-select → clipboard (OSC 52 + system tools)
106
112
  enableAutoCopyOnSelection(
107
113
  renderer as unknown as import("./services/clipboard").ClipboardRenderer
@@ -178,7 +178,8 @@ function killProc(
178
178
  * [ { "results": [ {...}, {...} ], "success": true,
179
179
  * "meta": { "duration": 0.123, "rows_read": 42, ... } } ]
180
180
  */
181
- function parseDbQueryResult(stdout: string): DbQueryResult {
181
+ /** Exported for unit tests — pure stdout → DbQueryResult normalizer. */
182
+ export function parseDbQueryResult(stdout: string): DbQueryResult {
182
183
  const cleaned = stdout.trim();
183
184
  if (!cleaned) {
184
185
  return {
@@ -266,7 +267,7 @@ function parseDbQueryResult(stdout: string): DbQueryResult {
266
267
  }
267
268
 
268
269
  /** Compute the {@link QueueDepthStatus} for a given depth + paused flag. */
269
- function deriveQueueDepthStatus(
270
+ export function deriveQueueDepthStatus(
270
271
  depth: number,
271
272
  paused: boolean
272
273
  ): QueueDepthStatus {
@@ -285,7 +286,8 @@ function deriveQueueDepthStatus(
285
286
  * Expected envelope shape:
286
287
  * { "queues": [ { "queue_name": "...", "producers_total_count": 2, ... } ] }
287
288
  */
288
- function parseQueueDepths(stdout: string): QueueDepth[] {
289
+ /** Exported for unit tests — pure queue-depth envelope parser. */
290
+ export function parseQueueDepths(stdout: string): QueueDepth[] {
289
291
  const timestamp = new Date().toISOString();
290
292
  const cleaned = stdout.trim();
291
293
  if (!cleaned) return [];
@@ -349,7 +351,8 @@ function parseQueueDepths(stdout: string): QueueDepth[] {
349
351
  * Values are intentionally omitted — the viewer is strictly read-only.
350
352
  * The parser never throws on malformed input.
351
353
  */
352
- function parseSecretsList(stdout: string): SecretMetadata[] {
354
+ /** Exported for unit tests — pure secrets-list envelope parser. */
355
+ export function parseSecretsList(stdout: string): SecretMetadata[] {
353
356
  const cleaned = stdout.trim();
354
357
  if (!cleaned) return [];
355
358
 
@@ -357,12 +360,8 @@ function parseSecretsList(stdout: string): SecretMetadata[] {
357
360
  if (parsed === null) return [];
358
361
 
359
362
  // Single-worker envelope: { worker: string, secrets: string[] }
360
- if (
361
- parsed !== null &&
362
- typeof parsed === "object" &&
363
- "worker" in parsed &&
364
- "secrets" in parsed
365
- ) {
363
+ // `parsed` is non-null after the early return above.
364
+ if (typeof parsed === "object" && "worker" in parsed && "secrets" in parsed) {
366
365
  const envelope = parsed as { worker: string; secrets: unknown };
367
366
  if (Array.isArray(envelope.secrets)) {
368
367
  const result: SecretMetadata[] = [];
@@ -381,7 +380,7 @@ function parseSecretsList(stdout: string): SecretMetadata[] {
381
380
  }
382
381
 
383
382
  // All-workers envelope: Record<string, string[]>
384
- if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) {
383
+ if (typeof parsed === "object" && !Array.isArray(parsed)) {
385
384
  const entries = Object.entries(parsed as Record<string, unknown>);
386
385
  const result: SecretMetadata[] = [];
387
386
  for (const [, value] of entries) {
@@ -408,7 +407,8 @@ function parseSecretsList(stdout: string): SecretMetadata[] {
408
407
  * Cloudflare secrets store does not expose per-secret type metadata,
409
408
  * so we derive it from the key name suffix.
410
409
  */
411
- function inferSecretType(name: string): SecretMetadata["type"] {
410
+ /** Exported for unit tests — naming-convention secret type inference. */
411
+ export function inferSecretType(name: string): SecretMetadata["type"] {
412
412
  const lower = name.toLowerCase();
413
413
  if (lower.includes("key") || lower.includes("api")) return "api_key";
414
414
  if (lower.includes("token")) return "token";
@@ -450,7 +450,8 @@ interface WranglerKeyRecord {
450
450
  * This parser accepts both. Unknown rows are skipped; the parser never
451
451
  * throws on malformed input — it returns an empty `keys` array instead.
452
452
  */
453
- function parseKvList(
453
+ /** Exported for unit tests — pure KV list + manifest merge. */
454
+ export function parseKvList(
454
455
  stdout: string,
455
456
  manifest: Map<
456
457
  string,
@@ -464,10 +465,10 @@ function parseKvList(
464
465
  if (parsed === null) return [];
465
466
 
466
467
  let records: unknown;
468
+ // `parsed` is non-null after the early return above.
467
469
  if (Array.isArray(parsed)) {
468
470
  records = parsed;
469
471
  } else if (
470
- parsed !== null &&
471
472
  typeof parsed === "object" &&
472
473
  "keys" in parsed &&
473
474
  Array.isArray((parsed as { keys: unknown }).keys)
@@ -510,7 +511,8 @@ function parseKvList(
510
511
  * return a `Map` keyed by key name. Returns an empty map on any failure
511
512
  * — the view still functions, it just cannot flag secret keys.
512
513
  */
513
- function parseKvManifest(
514
+ /** Exported for unit tests — pure KV manifest parser. */
515
+ export function parseKvManifest(
514
516
  stdout: string
515
517
  ): Map<string, { type: "boolean" | "number" | "string"; secret: boolean }> {
516
518
  const map = new Map<
@@ -524,8 +526,8 @@ function parseKvManifest(
524
526
  if (parsed === null) return map;
525
527
 
526
528
  // Manifest shape: { namespace: "CONFIG_KV", keys: [{ key, type, default, ... }] }
529
+ // `parsed` is non-null after the early return above.
527
530
  const keys =
528
- parsed !== null &&
529
531
  typeof parsed === "object" &&
530
532
  "keys" in parsed &&
531
533
  Array.isArray((parsed as { keys: unknown }).keys)
@@ -551,8 +553,8 @@ function parseKvManifest(
551
553
  // eslint-disable-next-line no-control-regex -- intentional: matches ESC (\x1b) sequences
552
554
  const ANSI_PATTERN = /\x1b\[[0-9;]*[A-Za-z]/g;
553
555
 
554
- /** Remove ANSI color escape codes from a string. */
555
- function stripAnsi(input: string): string {
556
+ /** Remove ANSI color escape codes from a string. Exported for unit tests. */
557
+ export function stripAnsi(input: string): string {
556
558
  return input.replace(ANSI_PATTERN, "");
557
559
  }
558
560
 
@@ -579,7 +581,8 @@ interface WranglerQueueRecord {
579
581
  * switches the format for set actions), so we detect the engaged/released
580
582
  * state from the message text.
581
583
  */
582
- function parseKillSwitchStatus(
584
+ /** Exported for unit tests — pure kill-switch status parser. */
585
+ export function parseKillSwitchStatus(
583
586
  action: KillSwitchAction,
584
587
  stdout: string
585
588
  ): KillSwitchStatus {
@@ -1521,7 +1524,8 @@ class CliBridgeImpl {
1521
1524
  /**
1522
1525
  * Parse `hoox pyne health --json` stdout into {@link PyneHealthResult}.
1523
1526
  */
1524
- function parsePyneHealth(stdout: string): PyneHealthResult {
1527
+ /** Exported for unit tests — pure pyne health envelope parser. */
1528
+ export function parsePyneHealth(stdout: string): PyneHealthResult {
1525
1529
  const timestamp = new Date().toISOString();
1526
1530
  const cleaned = stdout.trim();
1527
1531
  if (!cleaned) {
@@ -1567,7 +1571,8 @@ function parsePyneHealth(stdout: string): PyneHealthResult {
1567
1571
  };
1568
1572
  }
1569
1573
 
1570
- function parseAgentHealth(stdout: string): AgentHealthResult {
1574
+ /** Exported for unit tests — pure agent health envelope parser. */
1575
+ export function parseAgentHealth(stdout: string): AgentHealthResult {
1571
1576
  const timestamp = new Date().toISOString();
1572
1577
  const cleaned = stdout.trim();
1573
1578
  if (!cleaned) {
@@ -1624,7 +1629,8 @@ function parseAgentHealth(stdout: string): AgentHealthResult {
1624
1629
  * exists so future manifest changes can light up the namespace column
1625
1630
  * without touching the viewer.
1626
1631
  */
1627
- function extractNamespaceId(_manifestStdout: string): string | null {
1632
+ /** Exported for unit tests — reserved namespace-id extractor (currently always null). */
1633
+ export function extractNamespaceId(_manifestStdout: string): string | null {
1628
1634
  // Reserved for future CLI versions — manifest envelope does not yet
1629
1635
  // surface the namespace ID. Kept as a separate function to keep the
1630
1636
  // test surface stable.
@@ -197,6 +197,33 @@ export function agentChatStream(
197
197
  let completed = false;
198
198
 
199
199
  const finished = (async () => {
200
+ // Match workers/SSE: Bearer + optional CF Access service-token headers
201
+ let authHeaders: Record<string, string> = {};
202
+ let resolvedBase = apiBase;
203
+ try {
204
+ const { resolveOperatorTransportProfile, buildOperatorAuthHeaders } =
205
+ await import("@hoox-sh/hoox-shared");
206
+ const profile = resolveOperatorTransportProfile(process.env as never, {
207
+ configApiUrl: apiBase,
208
+ configApiToken: apiToken,
209
+ });
210
+ authHeaders = buildOperatorAuthHeaders(profile);
211
+ // Prefer profile base when caller passed default localhost but env has remote
212
+ if (apiBase === "http://localhost:8787" && profile.apiBase) {
213
+ resolvedBase = profile.apiBase;
214
+ } else {
215
+ resolvedBase = apiBase.replace(/\/$/, "") || profile.apiBase;
216
+ }
217
+ // Ensure Bearer from explicit apiToken if profile missed it
218
+ if (apiToken && !authHeaders.Authorization) {
219
+ authHeaders.Authorization = `Bearer ${apiToken}`;
220
+ }
221
+ } catch {
222
+ if (apiToken) {
223
+ authHeaders = { Authorization: `Bearer ${apiToken}` };
224
+ }
225
+ }
226
+
200
227
  let attempt = 0;
201
228
 
202
229
  while (
@@ -204,7 +231,7 @@ export function agentChatStream(
204
231
  attempt < SSE_MAX_RECONNECT_ATTEMPTS
205
232
  ) {
206
233
  try {
207
- const url = `${apiBase.replace(/\/$/, "")}/api/agent/chat`;
234
+ const url = `${resolvedBase.replace(/\/$/, "")}/api/agent/chat`;
208
235
  const body = JSON.stringify({
209
236
  messages: params.messages.map((m) => ({
210
237
  role: m.role,
@@ -224,7 +251,7 @@ export function agentChatStream(
224
251
  headers: {
225
252
  "Content-Type": "application/json",
226
253
  Accept: "text/event-stream",
227
- ...(apiToken ? { Authorization: `Bearer ${apiToken}` } : {}),
254
+ ...authHeaders,
228
255
  },
229
256
  body,
230
257
  signal: abortController.signal,