@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
@@ -21,7 +21,11 @@
21
21
  * for any CLI failure, regardless of which view triggered it.
22
22
  */
23
23
  import * as path from "path";
24
- import { getHooxRepoPath, resolveHooxRuntimeRoot } from "@hoox-sh/hoox-shared";
24
+ import {
25
+ getHooxRepoPath,
26
+ resolveHooxRuntimeRoot,
27
+ isTruthyKillSwitchFlag,
28
+ } from "@hoox-sh/hoox-shared";
25
29
  import type { CliResult, CliErrorType, CliErrorDetails } from "../../types";
26
30
  import type {
27
31
  ExecOptions,
@@ -129,6 +133,21 @@ export function tryParseJsonLoose(stdout: string): unknown | null {
129
133
  return null;
130
134
  }
131
135
 
136
+ /** Unwrap `hoox config kv get --json` `{ key, value }` into the stored string. */
137
+ function unwrapKvGetPayload(payload: unknown): string | null | undefined {
138
+ if (payload == null) return undefined;
139
+ if (typeof payload !== "object" || Array.isArray(payload)) return undefined;
140
+ if (!("value" in payload)) return undefined;
141
+ const v = (payload as { value: unknown }).value;
142
+ if (v == null) return null;
143
+ if (typeof v === "string") return v;
144
+ try {
145
+ return JSON.stringify(v);
146
+ } catch {
147
+ return String(v);
148
+ }
149
+ }
150
+
132
151
  /**
133
152
  * Redact CLI argv values that are likely secrets before logging or
134
153
  * surfacing in the status bar (`command` field).
@@ -241,8 +260,8 @@ export function parseDbQueryResult(stdout: string): DbQueryResult {
241
260
  }
242
261
 
243
262
  // Derive column order from the first row's keys.
244
- const columns =
245
- rows.length > 0 && rows[0] !== null ? Object.keys(rows[0]) : [];
263
+ const firstRow = rows[0];
264
+ const columns = firstRow ? Object.keys(firstRow) : [];
246
265
 
247
266
  // wrangler's `meta.duration` is reported in seconds (float). Convert to ms.
248
267
  let executionTimeMs: number | null = null;
@@ -369,6 +388,7 @@ export function parseSecretsList(stdout: string): SecretMetadata[] {
369
388
  if (typeof item === "string") {
370
389
  result.push({
371
390
  name: item,
391
+ worker: envelope.worker,
372
392
  type: inferSecretType(item),
373
393
  source: "config",
374
394
  });
@@ -383,12 +403,13 @@ export function parseSecretsList(stdout: string): SecretMetadata[] {
383
403
  if (typeof parsed === "object" && !Array.isArray(parsed)) {
384
404
  const entries = Object.entries(parsed as Record<string, unknown>);
385
405
  const result: SecretMetadata[] = [];
386
- for (const [, value] of entries) {
406
+ for (const [worker, value] of entries) {
387
407
  if (Array.isArray(value)) {
388
408
  for (const item of value) {
389
409
  if (typeof item === "string") {
390
410
  result.push({
391
411
  name: item,
412
+ worker,
392
413
  type: inferSecretType(item),
393
414
  source: "config",
394
415
  });
@@ -613,14 +634,14 @@ export function parseKillSwitchStatus(
613
634
  if (valueMatch) {
614
635
  const raw = valueMatch[1] ?? null;
615
636
  return {
616
- engaged: raw === "true",
637
+ engaged: isTruthyKillSwitchFlag(raw),
617
638
  rawValue: raw,
618
639
  timestamp,
619
640
  action,
620
641
  };
621
642
  }
622
643
 
623
- // Unknown output — treat as released (fail-open for display, but flag raw)
644
+ // Unknown output — do not claim the switch is released
624
645
  return { engaged: false, rawValue: null, timestamp, action };
625
646
  }
626
647
 
@@ -963,7 +984,22 @@ class CliBridgeImpl {
963
984
  let data: T | null = null;
964
985
  if (options?.json && exitCode === 0 && stdout.trim()) {
965
986
  const parsed = tryParseJsonLoose(stdout);
966
- if (parsed !== null) data = parsed as T;
987
+ if (parsed !== null) {
988
+ data = parsed as T;
989
+ } else {
990
+ return {
991
+ success: false,
992
+ exitCode,
993
+ stdout: this.truncateOutput(stdout),
994
+ stderr: this.truncateOutput(
995
+ stderrResult || "CLI --json output was not valid JSON"
996
+ ),
997
+ data: null,
998
+ duration: performance.now() - start,
999
+ command,
1000
+ errorType: "non-zero-exit",
1001
+ };
1002
+ }
967
1003
  }
968
1004
 
969
1005
  // Classify the failure (success leaves errorType=null).
@@ -1386,16 +1422,24 @@ class CliBridgeImpl {
1386
1422
  tag: `config:kv:get:${key}`,
1387
1423
  }).then((result) => {
1388
1424
  if (!result.success) {
1389
- // Missing key is not a hard error — the CLI exits non-zero with
1390
- // a "not found" message and the viewer should display an empty
1391
- // value rather than an error pill.
1392
- const combined = `${result.stdout}\n${result.stderr}`.toLowerCase();
1393
- if (combined.includes("not found")) {
1425
+ const combined = `${result.stdout}\n${result.stderr}`;
1426
+ const missingKey = /Key\s+"[^"]+"\s+not found in KV namespace/i.test(
1427
+ combined
1428
+ );
1429
+ if (missingKey && result.errorType !== "binary-not-found") {
1394
1430
  return { ...result, data: null, success: true };
1395
1431
  }
1396
1432
  return { ...result, data: null };
1397
1433
  }
1434
+ const fromData = unwrapKvGetPayload(result.data);
1435
+ if (fromData !== undefined) {
1436
+ return { ...result, data: fromData };
1437
+ }
1398
1438
  const cleaned = result.stdout.replace(/\n$/, "").trim();
1439
+ const fromStdout = unwrapKvGetPayload(tryParseJsonLoose(cleaned));
1440
+ if (fromStdout !== undefined) {
1441
+ return { ...result, data: fromStdout };
1442
+ }
1399
1443
  return {
1400
1444
  ...result,
1401
1445
  data: cleaned.length > 0 ? cleaned : null,
@@ -270,7 +270,10 @@ export function agentChatStream(
270
270
 
271
271
  while (!abortController.signal.aborted) {
272
272
  const { done, value } = await reader.read();
273
- if (done) break;
273
+ if (done) {
274
+ completed = true;
275
+ break;
276
+ }
274
277
 
275
278
  buffer += decoder.decode(value, { stream: true });
276
279
  const lines = buffer.split("\n");
@@ -80,11 +80,7 @@ export interface KillSwitchStatus {
80
80
  * - no data → "unknown"
81
81
  */
82
82
  export type QueueDepthStatus =
83
- | "healthy"
84
- | "backlogged"
85
- | "critical"
86
- | "paused"
87
- | "unknown";
83
+ "healthy" | "backlogged" | "critical" | "paused" | "unknown";
88
84
 
89
85
  /** Per-queue depth info. See {@link QueueDepthStatus} for status semantics. */
90
86
  export interface QueueDepth {
@@ -139,8 +135,7 @@ export interface DbQueryResult {
139
135
  * command to the local `hoox` binary.
140
136
  */
141
137
  export type SqlValidationResult =
142
- | { readonly: true }
143
- | { readonly: false; reason: string };
138
+ { readonly: true } | { readonly: false; reason: string };
144
139
 
145
140
  /**
146
141
  * A single Cloudflare Workers secret metadata (read-only, no values exposed).
@@ -150,8 +145,10 @@ export type SqlValidationResult =
150
145
  * (`hoox config secrets list`) only surfaces names/types anyway.
151
146
  */
152
147
  export interface SecretMetadata {
153
- /** Secret name / key, e.g. "BINANCE_KEY_BINDING" or "OPENAI_API_KEY". */
148
+ /** Secret name / key, e.g. "EXCHANGE_KEY_BINDING" or "OPENAI_API_KEY". */
154
149
  name: string;
150
+ /** Worker that declared the secret (all-workers list). */
151
+ worker?: string;
155
152
  /**
156
153
  * Inferred type based on naming conventions. Cloudflare secrets store
157
154
  * does not expose per-secret type metadata, so we derive it from the
@@ -239,7 +236,7 @@ export interface KvKeySnapshot {
239
236
  export interface ModelHealth {
240
237
  /** Provider name, e.g. "Workers AI", "OpenAI", "Anthropic", "Google", "Azure" */
241
238
  name: string;
242
- /** Provider-specific model identifier, e.g. "@cf/meta/llama-3.1-8b-instruct" */
239
+ /** Provider-specific model identifier, e.g. "@cf/meta/llama-3.1-8b-instruct-fp8" */
243
240
  model: string;
244
241
  /** Operational state */
245
242
  status: "online" | "degraded" | "offline";
@@ -624,8 +624,18 @@ describe("parseSecretsList", () => {
624
624
  })
625
625
  );
626
626
  expect(out).toEqual([
627
- { name: "API_KEY", type: "api_key", source: "config" },
628
- { name: "auth_token", type: "token", source: "config" },
627
+ {
628
+ name: "API_KEY",
629
+ worker: "trade-worker",
630
+ type: "api_key",
631
+ source: "config",
632
+ },
633
+ {
634
+ name: "auth_token",
635
+ worker: "trade-worker",
636
+ type: "token",
637
+ source: "config",
638
+ },
629
639
  ]);
630
640
  });
631
641
 
@@ -1118,7 +1128,10 @@ describe("CliBridge methods (exec stubbed)", () => {
1118
1128
  });
1119
1129
 
1120
1130
  it("configKvGet treats not found as success null", async () => {
1121
- bridge.exec = (async () => failExec("key not found")) as typeof bridge.exec;
1131
+ bridge.exec = (async () =>
1132
+ failExec(
1133
+ 'Key "missing" not found in KV namespace'
1134
+ )) as typeof bridge.exec;
1122
1135
  const result = (await bridge.configKvGet("missing")) as CliResult<
1123
1136
  string | null
1124
1137
  >;
@@ -1127,7 +1140,10 @@ describe("CliBridge methods (exec stubbed)", () => {
1127
1140
  });
1128
1141
 
1129
1142
  it("configKvGet returns trimmed value", async () => {
1130
- bridge.exec = (async () => okExec("hello\n")) as typeof bridge.exec;
1143
+ bridge.exec = (async () =>
1144
+ okExec(
1145
+ JSON.stringify({ key: "k", value: "hello" })
1146
+ )) as typeof bridge.exec;
1131
1147
  const result = (await bridge.configKvGet("k")) as CliResult<string | null>;
1132
1148
  expect(result.data).toBe("hello");
1133
1149
  });
@@ -59,8 +59,8 @@ export async function copyViaSystemClipboard(
59
59
  stdout: "ignore",
60
60
  stderr: "ignore",
61
61
  });
62
- proc.stdin.write(text);
63
- proc.stdin.end();
62
+ void proc.stdin.write(text);
63
+ void proc.stdin.end();
64
64
 
65
65
  let timedOut = false;
66
66
  const timer = setTimeout(() => {
@@ -134,8 +134,8 @@ describe("useServiceStore", () => {
134
134
 
135
135
  const state = useServiceStore.getState();
136
136
  expect(state.workers).toHaveLength(2);
137
- expect(state.workers[0].name).toBe("alpha");
138
- expect(state.workers[1].name).toBe("beta");
137
+ expect(state.workers[0]?.name).toBe("alpha");
138
+ expect(state.workers[1]?.name).toBe("beta");
139
139
  expect(state.lastUpdated).toBeGreaterThan(0);
140
140
  });
141
141
 
@@ -714,13 +714,13 @@ describe("useServiceStore", () => {
714
714
 
715
715
  const alerts = useServiceStore.getState().alerts;
716
716
  expect(alerts).toHaveLength(1);
717
- expect(alerts[0].severity).toBe("error");
718
- expect(alerts[0].type).toBe("connection");
719
- expect(alerts[0].message).toContain("CLI failure");
720
- expect(alerts[0].message).toContain("Build failed: missing import");
721
- expect(alerts[0].acknowledged).toBe(false);
722
- expect(alerts[0].id).toBeTruthy();
723
- expect(alerts[0].timestamp).toBeGreaterThan(0);
717
+ expect(alerts[0]?.severity).toBe("error");
718
+ expect(alerts[0]?.type).toBe("connection");
719
+ expect(alerts[0]?.message).toContain("CLI failure");
720
+ expect(alerts[0]?.message).toContain("Build failed: missing import");
721
+ expect(alerts[0]?.acknowledged).toBe(false);
722
+ expect(alerts[0]?.id).toBeTruthy();
723
+ expect(alerts[0]?.timestamp).toBeGreaterThan(0);
724
724
  });
725
725
 
726
726
  it("appends multiple alerts without dropping older ones under the cap", () => {
@@ -733,8 +733,8 @@ describe("useServiceStore", () => {
733
733
 
734
734
  const alerts = useServiceStore.getState().alerts;
735
735
  expect(alerts).toHaveLength(2);
736
- expect(alerts[0].message).toContain("Build failed: missing import");
737
- expect(alerts[1].message).toContain("second failure");
736
+ expect(alerts[0]?.message).toContain("Build failed: missing import");
737
+ expect(alerts[1]?.message).toContain("second failure");
738
738
  });
739
739
 
740
740
  it("respects the MAX_ALERTS cap of 100 entries", () => {
@@ -747,7 +747,7 @@ describe("useServiceStore", () => {
747
747
  expect(useServiceStore.getState().alerts).toHaveLength(100);
748
748
  // Newest entries survive
749
749
  const alerts = useServiceStore.getState().alerts;
750
- expect(alerts[alerts.length - 1].message).toContain("failure 109");
750
+ expect(alerts[alerts.length - 1]?.message).toContain("failure 109");
751
751
  });
752
752
 
753
753
  it("also works when stderr is empty (falls back to command summary)", () => {
@@ -761,21 +761,24 @@ describe("useServiceStore", () => {
761
761
  expect(lastError).toBe(cliFailure.command);
762
762
  // And the alert still contains the command
763
763
  const alerts = useServiceStore.getState().alerts;
764
- expect(alerts[0].message).toContain("CLI failure");
765
- expect(alerts[0].message).toContain("hoox deploy all");
764
+ expect(alerts[0]?.message).toContain("CLI failure");
765
+ expect(alerts[0]?.message).toContain("hoox deploy all");
766
766
  });
767
767
  });
768
768
 
769
769
  // ── Ring Buffer Limits ────────────────────────────────────────────────────
770
770
 
771
771
  describe("ring buffer limits", () => {
772
+ // NOTE: each push through the immer-backed store costs a few ms, so
773
+ // these bulk-fill loops get explicit timeouts instead of bun's 5s
774
+ // default (they time out on loaded machines otherwise).
772
775
  it("caps trade stream at 500 entries", () => {
773
776
  const store = useServiceStore.getState();
774
777
  for (let i = 0; i < 600; i++) {
775
778
  store.pushTrade(makeTrade({ id: `t${i}` }));
776
779
  }
777
780
  expect(useServiceStore.getState().tradeStream).toHaveLength(500);
778
- });
781
+ }, 30000);
779
782
 
780
783
  it("trade stream keeps newest entries when exceeding cap", () => {
781
784
  const store = useServiceStore.getState();
@@ -788,7 +791,7 @@ describe("useServiceStore", () => {
788
791
  expect(stream).toHaveLength(500);
789
792
  // "first" should be dropped
790
793
  expect(stream.find((t) => t.id === "first")).toBeUndefined();
791
- });
794
+ }, 30000);
792
795
 
793
796
  it("caps alerts at 100 entries", () => {
794
797
  for (let i = 0; i < 150; i++) {
@@ -818,7 +821,7 @@ describe("useServiceStore", () => {
818
821
  useServiceStore.getState().pushLog(makeLog({ id: `l${i}` }));
819
822
  }
820
823
  expect(useServiceStore.getState().logs).toHaveLength(1000);
821
- });
824
+ }, 30000);
822
825
 
823
826
  it("logs buffer keeps newest entries when exceeding cap", () => {
824
827
  useServiceStore.getState().pushLog(makeLog({ id: "first-log" }));
@@ -828,7 +831,7 @@ describe("useServiceStore", () => {
828
831
  expect(
829
832
  useServiceStore.getState().logs.find((l) => l.id === "first-log")
830
833
  ).toBeUndefined();
831
- });
834
+ }, 30000);
832
835
 
833
836
  it("trade stream preserves order (FIFO)", () => {
834
837
  const store = useServiceStore.getState();
@@ -837,9 +840,9 @@ describe("useServiceStore", () => {
837
840
  store.pushTrade(makeTrade({ id: "c", timestamp: 3 }));
838
841
 
839
842
  const stream = useServiceStore.getState().tradeStream;
840
- expect(stream[0].id).toBe("a");
841
- expect(stream[1].id).toBe("b");
842
- expect(stream[2].id).toBe("c");
843
+ expect(stream[0]?.id).toBe("a");
844
+ expect(stream[1]?.id).toBe("b");
845
+ expect(stream[2]?.id).toBe("c");
843
846
  });
844
847
  });
845
848
 
@@ -854,7 +857,7 @@ describe("useServiceStore", () => {
854
857
  useServiceStore.getState().addAlert(alert);
855
858
 
856
859
  expect(useServiceStore.getState().alerts).toHaveLength(1);
857
- expect(useServiceStore.getState().alerts[0].message).toBe(
860
+ expect(useServiceStore.getState().alerts[0]?.message).toBe(
858
861
  "CPU threshold exceeded"
859
862
  );
860
863
  });
@@ -947,7 +950,7 @@ describe("useServiceStore", () => {
947
950
  it("subscribes to SSE trade stream", async () => {
948
951
  await useServiceStore.getState().streamTrades();
949
952
  expect(subscribeSSEMock).toHaveBeenCalledTimes(1);
950
- expect(subscribeSSEMock.mock.calls[0][0]).toBe("/v1/trades/stream");
953
+ expect(subscribeSSEMock.mock.calls[0]?.[0]).toBe("/v1/trades/stream");
951
954
  });
952
955
 
953
956
  it("streamTrades does not throw on SSE connection failure", async () => {
@@ -971,7 +974,7 @@ describe("useServiceStore", () => {
971
974
  const trade = makeTrade({ id: "sse-t1", symbol: "ETH" });
972
975
  emitSseEvent(trade, "/v1/trades");
973
976
  expect(useServiceStore.getState().tradeStream).toHaveLength(1);
974
- expect(useServiceStore.getState().tradeStream[0].id).toBe("sse-t1");
977
+ expect(useServiceStore.getState().tradeStream[0]?.id).toBe("sse-t1");
975
978
  });
976
979
  });
977
980
 
@@ -979,7 +982,7 @@ describe("useServiceStore", () => {
979
982
  it("subscribes to SSE log stream", async () => {
980
983
  await useServiceStore.getState().streamLogs();
981
984
  expect(subscribeSSEMock).toHaveBeenCalledTimes(1);
982
- expect(subscribeSSEMock.mock.calls[0][0]).toBe("/v1/logs/stream");
985
+ expect(subscribeSSEMock.mock.calls[0]?.[0]).toBe("/v1/logs/stream");
983
986
  });
984
987
 
985
988
  it("pushes logs from SSE events into the ring buffer", async () => {
@@ -987,7 +990,7 @@ describe("useServiceStore", () => {
987
990
  const log = makeLog({ id: "sse-l1", message: "from-sse" });
988
991
  emitSseEvent(log, "/v1/logs");
989
992
  expect(useServiceStore.getState().logs).toHaveLength(1);
990
- expect(useServiceStore.getState().logs[0].message).toBe("from-sse");
993
+ expect(useServiceStore.getState().logs[0]?.message).toBe("from-sse");
991
994
  });
992
995
  });
993
996
 
@@ -63,6 +63,7 @@ describe("session restore / save", () => {
63
63
  expect(session.activeView).toBe("dashboard");
64
64
  expect(session.sidebarExpanded).toBe(true);
65
65
  expect(session.lastData).toBe(0);
66
+ expect(session.fromDisk).toBe(false);
66
67
  });
67
68
 
68
69
  it("round-trips every ViewId (all 16)", async () => {
@@ -74,6 +75,7 @@ describe("session restore / save", () => {
74
75
  expect(restored.sidebarExpanded).toBe(true);
75
76
  expect(restored.windowSize).toEqual({ cols: 100, rows: 40 });
76
77
  expect(restored.lastData).toBe(12345);
78
+ expect(restored.fromDisk).toBe(true);
77
79
  }
78
80
  });
79
81
 
package/src/test-setup.ts CHANGED
@@ -65,7 +65,7 @@ try {
65
65
  // - The Temporal Dead Zone race condition is avoided because we've
66
66
  // already pre-loaded @opentui/core above
67
67
 
68
- mock.module("@opentui/core/testing", () => {
68
+ void mock.module("@opentui/core/testing", () => {
69
69
  if (coreAvailable && realCreateTestRenderer) {
70
70
  return {
71
71
  // Spread any other exports from the real module (setRendererCapabilities,
@@ -157,10 +157,7 @@ installSpawnShim();
157
157
  //
158
158
  // Per-test overrides: import { cliBridgeDouble, resetCliBridgeDouble } from
159
159
  // "./cli-bridge-test-double" and use mockResolvedValue / mockImplementation.
160
- import {
161
- cliBridgeDouble,
162
- createCliBridgeModuleMock,
163
- } from "./cli-bridge-test-double";
160
+ import { createCliBridgeModuleMock } from "./cli-bridge-test-double";
164
161
 
165
162
  const cliBridgeModule = await createCliBridgeModuleMock();
166
163
 
@@ -180,7 +177,7 @@ function installCliBridgeDouble(): void {
180
177
  ];
181
178
  for (const p of paths) {
182
179
  try {
183
- mock.module(p, () => cliBridgeModule);
180
+ void mock.module(p, () => cliBridgeModule);
184
181
  } catch {
185
182
  // Specifier may be invalid in some environments — ignore
186
183
  }
@@ -189,11 +186,6 @@ function installCliBridgeDouble(): void {
189
186
 
190
187
  installCliBridgeDouble();
191
188
 
192
- // Expose for tests that need the double without importing the helper path
193
- (
194
- globalThis as unknown as { __hooxCliBridgeDouble?: typeof cliBridgeDouble }
195
- ).__hooxCliBridgeDouble = cliBridgeDouble;
196
-
197
189
  // ── Shared network doubles (api-client + SSE) ────────────────────────────────
198
190
  //
199
191
  // service-store (and views that call fetchWorkers / stream*) must not hit a
@@ -257,14 +249,14 @@ async function installNetworkDoubles(): Promise<void> {
257
249
 
258
250
  for (const p of apiPaths) {
259
251
  try {
260
- mock.module(p, apiFactory);
252
+ void mock.module(p, apiFactory);
261
253
  } catch {
262
254
  // ignore bad specifier
263
255
  }
264
256
  }
265
257
  for (const p of ssePaths) {
266
258
  try {
267
- mock.module(p, sseFactory);
259
+ void mock.module(p, sseFactory);
268
260
  } catch {
269
261
  // ignore
270
262
  }
package/src/test-utils.ts CHANGED
@@ -20,7 +20,7 @@
20
20
  */
21
21
 
22
22
  import type { ReactNode } from "react";
23
- import type { ViewId } from "@hoox-sh/hoox-shared";
23
+ import { VIEW_IDS, type ViewId } from "@hoox-sh/hoox-shared";
24
24
 
25
25
  export {
26
26
  cliBridgeDouble,
@@ -43,35 +43,15 @@ export {
43
43
  sseSubscriptions,
44
44
  } from "./network-test-double";
45
45
 
46
- /** Canonical 16 ViewIds — keep aligned with types.ts + view-registry. */
47
- export const ALL_VIEW_IDS: ViewId[] = [
48
- "dashboard",
49
- "workers",
50
- "worker-detail",
51
- "trade-monitor",
52
- "logs-viewer",
53
- "service-manager",
54
- "config-editor",
55
- "setup-wizard",
56
- "settings",
57
- "queue-depth",
58
- "kv-viewer",
59
- "secrets-viewer",
60
- "db-query",
61
- "ai-chat",
62
- "edge-topology",
63
- "worker-settings",
64
- ];
46
+ /** Canonical ViewIds — same tuple as shared `VIEW_IDS`. */
47
+ export const ALL_VIEW_IDS: readonly ViewId[] = VIEW_IDS;
65
48
 
66
49
  // ── Type Aliases ─────────────────────────────────────────────────────────────
67
50
 
68
51
  export type WorkerStatus = "operational" | "degraded" | "down";
69
52
  export type LogLevel = "debug" | "info" | "warn" | "error";
70
53
  export type ConnectionStatus =
71
- | "connected"
72
- | "polling"
73
- | "offline"
74
- | "reconnecting";
54
+ "connected" | "polling" | "offline" | "reconnecting";
75
55
  export type TradeSide = "buy" | "sell";
76
56
 
77
57
  export interface TestWorkerInfo {
package/src/types.ts CHANGED
@@ -7,34 +7,19 @@
7
7
  * TUI-specific types — Navigation, modals, keyboard shortcuts.
8
8
  */
9
9
 
10
- import type { CliErrorType, CliErrorDetails } from "@hoox-sh/hoox-shared";
10
+ import {
11
+ VIEW_IDS,
12
+ type ViewId,
13
+ type CliErrorType,
14
+ type CliErrorDetails,
15
+ } from "@hoox-sh/hoox-shared";
11
16
 
12
17
  // Re-export shared CLI error types for convenience so consumers can
13
18
  // import everything from the tui/types module.
14
- export type { CliErrorType, CliErrorDetails };
15
-
16
- /** All primary views registered with the TUI. */
17
- export const ALL_VIEWS = [
18
- "dashboard",
19
- "workers",
20
- "worker-detail",
21
- "trade-monitor",
22
- "logs-viewer",
23
- "service-manager",
24
- "config-editor",
25
- "setup-wizard",
26
- "settings",
27
- "queue-depth",
28
- "kv-viewer",
29
- "secrets-viewer",
30
- "db-query",
31
- "ai-chat",
32
- "edge-topology",
33
- "worker-settings",
34
- ] as const;
35
-
36
- export type ViewId = (typeof ALL_VIEWS)[number];
19
+ export type { CliErrorType, CliErrorDetails, ViewId };
20
+ export { VIEW_IDS as ALL_VIEWS };
37
21
 
22
+ /** Sidebar / tab labels for each {@link ViewId}. */
38
23
  export const VIEW_LABELS: Record<ViewId, string> = {
39
24
  dashboard: "DASHBOARD",
40
25
  workers: "WORKERS",
@@ -54,29 +39,6 @@ export const VIEW_LABELS: Record<ViewId, string> = {
54
39
  "worker-settings": "W SETTINGS",
55
40
  };
56
41
 
57
- export const VIEW_ORDER: ViewId[] = [
58
- "dashboard",
59
- "workers",
60
- "worker-detail",
61
- "trade-monitor",
62
- "logs-viewer",
63
- "service-manager",
64
- "config-editor",
65
- "setup-wizard",
66
- "settings",
67
- "queue-depth",
68
- "kv-viewer",
69
- "secrets-viewer",
70
- "db-query",
71
- "ai-chat",
72
- "edge-topology",
73
- "worker-settings",
74
- ];
75
-
76
- export function viewIndex(view: ViewId): number {
77
- return VIEW_ORDER.indexOf(view);
78
- }
79
-
80
42
  export type ModalType = "confirm" | "choice" | "loading" | "prompt";
81
43
 
82
44
  export interface ModalState {
@@ -31,10 +31,15 @@ function hexToRgb(hex: string): { r: number; g: number; b: number } | null {
31
31
  /^#?([0-9A-Fa-f]{2})([0-9A-Fa-f]{2})([0-9A-Fa-f]{2})$/
32
32
  );
33
33
  if (!match) return null;
34
+ const rHex = match[1];
35
+ const gHex = match[2];
36
+ const bHex = match[3];
37
+ if (rHex === undefined || gHex === undefined || bHex === undefined)
38
+ return null;
34
39
  return {
35
- r: parseInt(match[1], 16),
36
- g: parseInt(match[2], 16),
37
- b: parseInt(match[3], 16),
40
+ r: parseInt(rHex, 16),
41
+ g: parseInt(gHex, 16),
42
+ b: parseInt(bHex, 16),
38
43
  };
39
44
  }
40
45
 
@@ -4,7 +4,7 @@
4
4
  */
5
5
 
6
6
  import { describe, it, expect } from "bun:test";
7
- import type { ViewId } from "@hoox-sh/hoox-shared";
7
+ import { VIEW_IDS, type ViewId } from "@hoox-sh/hoox-shared";
8
8
  import {
9
9
  VIEW_REGISTRY,
10
10
  REGISTERED_VIEW_IDS,
@@ -19,24 +19,7 @@ import {
19
19
  ALL_PALETTE_COMMANDS,
20
20
  } from "./view-registry";
21
21
 
22
- const ALL_VIEWS: ViewId[] = [
23
- "dashboard",
24
- "workers",
25
- "worker-detail",
26
- "trade-monitor",
27
- "logs-viewer",
28
- "service-manager",
29
- "config-editor",
30
- "setup-wizard",
31
- "settings",
32
- "queue-depth",
33
- "kv-viewer",
34
- "secrets-viewer",
35
- "db-query",
36
- "ai-chat",
37
- "edge-topology",
38
- "worker-settings",
39
- ];
22
+ const ALL_VIEWS: ViewId[] = [...VIEW_IDS];
40
23
 
41
24
  describe("view-registry", () => {
42
25
  it("covers every ViewId exactly once in order", () => {