@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,717 @@
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
+ * KV Viewer — Read-only dashboard for the Cloudflare CONFIG_KV namespace.
9
+ *
10
+ * Layout:
11
+ * ┌───────────────────────────────────────────────────────────┐
12
+ * │ KV VIEWER 12 keys · 3 secret · ◉ 5s auto [⚠ read-only]
13
+ * │───────────────────────────────────────────────────────────
14
+ * │ [search…] Last sampled 12:34:56
15
+ * │───────────────────────────────────────────────────────────
16
+ * │ KEY NAME SIZE TYPE MOD STATUS
17
+ * │ trade:kill_switch 4 B bool — SECRET
18
+ * │ agent:openai_key 51 B str — SECRET
19
+ * │ routing:dynamic:enabled 4 B bool — OK
20
+ * │ … │
21
+ * │───────────────────────────────────────────────────────────
22
+ * │ ! Values are read-only in the TUI. Use `hoox config kv …` │
23
+ * │ for writes. Secret values are flagged with a banner. │
24
+ * │ [REFRESH] │
25
+ * └───────────────────────────────────────────────────────────┘
26
+ *
27
+ * Pattern established for the TUI feature-parity batch:
28
+ * - Pure function component, no props required
29
+ * - Subscribes to useUIStore (so auto-refresh can pause when not active)
30
+ * - Calls cliBridge.configKvList() on mount + every 5s while active
31
+ * - Wraps content in <ErrorBoundary viewName="KV Viewer">
32
+ * - Renders an empty/error state instead of throwing
33
+ * - Read-only: write operations are intentionally *not* exposed
34
+ * here. The TUI never sends `kv set` / `kv delete` — those are
35
+ * CLI-only.
36
+ *
37
+ * Security:
38
+ * - Secret keys (per the KV manifest) are flagged with a banner.
39
+ * - Values are read on-demand via cliBridge.configKvGet(); the
40
+ * banner is shown before the first reveal.
41
+ * - We deliberately do not mask values — the user explicitly opted
42
+ * in to viewing them. The CLI equivalent (`hoox config kv get`)
43
+ * is the same: no masking.
44
+ *
45
+ * Search/filter:
46
+ * - Case-insensitive substring match against the key name.
47
+ * - The total key count in the header reflects the *unfiltered* list
48
+ * while the table shows the filtered count.
49
+ */
50
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
51
+ import { useKeyboard } from "@opentui/react";
52
+ import { Colors, useUIStore } from "@hoox-sh/hoox-shared";
53
+ import { ErrorBoundary } from "../shared/error-boundary";
54
+ import { Spinner, EmptyState } from "../shared/spinner";
55
+ import { ViewHeader } from "../shared/view-header";
56
+ import { cliBridge } from "../../services/cli-bridge";
57
+ import type { KvKey, KvKeySnapshot } from "../../services/cli-bridge";
58
+
59
+ /** Auto-refresh interval in milliseconds. */
60
+ export const REFRESH_INTERVAL_MS = 5_000;
61
+
62
+ /** Cap rendered rows so large namespaces stay responsive. */
63
+ export const MAX_VISIBLE_KEYS = 200;
64
+
65
+ /** Search debounce for filter recompute. */
66
+ const SEARCH_DEBOUNCE_MS = 150;
67
+
68
+ /**
69
+ * Strip control characters from KV values before terminal display.
70
+ * Values are user-requested (reveal), but must not inject ESC sequences.
71
+ */
72
+ export function sanitizeKvValue(value: string, maxLen = 4_000): string {
73
+ const cleaned = value.replace(
74
+ /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g,
75
+ ""
76
+ );
77
+ if (cleaned.length <= maxLen) return cleaned;
78
+ return cleaned.slice(0, maxLen - 1) + "\u2026";
79
+ }
80
+
81
+ /** Case-insensitive key name filter. */
82
+ export function filterKvKeys(keys: readonly KvKey[], search: string): KvKey[] {
83
+ const q = search.trim().toLowerCase();
84
+ if (q.length === 0) return keys as KvKey[];
85
+ return keys.filter((k) => k.name.toLowerCase().includes(q));
86
+ }
87
+
88
+ /**
89
+ * Format a byte size as a human-readable string (`4 B`, `1.2 KB`, …).
90
+ * Used in the SIZE column. `null` renders as `—`.
91
+ */
92
+ function formatSize(bytes: number | null): string {
93
+ if (bytes === null) return "—";
94
+ if (bytes < 1024) return `${bytes} B`;
95
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
96
+ return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
97
+ }
98
+
99
+ /** Render the `lastModified` timestamp as HH:MM:SS on today's date, or `—`. */
100
+ function formatTime(iso: string | null): string {
101
+ if (iso === null) return "—";
102
+ const ms = Date.parse(iso);
103
+ if (Number.isNaN(ms)) return "—";
104
+ const d = new Date(ms);
105
+ const hh = d.getHours().toString().padStart(2, "0");
106
+ const mm = d.getMinutes().toString().padStart(2, "0");
107
+ const ss = d.getSeconds().toString().padStart(2, "0");
108
+ return `${hh}:${mm}:${ss}`;
109
+ }
110
+
111
+ /** Render the type column. `null` becomes `—`. */
112
+ function formatType(t: KvKey["manifestType"]): string {
113
+ if (t === null) return "—";
114
+ return t;
115
+ }
116
+
117
+ /** Pick a color for a row based on whether the key is secret. */
118
+ function rowColor(isSecret: boolean): string {
119
+ return isSecret ? Colors.warning : Colors.foreground;
120
+ }
121
+
122
+ // ─── Sub-component: Single KV Row ─────────────────────────────────────────────
123
+
124
+ interface KvRowProps {
125
+ keyName: string;
126
+ size: string;
127
+ type: string;
128
+ modified: string;
129
+ isSecret: boolean;
130
+ isSelected: boolean;
131
+ onSelect: (name: string) => void;
132
+ }
133
+
134
+ function KvRow({
135
+ keyName,
136
+ size,
137
+ type,
138
+ modified,
139
+ isSecret,
140
+ isSelected,
141
+ onSelect,
142
+ }: KvRowProps) {
143
+ const fg = isSelected ? Colors.accent : rowColor(isSecret);
144
+ const status = isSecret ? "SECRET" : "OK";
145
+ const statusColor = isSecret ? Colors.warning : Colors.success;
146
+ return (
147
+ <box
148
+ flexDirection="row"
149
+ gap={2}
150
+ paddingLeft={1}
151
+ paddingRight={1}
152
+ alignItems="center"
153
+ backgroundColor={isSelected ? Colors.card : undefined}
154
+ onMouseUp={() => onSelect(keyName)}
155
+ >
156
+ <text fg={fg} bold={isSelected}>
157
+ {keyName}
158
+ </text>
159
+ <text fg={Colors.muted} dim>
160
+ {size.padStart(8, " ")}
161
+ </text>
162
+ <text fg={Colors.muted}>{type.padStart(4, " ")}</text>
163
+ <text fg={Colors.muted} dim>
164
+ {modified.padStart(8, " ")}
165
+ </text>
166
+ <text fg={statusColor} bold>
167
+ {status}
168
+ </text>
169
+ </box>
170
+ );
171
+ }
172
+
173
+ // ─── Sub-component: Detail Pane (selected key + value) ────────────────────────
174
+
175
+ interface DetailPaneProps {
176
+ selectedKey: KvKey | null;
177
+ value: string | null;
178
+ loading: boolean;
179
+ valueError: string | null;
180
+ onReveal: () => void;
181
+ onHide: () => void;
182
+ revealed: boolean;
183
+ }
184
+
185
+ function DetailPane({
186
+ selectedKey,
187
+ value,
188
+ loading,
189
+ valueError,
190
+ onReveal,
191
+ onHide,
192
+ revealed,
193
+ }: DetailPaneProps) {
194
+ if (!selectedKey) {
195
+ return (
196
+ <box
197
+ flexDirection="column"
198
+ flexGrow={1}
199
+ gap={1}
200
+ border={true}
201
+ borderStyle="single"
202
+ borderColor={Colors.border}
203
+ paddingX={1}
204
+ paddingY={0}
205
+ >
206
+ <text fg={Colors.muted} dim>
207
+ Select a key to view its value.
208
+ </text>
209
+ <text fg={Colors.dim} dim>
210
+ ↑↓ navigate · Enter reveal · Esc clear
211
+ </text>
212
+ </box>
213
+ );
214
+ }
215
+
216
+ return (
217
+ <box
218
+ flexDirection="column"
219
+ flexGrow={1}
220
+ gap={1}
221
+ border={true}
222
+ borderStyle="single"
223
+ borderColor={Colors.border}
224
+ paddingX={1}
225
+ paddingY={0}
226
+ >
227
+ {/* Selected key header */}
228
+ <box flexDirection="row" gap={2} alignItems="center">
229
+ <text fg={Colors.accent} bold>
230
+ {selectedKey.name}
231
+ </text>
232
+ {selectedKey.isSecret && (
233
+ <text fg={Colors.warning} bold>
234
+ ⚠ SECRET
235
+ </text>
236
+ )}
237
+ </box>
238
+ <text fg={Colors.border} dim>
239
+ {"─".repeat(60)}
240
+ </text>
241
+
242
+ {/* Secret warning before reveal */}
243
+ {selectedKey.isSecret && !revealed && (
244
+ <box flexDirection="column" gap={1} paddingLeft={1}>
245
+ <text fg={Colors.warning} bold>
246
+ ⚠ This key may contain a secret value (API key, password, etc.).
247
+ </text>
248
+ <box flexDirection="row" gap={1}>
249
+ <text fg={Colors.muted}>
250
+ The value will be displayed as plain text. Press
251
+ </text>
252
+ <text fg={Colors.accent}>[REVEAL]</text>
253
+ <text fg={Colors.muted}>to view it.</text>
254
+ </box>
255
+ </box>
256
+ )}
257
+
258
+ {/* Value display */}
259
+ {revealed ? (
260
+ <box flexDirection="column" gap={1} paddingLeft={1} flexGrow={1}>
261
+ {loading ? (
262
+ <box alignItems="center" justifyContent="center" flexGrow={1}>
263
+ <Spinner label="Loading value..." />
264
+ </box>
265
+ ) : valueError ? (
266
+ <text fg={Colors.error}>! {valueError}</text>
267
+ ) : value === null ? (
268
+ <text fg={Colors.muted} dim>
269
+ (empty value)
270
+ </text>
271
+ ) : (
272
+ <text fg={Colors.foreground}>{sanitizeKvValue(value)}</text>
273
+ )}
274
+ </box>
275
+ ) : null}
276
+
277
+ {/* Action buttons */}
278
+ <box flexDirection="row" gap={2} paddingLeft={1}>
279
+ {revealed ? (
280
+ <text fg={Colors.muted} onMouseUp={onHide}>
281
+ [HIDE]
282
+ </text>
283
+ ) : (
284
+ <text fg={Colors.accent} bold onMouseUp={onReveal}>
285
+ [REVEAL]
286
+ </text>
287
+ )}
288
+ </box>
289
+ </box>
290
+ );
291
+ }
292
+
293
+ // ─── Sub-component: Search Box ────────────────────────────────────────────────
294
+
295
+ interface SearchBoxProps {
296
+ query: string;
297
+ onChange: (next: string) => void;
298
+ active: boolean;
299
+ onActivate: () => void;
300
+ onDeactivate: () => void;
301
+ }
302
+
303
+ function SearchBox({
304
+ query,
305
+ onChange,
306
+ active,
307
+ onActivate,
308
+ onDeactivate,
309
+ }: SearchBoxProps) {
310
+ useKeyboard((key) => {
311
+ if (!active) return;
312
+ if (key.name === "escape") {
313
+ onDeactivate();
314
+ return;
315
+ }
316
+ if (key.name === "backspace" || key.name === "delete") {
317
+ onChange(query.slice(0, -1));
318
+ return;
319
+ }
320
+ if (key.name === "return") {
321
+ onDeactivate();
322
+ return;
323
+ }
324
+ if (key.sequence && key.sequence.length === 1 && key.sequence >= " ") {
325
+ onChange(query + key.sequence);
326
+ }
327
+ });
328
+
329
+ return (
330
+ <box
331
+ flexDirection="row"
332
+ gap={1}
333
+ paddingLeft={1}
334
+ paddingRight={1}
335
+ alignItems="center"
336
+ border={true}
337
+ borderStyle="single"
338
+ borderColor={active ? Colors.accent : Colors.border}
339
+ onMouseUp={onActivate}
340
+ >
341
+ <text fg={Colors.muted} dim>
342
+ search:
343
+ </text>
344
+ <text fg={Colors.foreground}>
345
+ {query.length > 0 ? query : active ? "_" : "(press / to filter)"}
346
+ </text>
347
+ </box>
348
+ );
349
+ }
350
+
351
+ // ─── Main KvViewer View ──────────────────────────────────────────────────────
352
+
353
+ /**
354
+ * KvViewer — Main view showing the CONFIG_KV namespace keys.
355
+ *
356
+ * The view follows the same architectural pattern as the queue-depth
357
+ * viewer (see `queue-depth.tsx` for the original):
358
+ * 1. Pure function component, no required props
359
+ * 2. Subscribes to `useUIStore.activeView` to pause auto-refresh
360
+ * when the user navigates away
361
+ * 3. Calls a `cliBridge.<method>()` on mount + on a fixed interval
362
+ * (5s here) — refreshes can also be triggered manually via a
363
+ * button
364
+ * 4. Renders an explicit empty/error state instead of throwing
365
+ * 5. Wraps in <ErrorBoundary viewName="KV Viewer"> so a render bug
366
+ * in this view never crashes the whole TUI
367
+ */
368
+ export function KvViewer() {
369
+ const activeView = useUIStore((s) => s.activeView);
370
+ const isActive = activeView === "kv-viewer";
371
+
372
+ const [snapshot, setSnapshot] = useState<KvKeySnapshot | null>(null);
373
+ const [loading, setLoading] = useState(false);
374
+ const [error, setError] = useState<string | null>(null);
375
+ const [search, setSearch] = useState("");
376
+ const [debouncedSearch, setDebouncedSearch] = useState("");
377
+ const [searchActive, setSearchActive] = useState(false);
378
+ const searchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
379
+
380
+ const [selectedKeyName, setSelectedKeyName] = useState<string | null>(null);
381
+ const [revealed, setRevealed] = useState(false);
382
+ const [value, setValue] = useState<string | null>(null);
383
+ const [valueLoading, setValueLoading] = useState(false);
384
+ const [valueError, setValueError] = useState<string | null>(null);
385
+
386
+ const listGenRef = useRef(0);
387
+ const revealGenRef = useRef(0);
388
+
389
+ // Debounce search
390
+ useEffect(() => {
391
+ if (searchTimerRef.current) clearTimeout(searchTimerRef.current);
392
+ searchTimerRef.current = setTimeout(() => {
393
+ setDebouncedSearch(search);
394
+ searchTimerRef.current = null;
395
+ }, SEARCH_DEBOUNCE_MS);
396
+ return () => {
397
+ if (searchTimerRef.current) {
398
+ clearTimeout(searchTimerRef.current);
399
+ searchTimerRef.current = null;
400
+ }
401
+ };
402
+ }, [search]);
403
+
404
+ // ── Fetch handler ─────────────────────────────────────────────────────
405
+ const refresh = useCallback(async (opts?: { soft?: boolean }) => {
406
+ const soft = opts?.soft === true;
407
+ const gen = ++listGenRef.current;
408
+ if (!soft) setLoading(true);
409
+ const result = await cliBridge.configKvList();
410
+ if (gen !== listGenRef.current) return;
411
+ if (result.success && result.data) {
412
+ setSnapshot(result.data);
413
+ setError(null);
414
+ } else {
415
+ setError(result.stderr || result.stdout || "Failed to read KV keys");
416
+ }
417
+ setLoading(false);
418
+ }, []);
419
+
420
+ // Initial load on mount + invalidate in-flight on unmount.
421
+ useEffect(() => {
422
+ void refresh({ soft: false });
423
+ return () => {
424
+ listGenRef.current += 1;
425
+ revealGenRef.current += 1;
426
+ };
427
+ }, [refresh]);
428
+
429
+ // Auto-refresh every 5s while the view is the active view (soft = no flicker).
430
+ useEffect(() => {
431
+ if (!isActive) return;
432
+ const handle = setInterval(() => {
433
+ void refresh({ soft: true });
434
+ }, REFRESH_INTERVAL_MS);
435
+ return () => clearInterval(handle);
436
+ }, [isActive, refresh]);
437
+
438
+ // ── Derived data ─────────────────────────────────────────────────────
439
+ const allKeys = snapshot?.keys ?? [];
440
+ const filteredKeys = useMemo(
441
+ () => filterKvKeys(allKeys, debouncedSearch),
442
+ [allKeys, debouncedSearch]
443
+ );
444
+ const visibleKeys = useMemo(
445
+ () => filteredKeys.slice(0, MAX_VISIBLE_KEYS),
446
+ [filteredKeys]
447
+ );
448
+
449
+ const secretCount = useMemo(
450
+ () => allKeys.filter((k) => k.isSecret).length,
451
+ [allKeys]
452
+ );
453
+
454
+ const selectedKey = useMemo(() => {
455
+ if (!selectedKeyName) return null;
456
+ return allKeys.find((k) => k.name === selectedKeyName) ?? null;
457
+ }, [allKeys, selectedKeyName]);
458
+
459
+ // When the selected key changes, reset the reveal/value state and
460
+ // invalidate any in-flight value fetch for the previous key.
461
+ useEffect(() => {
462
+ revealGenRef.current += 1;
463
+ setRevealed(false);
464
+ setValue(null);
465
+ setValueError(null);
466
+ setValueLoading(false);
467
+ }, [selectedKeyName]);
468
+
469
+ // ── Detail pane callbacks ────────────────────────────────────────────
470
+ const handleReveal = useCallback(async () => {
471
+ if (!selectedKey) return;
472
+ // Secret keys require explicit reveal; non-secrets also use reveal UX
473
+ // so we never auto-fetch values on selection (sensitive-value handling).
474
+ const gen = ++revealGenRef.current;
475
+ setRevealed(true);
476
+ setValueLoading(true);
477
+ setValueError(null);
478
+ const result = await cliBridge.configKvGet(selectedKey.name);
479
+ if (gen !== revealGenRef.current) return;
480
+ if (result.success) {
481
+ setValue(result.data ?? null);
482
+ } else {
483
+ setValueError(result.stderr || result.stdout || "Failed to read value");
484
+ }
485
+ setValueLoading(false);
486
+ }, [selectedKey]);
487
+
488
+ const handleHide = useCallback(() => {
489
+ revealGenRef.current += 1; // cancel in-flight get
490
+ setRevealed(false);
491
+ setValue(null);
492
+ setValueError(null);
493
+ setValueLoading(false);
494
+ }, []);
495
+
496
+ // ── Global keyboard handling (active only when this view is on top) ──
497
+ useKeyboard((key) => {
498
+ if (!isActive) return;
499
+ if (searchActive) return; // SearchBox handles its own keys.
500
+ if (key.name === "slash" || (key.ctrl && key.name === "f")) {
501
+ setSearchActive(true);
502
+ return;
503
+ }
504
+ if (key.name === "escape") {
505
+ if (selectedKeyName) setSelectedKeyName(null);
506
+ return;
507
+ }
508
+ if (key.name === "up") {
509
+ if (visibleKeys.length === 0) return;
510
+ const idx = selectedKeyName
511
+ ? visibleKeys.findIndex((k) => k.name === selectedKeyName)
512
+ : -1;
513
+ const next = Math.max(0, idx - 1);
514
+ setSelectedKeyName(visibleKeys[next]?.name ?? null);
515
+ return;
516
+ }
517
+ if (key.name === "down") {
518
+ if (visibleKeys.length === 0) return;
519
+ const idx = selectedKeyName
520
+ ? visibleKeys.findIndex((k) => k.name === selectedKeyName)
521
+ : -1;
522
+ const next = Math.min(visibleKeys.length - 1, idx + 1);
523
+ setSelectedKeyName(visibleKeys[next]?.name ?? null);
524
+ return;
525
+ }
526
+ if (key.name === "return") {
527
+ if (selectedKey && !revealed) {
528
+ void handleReveal();
529
+ }
530
+ return;
531
+ }
532
+ });
533
+
534
+ // ── Render ────────────────────────────────────────────────────────────
535
+ return (
536
+ <ErrorBoundary viewName="KV Viewer">
537
+ <box flexDirection="column" flexGrow={1} padding={1} gap={1}>
538
+ <ViewHeader
539
+ title="KV VIEWER"
540
+ showDivider={false}
541
+ meta={
542
+ <box flexDirection="row" gap={2} alignItems="center">
543
+ <text fg={Colors.muted} dim>
544
+ {`${allKeys.length} key${allKeys.length === 1 ? "" : "s"} · ${secretCount} secret`}
545
+ </text>
546
+ {debouncedSearch.length > 0 ? (
547
+ <text fg={Colors.info} dim>
548
+ {`(${filteredKeys.length} match${filteredKeys.length === 1 ? "" : "es"})`}
549
+ </text>
550
+ ) : null}
551
+ {filteredKeys.length > MAX_VISIBLE_KEYS ? (
552
+ <text fg={Colors.muted} dim>
553
+ {`showing ${MAX_VISIBLE_KEYS}`}
554
+ </text>
555
+ ) : null}
556
+ <text fg={Colors.warning} bold>
557
+ ⚠ read-only
558
+ </text>
559
+ <text fg={Colors.info} dim>
560
+ {`◉ ${REFRESH_INTERVAL_MS / 1000}s auto`}
561
+ </text>
562
+ </box>
563
+ }
564
+ />
565
+
566
+ {/* Search + last sampled row */}
567
+ <box flexDirection="row" gap={1} alignItems="center">
568
+ <SearchBox
569
+ query={search}
570
+ onChange={setSearch}
571
+ active={searchActive}
572
+ onActivate={() => setSearchActive(true)}
573
+ onDeactivate={() => setSearchActive(false)}
574
+ />
575
+ <text fg={Colors.muted} dim>
576
+ {snapshot
577
+ ? `Last sampled ${formatTime(snapshot.timestamp)}`
578
+ : "Last sampled —"}
579
+ </text>
580
+ </box>
581
+
582
+ {/* Main content: list + detail */}
583
+ <box flexDirection="row" flexGrow={1} gap={1}>
584
+ {/* Left pane: scrollable list */}
585
+ <box
586
+ flexDirection="column"
587
+ flexGrow={1}
588
+ border={true}
589
+ borderStyle="single"
590
+ borderColor={Colors.border}
591
+ paddingX={1}
592
+ paddingY={0}
593
+ >
594
+ {/* Column header */}
595
+ <box flexDirection="row" gap={2} paddingLeft={1} paddingRight={1}>
596
+ <text fg={Colors.muted} dim>
597
+ KEY NAME
598
+ </text>
599
+ <text fg={Colors.muted} dim>
600
+ {"SIZE".padStart(8, " ")}
601
+ </text>
602
+ <text fg={Colors.muted} dim>
603
+ {"TYPE".padStart(4, " ")}
604
+ </text>
605
+ <text fg={Colors.muted} dim>
606
+ {"MOD".padStart(8, " ")}
607
+ </text>
608
+ <text fg={Colors.muted} dim>
609
+ STATUS
610
+ </text>
611
+ </box>
612
+ <text fg={Colors.border} dim>
613
+ {"─".repeat(80)}
614
+ </text>
615
+
616
+ {/* Body */}
617
+ {loading && allKeys.length === 0 ? (
618
+ <box
619
+ padding={1}
620
+ alignItems="center"
621
+ justifyContent="center"
622
+ flexGrow={1}
623
+ >
624
+ <Spinner label="Loading KV keys..." />
625
+ </box>
626
+ ) : error && allKeys.length === 0 ? (
627
+ <box padding={1} flexDirection="column" gap={0}>
628
+ <text fg={Colors.error} bold>
629
+ ! {error.length > 60 ? error.slice(0, 57) + "…" : error}
630
+ </text>
631
+ <text fg={Colors.muted} dim>
632
+ Make sure wrangler is installed and authenticated.
633
+ </text>
634
+ </box>
635
+ ) : allKeys.length === 0 ? (
636
+ <box padding={1} flexGrow={1}>
637
+ <EmptyState
638
+ message="No keys in CONFIG_KV."
639
+ suggestion="Populate with `hoox config kv apply-manifest`"
640
+ icon="🔑"
641
+ />
642
+ </box>
643
+ ) : filteredKeys.length === 0 ? (
644
+ <box padding={1} flexGrow={1}>
645
+ <EmptyState
646
+ message={`No keys match "${search}".`}
647
+ suggestion="Press / to clear."
648
+ icon="🔍"
649
+ />
650
+ </box>
651
+ ) : (
652
+ <scrollbox width="100%" flexGrow={1}>
653
+ {visibleKeys.map((k) => (
654
+ <KvRow
655
+ key={k.name}
656
+ keyName={k.name}
657
+ size={formatSize(k.valueSize)}
658
+ type={formatType(k.manifestType)}
659
+ modified={formatTime(k.lastModified)}
660
+ isSecret={k.isSecret}
661
+ isSelected={k.name === selectedKeyName}
662
+ onSelect={(name) => setSelectedKeyName(name)}
663
+ />
664
+ ))}
665
+ </scrollbox>
666
+ )}
667
+ </box>
668
+
669
+ {/* Right pane: detail / value viewer */}
670
+ <DetailPane
671
+ selectedKey={selectedKey}
672
+ value={value}
673
+ loading={valueLoading}
674
+ valueError={valueError}
675
+ onReveal={() => void handleReveal()}
676
+ onHide={handleHide}
677
+ revealed={revealed}
678
+ />
679
+ </box>
680
+
681
+ {/* Footer: refresh + read-only warning */}
682
+ <box
683
+ flexDirection="row"
684
+ justifyContent="space-between"
685
+ paddingLeft={1}
686
+ paddingRight={1}
687
+ alignItems="center"
688
+ >
689
+ <box flexDirection="row" gap={2} alignItems="center">
690
+ <text
691
+ fg={loading ? Colors.muted : Colors.accent}
692
+ bg={Colors.card}
693
+ dim={loading}
694
+ onMouseUp={loading ? undefined : () => void refresh()}
695
+ >
696
+ {loading ? " ... " : " [REFRESH] "}
697
+ </text>
698
+ {error && allKeys.length > 0 && (
699
+ <text fg={Colors.warning} dim>
700
+ ! {error.length > 50 ? error.slice(0, 47) + "…" : error}
701
+ </text>
702
+ )}
703
+ </box>
704
+ <box flexDirection="row" gap={1}>
705
+ <text fg={Colors.muted} dim>
706
+ Use
707
+ </text>
708
+ <text fg={Colors.accent}>hoox config kv get|set|delete</text>
709
+ <text fg={Colors.muted} dim>
710
+ in CLI for writes
711
+ </text>
712
+ </box>
713
+ </box>
714
+ </box>
715
+ </ErrorBoundary>
716
+ );
717
+ }