@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,361 @@
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
+ * CommandPalette — an overlay modal with fuzzy-searchable command registry.
9
+ *
10
+ * Features:
11
+ * - Dimmed backdrop overlay (backdropOpacity).
12
+ * - Fuzzy-search input at top with caret indicator.
13
+ * - Results list: command name (bold), category badge (view/action/setting),
14
+ * shortcut hint on the right.
15
+ * - ↑↓ navigate results, Enter to select, Esc to dismiss.
16
+ * - Commands are provided via a registry prop so consumers can
17
+ * populate from views, actions, and settings.
18
+ *
19
+ * Colors use Hoox design tokens — no hardcoded hex.
20
+ * Keyboard handling via @opentui/react useKeyboard hook.
21
+ */
22
+ import { useState, useMemo, useEffect } from "react";
23
+ import { useKeyboard } from "@opentui/react";
24
+ import { Colors } from "@hoox-sh/hoox-shared";
25
+
26
+ // ── Types ──────────────────────────────────────────────────────────────────
27
+
28
+ /** Category tag for grouping commands in the palette */
29
+ export type CommandCategory = "view" | "action" | "setting";
30
+
31
+ /** A single command entry in the registry */
32
+ export interface CommandEntry {
33
+ /** Unique command identifier */
34
+ id: string;
35
+ /** Display name shown in results (bold) */
36
+ name: string;
37
+ /** Category used for the badge */
38
+ category: CommandCategory;
39
+ /** Optional keyboard shortcut shown on the right */
40
+ shortcut?: string;
41
+ /** Optional search aliases to improve fuzzy matching */
42
+ aliases?: string[];
43
+ /** Extra description shown dimmed below the name */
44
+ description?: string;
45
+ }
46
+
47
+ /** Result of selecting a command */
48
+ export interface CommandSelection {
49
+ command: CommandEntry;
50
+ /** Type of action to perform */
51
+ action: "setView" | "execute";
52
+ }
53
+
54
+ export interface CommandPaletteProps {
55
+ /** Whether the palette overlay is visible */
56
+ visible: boolean;
57
+ /** Registry of all available commands */
58
+ commands: CommandEntry[];
59
+ /** Called when user selects a command (Enter) or dismisses with Escape */
60
+ onSelect: (selection: CommandSelection) => void;
61
+ /** Called when user dismisses the palette (Escape) */
62
+ onDismiss: () => void;
63
+ }
64
+
65
+ // ── Category badge colors ──────────────────────────────────────────────────
66
+
67
+ const CATEGORY_BADGE: Record<
68
+ CommandCategory,
69
+ { label: string; color: string }
70
+ > = {
71
+ view: { label: "view", color: Colors.info },
72
+ action: { label: "action", color: Colors.accent },
73
+ setting: { label: "setting", color: Colors.warning },
74
+ };
75
+
76
+ // ── Fuzzy filter ───────────────────────────────────────────────────────────
77
+
78
+ /**
79
+ * Simple fuzzy match — checks if all characters in `query` appear
80
+ * in order within the candidate string (case-insensitive).
81
+ */
82
+ function fuzzyMatch(query: string, candidate: string): boolean {
83
+ const q = query.toLowerCase();
84
+ const c = candidate.toLowerCase();
85
+ let qi = 0;
86
+ for (let ci = 0; ci < c.length && qi < q.length; ci++) {
87
+ if (c[ci] === q[qi]) qi++;
88
+ }
89
+ return qi === q.length;
90
+ }
91
+
92
+ /** Score a command entry against a query (higher = better match) */
93
+ function scoreCommand(query: string, cmd: CommandEntry): number {
94
+ if (!query) return 1; // no query, keep natural order
95
+ const q = query.toLowerCase();
96
+
97
+ // Exact prefix match on name is strongest
98
+ if (cmd.name.toLowerCase().startsWith(q)) return 100;
99
+
100
+ // Fuzzy match on name
101
+ if (fuzzyMatch(q, cmd.name)) return 50;
102
+
103
+ // Match in description
104
+ if (cmd.description && fuzzyMatch(q, cmd.description)) return 30;
105
+
106
+ // Match in aliases
107
+ if (cmd.aliases?.some((a) => fuzzyMatch(q, a))) return 20;
108
+
109
+ return -1; // no match
110
+ }
111
+
112
+ /** Filter and sort commands by fuzzy relevance */
113
+ function filterCommands(
114
+ commands: CommandEntry[],
115
+ query: string
116
+ ): CommandEntry[] {
117
+ if (!query.trim()) return commands;
118
+
119
+ const scored = commands
120
+ .map((cmd) => ({ cmd, score: scoreCommand(query, cmd) }))
121
+ .filter(({ score }) => score >= 0)
122
+ .sort((a, b) => b.score - a.score);
123
+
124
+ // Deduplicate by id (keep highest score)
125
+ const seen = new Set<string>();
126
+ const deduped: CommandEntry[] = [];
127
+ for (const { cmd } of scored) {
128
+ if (!seen.has(cmd.id)) {
129
+ seen.add(cmd.id);
130
+ deduped.push(cmd);
131
+ }
132
+ }
133
+ return deduped;
134
+ }
135
+
136
+ // ── Component ──────────────────────────────────────────────────────────────
137
+
138
+ export function CommandPalette({
139
+ visible,
140
+ commands,
141
+ onSelect,
142
+ onDismiss,
143
+ }: CommandPaletteProps) {
144
+ const [query, setQuery] = useState("");
145
+ const [selectedIndex, setSelectedIndex] = useState(0);
146
+
147
+ // Filtered & scored results
148
+ const filtered = useMemo(
149
+ () => filterCommands(commands, query),
150
+ [commands, query]
151
+ );
152
+
153
+ // Clamp selection when the filtered list shrinks (e.g. tighter query)
154
+ const safeIndex =
155
+ filtered.length === 0 ? 0 : Math.min(selectedIndex, filtered.length - 1);
156
+
157
+ // Reset local state when the palette is dismissed so next open is clean
158
+ useEffect(() => {
159
+ if (!visible) {
160
+ setQuery("");
161
+ setSelectedIndex(0);
162
+ }
163
+ }, [visible]);
164
+
165
+ // Keep selectedIndex in range after filter changes
166
+ useEffect(() => {
167
+ if (selectedIndex !== safeIndex) {
168
+ setSelectedIndex(safeIndex);
169
+ }
170
+ }, [safeIndex, selectedIndex]);
171
+
172
+ // ── Keyboard handling (active only when visible) ─────────────────────
173
+
174
+ useKeyboard((key) => {
175
+ if (!visible) return;
176
+
177
+ // Dismiss
178
+ if (key.name === "escape") {
179
+ setQuery("");
180
+ setSelectedIndex(0);
181
+ onDismiss();
182
+ return;
183
+ }
184
+
185
+ // Navigate
186
+ if (key.name === "up") {
187
+ setSelectedIndex((i) => {
188
+ const last = Math.max(0, filtered.length - 1);
189
+ const cur = Math.min(i, last);
190
+ return cur > 0 ? cur - 1 : last;
191
+ });
192
+ return;
193
+ }
194
+ if (key.name === "down") {
195
+ setSelectedIndex((i) => {
196
+ const last = Math.max(0, filtered.length - 1);
197
+ const cur = Math.min(i, last);
198
+ return cur < last ? cur + 1 : 0;
199
+ });
200
+ return;
201
+ }
202
+
203
+ // Select (return / enter)
204
+ if (key.name === "return" || key.name === "enter") {
205
+ const selected = filtered[safeIndex];
206
+ if (selected) {
207
+ setQuery("");
208
+ setSelectedIndex(0);
209
+ onSelect({
210
+ command: selected,
211
+ action: selected.category === "view" ? "setView" : "execute",
212
+ });
213
+ }
214
+ return;
215
+ }
216
+
217
+ // Delete
218
+ if (key.name === "backspace" || key.name === "delete") {
219
+ setQuery((q) => q.slice(0, -1));
220
+ setSelectedIndex(0);
221
+ return;
222
+ }
223
+
224
+ // Character input (printable keys only — no modifiers)
225
+ if (
226
+ key.sequence &&
227
+ !key.ctrl &&
228
+ !key.alt &&
229
+ !key.meta &&
230
+ key.sequence.length === 1 &&
231
+ key.name !== "space" // space handled separately
232
+ ) {
233
+ const char = key.sequence;
234
+ // Only accept printable ASCII
235
+ if (char >= " " && char <= "~") {
236
+ setQuery((q) => q + char);
237
+ setSelectedIndex(0);
238
+ return;
239
+ }
240
+ }
241
+
242
+ // Space
243
+ if (key.name === "space") {
244
+ setQuery((q) => q + " ");
245
+ setSelectedIndex(0);
246
+ }
247
+ });
248
+
249
+ // ── Render (hidden when not visible) ────────────────────────────────
250
+
251
+ if (!visible) return null;
252
+
253
+ return (
254
+ <box
255
+ position="absolute"
256
+ left={0}
257
+ top={0}
258
+ width="100%"
259
+ height="100%"
260
+ flexDirection="column"
261
+ justifyContent="center"
262
+ alignItems="center"
263
+ >
264
+ {/* Modal container */}
265
+ <box
266
+ flexDirection="column"
267
+ width={50}
268
+ padding={1}
269
+ gap={0}
270
+ border={true}
271
+ borderStyle="double"
272
+ borderColor={Colors.accent}
273
+ backgroundColor={Colors.background}
274
+ >
275
+ {/* ── Search input row ──────────────────────────────────────── */}
276
+ <box flexDirection="row" gap={1} paddingLeft={1} paddingRight={1}>
277
+ <text fg={Colors.accent}>▸</text>
278
+ <text fg={Colors.foreground}>{query}</text>
279
+ {/* Blinking caret */}
280
+ <text fg={Colors.accent} blink>
281
+ █
282
+ </text>
283
+ </box>
284
+
285
+ {/* ── Divider ────────────────────────────────────────────────── */}
286
+ <box
287
+ height={1}
288
+ border={true}
289
+ borderStyle="single"
290
+ borderColor={Colors.border}
291
+ />
292
+
293
+ {/* ── Results list ───────────────────────────────────────────── */}
294
+ <box flexDirection="column" gap={0} paddingTop={0}>
295
+ {filtered.length === 0 && query.length > 0 ? (
296
+ /* No results state */
297
+ <box paddingLeft={2} paddingTop={1} paddingBottom={1}>
298
+ <text dim fg={Colors.muted}>
299
+ No matching commands
300
+ </text>
301
+ </box>
302
+ ) : (
303
+ filtered.map((cmd, idx) => (
304
+ <box
305
+ key={cmd.id}
306
+ flexDirection="row"
307
+ justifyContent="space-between"
308
+ paddingLeft={idx === safeIndex ? 1 : 2}
309
+ paddingRight={1}
310
+ bg={idx === safeIndex ? Colors.card : undefined}
311
+ >
312
+ {/* Left side: selection arrow + name + category badge */}
313
+ <box flexDirection="row" gap={1}>
314
+ {idx === safeIndex ? (
315
+ <text fg={Colors.accent}>▶</text>
316
+ ) : (
317
+ <text> </text>
318
+ )}
319
+ <text
320
+ fg={idx === safeIndex ? Colors.foreground : Colors.muted}
321
+ bold
322
+ >
323
+ {cmd.name}
324
+ </text>
325
+ {/* Category badge */}
326
+ <text
327
+ fg={CATEGORY_BADGE[cmd.category].color}
328
+ dim={idx !== safeIndex}
329
+ >
330
+ {CATEGORY_BADGE[cmd.category].label}
331
+ </text>
332
+ </box>
333
+
334
+ {/* Right side: shortcut hint */}
335
+ <box flexDirection="row" gap={0}>
336
+ {cmd.shortcut && (
337
+ <text dim fg={Colors.muted}>
338
+ {cmd.shortcut}
339
+ </text>
340
+ )}
341
+ </box>
342
+ </box>
343
+ ))
344
+ )}
345
+ </box>
346
+
347
+ {/* ── Footer hint ────────────────────────────────────────────── */}
348
+ <box
349
+ flexDirection="row"
350
+ justifyContent="flex-end"
351
+ paddingTop={0}
352
+ paddingRight={1}
353
+ >
354
+ <text dim fg={Colors.muted}>
355
+ Esc to close
356
+ </text>
357
+ </box>
358
+ </box>
359
+ </box>
360
+ );
361
+ }
@@ -0,0 +1,75 @@
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
+ * CoolBrackets — static corner/pair brackets in brand accent colors.
9
+ * No animation (no rainbow / spectrum cycling).
10
+ */
11
+ import type { ReactNode } from "react";
12
+ import { Colors } from "@hoox-sh/hoox-shared";
13
+
14
+ /**
15
+ * Focus / accent color helper (static — kept for call-site compatibility).
16
+ */
17
+ export function useCoolHue(
18
+ _intervalMs = 120,
19
+ enabled = true
20
+ ): { color: string; index: number } {
21
+ return {
22
+ index: 0,
23
+ color: enabled ? Colors.accent : Colors.border,
24
+ };
25
+ }
26
+
27
+ export interface CoolBracketsProps {
28
+ /** Content between the brackets */
29
+ children?: ReactNode;
30
+ /** Left glyph (default ┌) */
31
+ open?: string;
32
+ /** Right glyph (default ┐) */
33
+ close?: string;
34
+ /** @deprecated Ignored — animation removed */
35
+ intervalMs?: number;
36
+ /** @deprecated Always static now */
37
+ static?: boolean;
38
+ /** @deprecated Ignored — animation removed */
39
+ phase?: number;
40
+ /** Gap between open, children, close */
41
+ gap?: number;
42
+ }
43
+
44
+ /**
45
+ * Renders open + optional children + close with fixed accent colors.
46
+ */
47
+ export function CoolBrackets({
48
+ children,
49
+ open = "┌",
50
+ close = "┐",
51
+ gap = 1,
52
+ }: CoolBracketsProps) {
53
+ return (
54
+ <box flexDirection="row" gap={gap} alignItems="center">
55
+ <text fg={Colors.accent}>{open}</text>
56
+ {children ?? null}
57
+ <text fg={Colors.accent}>{close}</text>
58
+ </box>
59
+ );
60
+ }
61
+
62
+ /**
63
+ * Inline single glyph in accent color (for ▸ markers, pipes, etc.).
64
+ */
65
+ export function CoolGlyph({
66
+ char,
67
+ }: {
68
+ char: string;
69
+ /** @deprecated Ignored — animation removed */
70
+ intervalMs?: number;
71
+ /** @deprecated Ignored — animation removed */
72
+ phase?: number;
73
+ }) {
74
+ return <text fg={Colors.accent}>{char}</text>;
75
+ }
@@ -0,0 +1,167 @@
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
+ * Crash Screen — rendered when an unhandled error escapes all error boundaries.
9
+ *
10
+ * Displays:
11
+ * - "Something went wrong" banner
12
+ * - Error message (first line)
13
+ * - Three action buttons: [Restart] [Safe Mode] [Report Bug]
14
+ *
15
+ * This component is a plain function (not a class) because it's meant to be
16
+ * rendered in a degraded state — no hooks, no stores, no external state.
17
+ * Colors use Hoox design tokens via @hoox-sh/hoox-shared.
18
+ */
19
+ import { Colors } from "@hoox-sh/hoox-shared";
20
+ import { redactSecretsInText } from "../../services/dev-log";
21
+
22
+ // ─── Types ───────────────────────────────────────────────────────────────────
23
+
24
+ export type CrashAction = "restart" | "safe-mode" | "report-bug";
25
+
26
+ export interface CrashScreenProps {
27
+ /** The error that caused the crash */
28
+ error: Error;
29
+ /** Callback for crash action buttons */
30
+ onAction: (action: CrashAction) => void;
31
+ /** Whether we're in safe mode (affects display) */
32
+ safeMode?: boolean;
33
+ }
34
+
35
+ /** Safe, single-line display text for crash UI (secrets scrubbed, length capped). */
36
+ export function formatCrashMessage(error: Error, maxLen = 120): string {
37
+ const first = (error.message || "Unknown error").split("\n")[0] ?? "";
38
+ const scrubbed = redactSecretsInText(first).trim() || "Unknown error";
39
+ return scrubbed.length > maxLen
40
+ ? `${scrubbed.slice(0, maxLen - 1)}…`
41
+ : scrubbed;
42
+ }
43
+
44
+ // ─── Component ───────────────────────────────────────────────────────────────
45
+
46
+ export function CrashScreen({
47
+ error,
48
+ onAction,
49
+ safeMode = false,
50
+ }: CrashScreenProps) {
51
+ const messageLine = formatCrashMessage(error);
52
+ const secondRaw = error.message.includes("\n")
53
+ ? error.message.split("\n")[1]
54
+ : undefined;
55
+ const secondLine = secondRaw
56
+ ? redactSecretsInText(secondRaw).slice(0, 80)
57
+ : undefined;
58
+
59
+ return (
60
+ <box
61
+ flexDirection="column"
62
+ width="100%"
63
+ height="100%"
64
+ justifyContent="center"
65
+ alignItems="center"
66
+ padding={2}
67
+ gap={2}
68
+ backgroundColor={Colors.background}
69
+ >
70
+ {/* ── Banner ───────────────────────────────────────────────────────── */}
71
+ <box
72
+ flexDirection="column"
73
+ alignItems="center"
74
+ border={true}
75
+ borderStyle="double"
76
+ borderColor={Colors.error}
77
+ padding={2}
78
+ gap={1}
79
+ backgroundColor={Colors.card}
80
+ >
81
+ {/* Title */}
82
+ <text fg={Colors.error} bold>
83
+ ╔══════════════════════════════════════╗
84
+ </text>
85
+ <text fg={Colors.error} bold>
86
+ ║ Something went wrong ║
87
+ </text>
88
+ <text fg={Colors.error} bold>
89
+ ╚══════════════════════════════════════╝
90
+ </text>
91
+
92
+ {/* Safe mode indicator */}
93
+ {safeMode && (
94
+ <text fg={Colors.warning} bold>
95
+ (Safe Mode — UI only, no API / CLI / SSE)
96
+ </text>
97
+ )}
98
+
99
+ {/* Error message (redacted — never show tokens) */}
100
+ <box paddingTop={1} flexDirection="column" alignItems="center">
101
+ <text fg={Colors.muted}>{messageLine}</text>
102
+ {secondLine ? (
103
+ <text fg={Colors.dim} dim>
104
+ {secondLine}
105
+ </text>
106
+ ) : null}
107
+ </box>
108
+ </box>
109
+
110
+ {/* ── Action Buttons ───────────────────────────────────────────────── */}
111
+ <box flexDirection="row" gap={2} paddingTop={1}>
112
+ {/* [Restart] — re-initialize the renderer */}
113
+ <box
114
+ border={true}
115
+ borderStyle="single"
116
+ borderColor={Colors.accent}
117
+ paddingLeft={2}
118
+ paddingRight={2}
119
+ >
120
+ <text
121
+ fg={Colors.accent}
122
+ bg={Colors.card}
123
+ onMouseUp={() => onAction("restart")}
124
+ >
125
+ {" [Restart] "}
126
+ </text>
127
+ </box>
128
+
129
+ {/* [Safe Mode] — start with minimal config */}
130
+ {!safeMode && (
131
+ <box
132
+ border={true}
133
+ borderStyle="single"
134
+ borderColor={Colors.warning}
135
+ paddingLeft={2}
136
+ paddingRight={2}
137
+ >
138
+ <text
139
+ fg={Colors.warning}
140
+ bg={Colors.card}
141
+ onMouseUp={() => onAction("safe-mode")}
142
+ >
143
+ {" [Safe Mode] "}
144
+ </text>
145
+ </box>
146
+ )}
147
+
148
+ {/* [Report Bug] — write error to file / console */}
149
+ <box
150
+ border={true}
151
+ borderStyle="single"
152
+ borderColor={Colors.muted}
153
+ paddingLeft={2}
154
+ paddingRight={2}
155
+ >
156
+ <text
157
+ fg={Colors.muted}
158
+ bg={Colors.card}
159
+ onMouseUp={() => onAction("report-bug")}
160
+ >
161
+ {" [Report Bug] "}
162
+ </text>
163
+ </box>
164
+ </box>
165
+ </box>
166
+ );
167
+ }
@@ -0,0 +1,99 @@
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
+ * React-style error boundary for the TUI.
9
+ * Catches render errors in child views and displays a styled recovery panel
10
+ * with the view name, error message, and a [Retry] button.
11
+ *
12
+ * Colors use Hoox design tokens — no hardcoded hex.
13
+ */
14
+ import { Component, type ReactNode } from "react";
15
+ import { Colors } from "@hoox-sh/hoox-shared";
16
+ import { redactSecretsInText } from "../../services/dev-log";
17
+
18
+ // ── Types ──────────────────────────────────────────────────────────────────
19
+
20
+ interface ErrorBoundaryProps {
21
+ /** Name of the view being wrapped (shown in error UI) */
22
+ viewName: string;
23
+ /** Child content to protect */
24
+ children: ReactNode;
25
+ }
26
+
27
+ interface ErrorBoundaryState {
28
+ error: Error | null;
29
+ }
30
+
31
+ /** First line of error message, secrets scrubbed, length-capped for chrome. */
32
+ export function formatBoundaryErrorMessage(error: Error, maxLen = 160): string {
33
+ const first = (error.message || "Unknown error").split("\n")[0] ?? "";
34
+ const scrubbed = redactSecretsInText(first).trim() || "Unknown error";
35
+ return scrubbed.length > maxLen
36
+ ? `${scrubbed.slice(0, maxLen - 1)}…`
37
+ : scrubbed;
38
+ }
39
+
40
+ // ── Component ──────────────────────────────────────────────────────────────
41
+
42
+ export class ErrorBoundary extends Component<
43
+ ErrorBoundaryProps,
44
+ ErrorBoundaryState
45
+ > {
46
+ state: ErrorBoundaryState = { error: null };
47
+
48
+ static getDerivedStateFromError(error: Error): ErrorBoundaryState {
49
+ return { error };
50
+ }
51
+
52
+ /** Reset error state to retry rendering children */
53
+ private handleRetry = () => {
54
+ this.setState({ error: null });
55
+ };
56
+
57
+ render(): ReactNode {
58
+ if (this.state.error) {
59
+ const message = formatBoundaryErrorMessage(this.state.error);
60
+ return (
61
+ <box
62
+ flexDirection="column"
63
+ padding={2}
64
+ gap={1}
65
+ border={true}
66
+ borderStyle="single"
67
+ borderColor={Colors.border}
68
+ backgroundColor={Colors.card}
69
+ >
70
+ {/* Header: view name + error indicator */}
71
+ <box flexDirection="row" gap={1}>
72
+ <text fg={Colors.error} bold>
73
+ ⚠
74
+ </text>
75
+ <text fg={Colors.error} bold>
76
+ Failed to load {this.props.viewName}
77
+ </text>
78
+ </box>
79
+
80
+ {/* Error message (redacted, first line only) */}
81
+ <text fg={Colors.muted}>{message}</text>
82
+
83
+ {/* Retry action */}
84
+ <box paddingTop={1}>
85
+ <text
86
+ fg={Colors.accent}
87
+ bg={Colors.card}
88
+ onMouseUp={this.handleRetry}
89
+ >
90
+ {" [Retry] "}
91
+ </text>
92
+ </box>
93
+ </box>
94
+ );
95
+ }
96
+
97
+ return this.props.children;
98
+ }
99
+ }