@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,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
+ }