@hoox-sh/hoox-tui 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +247 -0
- package/bin/hoox-tui.js +35 -0
- package/dist/main.js +17594 -0
- package/package.json +72 -0
- package/src/app.tsx +696 -0
- package/src/cli-bridge-test-double.ts +212 -0
- package/src/components/layout/layout.test.tsx +205 -0
- package/src/components/layout/sidebar.tsx +69 -0
- package/src/components/layout/statusbar.test.tsx +423 -0
- package/src/components/layout/statusbar.tsx +348 -0
- package/src/components/shared/animated-border.tsx +58 -0
- package/src/components/shared/chrome.test.tsx +29 -0
- package/src/components/shared/command-palette.tsx +361 -0
- package/src/components/shared/cool-brackets.tsx +75 -0
- package/src/components/shared/crash-screen.tsx +167 -0
- package/src/components/shared/error-boundary.tsx +99 -0
- package/src/components/shared/keybinding-hint.tsx +37 -0
- package/src/components/shared/panel.tsx +61 -0
- package/src/components/shared/quit-modal.tsx +65 -0
- package/src/components/shared/shared.test.tsx +538 -0
- package/src/components/shared/spinner.tsx +85 -0
- package/src/components/shared/status-dot.tsx +54 -0
- package/src/components/shared/view-header.tsx +53 -0
- package/src/components/ui/connection-toasts.test.ts +50 -0
- package/src/components/ui/connection-toasts.ts +215 -0
- package/src/components/ui/dialog.tsx +281 -0
- package/src/components/ui/select.tsx +197 -0
- package/src/components/ui/toast.tsx +185 -0
- package/src/components/views/ai-chat.test.tsx +81 -0
- package/src/components/views/ai-chat.tsx +576 -0
- package/src/components/views/config-editor/action-bar.tsx +102 -0
- package/src/components/views/config-editor/code-editor.tsx +597 -0
- package/src/components/views/config-editor/file-tree.tsx +113 -0
- package/src/components/views/config-editor/types.ts +37 -0
- package/src/components/views/config-editor.test.tsx +465 -0
- package/src/components/views/config-editor.tsx +709 -0
- package/src/components/views/dashboard/alerts-panel.tsx +154 -0
- package/src/components/views/dashboard/auto-repair-panel.tsx +229 -0
- package/src/components/views/dashboard/model-health-section.tsx +276 -0
- package/src/components/views/dashboard/pyne-health-section.tsx +112 -0
- package/src/components/views/dashboard.test.tsx +347 -0
- package/src/components/views/dashboard.tsx +647 -0
- package/src/components/views/db-query.test.tsx +115 -0
- package/src/components/views/db-query.tsx +849 -0
- package/src/components/views/edge-topology.test.tsx +198 -0
- package/src/components/views/edge-topology.tsx +572 -0
- package/src/components/views/kv-viewer.test.tsx +241 -0
- package/src/components/views/kv-viewer.tsx +717 -0
- package/src/components/views/logs-viewer.test.tsx +273 -0
- package/src/components/views/logs-viewer.tsx +616 -0
- package/src/components/views/queue-depth.test.tsx +221 -0
- package/src/components/views/queue-depth.tsx +376 -0
- package/src/components/views/secrets-viewer.test.tsx +65 -0
- package/src/components/views/secrets-viewer.tsx +548 -0
- package/src/components/views/service-manager.test.tsx +358 -0
- package/src/components/views/service-manager.tsx +946 -0
- package/src/components/views/settings.test.tsx +648 -0
- package/src/components/views/settings.tsx +1497 -0
- package/src/components/views/setup-wizard.test.tsx +297 -0
- package/src/components/views/setup-wizard.tsx +1321 -0
- package/src/components/views/trade-monitor.test.tsx +592 -0
- package/src/components/views/trade-monitor.tsx +745 -0
- package/src/components/views/worker-detail.test.tsx +289 -0
- package/src/components/views/worker-detail.tsx +676 -0
- package/src/components/views/worker-settings.test.tsx +43 -0
- package/src/components/views/worker-settings.tsx +563 -0
- package/src/components/views/workers-overview.test.tsx +260 -0
- package/src/components/views/workers-overview.tsx +631 -0
- package/src/hooks/hooks.test.ts +72 -0
- package/src/hooks/index.ts +11 -0
- package/src/hooks/renderer-ref.ts +22 -0
- package/src/hooks/use-keyboard.ts +130 -0
- package/src/hooks/use-polling.ts +73 -0
- package/src/hooks/use-service-data.ts +18 -0
- package/src/main.tsx +117 -0
- package/src/network-test-double.ts +133 -0
- package/src/opentui-augment.d.ts +45 -0
- package/src/opentui-jsx.d.ts +70 -0
- package/src/services/cli-bridge/index.ts +1662 -0
- package/src/services/cli-bridge/standalone.ts +315 -0
- package/src/services/cli-bridge/types.ts +307 -0
- package/src/services/cli-bridge.test.ts +406 -0
- package/src/services/clipboard.test.ts +57 -0
- package/src/services/clipboard.ts +180 -0
- package/src/services/dashboard-settings-loader.ts +31 -0
- package/src/services/dev-log.test.ts +168 -0
- package/src/services/dev-log.ts +194 -0
- package/src/services/hoox-path-service.test.ts +183 -0
- package/src/services/hoox-path-service.ts +185 -0
- package/src/services/tui-connection.test.ts +212 -0
- package/src/services/tui-connection.ts +236 -0
- package/src/services/tui-storage.test.ts +92 -0
- package/src/services/tui-storage.ts +86 -0
- package/src/stores/config-store.test.ts +360 -0
- package/src/stores/service-store.test.ts +1026 -0
- package/src/stores/session-restore.test.ts +134 -0
- package/src/stores/ui-store.test.ts +276 -0
- package/src/test-setup.ts +265 -0
- package/src/test-utils.ts +179 -0
- package/src/types.ts +153 -0
- package/src/utils/colors.test.ts +326 -0
- package/src/utils/formatters.test.ts +236 -0
- package/src/view-registry.test.ts +132 -0
- package/src/view-registry.tsx +368 -0
|
@@ -0,0 +1,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
|
+
}
|