@hoox-sh/hoox-tui 0.3.1 → 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 (53) hide show
  1. package/dist/main.js +1400 -2764
  2. package/package.json +3 -8
  3. package/src/app.tsx +17 -15
  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 +17 -0
  7. package/src/components/ui/dialog.tsx +434 -172
  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.test.tsx +8 -27
  23. package/src/components/views/service-manager.tsx +1 -0
  24. package/src/components/views/settings.test.tsx +2 -7
  25. package/src/components/views/settings.tsx +43 -60
  26. package/src/components/views/setup-wizard.test.tsx +12 -14
  27. package/src/components/views/setup-wizard.tsx +61 -59
  28. package/src/components/views/worker-detail.test.tsx +2 -2
  29. package/src/components/views/worker-settings.tsx +6 -6
  30. package/src/components/views/workers-overview.tsx +1 -1
  31. package/src/hooks/hooks.test.ts +10 -174
  32. package/src/hooks/index.ts +1 -3
  33. package/src/hooks/renderer-ref.ts +1 -0
  34. package/src/hooks/shell-overlay.ts +3 -2
  35. package/src/hooks/use-polling.ts +12 -0
  36. package/src/services/cli-bridge/index.ts +56 -12
  37. package/src/services/cli-bridge/standalone.ts +4 -1
  38. package/src/services/cli-bridge/types.ts +6 -9
  39. package/src/services/cli-bridge.test.ts +20 -4
  40. package/src/services/clipboard.ts +2 -2
  41. package/src/stores/service-store.test.ts +29 -26
  42. package/src/stores/session-restore.test.ts +2 -0
  43. package/src/test-setup.ts +5 -13
  44. package/src/test-utils.ts +4 -24
  45. package/src/types.ts +9 -47
  46. package/src/utils/colors.test.ts +8 -3
  47. package/src/view-registry.test.ts +2 -19
  48. package/src/view-registry.tsx +7 -20
  49. package/src/components/shared/animated-border.tsx +0 -58
  50. package/src/components/shared/keybinding-hint.tsx +0 -37
  51. package/src/components/ui/select.tsx +0 -197
  52. package/src/hooks/use-keyboard.ts +0 -130
  53. package/src/hooks/use-service-data.ts +0 -35
@@ -159,7 +159,7 @@ export function tokenizeTomlLine(line: string): TokenSpan[] {
159
159
  // ─── Syntax Highlighting — JSON ───────────────────────────────────────────────
160
160
 
161
161
  /** Color map for JSON token types. */
162
- const JSON_COLORS: Record<string, { fg: string; bold: boolean }> = {
162
+ const JSON_COLORS = {
163
163
  brace: { fg: Colors.foreground, bold: false },
164
164
  key: { fg: Colors.accent, bold: false },
165
165
  colon: { fg: Colors.foreground, bold: false },
@@ -171,7 +171,7 @@ const JSON_COLORS: Record<string, { fg: string; bold: boolean }> = {
171
171
  whitespace: { fg: Colors.foreground, bold: false },
172
172
  error: { fg: Colors.error, bold: true },
173
173
  comment: { fg: Colors.muted, bold: false },
174
- };
174
+ } as const;
175
175
 
176
176
  /**
177
177
  * Tokenize a single line of JSON text into colored spans.
@@ -202,8 +202,9 @@ export function tokenizeJsonLine(line: string): TokenSpan[] {
202
202
  }
203
203
 
204
204
  // Braces/brackets at start
205
- if ("{]}[".includes(trimmed[0])) {
206
- spans.push({ text: trimmed[0], color: JSON_COLORS.brace.fg, bold: true });
205
+ const firstChar = trimmed[0];
206
+ if (firstChar !== undefined && "{]}[".includes(firstChar)) {
207
+ spans.push({ text: firstChar, color: JSON_COLORS.brace.fg, bold: true });
207
208
  const rest = tokenizeJsonLine(trimmed.slice(1));
208
209
  spans.push(...rest);
209
210
  return spans;
@@ -218,6 +219,7 @@ export function tokenizeJsonLine(line: string): TokenSpan[] {
218
219
  trimmed.endsWith("]")
219
220
  ) {
220
221
  const last = trimmed[trimmed.length - 1];
222
+ if (last === undefined) return spans;
221
223
  const prefix = trimmed.slice(0, -1);
222
224
  const prefixSpans = tokenizeJsonLine(prefix);
223
225
  spans.push(...prefixSpans);
@@ -400,8 +402,8 @@ export function validateSyntax(
400
402
  const colMatch =
401
403
  msg.match(/column\s+(\d+)/i) ?? msg.match(/position\s+(\d+)/i);
402
404
  errors.push({
403
- line: lineMatch ? parseInt(lineMatch[1]) : 1,
404
- column: colMatch ? parseInt(colMatch[1]) : 0,
405
+ line: lineMatch?.[1] !== undefined ? parseInt(lineMatch[1], 10) : 1,
406
+ column: colMatch?.[1] !== undefined ? parseInt(colMatch[1], 10) : 0,
405
407
  message: msg,
406
408
  });
407
409
  }
@@ -101,22 +101,25 @@ describe("ConfigEditor", () => {
101
101
  describe("CONFIG_TREE_BLUEPRINT", () => {
102
102
  it("has config directory as root", async () => {
103
103
  expect(CONFIG_TREE_BLUEPRINT.length).toBeGreaterThan(0);
104
- expect(CONFIG_TREE_BLUEPRINT[0].name).toBe("config");
105
- expect(CONFIG_TREE_BLUEPRINT[0].type).toBe("directory");
104
+ expect(CONFIG_TREE_BLUEPRINT[0]?.name).toBe("config");
105
+ expect(CONFIG_TREE_BLUEPRINT[0]?.type).toBe("directory");
106
106
  });
107
107
 
108
108
  it("contains config files without secret-like .env entries", async () => {
109
- const children = CONFIG_TREE_BLUEPRINT[0].children!;
109
+ const root = CONFIG_TREE_BLUEPRINT[0];
110
+ expect(root?.children).toBeDefined();
111
+ const children = root!.children!;
110
112
  // wrangler.toml, trade.config.json, risk.config.json, strategies (no .env)
111
113
  expect(children.length).toBe(4);
112
114
  expect(children.some((c) => c.name === ".env")).toBe(false);
113
115
  });
114
116
 
115
117
  it("strategies directory has 3 config files", async () => {
116
- const strategies = CONFIG_TREE_BLUEPRINT[0].children!.find(
117
- (c) => c.name === "strategies"
118
- );
118
+ const root = CONFIG_TREE_BLUEPRINT[0];
119
+ expect(root?.children).toBeDefined();
120
+ const strategies = root!.children!.find((c) => c.name === "strategies");
119
121
  expect(strategies).toBeDefined();
122
+ expect(strategies!.children).toBeDefined();
120
123
  expect(strategies!.children!.length).toBe(3);
121
124
  const names = strategies!.children!.map((c) => c.name);
122
125
  expect(names).toContain("grid.config.json");
@@ -139,10 +142,10 @@ describe("ConfigEditor", () => {
139
142
  ];
140
143
  const flat = flattenTree(nodes);
141
144
  expect(flat.length).toBe(2); // directory + file
142
- expect(flat[0].node.name).toBe("dir");
143
- expect(flat[0].level).toBe(0);
144
- expect(flat[1].node.name).toBe("file.txt");
145
- expect(flat[1].level).toBe(1);
145
+ expect(flat[0]?.node.name).toBe("dir");
146
+ expect(flat[0]?.level).toBe(0);
147
+ expect(flat[1]?.node.name).toBe("file.txt");
148
+ expect(flat[1]?.level).toBe(1);
146
149
  });
147
150
 
148
151
  it("returns empty array for empty input", async () => {
@@ -180,8 +183,8 @@ describe("ConfigEditor", () => {
180
183
  it("highlights full-line comments as muted", async () => {
181
184
  const tokens = tokenizeTomlLine("# this is a comment");
182
185
  expect(tokens.length).toBeGreaterThan(0);
183
- expect(tokens[0].text).toContain("#");
184
- expect(tokens[0].color).toBe(Colors.muted);
186
+ expect(tokens[0]?.text).toContain("#");
187
+ expect(tokens[0]?.color).toBe(Colors.muted);
185
188
  });
186
189
 
187
190
  it("highlights section headers as accent bold", async () => {
@@ -389,8 +392,8 @@ describe("ConfigEditor", () => {
389
392
  // line 3 (unclosed string); the parser may point to the same
390
393
  // line or to the first line where it becomes unrecoverable.
391
394
  // Either way, the error must NOT be on a known-valid line.
392
- expect(errors[0].line).toBeGreaterThanOrEqual(3);
393
- expect(errors[0].line).toBeLessThanOrEqual(4);
395
+ expect(errors[0]?.line).toBeGreaterThanOrEqual(3);
396
+ expect(errors[0]?.line).toBeLessThanOrEqual(4);
394
397
  });
395
398
 
396
399
  it("reports 1-based column for invalid TOML", async () => {
@@ -398,16 +401,16 @@ describe("ConfigEditor", () => {
398
401
  const errors = validateSyntax(toml, "toml");
399
402
  expect(errors.length).toBeGreaterThan(0);
400
403
  // Column must be at least 1 (1-based) for a useful error marker.
401
- expect(errors[0].column).toBeGreaterThanOrEqual(1);
404
+ expect(errors[0]?.column).toBeGreaterThanOrEqual(1);
402
405
  });
403
406
 
404
407
  it("includes the parser's error message verbatim", async () => {
405
408
  const toml = 'key = = "invalid"\n';
406
409
  const errors = validateSyntax(toml, "toml");
407
410
  expect(errors.length).toBeGreaterThan(0);
408
- expect(errors[0].message.length).toBeGreaterThan(0);
411
+ expect(errors[0]?.message.length).toBeGreaterThan(0);
409
412
  // Must not be the old generic "Unbalanced brackets" message.
410
- expect(errors[0].message).not.toMatch(/unbalanced/i);
413
+ expect(errors[0]?.message).not.toMatch(/unbalanced/i);
411
414
  });
412
415
 
413
416
  it("returns empty errors for empty TOML", async () => {
@@ -544,20 +544,22 @@ export function ConfigEditor() {
544
544
  const handleUndo = useCallback(() => {
545
545
  if (undoStack.length === 0 || !selectedFile) return;
546
546
  const prev = undoStack[undoStack.length - 1];
547
+ if (prev === undefined) return;
547
548
  setUndoStack((s) => s.slice(0, -1));
548
549
  setRedoStack((s) => [...s, currentContent]);
549
- setFileContents((contents) => new Map(contents).set(selectedFile!, prev));
550
- setUnsavedPaths((paths) => new Set(paths).add(selectedFile!));
550
+ setFileContents((contents) => new Map(contents).set(selectedFile, prev));
551
+ setUnsavedPaths((paths) => new Set(paths).add(selectedFile));
551
552
  setStatusMessage("Undo");
552
553
  }, [undoStack, currentContent, selectedFile]);
553
554
 
554
555
  const handleRedo = useCallback(() => {
555
556
  if (redoStack.length === 0 || !selectedFile) return;
556
557
  const next = redoStack[redoStack.length - 1];
558
+ if (next === undefined) return;
557
559
  setRedoStack((s) => s.slice(0, -1));
558
560
  setUndoStack((s) => [...s, currentContent]);
559
- setFileContents((prev) => new Map(prev).set(selectedFile!, next));
560
- setUnsavedPaths((paths) => new Set(paths).add(selectedFile!));
561
+ setFileContents((prev) => new Map(prev).set(selectedFile, next));
562
+ setUnsavedPaths((paths) => new Set(paths).add(selectedFile));
561
563
  setStatusMessage("Redo");
562
564
  }, [redoStack, currentContent, selectedFile]);
563
565
 
@@ -624,7 +626,7 @@ export function ConfigEditor() {
624
626
  setFocusedTreeIdx((i) => Math.min(flatFiles.length - 1, i + 1));
625
627
  break;
626
628
  case "enter":
627
- if (focusedFilePath) selectFile(focusedFilePath);
629
+ if (focusedFilePath) void selectFile(focusedFilePath);
628
630
  break;
629
631
  }
630
632
  }
@@ -641,7 +643,7 @@ export function ConfigEditor() {
641
643
  }
642
644
 
643
645
  if (key.ctrl && key.name === "s" && selectedFile) {
644
- handleSave();
646
+ void handleSave();
645
647
  }
646
648
  });
647
649
 
@@ -4,14 +4,12 @@
4
4
  */
5
5
 
6
6
  /** @jsxImportSource @opentui/react */
7
- import { useState, useCallback, useEffect } from "react";
7
+ import { useState, useCallback, useEffect, useRef } from "react";
8
8
  import { Colors, WorkerStatusColor } from "@hoox-sh/hoox-shared";
9
9
  import { StatusDot } from "../../shared/status-dot";
10
10
  import { cliBridge } from "../../../services/cli-bridge";
11
11
  import type { ModelHealth } from "../../../services/cli-bridge";
12
-
13
- /** Polling interval for AI model health checks (30 seconds). */
14
- const MODEL_HEALTH_POLL_MS = 30_000;
12
+ import { usePolling } from "../../../hooks/use-polling";
15
13
 
16
14
  /**
17
15
  * Map model status to a StatusDot-compatible status string.
@@ -116,7 +114,7 @@ function ModelHealthRow({
116
114
  /**
117
115
  * ModelHealthSection — displays health status of all configured AI providers.
118
116
  *
119
- * Auto-refreshes every 30 seconds when the dashboard is active.
117
+ * Auto-refreshes on the Settings interval when the dashboard is active.
120
118
  * Supports multiple providers: Workers AI, OpenAI, Anthropic, Google, Azure.
121
119
  * Click on a provider to expand detailed stats (latency, daily usage, error).
122
120
  * Cancels in-flight probes on unmount; race-safe via generation counter.
@@ -127,51 +125,45 @@ function ModelHealthSection() {
127
125
  const [loading, setLoading] = useState(true);
128
126
  const [error, setError] = useState<string | null>(null);
129
127
  const [lastRefresh, setLastRefresh] = useState<number | null>(null);
128
+ const genRef = useRef(0);
129
+ const inFlightRef = useRef(false);
130
130
 
131
- // Initial fetch + 30s polling with cancel + race safety
132
- useEffect(() => {
133
- let cancelled = false;
134
- let generation = 0;
135
- let inFlight = false;
136
-
137
- const fetchHealth = async () => {
138
- if (cancelled || inFlight) return;
139
- inFlight = true;
140
- const gen = ++generation;
141
- try {
142
- const result = await cliBridge.agentHealthCheck();
143
- if (cancelled || gen !== generation) return;
144
- if (result.success && result.data) {
145
- setProviders(result.data.providers ?? []);
146
- setError(null);
147
- setLastRefresh(Date.now());
148
- } else {
149
- // Keep prior providers if any; surface probe failure distinctly
150
- setError(
151
- result.stderr ||
152
- result.stdout ||
153
- "AI health probe failed (CLI offline?)"
154
- );
155
- }
156
- } catch (err) {
157
- if (cancelled || gen !== generation) return;
158
- setError(err instanceof Error ? err.message : String(err));
159
- } finally {
160
- inFlight = false;
161
- if (!cancelled && gen === generation) setLoading(false);
131
+ const fetchHealth = useCallback(async () => {
132
+ if (inFlightRef.current) return;
133
+ inFlightRef.current = true;
134
+ const gen = ++genRef.current;
135
+ try {
136
+ const result = await cliBridge.agentHealthCheck();
137
+ if (gen !== genRef.current) return;
138
+ if (result.success && result.data) {
139
+ setProviders(result.data.providers ?? []);
140
+ setError(null);
141
+ setLastRefresh(Date.now());
142
+ } else {
143
+ // Keep prior providers if any; surface probe failure distinctly
144
+ setError(
145
+ result.stderr ||
146
+ result.stdout ||
147
+ "AI health probe failed (CLI offline?)"
148
+ );
162
149
  }
163
- };
164
-
165
- void fetchHealth();
166
- const interval = setInterval(() => {
167
- void fetchHealth();
168
- }, MODEL_HEALTH_POLL_MS);
169
- return () => {
170
- cancelled = true;
171
- clearInterval(interval);
172
- };
150
+ } catch (err) {
151
+ if (gen !== genRef.current) return;
152
+ setError(err instanceof Error ? err.message : String(err));
153
+ } finally {
154
+ inFlightRef.current = false;
155
+ if (gen === genRef.current) setLoading(false);
156
+ }
173
157
  }, []);
174
158
 
159
+ useEffect(
160
+ () => () => {
161
+ genRef.current += 1;
162
+ },
163
+ []
164
+ );
165
+ usePolling({ callback: fetchHealth, immediate: true });
166
+
175
167
  const toggleExpand = useCallback((index: number) => {
176
168
  setExpandedIndex((prev) => (prev === index ? null : index));
177
169
  }, []);
@@ -4,13 +4,12 @@
4
4
  */
5
5
 
6
6
  /** @jsxImportSource @opentui/react */
7
- import { useState, useEffect } from "react";
7
+ import { useState, useEffect, useCallback, useRef } from "react";
8
8
  import { Colors, WorkerStatusColor } from "@hoox-sh/hoox-shared";
9
9
  import { StatusDot } from "../../shared/status-dot";
10
10
  import { cliBridge } from "../../../services/cli-bridge";
11
11
  import type { PyneHealthResult } from "../../../services/cli-bridge";
12
-
13
- const POLL_MS = 30_000;
12
+ import { usePolling } from "../../../hooks/use-polling";
14
13
 
15
14
  function statusToDot(
16
15
  status: PyneHealthResult["status"]
@@ -27,54 +26,50 @@ function statusToDot(
27
26
  export function PyneHealthSection() {
28
27
  const [result, setResult] = useState<PyneHealthResult | null>(null);
29
28
  const [loading, setLoading] = useState(true);
29
+ const genRef = useRef(0);
30
+ const inFlightRef = useRef(false);
30
31
 
31
- // Poll with cancel-on-unmount and skip overlapping probes
32
- useEffect(() => {
33
- let cancelled = false;
34
- let generation = 0;
35
- let inFlight = false;
36
-
37
- const refresh = async () => {
38
- if (cancelled || inFlight) return;
39
- inFlight = true;
40
- const gen = ++generation;
41
- try {
42
- const res = await cliBridge.pyneHealthCheck();
43
- if (cancelled || gen !== generation) return;
44
- if (res.data) {
45
- setResult(res.data);
46
- } else {
47
- setResult({
48
- worker: "pyne-worker",
49
- url: "",
50
- status: "down",
51
- error: res.stderr || "probe failed",
52
- timestamp: new Date().toISOString(),
53
- });
54
- }
55
- } catch (err) {
56
- if (cancelled || gen !== generation) return;
32
+ const refresh = useCallback(async () => {
33
+ if (inFlightRef.current) return;
34
+ inFlightRef.current = true;
35
+ const gen = ++genRef.current;
36
+ try {
37
+ const res = await cliBridge.pyneHealthCheck();
38
+ if (gen !== genRef.current) return;
39
+ if (res.data) {
40
+ setResult(res.data);
41
+ } else {
57
42
  setResult({
58
43
  worker: "pyne-worker",
59
44
  url: "",
60
45
  status: "down",
61
- error: err instanceof Error ? err.message : "probe failed",
46
+ error: res.stderr || "probe failed",
62
47
  timestamp: new Date().toISOString(),
63
48
  });
64
- } finally {
65
- inFlight = false;
66
- if (!cancelled && gen === generation) setLoading(false);
67
49
  }
68
- };
69
-
70
- void refresh();
71
- const id = setInterval(() => void refresh(), POLL_MS);
72
- return () => {
73
- cancelled = true;
74
- clearInterval(id);
75
- };
50
+ } catch (err) {
51
+ if (gen !== genRef.current) return;
52
+ setResult({
53
+ worker: "pyne-worker",
54
+ url: "",
55
+ status: "down",
56
+ error: err instanceof Error ? err.message : "probe failed",
57
+ timestamp: new Date().toISOString(),
58
+ });
59
+ } finally {
60
+ inFlightRef.current = false;
61
+ if (gen === genRef.current) setLoading(false);
62
+ }
76
63
  }, []);
77
64
 
65
+ useEffect(
66
+ () => () => {
67
+ genRef.current += 1;
68
+ },
69
+ []
70
+ );
71
+ usePolling({ callback: refresh, immediate: true });
72
+
78
73
  const status = result?.status ?? "down";
79
74
  const dot = statusToDot(status);
80
75
  const color = WorkerStatusColor[dot];
@@ -37,6 +37,7 @@ import { PyneHealthSection } from "./dashboard/pyne-health-section";
37
37
  import { AlertsPanel } from "./dashboard/alerts-panel";
38
38
  import { useUIStore } from "@hoox-sh/hoox-shared";
39
39
  import { useViewKeyboard } from "../../hooks/shell-overlay";
40
+ import { usePolling } from "../../hooks/use-polling";
40
41
 
41
42
  // ─── Props ────────────────────────────────────────────────────────────────────
42
43
 
@@ -71,42 +72,38 @@ function formatStatNumber(value: number, isPnl = false): string {
71
72
 
72
73
  /**
73
74
  * KillSwitchStatusBadge — Compact trade kill-switch indicator for the dashboard
74
- * header. Auto-refreshes on mount and shows the engaged/released state with a
75
- * color-coded status dot. Errors degrade gracefully (badge just shows UNKNOWN).
75
+ * header. Auto-refreshes on the Settings interval and shows the engaged/released
76
+ * state with a color-coded status dot. Errors degrade to UNKNOWN.
76
77
  */
77
- /** How often the dashboard kill-switch badge re-probes (ms). */
78
- const KILL_SWITCH_POLL_MS = 15_000;
79
-
80
78
  function KillSwitchStatusBadge() {
81
79
  const [state, setState] = useState<"engaged" | "released" | "unknown">(
82
80
  "unknown"
83
81
  );
84
- useEffect(() => {
85
- let cancelled = false;
86
- const refresh = async () => {
87
- try {
88
- const result = await cliBridge.monitorKillSwitch("show");
89
- if (cancelled) return;
90
- if (result.success && result.data) {
91
- setState(result.data.engaged ? "engaged" : "released");
92
- } else {
93
- setState("unknown");
94
- }
95
- } catch {
96
- if (!cancelled) setState("unknown");
82
+ const genRef = useRef(0);
83
+
84
+ const refresh = useCallback(async () => {
85
+ const gen = ++genRef.current;
86
+ try {
87
+ const result = await cliBridge.monitorKillSwitch("show");
88
+ if (gen !== genRef.current) return;
89
+ if (result.success && result.data) {
90
+ setState(result.data.engaged ? "engaged" : "released");
91
+ } else {
92
+ setState("unknown");
97
93
  }
98
- };
99
- void refresh();
100
- // Re-probe periodically so engage/release in Service Manager is visible here
101
- const timer = setInterval(() => {
102
- void refresh();
103
- }, KILL_SWITCH_POLL_MS);
104
- return () => {
105
- cancelled = true;
106
- clearInterval(timer);
107
- };
94
+ } catch {
95
+ if (gen === genRef.current) setState("unknown");
96
+ }
108
97
  }, []);
109
98
 
99
+ useEffect(
100
+ () => () => {
101
+ genRef.current += 1;
102
+ },
103
+ []
104
+ );
105
+ usePolling({ callback: refresh, immediate: true });
106
+
110
107
  const label =
111
108
  state === "engaged"
112
109
  ? "KILL SWITCH ENGAGED"
@@ -23,9 +23,10 @@ import {
23
23
  filterKvKeys,
24
24
  sanitizeKvValue,
25
25
  MAX_VISIBLE_KEYS,
26
- REFRESH_INTERVAL_MS,
27
26
  } from "./kv-viewer";
27
+ import { formatPollIntervalLabel } from "../../hooks/use-polling";
28
28
  import { Colors, type ViewId } from "@hoox-sh/hoox-shared";
29
+ import { ALL_VIEWS } from "../../types";
29
30
  import { useUIStore } from "@hoox-sh/hoox-shared/stores/ui-store";
30
31
  import {
31
32
  cliBridgeDouble,
@@ -124,8 +125,8 @@ describe("KvViewer", () => {
124
125
 
125
126
  // ── Pattern contract (mirrors queue-depth subtask 02) ──────────────────
126
127
 
127
- it("auto-refreshes every 5 seconds while active", () => {
128
- expect(REFRESH_INTERVAL_MS).toBe(5_000);
128
+ it("labels configured poll intervals", () => {
129
+ expect(formatPollIntervalLabel(5_000)).toBe("5s auto");
129
130
  });
130
131
 
131
132
  it("filters keys case-insensitively by name", () => {
@@ -235,19 +236,6 @@ describe("KvViewer", () => {
235
236
  });
236
237
 
237
238
  it("is included in the local ALL_VIEWS list (tui/src/types.ts)", () => {
238
- const ALL_VIEWS: string[] = [
239
- "dashboard",
240
- "workers",
241
- "worker-detail",
242
- "trade-monitor",
243
- "logs-viewer",
244
- "service-manager",
245
- "config-editor",
246
- "setup-wizard",
247
- "settings",
248
- "queue-depth",
249
- "kv-viewer",
250
- ];
251
239
  expect(ALL_VIEWS).toContain("kv-viewer");
252
240
  // ViewId is derived from ALL_VIEWS so the local type must include it
253
241
  // as well. The TypeScript type-check is the real assertion; this
@@ -49,16 +49,14 @@
49
49
  */
50
50
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
51
51
 
52
- import { Colors, useUIStore } from "@hoox-sh/hoox-shared";
52
+ import { Colors, useUIStore, useConfigStore } from "@hoox-sh/hoox-shared";
53
53
  import { ErrorBoundary } from "../shared/error-boundary";
54
54
  import { Spinner, EmptyState } from "../shared/spinner";
55
55
  import { ViewHeader } from "../shared/view-header";
56
56
  import { cliBridge } from "../../services/cli-bridge";
57
57
  import type { KvKey, KvKeySnapshot } from "../../services/cli-bridge";
58
58
  import { useViewKeyboard } from "../../hooks/shell-overlay";
59
-
60
- /** Auto-refresh interval in milliseconds. */
61
- export const REFRESH_INTERVAL_MS = 5_000;
59
+ import { usePolling, formatPollIntervalLabel } from "../../hooks/use-polling";
62
60
 
63
61
  /** Cap rendered rows so large namespaces stay responsive. */
64
62
  export const MAX_VISIBLE_KEYS = 200;
@@ -427,14 +425,12 @@ export function KvViewer() {
427
425
  };
428
426
  }, [refresh]);
429
427
 
430
- // Auto-refresh every 5s while the view is the active view (soft = no flicker).
431
- useEffect(() => {
432
- if (!isActive) return;
433
- const handle = setInterval(() => {
434
- void refresh({ soft: true });
435
- }, REFRESH_INTERVAL_MS);
436
- return () => clearInterval(handle);
437
- }, [isActive, refresh]);
428
+ const refreshIntervalMs = useConfigStore((s) => s.refreshIntervalMs);
429
+ usePolling({
430
+ callback: () => refresh({ soft: true }),
431
+ enabled: isActive,
432
+ immediate: false,
433
+ });
438
434
 
439
435
  // ── Derived data ─────────────────────────────────────────────────────
440
436
  const allKeys = snapshot?.keys ?? [];
@@ -558,7 +554,7 @@ export function KvViewer() {
558
554
  ⚠ read-only
559
555
  </text>
560
556
  <text fg={Colors.info} dim>
561
- {`◉ ${REFRESH_INTERVAL_MS / 1000}s auto`}
557
+ {`◉ ${formatPollIntervalLabel(refreshIntervalMs)}`}
562
558
  </text>
563
559
  </box>
564
560
  }
@@ -113,7 +113,7 @@ describe("LogsViewer filter logic", () => {
113
113
  });
114
114
  // Entries with workerId=w1 (alpha-worker) + entries with no workerId
115
115
  expect(result).toHaveLength(1); // only id=5 has workerId=w1
116
- expect(result[0].id).toBe("5");
116
+ expect(result[0]?.id).toBe("5");
117
117
  });
118
118
 
119
119
  it("filters by text search (case-insensitive)", () => {
@@ -125,7 +125,7 @@ describe("LogsViewer filter logic", () => {
125
125
  workers,
126
126
  });
127
127
  expect(result).toHaveLength(1);
128
- expect(result[0].message).toBe("Trade executed");
128
+ expect(result[0]?.message).toBe("Trade executed");
129
129
  });
130
130
 
131
131
  it("combines filters with AND logic", () => {
@@ -138,7 +138,7 @@ describe("LogsViewer filter logic", () => {
138
138
  });
139
139
  // Must be error OR info, alpha-worker, contains "auth"
140
140
  expect(result).toHaveLength(1);
141
- expect(result[0].id).toBe("5"); // error + w1 + "Auth failure"
141
+ expect(result[0]?.id).toBe("5"); // error + w1 + "Auth failure"
142
142
  });
143
143
 
144
144
  it("handles empty text search as pass-through", () => {
@@ -263,7 +263,7 @@ describe("LogsViewer sanitization & caps", () => {
263
263
  workers: [],
264
264
  });
265
265
  expect(result).toHaveLength(1);
266
- expect(result[0].id).toBe("1");
266
+ expect(result[0]?.id).toBe("1");
267
267
  });
268
268
 
269
269
  it("caps visible render window below store ring buffer (1000)", () => {
@@ -17,11 +17,8 @@
17
17
  import { describe, it, expect, beforeEach, afterEach } from "bun:test";
18
18
  import { createRoot } from "@opentui/react";
19
19
  import { createCliRenderer } from "@opentui/core";
20
- import {
21
- QueueDepthView,
22
- sortQueuesByPressure,
23
- REFRESH_INTERVAL_MS,
24
- } from "./queue-depth";
20
+ import { QueueDepthView, sortQueuesByPressure } from "./queue-depth";
21
+ import { formatPollIntervalLabel } from "../../hooks/use-polling";
25
22
  import { Colors } from "@hoox-sh/hoox-shared";
26
23
  import { useUIStore } from "@hoox-sh/hoox-shared/stores/ui-store";
27
24
  import {
@@ -126,8 +123,9 @@ describe("QueueDepthView", () => {
126
123
 
127
124
  // ── Refresh interval contract ─────────────────────────────────────────
128
125
 
129
- it("auto-refreshes every 5 seconds while active", () => {
130
- expect(REFRESH_INTERVAL_MS).toBe(5_000);
126
+ it("labels configured poll intervals", () => {
127
+ expect(formatPollIntervalLabel(5_000)).toBe("5s auto");
128
+ expect(formatPollIntervalLabel(500)).toBe("500ms auto");
131
129
  });
132
130
 
133
131
  it("sorts queues by pressure: critical → backlogged → paused → healthy", () => {