@hoox-sh/hoox-tui 0.2.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 (104) hide show
  1. package/README.md +247 -0
  2. package/bin/hoox-tui.js +35 -0
  3. package/dist/main.js +17594 -0
  4. package/package.json +72 -0
  5. package/src/app.tsx +696 -0
  6. package/src/cli-bridge-test-double.ts +212 -0
  7. package/src/components/layout/layout.test.tsx +205 -0
  8. package/src/components/layout/sidebar.tsx +69 -0
  9. package/src/components/layout/statusbar.test.tsx +423 -0
  10. package/src/components/layout/statusbar.tsx +348 -0
  11. package/src/components/shared/animated-border.tsx +58 -0
  12. package/src/components/shared/chrome.test.tsx +29 -0
  13. package/src/components/shared/command-palette.tsx +361 -0
  14. package/src/components/shared/cool-brackets.tsx +75 -0
  15. package/src/components/shared/crash-screen.tsx +167 -0
  16. package/src/components/shared/error-boundary.tsx +99 -0
  17. package/src/components/shared/keybinding-hint.tsx +37 -0
  18. package/src/components/shared/panel.tsx +61 -0
  19. package/src/components/shared/quit-modal.tsx +65 -0
  20. package/src/components/shared/shared.test.tsx +538 -0
  21. package/src/components/shared/spinner.tsx +85 -0
  22. package/src/components/shared/status-dot.tsx +54 -0
  23. package/src/components/shared/view-header.tsx +53 -0
  24. package/src/components/ui/connection-toasts.test.ts +50 -0
  25. package/src/components/ui/connection-toasts.ts +215 -0
  26. package/src/components/ui/dialog.tsx +281 -0
  27. package/src/components/ui/select.tsx +197 -0
  28. package/src/components/ui/toast.tsx +185 -0
  29. package/src/components/views/ai-chat.test.tsx +81 -0
  30. package/src/components/views/ai-chat.tsx +576 -0
  31. package/src/components/views/config-editor/action-bar.tsx +102 -0
  32. package/src/components/views/config-editor/code-editor.tsx +597 -0
  33. package/src/components/views/config-editor/file-tree.tsx +113 -0
  34. package/src/components/views/config-editor/types.ts +37 -0
  35. package/src/components/views/config-editor.test.tsx +465 -0
  36. package/src/components/views/config-editor.tsx +709 -0
  37. package/src/components/views/dashboard/alerts-panel.tsx +154 -0
  38. package/src/components/views/dashboard/auto-repair-panel.tsx +229 -0
  39. package/src/components/views/dashboard/model-health-section.tsx +276 -0
  40. package/src/components/views/dashboard/pyne-health-section.tsx +112 -0
  41. package/src/components/views/dashboard.test.tsx +347 -0
  42. package/src/components/views/dashboard.tsx +647 -0
  43. package/src/components/views/db-query.test.tsx +115 -0
  44. package/src/components/views/db-query.tsx +849 -0
  45. package/src/components/views/edge-topology.test.tsx +198 -0
  46. package/src/components/views/edge-topology.tsx +572 -0
  47. package/src/components/views/kv-viewer.test.tsx +241 -0
  48. package/src/components/views/kv-viewer.tsx +717 -0
  49. package/src/components/views/logs-viewer.test.tsx +273 -0
  50. package/src/components/views/logs-viewer.tsx +616 -0
  51. package/src/components/views/queue-depth.test.tsx +221 -0
  52. package/src/components/views/queue-depth.tsx +376 -0
  53. package/src/components/views/secrets-viewer.test.tsx +65 -0
  54. package/src/components/views/secrets-viewer.tsx +548 -0
  55. package/src/components/views/service-manager.test.tsx +358 -0
  56. package/src/components/views/service-manager.tsx +946 -0
  57. package/src/components/views/settings.test.tsx +648 -0
  58. package/src/components/views/settings.tsx +1497 -0
  59. package/src/components/views/setup-wizard.test.tsx +297 -0
  60. package/src/components/views/setup-wizard.tsx +1321 -0
  61. package/src/components/views/trade-monitor.test.tsx +592 -0
  62. package/src/components/views/trade-monitor.tsx +745 -0
  63. package/src/components/views/worker-detail.test.tsx +289 -0
  64. package/src/components/views/worker-detail.tsx +676 -0
  65. package/src/components/views/worker-settings.test.tsx +43 -0
  66. package/src/components/views/worker-settings.tsx +563 -0
  67. package/src/components/views/workers-overview.test.tsx +260 -0
  68. package/src/components/views/workers-overview.tsx +631 -0
  69. package/src/hooks/hooks.test.ts +72 -0
  70. package/src/hooks/index.ts +11 -0
  71. package/src/hooks/renderer-ref.ts +22 -0
  72. package/src/hooks/use-keyboard.ts +130 -0
  73. package/src/hooks/use-polling.ts +73 -0
  74. package/src/hooks/use-service-data.ts +18 -0
  75. package/src/main.tsx +117 -0
  76. package/src/network-test-double.ts +133 -0
  77. package/src/opentui-augment.d.ts +45 -0
  78. package/src/opentui-jsx.d.ts +70 -0
  79. package/src/services/cli-bridge/index.ts +1662 -0
  80. package/src/services/cli-bridge/standalone.ts +315 -0
  81. package/src/services/cli-bridge/types.ts +307 -0
  82. package/src/services/cli-bridge.test.ts +406 -0
  83. package/src/services/clipboard.test.ts +57 -0
  84. package/src/services/clipboard.ts +180 -0
  85. package/src/services/dashboard-settings-loader.ts +31 -0
  86. package/src/services/dev-log.test.ts +168 -0
  87. package/src/services/dev-log.ts +194 -0
  88. package/src/services/hoox-path-service.test.ts +183 -0
  89. package/src/services/hoox-path-service.ts +185 -0
  90. package/src/services/tui-connection.test.ts +212 -0
  91. package/src/services/tui-connection.ts +236 -0
  92. package/src/services/tui-storage.test.ts +92 -0
  93. package/src/services/tui-storage.ts +86 -0
  94. package/src/stores/config-store.test.ts +360 -0
  95. package/src/stores/service-store.test.ts +1026 -0
  96. package/src/stores/session-restore.test.ts +134 -0
  97. package/src/stores/ui-store.test.ts +276 -0
  98. package/src/test-setup.ts +265 -0
  99. package/src/test-utils.ts +179 -0
  100. package/src/types.ts +153 -0
  101. package/src/utils/colors.test.ts +326 -0
  102. package/src/utils/formatters.test.ts +236 -0
  103. package/src/view-registry.test.ts +132 -0
  104. package/src/view-registry.tsx +368 -0
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Copyright (c) 2026 HOOX · HOOX · jango-blockchained (hoox-sh)
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ import { useState, useMemo, useEffect } from "react";
7
+ import { useKeyboard } from "@opentui/react";
8
+ import {
9
+ Colors,
10
+ AlertSeverityColor,
11
+ useServiceStore,
12
+ } from "@hoox-sh/hoox-shared";
13
+ import type { AlertSeverity } from "@hoox-sh/hoox-shared";
14
+
15
+ /** Severity label prefix */
16
+ const SEVERITY_LABEL: Record<AlertSeverity, string> = {
17
+ info: "INFO",
18
+ warning: "WARN",
19
+ error: "ERR",
20
+ critical: "CRIT",
21
+ };
22
+
23
+ /** Maximum alerts shown in the panel */
24
+ const MAX_VISIBLE_ALERTS = 50;
25
+
26
+ /**
27
+ * Format a timestamp (ms) to HH:MM:SS for alert display.
28
+ */
29
+ function formatTime(ts: number): string {
30
+ const d = new Date(ts);
31
+ return [
32
+ d.getHours().toString().padStart(2, "0"),
33
+ d.getMinutes().toString().padStart(2, "0"),
34
+ d.getSeconds().toString().padStart(2, "0"),
35
+ ].join(":");
36
+ }
37
+
38
+ /**
39
+ * AlertsPanel — scrollable list of recent alerts, newest first.
40
+ * Each alert row shows: [SEV] HH:MM:SS — message
41
+ * Color-coded by severity. Scrollable with ↑↓ keys.
42
+ */
43
+ function AlertsPanel() {
44
+ const alerts = useServiceStore((s) => s.alerts);
45
+ const [scrollOffset, setScrollOffset] = useState(0);
46
+
47
+ // Newest first, limited
48
+ const sortedAlerts = useMemo(() => {
49
+ return [...alerts]
50
+ .sort((a, b) => b.timestamp - a.timestamp)
51
+ .slice(0, MAX_VISIBLE_ALERTS);
52
+ }, [alerts]);
53
+
54
+ // Clamp selection when the alert list shrinks (ack/dismiss/race)
55
+ useEffect(() => {
56
+ setScrollOffset((o) => Math.min(o, Math.max(0, sortedAlerts.length - 1)));
57
+ }, [sortedAlerts.length]);
58
+
59
+ // Keyboard: scroll through alerts
60
+ useKeyboard((key) => {
61
+ if (key.name === "up") {
62
+ setScrollOffset((o) => Math.max(0, o - 1));
63
+ } else if (key.name === "down") {
64
+ setScrollOffset((o) =>
65
+ Math.min(Math.max(0, sortedAlerts.length - 1), o + 1)
66
+ );
67
+ }
68
+ });
69
+
70
+ return (
71
+ <box flexDirection="column">
72
+ {/* Section label */}
73
+ <box>
74
+ <text fg={Colors.foreground} bold dim>
75
+ ALERTS
76
+ </text>
77
+ </box>
78
+
79
+ {sortedAlerts.length === 0 ? (
80
+ <box paddingTop={1}>
81
+ <text fg={Colors.muted} dim>
82
+ No alerts
83
+ </text>
84
+ </box>
85
+ ) : (
86
+ <scrollbox
87
+ width="100%"
88
+ height={8}
89
+ border={true}
90
+ borderStyle="single"
91
+ borderColor={Colors.border}
92
+ paddingX={1}
93
+ paddingY={0}
94
+ >
95
+ {sortedAlerts.map((alert, i) => {
96
+ const color = AlertSeverityColor[alert.severity];
97
+ const label = SEVERITY_LABEL[alert.severity];
98
+ const isCritical = alert.severity === "critical";
99
+ const isSelected = i === scrollOffset;
100
+
101
+ return (
102
+ <box
103
+ key={alert.id}
104
+ flexDirection="row"
105
+ gap={1}
106
+ backgroundColor={isSelected ? Colors.card : undefined}
107
+ >
108
+ {/* Severity badge */}
109
+ <text fg={color} bold={isCritical} dim={!isCritical}>
110
+ [{label}]
111
+ </text>
112
+
113
+ {/* Timestamp */}
114
+ <text fg={Colors.muted} dim>
115
+ {formatTime(alert.timestamp)}
116
+ </text>
117
+
118
+ {/* Divider */}
119
+ <text fg={Colors.dim} dim>
120
+ —
121
+ </text>
122
+
123
+ {/* Message */}
124
+ <text fg={color} bold={isCritical} dim={alert.acknowledged}>
125
+ {alert.message.length > 60
126
+ ? alert.message.slice(0, 57) + "…"
127
+ : alert.message}
128
+ </text>
129
+
130
+ {/* Acknowledged marker */}
131
+ {alert.acknowledged && (
132
+ <text fg={Colors.dim} dim>
133
+ ✓
134
+ </text>
135
+ )}
136
+ </box>
137
+ );
138
+ })}
139
+ </scrollbox>
140
+ )}
141
+
142
+ {/* Scroll hint */}
143
+ {sortedAlerts.length > 0 && (
144
+ <box>
145
+ <text fg={Colors.dim} dim>
146
+ ↑↓ scroll · {scrollOffset + 1}/{sortedAlerts.length}
147
+ </text>
148
+ </box>
149
+ )}
150
+ </box>
151
+ );
152
+ }
153
+
154
+ export { AlertsPanel };
@@ -0,0 +1,229 @@
1
+ /**
2
+ * Copyright (c) 2026 HOOX · HOOX · jango-blockchained (hoox-sh)
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /** @jsxImportSource @opentui/react */
7
+ import { useMemo } from "react";
8
+ import { Colors } from "@hoox-sh/hoox-shared";
9
+
10
+ // ─── Types ─────────────────────────────────────────────────────────────────────
11
+
12
+ /**
13
+ * A single fix action result from `hoox check fix`.
14
+ * Mirrors the CLI's FixAction shape (`packages/cli/src/commands/check/types.ts`).
15
+ */
16
+ export interface RepairFixItem {
17
+ /** Human-readable description of the fix. */
18
+ description: string;
19
+ /** Type of fix: file, binding, flag, or config. */
20
+ type: "file" | "binding" | "flag" | "config";
21
+ /** Target path or identifier. */
22
+ target: string;
23
+ /** Whether the fix was successfully applied. */
24
+ applied: boolean;
25
+ /** Error message if application failed. */
26
+ error?: string;
27
+ /** Timestamp when the fix was attempted. */
28
+ timestamp: number;
29
+ }
30
+
31
+ /**
32
+ * UI state for the Auto-Repair results panel.
33
+ */
34
+ export type RepairState =
35
+ | { kind: "idle" }
36
+ | { kind: "running" }
37
+ | { kind: "error"; message: string }
38
+ | { kind: "results"; items: RepairFixItem[]; durationMs: number };
39
+
40
+ // ─── Sub-Components ────────────────────────────────────────────────────────────
41
+
42
+ /**
43
+ * Status badge for a single repair item: APPLIED, FAILED, or SKIPPED.
44
+ */
45
+ export function RepairStatusBadge({
46
+ status,
47
+ }: {
48
+ status: "applied" | "failed" | "skipped";
49
+ }) {
50
+ if (status === "applied") {
51
+ return (
52
+ <text fg={Colors.success} bold>
53
+ [APPLIED]
54
+ </text>
55
+ );
56
+ }
57
+ if (status === "failed") {
58
+ return (
59
+ <text fg={Colors.error} bold>
60
+ [FAILED]
61
+ </text>
62
+ );
63
+ }
64
+ return (
65
+ <text fg={Colors.warning} bold>
66
+ [SKIPPED]
67
+ </text>
68
+ );
69
+ }
70
+
71
+ /**
72
+ * Format a timestamp (ms) to HH:MM:SS for display.
73
+ */
74
+ export function formatRepairTime(ts: number): string {
75
+ const d = new Date(ts);
76
+ return [
77
+ d.getHours().toString().padStart(2, "0"),
78
+ d.getMinutes().toString().padStart(2, "0"),
79
+ d.getSeconds().toString().padStart(2, "0"),
80
+ ].join(":");
81
+ }
82
+
83
+ /**
84
+ * AutoRepairPanel — shows repair results below the health section.
85
+ * Displays each fix item with timestamp, description, and status.
86
+ * Persists until the user dismisses with ESC or the [DISMISS] button.
87
+ */
88
+ export function AutoRepairPanel({
89
+ state,
90
+ onDismiss,
91
+ onRerun,
92
+ }: {
93
+ state: RepairState;
94
+ onDismiss: () => void;
95
+ onRerun: () => void;
96
+ }) {
97
+ const resultCounts = useMemo(() => {
98
+ if (state.kind !== "results") return { applied: 0, failed: 0 };
99
+ let applied = 0;
100
+ let failed = 0;
101
+ for (const item of state.items) {
102
+ if (item.error) failed += 1;
103
+ else if (item.applied) applied += 1;
104
+ }
105
+ return { applied, failed };
106
+ }, [state]);
107
+
108
+ return (
109
+ <box
110
+ flexDirection="column"
111
+ border={true}
112
+ borderStyle="single"
113
+ borderColor={Colors.border}
114
+ backgroundColor={Colors.card}
115
+ paddingX={1}
116
+ paddingY={0}
117
+ gap={0}
118
+ >
119
+ {/* Header row */}
120
+ <box flexDirection="row" gap={2}>
121
+ <text fg={Colors.accent} bold>
122
+ AUTO-REPAIR RESULTS
123
+ </text>
124
+ {state.kind === "results" && (
125
+ <>
126
+ <text fg={Colors.success} bold>
127
+ {resultCounts.applied} applied
128
+ </text>
129
+ <text
130
+ fg={resultCounts.failed > 0 ? Colors.error : Colors.muted}
131
+ bold={resultCounts.failed > 0}
132
+ >
133
+ {resultCounts.failed} failed
134
+ </text>
135
+ <text fg={Colors.muted} dim>
136
+ {`(${(state.durationMs / 1000).toFixed(1)}s)`}
137
+ </text>
138
+ </>
139
+ )}
140
+ {state.kind === "running" && (
141
+ <text fg={Colors.info} bold>
142
+ running...
143
+ </text>
144
+ )}
145
+ <text fg={Colors.muted}>{" "}</text>
146
+ <text
147
+ fg={state.kind === "running" ? Colors.muted : Colors.accent}
148
+ bold={state.kind !== "running"}
149
+ dim={state.kind === "running"}
150
+ onMouseUp={state.kind === "running" ? undefined : onRerun}
151
+ >
152
+ [ RE-RUN ]
153
+ </text>
154
+ <text fg={Colors.warning} bold onMouseUp={onDismiss}>
155
+ [ DISMISS ]
156
+ </text>
157
+ </box>
158
+
159
+ {/* Divider */}
160
+ <text fg={Colors.border} dim>
161
+ {"─".repeat(80)}
162
+ </text>
163
+
164
+ {/* Error state */}
165
+ {state.kind === "error" && (
166
+ <box flexDirection="column" gap={0} paddingTop={1}>
167
+ <text fg={Colors.error} bold>
168
+ Auto-repair failed to run:
169
+ </text>
170
+ <text fg={Colors.foreground}>{state.message}</text>
171
+ </box>
172
+ )}
173
+
174
+ {/* Running state */}
175
+ {state.kind === "running" && (
176
+ <box flexDirection="column" gap={0} paddingTop={1}>
177
+ <text fg={Colors.muted} dim>
178
+ Running `hoox check fix` — this may take up to 60 seconds.
179
+ </text>
180
+ </box>
181
+ )}
182
+
183
+ {/* Results state */}
184
+ {state.kind === "results" && (
185
+ <box flexDirection="column" gap={0} paddingTop={1}>
186
+ {state.items.length === 0 ? (
187
+ <text fg={Colors.muted} dim>
188
+ No repairs were needed — all checks passed.
189
+ </text>
190
+ ) : (
191
+ state.items.map((item, idx) => {
192
+ const status: "applied" | "failed" | "skipped" = item.error
193
+ ? "failed"
194
+ : item.applied
195
+ ? "applied"
196
+ : "skipped";
197
+ return (
198
+ <box
199
+ key={`repair-${idx}-${item.target}`}
200
+ flexDirection="column"
201
+ gap={0}
202
+ >
203
+ <box flexDirection="row" gap={1} paddingLeft={1}>
204
+ <RepairStatusBadge status={status} />
205
+ <text fg={Colors.foreground} bold={status === "failed"}>
206
+ {item.description}
207
+ </text>
208
+ </box>
209
+ {/* Target */}
210
+ <text fg={Colors.dim} dim paddingLeft={6}>
211
+ {" target: "}
212
+ {item.target}
213
+ </text>
214
+ {/* Error details */}
215
+ {item.error && (
216
+ <text fg={Colors.error} paddingLeft={6}>
217
+ {" error: "}
218
+ {item.error}
219
+ </text>
220
+ )}
221
+ </box>
222
+ );
223
+ })
224
+ )}
225
+ </box>
226
+ )}
227
+ </box>
228
+ );
229
+ }
@@ -0,0 +1,276 @@
1
+ /**
2
+ * Copyright (c) 2026 HOOX · HOOX · jango-blockchained (hoox-sh)
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /** @jsxImportSource @opentui/react */
7
+ import { useState, useCallback, useEffect } from "react";
8
+ import { Colors, WorkerStatusColor } from "@hoox-sh/hoox-shared";
9
+ import { StatusDot } from "../../shared/status-dot";
10
+ import { cliBridge } from "../../../services/cli-bridge";
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;
15
+
16
+ /**
17
+ * Map model status to a StatusDot-compatible status string.
18
+ * online → operational, degraded → degraded, offline → down
19
+ */
20
+ function modelStatusToDot(
21
+ status: ModelHealth["status"]
22
+ ): "operational" | "degraded" | "down" {
23
+ if (status === "online") return "operational";
24
+ if (status === "degraded") return "degraded";
25
+ return "down";
26
+ }
27
+
28
+ /** Model status color via shared worker status map. */
29
+ function modelStatusColor(status: ModelHealth["status"]): string {
30
+ return WorkerStatusColor[modelStatusToDot(status)];
31
+ }
32
+
33
+ /**
34
+ * ModelHealthRow — a single expandable row showing one AI provider.
35
+ * Click to expand/collapse detailed stats (latency, daily usage, error).
36
+ */
37
+ function ModelHealthRow({
38
+ model,
39
+ isExpanded,
40
+ onToggle,
41
+ }: {
42
+ model: ModelHealth;
43
+ isExpanded: boolean;
44
+ onToggle: () => void;
45
+ }) {
46
+ const color = modelStatusColor(model.status);
47
+
48
+ return (
49
+ <box flexDirection="column" gap={0}>
50
+ {/* Main row — always visible */}
51
+ <box flexDirection="row" gap={1} paddingLeft={1}>
52
+ {/* Expand/collapse indicator */}
53
+ <text fg={Colors.muted} dim>
54
+ {isExpanded ? "▼" : "▶"}
55
+ </text>
56
+
57
+ {/* Status dot */}
58
+ <StatusDot status={modelStatusToDot(model.status)} />
59
+
60
+ {/* Provider name */}
61
+ <text fg={Colors.foreground} bold>
62
+ {model.name}
63
+ </text>
64
+
65
+ {/* Model identifier (truncated) */}
66
+ <text fg={Colors.muted} dim>
67
+ {model.model.length > 30
68
+ ? model.model.slice(0, 27) + "…"
69
+ : model.model}
70
+ </text>
71
+
72
+ {/* Expand hint */}
73
+ <text fg={Colors.accent} onMouseUp={onToggle}>
74
+ [DETAILS]
75
+ </text>
76
+ </box>
77
+
78
+ {/* Expanded details */}
79
+ {isExpanded && (
80
+ <box flexDirection="column" gap={0} paddingLeft={6}>
81
+ {/* Latency */}
82
+ <box flexDirection="row" gap={1} paddingLeft={2}>
83
+ <text fg={Colors.muted} dim>
84
+ latency:
85
+ </text>
86
+ <text fg={color}>
87
+ {model.latencyMs !== null ? `${model.latencyMs}ms` : "-"}
88
+ </text>
89
+ </box>
90
+
91
+ {/* Daily requests */}
92
+ <box flexDirection="row" gap={1} paddingLeft={2}>
93
+ <text fg={Colors.muted} dim>
94
+ daily requests:
95
+ </text>
96
+ <text fg={Colors.info}>
97
+ {model.dailyRequests !== null
98
+ ? model.dailyRequests.toLocaleString()
99
+ : "-"}
100
+ </text>
101
+ </box>
102
+
103
+ {/* Error message (if any) */}
104
+ {model.error && (
105
+ <text fg={Colors.error}>
106
+ {" error: "}
107
+ {model.error}
108
+ </text>
109
+ )}
110
+ </box>
111
+ )}
112
+ </box>
113
+ );
114
+ }
115
+
116
+ /**
117
+ * ModelHealthSection — displays health status of all configured AI providers.
118
+ *
119
+ * Auto-refreshes every 30 seconds when the dashboard is active.
120
+ * Supports multiple providers: Workers AI, OpenAI, Anthropic, Google, Azure.
121
+ * Click on a provider to expand detailed stats (latency, daily usage, error).
122
+ * Cancels in-flight probes on unmount; race-safe via generation counter.
123
+ */
124
+ function ModelHealthSection() {
125
+ const [providers, setProviders] = useState<ModelHealth[]>([]);
126
+ const [expandedIndex, setExpandedIndex] = useState<number | null>(null);
127
+ const [loading, setLoading] = useState(true);
128
+ const [error, setError] = useState<string | null>(null);
129
+ const [lastRefresh, setLastRefresh] = useState<number | null>(null);
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);
162
+ }
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
+ };
173
+ }, []);
174
+
175
+ const toggleExpand = useCallback((index: number) => {
176
+ setExpandedIndex((prev) => (prev === index ? null : index));
177
+ }, []);
178
+
179
+ if (loading && providers.length === 0) {
180
+ return (
181
+ <box flexDirection="column" gap={0}>
182
+ <text fg={Colors.foreground} bold dim>
183
+ AI MODEL HEALTH
184
+ </text>
185
+ <text fg={Colors.muted} dim paddingTop={1}>
186
+ Checking provider status…
187
+ </text>
188
+ </box>
189
+ );
190
+ }
191
+
192
+ if (providers.length === 0) {
193
+ return (
194
+ <box flexDirection="column" gap={0}>
195
+ <text fg={Colors.foreground} bold dim>
196
+ AI MODEL HEALTH
197
+ </text>
198
+ <text fg={error ? Colors.warning : Colors.muted} dim paddingTop={1}>
199
+ {error
200
+ ? `Unavailable: ${error.length > 50 ? error.slice(0, 47) + "…" : error}`
201
+ : "No AI providers configured"}
202
+ </text>
203
+ </box>
204
+ );
205
+ }
206
+
207
+ // Summary counts
208
+ const onlineCount = providers.filter((p) => p.status === "online").length;
209
+ const degradedCount = providers.filter((p) => p.status === "degraded").length;
210
+ const offlineCount = providers.filter((p) => p.status === "offline").length;
211
+
212
+ return (
213
+ <box flexDirection="column" gap={0}>
214
+ {/* Section header */}
215
+ <box flexDirection="row" gap={2}>
216
+ <text fg={Colors.foreground} bold dim>
217
+ AI MODEL HEALTH
218
+ </text>
219
+
220
+ {/* Status summary badges */}
221
+ {onlineCount > 0 && (
222
+ <text fg={Colors.success} dim>
223
+ {onlineCount} online
224
+ </text>
225
+ )}
226
+ {degradedCount > 0 && (
227
+ <text fg={Colors.warning} dim>
228
+ {degradedCount} degraded
229
+ </text>
230
+ )}
231
+ {offlineCount > 0 && (
232
+ <text fg={Colors.error} dim>
233
+ {offlineCount} offline
234
+ </text>
235
+ )}
236
+
237
+ {error && (
238
+ <text fg={Colors.warning} dim>
239
+ probe warn
240
+ </text>
241
+ )}
242
+
243
+ {/* Last refresh timestamp */}
244
+ {lastRefresh !== null && (
245
+ <text fg={Colors.muted} dim>
246
+ {`updated ${new Date(lastRefresh).toLocaleTimeString()}`}
247
+ </text>
248
+ )}
249
+ </box>
250
+
251
+ {/* Provider list */}
252
+ <box
253
+ flexDirection="column"
254
+ gap={0}
255
+ paddingTop={1}
256
+ border={true}
257
+ borderStyle="single"
258
+ borderColor={Colors.border}
259
+ backgroundColor={Colors.card}
260
+ paddingX={1}
261
+ paddingY={0}
262
+ >
263
+ {providers.map((model, index) => (
264
+ <ModelHealthRow
265
+ key={model.name}
266
+ model={model}
267
+ isExpanded={expandedIndex === index}
268
+ onToggle={() => toggleExpand(index)}
269
+ />
270
+ ))}
271
+ </box>
272
+ </box>
273
+ );
274
+ }
275
+
276
+ export { ModelHealthSection };