@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,548 @@
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
+ * Secrets Viewer — Read-only dashboard for Cloudflare Workers secrets.
9
+ *
10
+ * Layout:
11
+ * ┌───────────────────────────────────────────────────────────────┐
12
+ * │ SECRETS VIEWER 12 secrets · ◉ 5s auto [⚠ READ-ONLY] │
13
+ * │───────────────────────────────────────────────────────────────
14
+ * │ [! SECURITY !] Values are hidden for security reasons. │
15
+ * │ Use `hoox config secrets set` in CLI to manage│
16
+ * │───────────────────────────────────────────────────────────────
17
+ * │ [search…] Last sampled 12:34:56 │
18
+ * │───────────────────────────────────────────────────────────────
19
+ * │ SECRET NAME TYPE SOURCE STATUS │
20
+ * │ BINANCE_KEY_BINDING api_key config OK │
21
+ * │ OPENAI_API_KEY api_key config OK │
22
+ * │ TELEGRAM_BOT_TOKEN token config OK │
23
+ * │ … │
24
+ * │───────────────────────────────────────────────────────────────
25
+ * │ [REFRESH] │
26
+ * └───────────────────────────────────────────────────────────────┘
27
+ *
28
+ * Pattern established for the TUI feature-parity batch:
29
+ * - Pure function component, no props required
30
+ * - Subscribes to useUIStore (so auto-refresh can pause when not active)
31
+ * - Calls cliBridge.configSecretsList() on mount + every 5s while active
32
+ * - Wraps content in <ErrorBoundary viewName="Secrets Viewer">
33
+ * - Renders an empty/error state instead of throwing
34
+ * - Read-only: write operations are intentionally *not* exposed
35
+ * here. The TUI never sends `secrets set` / `secrets delete` —
36
+ * those are CLI-only.
37
+ *
38
+ * Security:
39
+ * - Values are NEVER fetched or displayed — this is strictly read-only
40
+ * - The prominent security warning banner is always visible
41
+ * - Only secret names, inferred types, and sources are shown
42
+ * - No ability to reveal or copy secret values
43
+ */
44
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
45
+ import { useKeyboard } from "@opentui/react";
46
+ import { Colors, useUIStore } from "@hoox-sh/hoox-shared";
47
+ import { ErrorBoundary } from "../shared/error-boundary";
48
+ import { Spinner, EmptyState } from "../shared/spinner";
49
+ import { ViewHeader } from "../shared/view-header";
50
+ import { cliBridge } from "../../services/cli-bridge";
51
+ import type {
52
+ SecretMetadata,
53
+ SecretsSnapshot,
54
+ } from "../../services/cli-bridge";
55
+
56
+ /** Auto-refresh interval in milliseconds. */
57
+ export const REFRESH_INTERVAL_MS = 5_000;
58
+
59
+ /** Cap rendered secret rows for large worker graphs. */
60
+ export const MAX_VISIBLE_SECRETS = 200;
61
+
62
+ const SEARCH_DEBOUNCE_MS = 150;
63
+
64
+ /** Case-insensitive name filter — values are never involved. */
65
+ export function filterSecrets(
66
+ secrets: readonly SecretMetadata[],
67
+ search: string
68
+ ): SecretMetadata[] {
69
+ const q = search.trim().toLowerCase();
70
+ if (q.length === 0) return secrets as SecretMetadata[];
71
+ return secrets.filter((s) => s.name.toLowerCase().includes(q));
72
+ }
73
+
74
+ /**
75
+ * Format a secret type for display in the TYPE column.
76
+ */
77
+ function formatType(type: SecretMetadata["type"]): string {
78
+ return type;
79
+ }
80
+
81
+ /**
82
+ * Color for the source badge.
83
+ */
84
+ function sourceColor(source: SecretMetadata["source"]): string {
85
+ return source === "Cloudflare" ? Colors.info : Colors.muted;
86
+ }
87
+
88
+ /**
89
+ * Pick a color for a row based on secret type severity.
90
+ */
91
+ function rowColor(type: SecretMetadata["type"]): string {
92
+ if (type === "api_key" || type === "token") {
93
+ return Colors.warning;
94
+ }
95
+ return Colors.foreground;
96
+ }
97
+
98
+ // ─── Sub-component: Single Secret Row ─────────────────────────────────────────
99
+
100
+ interface SecretRowProps {
101
+ secret: SecretMetadata;
102
+ isSelected: boolean;
103
+ onSelect: (name: string) => void;
104
+ }
105
+
106
+ function SecretRow({ secret, isSelected, onSelect }: SecretRowProps) {
107
+ const fg = isSelected ? Colors.accent : rowColor(secret.type);
108
+ return (
109
+ <box
110
+ flexDirection="row"
111
+ gap={2}
112
+ paddingLeft={1}
113
+ paddingRight={1}
114
+ alignItems="center"
115
+ backgroundColor={isSelected ? Colors.card : undefined}
116
+ onMouseUp={() => onSelect(secret.name)}
117
+ >
118
+ <text fg={fg} bold={isSelected}>
119
+ {secret.name}
120
+ </text>
121
+ <text fg={Colors.muted}>{formatType(secret.type).padStart(8, " ")}</text>
122
+ <text fg={sourceColor(secret.source)} dim>
123
+ {secret.source.padEnd(10, " ")}
124
+ </text>
125
+ </box>
126
+ );
127
+ }
128
+
129
+ // ─── Sub-component: Search Box ─────────────────────────────────────────────────
130
+
131
+ interface SearchBoxProps {
132
+ query: string;
133
+ onChange: (next: string) => void;
134
+ active: boolean;
135
+ onActivate: () => void;
136
+ onDeactivate: () => void;
137
+ }
138
+
139
+ function SearchBox({
140
+ query,
141
+ onChange,
142
+ active,
143
+ onActivate,
144
+ onDeactivate,
145
+ }: SearchBoxProps) {
146
+ // When active, capture printable keys for filtering (same pattern as KV Viewer).
147
+ useKeyboard((key) => {
148
+ if (!active) return;
149
+ if (key.name === "escape") {
150
+ onDeactivate();
151
+ return;
152
+ }
153
+ if (key.name === "backspace" || key.name === "delete") {
154
+ onChange(query.slice(0, -1));
155
+ return;
156
+ }
157
+ if (key.name === "return") {
158
+ onDeactivate();
159
+ return;
160
+ }
161
+ if (key.sequence && key.sequence.length === 1 && key.sequence >= " ") {
162
+ onChange(query + key.sequence);
163
+ }
164
+ });
165
+
166
+ return (
167
+ <box
168
+ flexDirection="row"
169
+ gap={1}
170
+ paddingLeft={1}
171
+ paddingRight={1}
172
+ alignItems="center"
173
+ border={true}
174
+ borderStyle="single"
175
+ borderColor={active ? Colors.accent : Colors.border}
176
+ onMouseUp={onActivate}
177
+ >
178
+ <text fg={Colors.muted} dim>
179
+ search:
180
+ </text>
181
+ <text fg={Colors.foreground}>
182
+ {query.length > 0 ? query : active ? "_" : "(press / to filter)"}
183
+ </text>
184
+ </box>
185
+ );
186
+ }
187
+
188
+ // ─── Main SecretsViewer View ───────────────────────────────────────────────────
189
+
190
+ /**
191
+ * SecretsViewer — Main view showing the declared secrets across all workers.
192
+ *
193
+ * The view follows the same architectural pattern as the KV viewer
194
+ * (see `kv-viewer.tsx` for the original):
195
+ * 1. Pure function component, no required props
196
+ * 2. Subscribes to `useUIStore.activeView` to pause auto-refresh
197
+ * when the user navigates away
198
+ * 3. Calls `cliBridge.configSecretsList()` on mount + on a fixed
199
+ * interval (5s) — refreshes can also be triggered manually via
200
+ * a button
201
+ * 4. Renders an explicit empty/error state instead of throwing
202
+ * 5. Wraps in <ErrorBoundary viewName="Secrets Viewer"> so a render
203
+ * bug in this view never crashes the whole TUI
204
+ *
205
+ * Security guarantees:
206
+ * - Values are NEVER fetched — only names and metadata are retrieved
207
+ * - The security warning banner is prominently displayed at all times
208
+ * - No "reveal" or "copy" functionality exists
209
+ * - Write operations are intentionally not exposed
210
+ */
211
+ export function SecretsViewer() {
212
+ const activeView = useUIStore((s) => s.activeView);
213
+ const isActive = activeView === "secrets-viewer";
214
+
215
+ const [snapshot, setSnapshot] = useState<SecretsSnapshot | null>(null);
216
+ const [loading, setLoading] = useState(false);
217
+ const [error, setError] = useState<string | null>(null);
218
+ const [search, setSearch] = useState("");
219
+ const [debouncedSearch, setDebouncedSearch] = useState("");
220
+ const [searchActive, setSearchActive] = useState(false);
221
+ const searchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
222
+ const listGenRef = useRef(0);
223
+
224
+ const [selectedSecretName, setSelectedSecretName] = useState<string | null>(
225
+ null
226
+ );
227
+
228
+ // Debounce search filter
229
+ useEffect(() => {
230
+ if (searchTimerRef.current) clearTimeout(searchTimerRef.current);
231
+ searchTimerRef.current = setTimeout(() => {
232
+ setDebouncedSearch(search);
233
+ searchTimerRef.current = null;
234
+ }, SEARCH_DEBOUNCE_MS);
235
+ return () => {
236
+ if (searchTimerRef.current) {
237
+ clearTimeout(searchTimerRef.current);
238
+ searchTimerRef.current = null;
239
+ }
240
+ };
241
+ }, [search]);
242
+
243
+ // ── Fetch handler — metadata only; NEVER requests secret values ───────────
244
+ const refresh = useCallback(async (opts?: { soft?: boolean }) => {
245
+ const soft = opts?.soft === true;
246
+ const gen = ++listGenRef.current;
247
+ if (!soft) setLoading(true);
248
+ // Security: only configSecretsList (names/types). No get/reveal API.
249
+ const result = await cliBridge.configSecretsList();
250
+ if (gen !== listGenRef.current) return;
251
+ if (result.success && result.data) {
252
+ setSnapshot(result.data);
253
+ setError(null);
254
+ } else {
255
+ setError(result.stderr || result.stdout || "Failed to read secrets");
256
+ }
257
+ setLoading(false);
258
+ }, []);
259
+
260
+ // Initial load on mount + invalidate in-flight on unmount.
261
+ useEffect(() => {
262
+ void refresh({ soft: false });
263
+ return () => {
264
+ listGenRef.current += 1;
265
+ };
266
+ }, [refresh]);
267
+
268
+ // Auto-refresh every 5s while the view is the active view (soft = no flicker).
269
+ useEffect(() => {
270
+ if (!isActive) return;
271
+ const handle = setInterval(() => {
272
+ void refresh({ soft: true });
273
+ }, REFRESH_INTERVAL_MS);
274
+ return () => clearInterval(handle);
275
+ }, [isActive, refresh]);
276
+
277
+ // ── Derived data ───────────────────────────────────────────────────────────
278
+ const allSecrets = snapshot?.secrets ?? [];
279
+ const filteredSecrets = useMemo(
280
+ () => filterSecrets(allSecrets, debouncedSearch),
281
+ [allSecrets, debouncedSearch]
282
+ );
283
+ const visibleSecrets = useMemo(
284
+ () => filteredSecrets.slice(0, MAX_VISIBLE_SECRETS),
285
+ [filteredSecrets]
286
+ );
287
+
288
+ const selectedSecret = useMemo(() => {
289
+ if (!selectedSecretName) return null;
290
+ return allSecrets.find((s) => s.name === selectedSecretName) ?? null;
291
+ }, [allSecrets, selectedSecretName]);
292
+
293
+ // ── Keyboard: list nav + open search (inactive while search is active) ───
294
+ useKeyboard((key) => {
295
+ if (!isActive) return;
296
+ if (searchActive) return; // SearchBox owns keys while active
297
+ if (key.name === "slash" || (key.ctrl && key.name === "f")) {
298
+ setSearchActive(true);
299
+ return;
300
+ }
301
+ if (key.name === "escape") {
302
+ if (selectedSecretName) setSelectedSecretName(null);
303
+ return;
304
+ }
305
+ if (key.name === "up") {
306
+ if (visibleSecrets.length === 0) return;
307
+ const idx = selectedSecretName
308
+ ? visibleSecrets.findIndex((s) => s.name === selectedSecretName)
309
+ : -1;
310
+ const next = Math.max(0, idx - 1);
311
+ setSelectedSecretName(visibleSecrets[next]?.name ?? null);
312
+ return;
313
+ }
314
+ if (key.name === "down") {
315
+ if (visibleSecrets.length === 0) return;
316
+ const idx = selectedSecretName
317
+ ? visibleSecrets.findIndex((s) => s.name === selectedSecretName)
318
+ : -1;
319
+ const next = Math.min(visibleSecrets.length - 1, idx + 1);
320
+ setSelectedSecretName(visibleSecrets[next]?.name ?? null);
321
+ return;
322
+ }
323
+ });
324
+
325
+ // ── Render ────────────────────────────────────────────────────────────────
326
+ return (
327
+ <ErrorBoundary viewName="Secrets Viewer">
328
+ <box flexDirection="column" flexGrow={1} padding={1} gap={1}>
329
+ <ViewHeader
330
+ title="SECRETS VIEWER"
331
+ showDivider={false}
332
+ meta={
333
+ <box flexDirection="row" gap={2} alignItems="center">
334
+ <text fg={Colors.muted} dim>
335
+ {`${allSecrets.length} secret${allSecrets.length === 1 ? "" : "s"}`}
336
+ </text>
337
+ {debouncedSearch.length > 0 ? (
338
+ <text fg={Colors.info} dim>
339
+ {`(${filteredSecrets.length} match${filteredSecrets.length === 1 ? "" : "es"})`}
340
+ </text>
341
+ ) : null}
342
+ {filteredSecrets.length > MAX_VISIBLE_SECRETS ? (
343
+ <text fg={Colors.muted} dim>
344
+ {`showing ${MAX_VISIBLE_SECRETS}`}
345
+ </text>
346
+ ) : null}
347
+ <text fg={Colors.warning} bold>
348
+ ⚠ READ-ONLY
349
+ </text>
350
+ <text fg={Colors.info} dim>
351
+ {`◉ ${REFRESH_INTERVAL_MS / 1000}s auto`}
352
+ </text>
353
+ </box>
354
+ }
355
+ />
356
+
357
+ {/* Security warning banner — ALWAYS visible */}
358
+ <box
359
+ flexDirection="column"
360
+ padding={1}
361
+ gap={0}
362
+ border={true}
363
+ borderStyle="single"
364
+ borderColor={Colors.warning}
365
+ backgroundColor={Colors.card}
366
+ >
367
+ <text fg={Colors.warning} bold>
368
+ ⚠ SECURITY: Values are hidden for security reasons.
369
+ </text>
370
+ <box flexDirection="row" gap={1}>
371
+ <text fg={Colors.muted} dim>
372
+ This view is strictly read-only. Use
373
+ </text>
374
+ <text fg={Colors.accent}>hoox config secrets set</text>
375
+ <text fg={Colors.muted} dim>
376
+ in CLI to manage secrets.
377
+ </text>
378
+ </box>
379
+ </box>
380
+
381
+ {/* Search + last sampled row */}
382
+ <box flexDirection="row" gap={1} alignItems="center">
383
+ <SearchBox
384
+ query={search}
385
+ onChange={setSearch}
386
+ active={searchActive}
387
+ onActivate={() => setSearchActive(true)}
388
+ onDeactivate={() => setSearchActive(false)}
389
+ />
390
+ <text fg={Colors.muted} dim>
391
+ {snapshot
392
+ ? `Last sampled ${snapshot.timestamp.slice(11, 19)}`
393
+ : "Last sampled —"}
394
+ </text>
395
+ </box>
396
+
397
+ {/* Main content: secret list + detail */}
398
+ <box flexDirection="row" flexGrow={1} gap={1}>
399
+ {/* Left pane: scrollable list */}
400
+ <box
401
+ flexDirection="column"
402
+ flexGrow={1}
403
+ border={true}
404
+ borderStyle="single"
405
+ borderColor={Colors.border}
406
+ >
407
+ {/* Column header */}
408
+ <box flexDirection="row" gap={2} paddingLeft={1} paddingRight={1}>
409
+ <text fg={Colors.muted} dim>
410
+ SECRET NAME
411
+ </text>
412
+ <text fg={Colors.muted} dim>
413
+ {"TYPE".padStart(8, " ")}
414
+ </text>
415
+ <text fg={Colors.muted} dim>
416
+ {"SOURCE".padEnd(10, " ")}
417
+ </text>
418
+ </box>
419
+ <text fg={Colors.border} dim>
420
+ {"─".repeat(80)}
421
+ </text>
422
+
423
+ {/* Body */}
424
+ {loading && allSecrets.length === 0 ? (
425
+ <box padding={1} flexGrow={1} justifyContent="center">
426
+ <Spinner label="Loading secrets…" />
427
+ </box>
428
+ ) : error && allSecrets.length === 0 ? (
429
+ <box padding={1} flexDirection="column" gap={0}>
430
+ <text fg={Colors.error} bold>
431
+ ! {error.length > 60 ? error.slice(0, 57) + "…" : error}
432
+ </text>
433
+ <text fg={Colors.muted} dim>
434
+ Make sure hoox CLI is installed and configured.
435
+ </text>
436
+ </box>
437
+ ) : allSecrets.length === 0 ? (
438
+ <box padding={1} flexGrow={1}>
439
+ <EmptyState
440
+ message="No secrets declared."
441
+ suggestion="Add secrets to wrangler.jsonc · hoox config secrets set"
442
+ icon="🔐"
443
+ />
444
+ </box>
445
+ ) : filteredSecrets.length === 0 ? (
446
+ <box padding={1} flexGrow={1}>
447
+ <EmptyState
448
+ message={`No secrets match "${search}".`}
449
+ suggestion="Press / to clear."
450
+ icon="🔍"
451
+ />
452
+ </box>
453
+ ) : (
454
+ <scrollbox width="100%" flexGrow={1}>
455
+ {visibleSecrets.map((s) => (
456
+ <SecretRow
457
+ key={s.name}
458
+ secret={s}
459
+ isSelected={s.name === selectedSecretName}
460
+ onSelect={(name) => setSelectedSecretName(name)}
461
+ />
462
+ ))}
463
+ </scrollbox>
464
+ )}
465
+ </box>
466
+
467
+ {/* Right pane: detail / info viewer */}
468
+ <box
469
+ flexDirection="column"
470
+ flexGrow={1}
471
+ border={true}
472
+ borderStyle="single"
473
+ borderColor={Colors.border}
474
+ >
475
+ {selectedSecret ? (
476
+ <box flexDirection="column" gap={1} padding={2}>
477
+ <text fg={Colors.accent} bold>
478
+ {selectedSecret.name}
479
+ </text>
480
+ <text fg={Colors.border} dim>
481
+ {"─".repeat(40)}
482
+ </text>
483
+ <box flexDirection="column" gap={0}>
484
+ <box flexDirection="row" gap={1}>
485
+ <text fg={Colors.muted}>Type:</text>
486
+ <text fg={Colors.foreground}>{selectedSecret.type}</text>
487
+ </box>
488
+ <box flexDirection="row" gap={1}>
489
+ <text fg={Colors.muted}>Source:</text>
490
+ <text fg={sourceColor(selectedSecret.source)}>
491
+ {selectedSecret.source}
492
+ </text>
493
+ </box>
494
+ </box>
495
+ <text fg={Colors.muted} dim>
496
+ Values are not available for viewing.
497
+ </text>
498
+ </box>
499
+ ) : (
500
+ <box flexDirection="column" flexGrow={1} padding={2} gap={1}>
501
+ <text fg={Colors.muted} dim>
502
+ Select a secret to view its metadata.
503
+ </text>
504
+ <text fg={Colors.dim} dim>
505
+ ↑↓ navigate · values hidden
506
+ </text>
507
+ </box>
508
+ )}
509
+ </box>
510
+ </box>
511
+
512
+ {/* Footer: refresh + read-only warning */}
513
+ <box
514
+ flexDirection="row"
515
+ justifyContent="space-between"
516
+ paddingLeft={1}
517
+ paddingRight={1}
518
+ alignItems="center"
519
+ >
520
+ <box flexDirection="row" gap={2} alignItems="center">
521
+ <text
522
+ fg={loading ? Colors.muted : Colors.accent}
523
+ bg={Colors.card}
524
+ dim={loading}
525
+ onMouseUp={loading ? undefined : () => void refresh()}
526
+ >
527
+ {loading ? " ... " : " [REFRESH] "}
528
+ </text>
529
+ {error && allSecrets.length > 0 && (
530
+ <text fg={Colors.warning} dim>
531
+ ! {error.length > 50 ? error.slice(0, 47) + "…" : error}
532
+ </text>
533
+ )}
534
+ </box>
535
+ <box flexDirection="row" gap={1}>
536
+ <text fg={Colors.muted} dim>
537
+ Use
538
+ </text>
539
+ <text fg={Colors.accent}>hoox config secrets set|delete</text>
540
+ <text fg={Colors.muted} dim>
541
+ in CLI for writes
542
+ </text>
543
+ </box>
544
+ </box>
545
+ </box>
546
+ </ErrorBoundary>
547
+ );
548
+ }