@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.
- package/dist/main.js +1069 -2734
- package/package.json +3 -3
- package/src/app.tsx +15 -11
- package/src/components/shared/shared.test.tsx +2 -2
- package/src/components/ui/connection-toasts.ts +6 -0
- package/src/components/ui/dialog-state.ts +2 -0
- package/src/components/ui/dialog.tsx +0 -13
- package/src/components/views/ai-chat.tsx +6 -1
- package/src/components/views/config-editor/code-editor.tsx +8 -6
- package/src/components/views/config-editor.test.tsx +20 -17
- package/src/components/views/config-editor.tsx +8 -6
- package/src/components/views/dashboard/model-health-section.tsx +38 -46
- package/src/components/views/dashboard/pyne-health-section.tsx +36 -41
- package/src/components/views/dashboard.tsx +25 -28
- package/src/components/views/kv-viewer.test.tsx +4 -16
- package/src/components/views/kv-viewer.tsx +9 -13
- package/src/components/views/logs-viewer.test.tsx +4 -4
- package/src/components/views/queue-depth.test.tsx +5 -7
- package/src/components/views/queue-depth.tsx +10 -14
- package/src/components/views/secrets-viewer.test.tsx +1 -1
- package/src/components/views/secrets-viewer.tsx +11 -15
- package/src/components/views/service-manager.tsx +1 -0
- package/src/components/views/settings.test.tsx +2 -7
- package/src/components/views/settings.tsx +43 -60
- package/src/components/views/setup-wizard.test.tsx +12 -14
- package/src/components/views/setup-wizard.tsx +61 -59
- package/src/components/views/worker-detail.test.tsx +2 -2
- package/src/components/views/worker-settings.tsx +6 -6
- package/src/components/views/workers-overview.tsx +1 -1
- package/src/hooks/hooks.test.ts +10 -174
- package/src/hooks/index.ts +1 -3
- package/src/hooks/renderer-ref.ts +1 -0
- package/src/hooks/use-polling.ts +12 -0
- package/src/services/cli-bridge/index.ts +56 -12
- package/src/services/cli-bridge/standalone.ts +4 -1
- package/src/services/cli-bridge/types.ts +6 -9
- package/src/services/cli-bridge.test.ts +20 -4
- package/src/services/clipboard.ts +2 -2
- package/src/stores/service-store.test.ts +29 -26
- package/src/stores/session-restore.test.ts +2 -0
- package/src/test-setup.ts +5 -13
- package/src/test-utils.ts +4 -24
- package/src/types.ts +9 -47
- package/src/utils/colors.test.ts +8 -3
- package/src/view-registry.test.ts +2 -19
- package/src/view-registry.tsx +7 -20
- package/src/components/shared/animated-border.tsx +0 -58
- package/src/components/shared/keybinding-hint.tsx +0 -37
- package/src/components/ui/select.tsx +0 -49
- package/src/hooks/use-keyboard.ts +0 -130
- package/src/hooks/use-service-data.ts +0 -35
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hoox-sh/hoox-tui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Hoox algorithmic trading terminal operations center — OpenTUI dashboard",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/main.js",
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
"prepublishOnly": "bun run typecheck && bun run build"
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"@hoox-sh/hoox-shared": "^1.
|
|
35
|
+
"@hoox-sh/hoox-shared": "^1.6.0",
|
|
36
36
|
"@opentui/core": "0.5.1",
|
|
37
37
|
"@opentui/react": "0.5.1",
|
|
38
38
|
"immer": "^11.1.8",
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
"zustand": "^5.0.0"
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
|
44
|
-
"@cloudflare/workers-types": "^5.
|
|
44
|
+
"@cloudflare/workers-types": "^5.20261001.1",
|
|
45
45
|
"@hoox/test-utils": "^0.0.1",
|
|
46
46
|
"@types/bun": "latest",
|
|
47
47
|
"typescript": "^6.0.3"
|
package/src/app.tsx
CHANGED
|
@@ -20,7 +20,12 @@
|
|
|
20
20
|
*/
|
|
21
21
|
import { useState, useEffect, useCallback } from "react";
|
|
22
22
|
import { useKeyboard } from "@opentui/react";
|
|
23
|
-
import {
|
|
23
|
+
import {
|
|
24
|
+
useServiceStore,
|
|
25
|
+
useUIStore,
|
|
26
|
+
useConfigStore,
|
|
27
|
+
Colors,
|
|
28
|
+
} from "@hoox-sh/hoox-shared";
|
|
24
29
|
import { restoreSession, saveSession } from "@hoox-sh/hoox-shared";
|
|
25
30
|
import type { ViewId } from "@hoox-sh/hoox-shared";
|
|
26
31
|
|
|
@@ -41,11 +46,7 @@ import {
|
|
|
41
46
|
toastReconnectedMode,
|
|
42
47
|
} from "./components/ui/connection-toasts";
|
|
43
48
|
import { getRendererRef } from "./hooks";
|
|
44
|
-
import {
|
|
45
|
-
DialogProvider,
|
|
46
|
-
useDialog,
|
|
47
|
-
type DialogHandle,
|
|
48
|
-
} from "./components/ui/dialog";
|
|
49
|
+
import { DialogProvider, useDialog } from "./components/ui/dialog";
|
|
49
50
|
import {
|
|
50
51
|
getViewFactory,
|
|
51
52
|
getViewShortcutMap,
|
|
@@ -132,11 +133,15 @@ function AppRootInner({ safeMode = false }: { safeMode?: boolean }) {
|
|
|
132
133
|
// Shared restoreSession validates all 16 ViewIds (unknown → dashboard).
|
|
133
134
|
useEffect(() => {
|
|
134
135
|
let cancelled = false;
|
|
135
|
-
(async () => {
|
|
136
|
+
void (async () => {
|
|
136
137
|
const session = await restoreSession();
|
|
137
138
|
if (cancelled) return;
|
|
138
139
|
|
|
139
|
-
const
|
|
140
|
+
const preferred = useConfigStore.getState().defaultView;
|
|
141
|
+
const view: ViewId =
|
|
142
|
+
!session.fromDisk && isRegisteredViewId(preferred)
|
|
143
|
+
? preferred
|
|
144
|
+
: session.activeView;
|
|
140
145
|
const expanded = session.sidebarExpanded;
|
|
141
146
|
|
|
142
147
|
if (isRegisteredViewId(view)) {
|
|
@@ -170,7 +175,7 @@ function AppRootInner({ safeMode = false }: { safeMode?: boolean }) {
|
|
|
170
175
|
}
|
|
171
176
|
let cancelled = false;
|
|
172
177
|
|
|
173
|
-
(async () => {
|
|
178
|
+
void (async () => {
|
|
174
179
|
const store = useServiceStore.getState();
|
|
175
180
|
const conn = resolveTuiConnectionEnv();
|
|
176
181
|
await tuiDevLog.info("connection", "startup data load begin", {
|
|
@@ -228,8 +233,7 @@ function AppRootInner({ safeMode = false }: { safeMode?: boolean }) {
|
|
|
228
233
|
if (result.success && result.data) {
|
|
229
234
|
const raw = result.data as Record<string, unknown>;
|
|
230
235
|
const rawWorkers = (raw.workers ?? raw.status ?? raw) as
|
|
231
|
-
| unknown
|
|
232
|
-
| Record<string, unknown>;
|
|
236
|
+
unknown[] | Record<string, unknown>;
|
|
233
237
|
const parsed = Array.isArray(rawWorkers)
|
|
234
238
|
? rawWorkers
|
|
235
239
|
: typeof rawWorkers === "object" && rawWorkers !== null
|
|
@@ -395,13 +395,13 @@ describe("Shared Components", () => {
|
|
|
395
395
|
it("filters to matching commands", () => {
|
|
396
396
|
const result = filterCommands(SAMPLE_COMMANDS, "dash");
|
|
397
397
|
expect(result.length).toBe(1);
|
|
398
|
-
expect(result[0]
|
|
398
|
+
expect(result[0]?.name).toBe("Dashboard");
|
|
399
399
|
});
|
|
400
400
|
|
|
401
401
|
it("sorts by score (best match first)", () => {
|
|
402
402
|
const result = filterCommands(SAMPLE_COMMANDS, "theme");
|
|
403
403
|
expect(result.length).toBe(2);
|
|
404
|
-
expect(result[0]
|
|
404
|
+
expect(result[0]?.name).toBe("Dark Theme"); // or Light Theme
|
|
405
405
|
});
|
|
406
406
|
|
|
407
407
|
it("deduplicates by id", () => {
|
|
@@ -17,16 +17,19 @@ import { remoteAuthMissingHint } from "../../services/tui-connection";
|
|
|
17
17
|
|
|
18
18
|
// ─── Pure message builders (testable without toast side effects) ─────────────
|
|
19
19
|
|
|
20
|
+
/** Status-bar toast copy for a successful LOCAL/REMOTE connect. */
|
|
20
21
|
export function messageConnected(mode: TuiMode, host: string): string {
|
|
21
22
|
const label = mode === "remote" ? "REMOTE" : "LOCAL";
|
|
22
23
|
return `Connected · ${label} · ${host}`;
|
|
23
24
|
}
|
|
24
25
|
|
|
26
|
+
/** Status-bar toast copy when the LOCAL/REMOTE session drops. */
|
|
25
27
|
export function messageConnectionLost(mode: TuiMode, host: string): string {
|
|
26
28
|
const label = mode === "remote" ? "REMOTE" : "LOCAL";
|
|
27
29
|
return `Connection lost · ${label} · ${host}`;
|
|
28
30
|
}
|
|
29
31
|
|
|
32
|
+
/** Status-bar toast copy after a reconnect, including downtime. */
|
|
30
33
|
export function messageReconnected(
|
|
31
34
|
mode: TuiMode,
|
|
32
35
|
host: string,
|
|
@@ -38,15 +41,18 @@ export function messageReconnected(
|
|
|
38
41
|
return `Reconnected · ${label} · ${host} · ${duration} downtime`;
|
|
39
42
|
}
|
|
40
43
|
|
|
44
|
+
/** Status-bar toast copy when auth is rejected. */
|
|
41
45
|
export function messageAuthRequired(mode: TuiMode, host: string): string {
|
|
42
46
|
const label = mode === "remote" ? "REMOTE" : "LOCAL";
|
|
43
47
|
return `Auth failed · ${label} · ${host}`;
|
|
44
48
|
}
|
|
45
49
|
|
|
50
|
+
/** Status-bar toast copy when remote mode has no API credentials. */
|
|
46
51
|
export function messageAuthMissing(host: string): string {
|
|
47
52
|
return `No API credentials · REMOTE · ${host}`;
|
|
48
53
|
}
|
|
49
54
|
|
|
55
|
+
/** Status-bar toast copy when the TUI starts without a live connection. */
|
|
50
56
|
export function messageOfflineStartup(
|
|
51
57
|
mode: TuiMode,
|
|
52
58
|
host: string,
|
|
@@ -6,10 +6,12 @@
|
|
|
6
6
|
/** Module-level open count for DialogProvider (avoids React store cycles). */
|
|
7
7
|
let openDialogCount = 0;
|
|
8
8
|
|
|
9
|
+
/** Record how many Hoox dialogs are currently open (shell overlay gate). */
|
|
9
10
|
export function setOpenDialogCount(n: number): void {
|
|
10
11
|
openDialogCount = Math.max(0, n);
|
|
11
12
|
}
|
|
12
13
|
|
|
14
|
+
/** True when any in-house dialog owns the keyboard. */
|
|
13
15
|
export function isHooxDialogOpen(): boolean {
|
|
14
16
|
return openDialogCount > 0;
|
|
15
17
|
}
|
|
@@ -541,16 +541,3 @@ export function showLoading(dialog: DialogHandle, message: string): () => void {
|
|
|
541
541
|
const id = dialog.show({ message });
|
|
542
542
|
return () => dialog.close(id);
|
|
543
543
|
}
|
|
544
|
-
|
|
545
|
-
// Re-export for select.tsx / tests that referenced dialog keyboard helpers
|
|
546
|
-
export type DialogId = string | number;
|
|
547
|
-
|
|
548
|
-
/** @deprecated No-op; local keyboard is handled inside overlays. */
|
|
549
|
-
export function useDialogKeyboard(
|
|
550
|
-
_handler: (key: { name?: string }) => void,
|
|
551
|
-
_dialogId?: DialogId
|
|
552
|
-
): void {
|
|
553
|
-
// Overlays own their own useKeyboard; this stub avoids @opentui-ui/dialog.
|
|
554
|
-
void _handler;
|
|
555
|
-
void _dialogId;
|
|
556
|
-
}
|
|
@@ -229,7 +229,12 @@ export function AiChatView() {
|
|
|
229
229
|
const [historyReady, setHistoryReady] = useState(false);
|
|
230
230
|
const [inputText, setInputText] = useState("");
|
|
231
231
|
const [selectedModel, setSelectedModel] = useState<AiModelOption>(
|
|
232
|
-
|
|
232
|
+
() =>
|
|
233
|
+
AI_MODEL_OPTIONS[0] ?? {
|
|
234
|
+
id: "workers-ai",
|
|
235
|
+
label: "Workers AI (Llama 3.1)",
|
|
236
|
+
provider: "cloudflare",
|
|
237
|
+
}
|
|
233
238
|
);
|
|
234
239
|
const [streamingContent, setStreamingContent] = useState("");
|
|
235
240
|
const [isStreaming, setIsStreaming] = useState(false);
|
|
@@ -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
|
|
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
|
-
|
|
206
|
-
|
|
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]
|
|
105
|
-
expect(CONFIG_TREE_BLUEPRINT[0]
|
|
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
|
|
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
|
|
117
|
-
|
|
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]
|
|
143
|
-
expect(flat[0]
|
|
144
|
-
expect(flat[1]
|
|
145
|
-
expect(flat[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]
|
|
184
|
-
expect(tokens[0]
|
|
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]
|
|
393
|
-
expect(errors[0]
|
|
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]
|
|
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]
|
|
411
|
+
expect(errors[0]?.message.length).toBeGreaterThan(0);
|
|
409
412
|
// Must not be the old generic "Unbalanced brackets" message.
|
|
410
|
-
expect(errors[0]
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
if (
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
if
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
if (
|
|
39
|
-
|
|
40
|
-
|
|
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:
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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];
|