@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.
- package/README.md +247 -0
- package/bin/hoox-tui.js +35 -0
- package/dist/main.js +17594 -0
- package/package.json +72 -0
- package/src/app.tsx +696 -0
- package/src/cli-bridge-test-double.ts +212 -0
- package/src/components/layout/layout.test.tsx +205 -0
- package/src/components/layout/sidebar.tsx +69 -0
- package/src/components/layout/statusbar.test.tsx +423 -0
- package/src/components/layout/statusbar.tsx +348 -0
- package/src/components/shared/animated-border.tsx +58 -0
- package/src/components/shared/chrome.test.tsx +29 -0
- package/src/components/shared/command-palette.tsx +361 -0
- package/src/components/shared/cool-brackets.tsx +75 -0
- package/src/components/shared/crash-screen.tsx +167 -0
- package/src/components/shared/error-boundary.tsx +99 -0
- package/src/components/shared/keybinding-hint.tsx +37 -0
- package/src/components/shared/panel.tsx +61 -0
- package/src/components/shared/quit-modal.tsx +65 -0
- package/src/components/shared/shared.test.tsx +538 -0
- package/src/components/shared/spinner.tsx +85 -0
- package/src/components/shared/status-dot.tsx +54 -0
- package/src/components/shared/view-header.tsx +53 -0
- package/src/components/ui/connection-toasts.test.ts +50 -0
- package/src/components/ui/connection-toasts.ts +215 -0
- package/src/components/ui/dialog.tsx +281 -0
- package/src/components/ui/select.tsx +197 -0
- package/src/components/ui/toast.tsx +185 -0
- package/src/components/views/ai-chat.test.tsx +81 -0
- package/src/components/views/ai-chat.tsx +576 -0
- package/src/components/views/config-editor/action-bar.tsx +102 -0
- package/src/components/views/config-editor/code-editor.tsx +597 -0
- package/src/components/views/config-editor/file-tree.tsx +113 -0
- package/src/components/views/config-editor/types.ts +37 -0
- package/src/components/views/config-editor.test.tsx +465 -0
- package/src/components/views/config-editor.tsx +709 -0
- package/src/components/views/dashboard/alerts-panel.tsx +154 -0
- package/src/components/views/dashboard/auto-repair-panel.tsx +229 -0
- package/src/components/views/dashboard/model-health-section.tsx +276 -0
- package/src/components/views/dashboard/pyne-health-section.tsx +112 -0
- package/src/components/views/dashboard.test.tsx +347 -0
- package/src/components/views/dashboard.tsx +647 -0
- package/src/components/views/db-query.test.tsx +115 -0
- package/src/components/views/db-query.tsx +849 -0
- package/src/components/views/edge-topology.test.tsx +198 -0
- package/src/components/views/edge-topology.tsx +572 -0
- package/src/components/views/kv-viewer.test.tsx +241 -0
- package/src/components/views/kv-viewer.tsx +717 -0
- package/src/components/views/logs-viewer.test.tsx +273 -0
- package/src/components/views/logs-viewer.tsx +616 -0
- package/src/components/views/queue-depth.test.tsx +221 -0
- package/src/components/views/queue-depth.tsx +376 -0
- package/src/components/views/secrets-viewer.test.tsx +65 -0
- package/src/components/views/secrets-viewer.tsx +548 -0
- package/src/components/views/service-manager.test.tsx +358 -0
- package/src/components/views/service-manager.tsx +946 -0
- package/src/components/views/settings.test.tsx +648 -0
- package/src/components/views/settings.tsx +1497 -0
- package/src/components/views/setup-wizard.test.tsx +297 -0
- package/src/components/views/setup-wizard.tsx +1321 -0
- package/src/components/views/trade-monitor.test.tsx +592 -0
- package/src/components/views/trade-monitor.tsx +745 -0
- package/src/components/views/worker-detail.test.tsx +289 -0
- package/src/components/views/worker-detail.tsx +676 -0
- package/src/components/views/worker-settings.test.tsx +43 -0
- package/src/components/views/worker-settings.tsx +563 -0
- package/src/components/views/workers-overview.test.tsx +260 -0
- package/src/components/views/workers-overview.tsx +631 -0
- package/src/hooks/hooks.test.ts +72 -0
- package/src/hooks/index.ts +11 -0
- package/src/hooks/renderer-ref.ts +22 -0
- package/src/hooks/use-keyboard.ts +130 -0
- package/src/hooks/use-polling.ts +73 -0
- package/src/hooks/use-service-data.ts +18 -0
- package/src/main.tsx +117 -0
- package/src/network-test-double.ts +133 -0
- package/src/opentui-augment.d.ts +45 -0
- package/src/opentui-jsx.d.ts +70 -0
- package/src/services/cli-bridge/index.ts +1662 -0
- package/src/services/cli-bridge/standalone.ts +315 -0
- package/src/services/cli-bridge/types.ts +307 -0
- package/src/services/cli-bridge.test.ts +406 -0
- package/src/services/clipboard.test.ts +57 -0
- package/src/services/clipboard.ts +180 -0
- package/src/services/dashboard-settings-loader.ts +31 -0
- package/src/services/dev-log.test.ts +168 -0
- package/src/services/dev-log.ts +194 -0
- package/src/services/hoox-path-service.test.ts +183 -0
- package/src/services/hoox-path-service.ts +185 -0
- package/src/services/tui-connection.test.ts +212 -0
- package/src/services/tui-connection.ts +236 -0
- package/src/services/tui-storage.test.ts +92 -0
- package/src/services/tui-storage.ts +86 -0
- package/src/stores/config-store.test.ts +360 -0
- package/src/stores/service-store.test.ts +1026 -0
- package/src/stores/session-restore.test.ts +134 -0
- package/src/stores/ui-store.test.ts +276 -0
- package/src/test-setup.ts +265 -0
- package/src/test-utils.ts +179 -0
- package/src/types.ts +153 -0
- package/src/utils/colors.test.ts +326 -0
- package/src/utils/formatters.test.ts +236 -0
- package/src/view-registry.test.ts +132 -0
- package/src/view-registry.tsx +368 -0
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2026 HOOX · HOOX · jango-blockchained (hoox-sh)
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Unit tests for WorkerSettingsView contracts:
|
|
8
|
+
* secret fields stay CLI-only, dangerous writes need double-confirm, etc.
|
|
9
|
+
*/
|
|
10
|
+
import { describe, it, expect } from "bun:test";
|
|
11
|
+
import { WorkerSettingsView } from "./worker-settings";
|
|
12
|
+
|
|
13
|
+
describe("WorkerSettingsView", () => {
|
|
14
|
+
it("is a function component", () => {
|
|
15
|
+
expect(WorkerSettingsView).toBeInstanceOf(Function);
|
|
16
|
+
expect(WorkerSettingsView.name).toBe("WorkerSettingsView");
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("secret fields never display cleartext values (display contract)", () => {
|
|
20
|
+
// Mirrors format path in the view: secrets always render as CLI placeholder.
|
|
21
|
+
const isSecret = true;
|
|
22
|
+
const display = isSecret ? "(secret — CLI)" : "should-not-appear";
|
|
23
|
+
expect(display).toBe("(secret — CLI)");
|
|
24
|
+
expect(display).not.toMatch(/sk-|token|password/i);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("dangerous fields require a second confirm before write (contract)", () => {
|
|
28
|
+
// Mirrors saveCurrent double-Enter gate for kind === "dangerous"
|
|
29
|
+
let pending: string | null = null;
|
|
30
|
+
const fieldKey = "risk:kill_switch";
|
|
31
|
+
const kind = "dangerous" as const;
|
|
32
|
+
|
|
33
|
+
// First Enter → arm confirm
|
|
34
|
+
if (kind === "dangerous" && pending !== fieldKey) {
|
|
35
|
+
pending = fieldKey;
|
|
36
|
+
}
|
|
37
|
+
expect(pending).toBe(fieldKey);
|
|
38
|
+
|
|
39
|
+
// Second Enter with same key → proceed
|
|
40
|
+
const shouldWrite = kind === "dangerous" && pending === fieldKey;
|
|
41
|
+
expect(shouldWrite).toBe(true);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -0,0 +1,563 @@
|
|
|
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
|
+
/**
|
|
8
|
+
* Worker Settings View — same options as the web dashboard Settings form,
|
|
9
|
+
* driven by workers/<name>/dashboard.jsonc manifests.
|
|
10
|
+
*
|
|
11
|
+
* Layout:
|
|
12
|
+
* Header
|
|
13
|
+
* [ Workers list ] [ Sections + fields for selected worker ]
|
|
14
|
+
*
|
|
15
|
+
* Values load from CONFIG_KV via `hoox config kv get`; saves use
|
|
16
|
+
* `hoox config kv set`. Secret fields are read-only (CLI command shown).
|
|
17
|
+
*/
|
|
18
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
19
|
+
import { useKeyboard } from "@opentui/react";
|
|
20
|
+
import {
|
|
21
|
+
Colors,
|
|
22
|
+
AGENT_CONFIG_KV_KEY,
|
|
23
|
+
applyAgentConfigFieldUpdates,
|
|
24
|
+
buildDashboardKvKey,
|
|
25
|
+
getAgentConfigEmbeddedValue,
|
|
26
|
+
isAgentConfigEmbeddedField,
|
|
27
|
+
isDashboardSectionEditable,
|
|
28
|
+
parseAgentConfigJson,
|
|
29
|
+
serializeAgentConfigForKv,
|
|
30
|
+
useUIStore,
|
|
31
|
+
type DashboardSettingField,
|
|
32
|
+
type WorkerDashboardManifest,
|
|
33
|
+
} from "@hoox-sh/hoox-shared";
|
|
34
|
+
import { ErrorBoundary } from "../shared/error-boundary";
|
|
35
|
+
import { ViewHeader } from "../shared/view-header";
|
|
36
|
+
import { Panel } from "../shared/panel";
|
|
37
|
+
import { Spinner, EmptyState } from "../shared/spinner";
|
|
38
|
+
import { cliBridge } from "../../services/cli-bridge";
|
|
39
|
+
import { loadDashboardSettingsManifests } from "../../services/dashboard-settings-loader";
|
|
40
|
+
|
|
41
|
+
function parseKvValue(
|
|
42
|
+
raw: string | null,
|
|
43
|
+
field: DashboardSettingField
|
|
44
|
+
): string | number | boolean {
|
|
45
|
+
if (raw === null || raw === "") return field.default;
|
|
46
|
+
let v: unknown = raw;
|
|
47
|
+
try {
|
|
48
|
+
v = JSON.parse(raw);
|
|
49
|
+
} catch {
|
|
50
|
+
// keep raw string
|
|
51
|
+
}
|
|
52
|
+
if (field.type === "boolean") {
|
|
53
|
+
if (typeof v === "boolean") return v;
|
|
54
|
+
if (v === "true") return true;
|
|
55
|
+
if (v === "false") return false;
|
|
56
|
+
return Boolean(field.default);
|
|
57
|
+
}
|
|
58
|
+
if (field.type === "number") {
|
|
59
|
+
const n = typeof v === "number" ? v : Number(v);
|
|
60
|
+
return Number.isFinite(n) ? n : (field.default as number);
|
|
61
|
+
}
|
|
62
|
+
return typeof v === "string" ? v : String(v);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Wire format for CONFIG_KV — match CLI apply-manifest / worker readers:
|
|
67
|
+
* plain strings stored raw; booleans/numbers as JSON literals.
|
|
68
|
+
*/
|
|
69
|
+
function formatForKv(value: string | number | boolean): string {
|
|
70
|
+
if (typeof value === "string") return value;
|
|
71
|
+
return JSON.stringify(value);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function displayValue(value: string | number | boolean): string {
|
|
75
|
+
if (typeof value === "boolean") return value ? "true" : "false";
|
|
76
|
+
return String(value);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function WorkerSettingsView() {
|
|
80
|
+
const activeView = useUIStore((s) => s.activeView);
|
|
81
|
+
const isActive = activeView === "worker-settings";
|
|
82
|
+
|
|
83
|
+
const [manifests, setManifests] = useState<WorkerDashboardManifest[]>([]);
|
|
84
|
+
const [workerIndex, setWorkerIndex] = useState(0);
|
|
85
|
+
const [fieldIndex, setFieldIndex] = useState(0);
|
|
86
|
+
const [values, setValues] = useState<
|
|
87
|
+
Record<string, string | number | boolean>
|
|
88
|
+
>({});
|
|
89
|
+
const [loading, setLoading] = useState(true);
|
|
90
|
+
const [saving, setSaving] = useState(false);
|
|
91
|
+
const [status, setStatus] = useState<string | null>(null);
|
|
92
|
+
const [error, setError] = useState<string | null>(null);
|
|
93
|
+
/**
|
|
94
|
+
* Field key awaiting a second Enter for dangerous writes.
|
|
95
|
+
* Cleared on navigation / cancel (Escape).
|
|
96
|
+
*/
|
|
97
|
+
const [pendingDangerousKey, setPendingDangerousKey] = useState<string | null>(
|
|
98
|
+
null
|
|
99
|
+
);
|
|
100
|
+
/** Monotonic generation so out-of-order loadValues results are ignored. */
|
|
101
|
+
const loadGeneration = useRef(0);
|
|
102
|
+
|
|
103
|
+
const selected = manifests[workerIndex] ?? null;
|
|
104
|
+
|
|
105
|
+
const flatFields = useMemo(() => {
|
|
106
|
+
if (!selected)
|
|
107
|
+
return [] as Array<{
|
|
108
|
+
sectionTitle: string;
|
|
109
|
+
field: DashboardSettingField;
|
|
110
|
+
kvKey: string;
|
|
111
|
+
}>;
|
|
112
|
+
const out: Array<{
|
|
113
|
+
sectionTitle: string;
|
|
114
|
+
field: DashboardSettingField;
|
|
115
|
+
kvKey: string;
|
|
116
|
+
}> = [];
|
|
117
|
+
for (const section of selected.sections) {
|
|
118
|
+
if (!isDashboardSectionEditable(section.id)) continue;
|
|
119
|
+
for (const field of section.fields) {
|
|
120
|
+
const embedded = isAgentConfigEmbeddedField(field.key);
|
|
121
|
+
out.push({
|
|
122
|
+
sectionTitle: section.title,
|
|
123
|
+
field,
|
|
124
|
+
kvKey: embedded
|
|
125
|
+
? `${AGENT_CONFIG_KV_KEY} · ${field.key}`
|
|
126
|
+
: buildDashboardKvKey(selected.worker, field.key),
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return out;
|
|
131
|
+
}, [selected]);
|
|
132
|
+
|
|
133
|
+
const loadValues = useCallback(async (manifest: WorkerDashboardManifest) => {
|
|
134
|
+
const gen = ++loadGeneration.current;
|
|
135
|
+
const fields: DashboardSettingField[] = [];
|
|
136
|
+
for (const section of manifest.sections) {
|
|
137
|
+
if (!isDashboardSectionEditable(section.id)) continue;
|
|
138
|
+
for (const field of section.fields) fields.push(field);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const needsAgentConfig = fields.some((f) =>
|
|
142
|
+
isAgentConfigEmbeddedField(f.key)
|
|
143
|
+
);
|
|
144
|
+
let agentConfig: Record<string, unknown> = {};
|
|
145
|
+
if (needsAgentConfig) {
|
|
146
|
+
const cfgResult = await cliBridge.configKvGet(AGENT_CONFIG_KV_KEY);
|
|
147
|
+
agentConfig = parseAgentConfigJson(
|
|
148
|
+
cfgResult.success ? cfgResult.data : null
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Parallel gets for flat keys; agent:config fields read from one blob.
|
|
153
|
+
const entries = await Promise.all(
|
|
154
|
+
fields.map(async (field) => {
|
|
155
|
+
if (field.kind === "secret") {
|
|
156
|
+
return [field.key, field.default] as const;
|
|
157
|
+
}
|
|
158
|
+
if (isAgentConfigEmbeddedField(field.key)) {
|
|
159
|
+
const v = getAgentConfigEmbeddedValue(agentConfig, field.key);
|
|
160
|
+
return [field.key, v ?? field.default] as const;
|
|
161
|
+
}
|
|
162
|
+
const kvKey = buildDashboardKvKey(manifest.worker, field.key);
|
|
163
|
+
const result = await cliBridge.configKvGet(kvKey);
|
|
164
|
+
return [
|
|
165
|
+
field.key,
|
|
166
|
+
parseKvValue(result.success ? result.data : null, field),
|
|
167
|
+
] as const;
|
|
168
|
+
})
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
if (gen !== loadGeneration.current) return;
|
|
172
|
+
const next: Record<string, string | number | boolean> = {};
|
|
173
|
+
for (const [k, v] of entries) next[k] = v;
|
|
174
|
+
setValues(next);
|
|
175
|
+
}, []);
|
|
176
|
+
|
|
177
|
+
const refresh = useCallback(async () => {
|
|
178
|
+
setLoading(true);
|
|
179
|
+
setError(null);
|
|
180
|
+
try {
|
|
181
|
+
const list = loadDashboardSettingsManifests();
|
|
182
|
+
setManifests(list);
|
|
183
|
+
if (list.length === 0) {
|
|
184
|
+
setError(
|
|
185
|
+
"No dashboard.jsonc manifests found. Set HOOX_REPO or run from the monorepo."
|
|
186
|
+
);
|
|
187
|
+
setLoading(false);
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
const idx = Math.min(workerIndex, list.length - 1);
|
|
191
|
+
setWorkerIndex(idx);
|
|
192
|
+
await loadValues(list[idx]!);
|
|
193
|
+
} catch (err) {
|
|
194
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
195
|
+
} finally {
|
|
196
|
+
setLoading(false);
|
|
197
|
+
}
|
|
198
|
+
}, [loadValues, workerIndex]);
|
|
199
|
+
|
|
200
|
+
useEffect(() => {
|
|
201
|
+
if (!isActive) return;
|
|
202
|
+
void refresh();
|
|
203
|
+
}, [isActive]); // refresh intentionally omitted — reload only on view enter
|
|
204
|
+
|
|
205
|
+
const selectWorker = useCallback(
|
|
206
|
+
async (idx: number) => {
|
|
207
|
+
if (idx < 0 || idx >= manifests.length) return;
|
|
208
|
+
setWorkerIndex(idx);
|
|
209
|
+
setFieldIndex(0);
|
|
210
|
+
setPendingDangerousKey(null);
|
|
211
|
+
setLoading(true);
|
|
212
|
+
try {
|
|
213
|
+
await loadValues(manifests[idx]!);
|
|
214
|
+
} finally {
|
|
215
|
+
// Only clear loading if this is still the latest load
|
|
216
|
+
if (loadGeneration.current > 0) setLoading(false);
|
|
217
|
+
}
|
|
218
|
+
},
|
|
219
|
+
[manifests, loadValues]
|
|
220
|
+
);
|
|
221
|
+
|
|
222
|
+
const cycleFieldValue = useCallback(
|
|
223
|
+
(delta: number) => {
|
|
224
|
+
const row = flatFields[fieldIndex];
|
|
225
|
+
if (!row || row.field.kind === "secret") return;
|
|
226
|
+
const { field } = row;
|
|
227
|
+
const current = values[field.key] ?? field.default;
|
|
228
|
+
|
|
229
|
+
if (field.type === "boolean") {
|
|
230
|
+
setValues((v) => ({ ...v, [field.key]: !current }));
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
if (
|
|
234
|
+
field.type === "select" &&
|
|
235
|
+
field.options &&
|
|
236
|
+
field.options.length > 0
|
|
237
|
+
) {
|
|
238
|
+
const opts = field.options.map((o) => o.value);
|
|
239
|
+
const cur = String(current);
|
|
240
|
+
let i = opts.indexOf(cur);
|
|
241
|
+
if (i < 0) i = 0;
|
|
242
|
+
const next = opts[(i + delta + opts.length) % opts.length]!;
|
|
243
|
+
// preserve number type when options are numeric defaults
|
|
244
|
+
const asNum = Number(next);
|
|
245
|
+
setValues((v) => ({
|
|
246
|
+
...v,
|
|
247
|
+
[field.key]:
|
|
248
|
+
field.default !== undefined && typeof field.default === "number"
|
|
249
|
+
? asNum
|
|
250
|
+
: next,
|
|
251
|
+
}));
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
if (field.type === "number") {
|
|
255
|
+
const n = typeof current === "number" ? current : Number(current);
|
|
256
|
+
const step = delta > 0 ? 1 : -1;
|
|
257
|
+
setValues((v) => ({
|
|
258
|
+
...v,
|
|
259
|
+
[field.key]: (Number.isFinite(n) ? n : 0) + step,
|
|
260
|
+
}));
|
|
261
|
+
}
|
|
262
|
+
},
|
|
263
|
+
[flatFields, fieldIndex, values]
|
|
264
|
+
);
|
|
265
|
+
|
|
266
|
+
const saveCurrent = useCallback(async () => {
|
|
267
|
+
const row = flatFields[fieldIndex];
|
|
268
|
+
if (!row || !selected) return;
|
|
269
|
+
if (row.field.kind === "secret") {
|
|
270
|
+
setPendingDangerousKey(null);
|
|
271
|
+
setStatus(
|
|
272
|
+
row.field.cliCommand
|
|
273
|
+
? `Secret — run: ${row.field.cliCommand}`
|
|
274
|
+
: "Secret — set via CLI (hoox secrets …)"
|
|
275
|
+
);
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Fail-closed confirm for dangerous fields: require a second Enter.
|
|
280
|
+
if (row.field.kind === "dangerous") {
|
|
281
|
+
if (pendingDangerousKey !== row.field.key) {
|
|
282
|
+
setPendingDangerousKey(row.field.key);
|
|
283
|
+
setStatus(
|
|
284
|
+
`Confirm dangerous write for "${row.field.label}" — press Enter again to save, Esc to cancel`
|
|
285
|
+
);
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
setPendingDangerousKey(null);
|
|
290
|
+
|
|
291
|
+
if (saving) return;
|
|
292
|
+
setSaving(true);
|
|
293
|
+
setStatus(null);
|
|
294
|
+
try {
|
|
295
|
+
const value = values[row.field.key] ?? row.field.default;
|
|
296
|
+
|
|
297
|
+
if (isAgentConfigEmbeddedField(row.field.key)) {
|
|
298
|
+
const cfgResult = await cliBridge.configKvGet(AGENT_CONFIG_KV_KEY);
|
|
299
|
+
if (!cfgResult.success && cfgResult.data == null) {
|
|
300
|
+
// Empty/missing agent:config is fine (starts from {}); only hard-fail on I/O errors
|
|
301
|
+
// when stderr indicates a real failure and data is unusable.
|
|
302
|
+
}
|
|
303
|
+
const current = parseAgentConfigJson(
|
|
304
|
+
cfgResult.success ? cfgResult.data : null
|
|
305
|
+
);
|
|
306
|
+
const next = applyAgentConfigFieldUpdates(current, {
|
|
307
|
+
[row.field.key]: value,
|
|
308
|
+
});
|
|
309
|
+
const result = await cliBridge.configKvSet(
|
|
310
|
+
AGENT_CONFIG_KV_KEY,
|
|
311
|
+
serializeAgentConfigForKv(next)
|
|
312
|
+
);
|
|
313
|
+
if (!result.success) {
|
|
314
|
+
setStatus(
|
|
315
|
+
`Save failed: ${result.stderr || result.stdout || "unknown error"}`
|
|
316
|
+
);
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
setStatus(
|
|
320
|
+
`Saved ${AGENT_CONFIG_KV_KEY} (${row.field.key}) = ${displayValue(value)}`
|
|
321
|
+
);
|
|
322
|
+
// risk:* also dual-write trade:* flat keys when they map (except embedded-only take_profit)
|
|
323
|
+
if (
|
|
324
|
+
row.field.key === "risk:max_daily_drawdown_percent" ||
|
|
325
|
+
row.field.key === "risk:trailing_stop_percent"
|
|
326
|
+
) {
|
|
327
|
+
const flatKey = buildDashboardKvKey(selected.worker, row.field.key);
|
|
328
|
+
const dual = await cliBridge.configKvSet(flatKey, formatForKv(value));
|
|
329
|
+
if (!dual.success) {
|
|
330
|
+
setStatus(
|
|
331
|
+
`Saved agent:config but dual-write ${flatKey} failed: ${dual.stderr || dual.stdout || "unknown error"}`
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
} else {
|
|
336
|
+
const flatKey = buildDashboardKvKey(selected.worker, row.field.key);
|
|
337
|
+
const result = await cliBridge.configKvSet(flatKey, formatForKv(value));
|
|
338
|
+
if (!result.success) {
|
|
339
|
+
setStatus(
|
|
340
|
+
`Save failed: ${result.stderr || result.stdout || "unknown error"}`
|
|
341
|
+
);
|
|
342
|
+
} else {
|
|
343
|
+
setStatus(`Saved ${flatKey} = ${displayValue(value)}`);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
} catch (err) {
|
|
347
|
+
setStatus(err instanceof Error ? err.message : String(err));
|
|
348
|
+
} finally {
|
|
349
|
+
setSaving(false);
|
|
350
|
+
}
|
|
351
|
+
}, [flatFields, fieldIndex, selected, values, pendingDangerousKey, saving]);
|
|
352
|
+
|
|
353
|
+
useKeyboard((key) => {
|
|
354
|
+
if (!isActive) return;
|
|
355
|
+
if (key.name === "escape") {
|
|
356
|
+
if (pendingDangerousKey) {
|
|
357
|
+
setPendingDangerousKey(null);
|
|
358
|
+
setStatus("Dangerous write cancelled");
|
|
359
|
+
}
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
if (key.name === "up") {
|
|
363
|
+
setPendingDangerousKey(null);
|
|
364
|
+
setFieldIndex((i) => Math.max(0, i - 1));
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
if (key.name === "down") {
|
|
368
|
+
setPendingDangerousKey(null);
|
|
369
|
+
setFieldIndex((i) => Math.min(Math.max(0, flatFields.length - 1), i + 1));
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
if (key.name === "left" || key.name === "pageup") {
|
|
373
|
+
void selectWorker(workerIndex - 1);
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
if (key.name === "right" || key.name === "pagedown") {
|
|
377
|
+
void selectWorker(workerIndex + 1);
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
if (key.name === "space" || key.name === "return" || key.name === "enter") {
|
|
381
|
+
if (key.name === "space") {
|
|
382
|
+
setPendingDangerousKey(null);
|
|
383
|
+
cycleFieldValue(1);
|
|
384
|
+
} else {
|
|
385
|
+
void saveCurrent();
|
|
386
|
+
}
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
if (key.name === "[" || key.raw === "[") {
|
|
390
|
+
setPendingDangerousKey(null);
|
|
391
|
+
cycleFieldValue(-1);
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
if (key.name === "]" || key.raw === "]") {
|
|
395
|
+
setPendingDangerousKey(null);
|
|
396
|
+
cycleFieldValue(1);
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
if (key.name === "r" && !key.ctrl && !key.meta) {
|
|
400
|
+
setPendingDangerousKey(null);
|
|
401
|
+
void refresh();
|
|
402
|
+
}
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
return (
|
|
406
|
+
<ErrorBoundary viewName="Worker Settings">
|
|
407
|
+
<box flexDirection="column" flexGrow={1} padding={1} gap={1}>
|
|
408
|
+
<ViewHeader
|
|
409
|
+
title="WORKER SETTINGS"
|
|
410
|
+
meta={
|
|
411
|
+
<text fg={Colors.muted} dim>
|
|
412
|
+
Same options as web dashboard · ←→ worker · ↑↓ field · Space edit
|
|
413
|
+
· Enter save · Esc cancel · R refresh
|
|
414
|
+
</text>
|
|
415
|
+
}
|
|
416
|
+
/>
|
|
417
|
+
|
|
418
|
+
{error ? <text fg={Colors.error}>{error}</text> : null}
|
|
419
|
+
{status ? (
|
|
420
|
+
<text fg={Colors.info} dim>
|
|
421
|
+
{status}
|
|
422
|
+
{saving ? " …" : ""}
|
|
423
|
+
</text>
|
|
424
|
+
) : null}
|
|
425
|
+
|
|
426
|
+
{loading && manifests.length === 0 ? (
|
|
427
|
+
<Spinner label="Loading dashboard.jsonc manifests…" />
|
|
428
|
+
) : manifests.length === 0 ? (
|
|
429
|
+
<EmptyState
|
|
430
|
+
message="No worker settings manifests"
|
|
431
|
+
suggestion="Ensure workers/*/dashboard.jsonc exist under HOOX_REPO"
|
|
432
|
+
icon="⚙"
|
|
433
|
+
/>
|
|
434
|
+
) : (
|
|
435
|
+
<box flexDirection="row" flexGrow={1} gap={1}>
|
|
436
|
+
<Panel title="WORKERS" width={28} elevated={false} compact focused>
|
|
437
|
+
{manifests.map((m, i) => (
|
|
438
|
+
<text
|
|
439
|
+
key={m.worker}
|
|
440
|
+
fg={i === workerIndex ? Colors.accent : Colors.muted}
|
|
441
|
+
bold={i === workerIndex}
|
|
442
|
+
onMouseUp={() => void selectWorker(i)}
|
|
443
|
+
>
|
|
444
|
+
{i === workerIndex ? "▸ " : " "}
|
|
445
|
+
{m.displayName}
|
|
446
|
+
</text>
|
|
447
|
+
))}
|
|
448
|
+
</Panel>
|
|
449
|
+
|
|
450
|
+
<Panel
|
|
451
|
+
title={selected?.displayName ?? "SETTINGS"}
|
|
452
|
+
flexGrow={1}
|
|
453
|
+
elevated={false}
|
|
454
|
+
compact
|
|
455
|
+
>
|
|
456
|
+
{selected?.description ? (
|
|
457
|
+
<text fg={Colors.dim} dim>
|
|
458
|
+
{selected.description}
|
|
459
|
+
</text>
|
|
460
|
+
) : null}
|
|
461
|
+
{selected?.worker === "agent-worker" ? (
|
|
462
|
+
<text fg={Colors.muted} dim>
|
|
463
|
+
Providers/models/risk numerics write agent:config ·
|
|
464
|
+
cron/behavior not wired · secrets via CLI
|
|
465
|
+
</text>
|
|
466
|
+
) : null}
|
|
467
|
+
|
|
468
|
+
{loading ? (
|
|
469
|
+
<Spinner label="Loading values from CONFIG_KV…" />
|
|
470
|
+
) : flatFields.length === 0 ? (
|
|
471
|
+
<text fg={Colors.muted}>No fields in this manifest.</text>
|
|
472
|
+
) : (
|
|
473
|
+
<scrollbox flexGrow={1} height={16}>
|
|
474
|
+
{flatFields.map((row, i) => {
|
|
475
|
+
const active = i === fieldIndex;
|
|
476
|
+
const val = values[row.field.key] ?? row.field.default;
|
|
477
|
+
const isSecret = row.field.kind === "secret";
|
|
478
|
+
const isDanger = row.field.kind === "dangerous";
|
|
479
|
+
return (
|
|
480
|
+
<box key={row.field.key} flexDirection="column" gap={0}>
|
|
481
|
+
{i === 0 ||
|
|
482
|
+
flatFields[i - 1]?.sectionTitle !== row.sectionTitle ? (
|
|
483
|
+
<text fg={Colors.accent} bold dim>
|
|
484
|
+
{row.sectionTitle}
|
|
485
|
+
</text>
|
|
486
|
+
) : null}
|
|
487
|
+
<box
|
|
488
|
+
flexDirection="row"
|
|
489
|
+
gap={1}
|
|
490
|
+
backgroundColor={active ? Colors.card : undefined}
|
|
491
|
+
>
|
|
492
|
+
<text
|
|
493
|
+
fg={
|
|
494
|
+
isSecret
|
|
495
|
+
? Colors.warning
|
|
496
|
+
: isDanger
|
|
497
|
+
? Colors.error
|
|
498
|
+
: active
|
|
499
|
+
? Colors.accent
|
|
500
|
+
: Colors.foreground
|
|
501
|
+
}
|
|
502
|
+
bold={active}
|
|
503
|
+
>
|
|
504
|
+
{row.field.label}
|
|
505
|
+
</text>
|
|
506
|
+
<text fg={Colors.dim} dim>
|
|
507
|
+
{row.kvKey}
|
|
508
|
+
</text>
|
|
509
|
+
<box flexGrow={1} />
|
|
510
|
+
<text
|
|
511
|
+
fg={
|
|
512
|
+
isSecret
|
|
513
|
+
? Colors.muted
|
|
514
|
+
: typeof val === "boolean"
|
|
515
|
+
? val
|
|
516
|
+
? Colors.success
|
|
517
|
+
: Colors.muted
|
|
518
|
+
: Colors.highlight
|
|
519
|
+
}
|
|
520
|
+
bold={!isSecret}
|
|
521
|
+
>
|
|
522
|
+
{isSecret
|
|
523
|
+
? "(secret — CLI)"
|
|
524
|
+
: formatFieldValue(row.field, val)}
|
|
525
|
+
</text>
|
|
526
|
+
</box>
|
|
527
|
+
{active && row.field.description ? (
|
|
528
|
+
<text fg={Colors.dim} dim>
|
|
529
|
+
{row.field.description}
|
|
530
|
+
</text>
|
|
531
|
+
) : null}
|
|
532
|
+
{active && isSecret && row.field.cliCommand ? (
|
|
533
|
+
<text fg={Colors.warning} dim>
|
|
534
|
+
{row.field.cliCommand}
|
|
535
|
+
</text>
|
|
536
|
+
) : null}
|
|
537
|
+
</box>
|
|
538
|
+
);
|
|
539
|
+
})}
|
|
540
|
+
</scrollbox>
|
|
541
|
+
)}
|
|
542
|
+
</Panel>
|
|
543
|
+
</box>
|
|
544
|
+
)}
|
|
545
|
+
</box>
|
|
546
|
+
</ErrorBoundary>
|
|
547
|
+
);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/** Format field value for the right column. */
|
|
551
|
+
function formatFieldValue(
|
|
552
|
+
field: DashboardSettingField,
|
|
553
|
+
val: string | number | boolean
|
|
554
|
+
): string {
|
|
555
|
+
if (field.type === "boolean") {
|
|
556
|
+
return val ? "[x] true" : "[ ] false";
|
|
557
|
+
}
|
|
558
|
+
if (field.type === "select") {
|
|
559
|
+
return `◀ ${displayValue(val)} ▶`;
|
|
560
|
+
}
|
|
561
|
+
const s = displayValue(val);
|
|
562
|
+
return s.length > 40 ? s.slice(0, 37) + "…" : s;
|
|
563
|
+
}
|