@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,281 @@
|
|
|
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
|
+
* Thin wrappers around @opentui-ui/dialog/react with Hoox color tokens.
|
|
9
|
+
* Provides ConfirmDialog, ChoiceDialog, and LoadingDialog helpers.
|
|
10
|
+
*
|
|
11
|
+
* All dialogs render centered with a dimmed backdrop (backdropOpacity 0.35)
|
|
12
|
+
* and use Colors tokens from @hoox-sh/hoox-shared for consistent Hoox branding.
|
|
13
|
+
*/
|
|
14
|
+
import { useState } from "react";
|
|
15
|
+
import {
|
|
16
|
+
type ChoiceContext,
|
|
17
|
+
type ConfirmContext,
|
|
18
|
+
type DialogId,
|
|
19
|
+
useDialogKeyboard,
|
|
20
|
+
} from "@opentui-ui/dialog/react";
|
|
21
|
+
import { Colors } from "@hoox-sh/hoox-shared";
|
|
22
|
+
|
|
23
|
+
// ── Types ──────────────────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
/** Options for confirm dialogs (Yes/No, Confirm/Cancel) */
|
|
26
|
+
export interface ConfirmDialogOptions {
|
|
27
|
+
/** Dialog title (bold, accent-colored) */
|
|
28
|
+
title: string;
|
|
29
|
+
/** Explanatory message below the title */
|
|
30
|
+
message: string;
|
|
31
|
+
/** Label for the affirmative button (default: "Confirm") */
|
|
32
|
+
confirmLabel?: string;
|
|
33
|
+
/** Label for the dismiss button (default: "Cancel") */
|
|
34
|
+
cancelLabel?: string;
|
|
35
|
+
/** Whether clicking outside the dialog dismisses it (default: true) */
|
|
36
|
+
closeOnClickOutside?: boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** A single choice item in a choice dialog */
|
|
40
|
+
export interface ChoiceOption<K extends string = string> {
|
|
41
|
+
key: K;
|
|
42
|
+
label: string;
|
|
43
|
+
description?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Options for choice dialogs */
|
|
47
|
+
export interface ChoiceDialogOptions<K extends string = string> {
|
|
48
|
+
/** Dialog title */
|
|
49
|
+
title: string;
|
|
50
|
+
/** Array of selectable options */
|
|
51
|
+
choices: ChoiceOption<K>[];
|
|
52
|
+
/** Value returned when dialog is dismissed via ESC/backdrop (default: undefined) */
|
|
53
|
+
fallback?: K;
|
|
54
|
+
/** Whether clicking outside dismisses (default: true) */
|
|
55
|
+
closeOnClickOutside?: boolean;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Dialog manager interface — subset of what useDialog() returns */
|
|
59
|
+
export interface DialogHandle {
|
|
60
|
+
confirm(options: {
|
|
61
|
+
content: (ctx: ConfirmContext) => unknown;
|
|
62
|
+
closeOnClickOutside?: boolean;
|
|
63
|
+
}): Promise<boolean>;
|
|
64
|
+
choice<K extends string>(options: {
|
|
65
|
+
content: (ctx: ChoiceContext<K>) => unknown;
|
|
66
|
+
fallback?: K;
|
|
67
|
+
closeOnClickOutside?: boolean;
|
|
68
|
+
}): Promise<K | undefined>;
|
|
69
|
+
show(options: {
|
|
70
|
+
content: () => unknown;
|
|
71
|
+
id?: string | number;
|
|
72
|
+
}): string | number;
|
|
73
|
+
close(id?: string | number): void;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ── Internal: keyboard-navigable choice list ──────────────────────────────
|
|
77
|
+
|
|
78
|
+
/** Props for the keyboard-navigable choice list rendered inside a dialog */
|
|
79
|
+
interface ChoiceListProps<K extends string = string> {
|
|
80
|
+
choices: ChoiceOption<K>[];
|
|
81
|
+
accentColor: string;
|
|
82
|
+
foregroundColor: string;
|
|
83
|
+
mutedColor: string;
|
|
84
|
+
highlightColor: string;
|
|
85
|
+
resolve: (value: K) => void;
|
|
86
|
+
dismiss: () => void;
|
|
87
|
+
dialogId: DialogId;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Internal component that renders a keyboard-navigable choice list.
|
|
92
|
+
* Uses useDialogKeyboard scoped to the dialog so only the topmost
|
|
93
|
+
* dialog receives keyboard events.
|
|
94
|
+
*/
|
|
95
|
+
function ChoiceList<K extends string = string>({
|
|
96
|
+
choices,
|
|
97
|
+
accentColor,
|
|
98
|
+
foregroundColor,
|
|
99
|
+
mutedColor,
|
|
100
|
+
highlightColor,
|
|
101
|
+
resolve,
|
|
102
|
+
dismiss,
|
|
103
|
+
dialogId,
|
|
104
|
+
}: ChoiceListProps<K>) {
|
|
105
|
+
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
106
|
+
|
|
107
|
+
useDialogKeyboard((key) => {
|
|
108
|
+
if (key.name === "down" || key.name === "j") {
|
|
109
|
+
setSelectedIndex((prev) => (prev < choices.length - 1 ? prev + 1 : 0));
|
|
110
|
+
}
|
|
111
|
+
if (key.name === "up" || key.name === "k") {
|
|
112
|
+
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : choices.length - 1));
|
|
113
|
+
}
|
|
114
|
+
if (key.name === "return") {
|
|
115
|
+
const choice = choices[selectedIndex];
|
|
116
|
+
if (choice) resolve(choice.key);
|
|
117
|
+
}
|
|
118
|
+
if (key.name === "escape") {
|
|
119
|
+
dismiss();
|
|
120
|
+
}
|
|
121
|
+
}, dialogId);
|
|
122
|
+
|
|
123
|
+
return (
|
|
124
|
+
<box flexDirection="column" gap={0}>
|
|
125
|
+
{choices.map((choice, idx) => (
|
|
126
|
+
<box flexDirection="row" gap={2} paddingLeft={1} paddingRight={1}>
|
|
127
|
+
{/* Selection indicator */}
|
|
128
|
+
<text fg={idx === selectedIndex ? accentColor : mutedColor}>
|
|
129
|
+
{idx === selectedIndex ? "▶" : " "}
|
|
130
|
+
</text>
|
|
131
|
+
{/* Choice label */}
|
|
132
|
+
<text
|
|
133
|
+
fg={idx === selectedIndex ? foregroundColor : mutedColor}
|
|
134
|
+
bg={idx === selectedIndex ? highlightColor : undefined}
|
|
135
|
+
onMouseUp={() => resolve(choice.key)}
|
|
136
|
+
>
|
|
137
|
+
{choice.label}
|
|
138
|
+
</text>
|
|
139
|
+
{/* Optional description */}
|
|
140
|
+
{choice.description && (
|
|
141
|
+
<text dim fg={mutedColor}>
|
|
142
|
+
— {choice.description}
|
|
143
|
+
</text>
|
|
144
|
+
)}
|
|
145
|
+
</box>
|
|
146
|
+
))}
|
|
147
|
+
</box>
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ── Dialog wrapper functions ───────────────────────────────────────────────
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Show a confirmation dialog styled with Hoox colors.
|
|
155
|
+
* Returns `true` if confirmed, `false` if canceled or dismissed.
|
|
156
|
+
*
|
|
157
|
+
* @example
|
|
158
|
+
* const confirmed = await showConfirm(dialog, {
|
|
159
|
+
* title: "Delete Worker",
|
|
160
|
+
* message: "This action cannot be undone.",
|
|
161
|
+
* confirmLabel: "Delete",
|
|
162
|
+
* cancelLabel: "Keep",
|
|
163
|
+
* })
|
|
164
|
+
*/
|
|
165
|
+
export async function showConfirm(
|
|
166
|
+
dialog: DialogHandle,
|
|
167
|
+
options: ConfirmDialogOptions
|
|
168
|
+
): Promise<boolean> {
|
|
169
|
+
return dialog.confirm({
|
|
170
|
+
content: (ctx: ConfirmContext) => (
|
|
171
|
+
<box flexDirection="column" padding={1} gap={1}>
|
|
172
|
+
<text fg={Colors.accent}>
|
|
173
|
+
<b>{options.title}</b>
|
|
174
|
+
</text>
|
|
175
|
+
<text fg={Colors.muted}>{options.message}</text>
|
|
176
|
+
<box flexDirection="row" gap={2} justifyContent="flex-end">
|
|
177
|
+
<text fg={Colors.muted} onMouseUp={() => ctx.resolve(false)}>
|
|
178
|
+
{" "}
|
|
179
|
+
{options.cancelLabel ?? "Cancel"}
|
|
180
|
+
{" "}
|
|
181
|
+
</text>
|
|
182
|
+
<text
|
|
183
|
+
fg={Colors.accent}
|
|
184
|
+
bg={Colors.card}
|
|
185
|
+
onMouseUp={() => ctx.resolve(true)}
|
|
186
|
+
>
|
|
187
|
+
{" "}
|
|
188
|
+
{options.confirmLabel ?? "Confirm"}
|
|
189
|
+
{" "}
|
|
190
|
+
</text>
|
|
191
|
+
</box>
|
|
192
|
+
</box>
|
|
193
|
+
),
|
|
194
|
+
closeOnClickOutside: options.closeOnClickOutside ?? true,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Show a multiple-choice dialog styled with Hoox colors.
|
|
200
|
+
* Returns the selected choice key, or `fallback` if dismissed.
|
|
201
|
+
*
|
|
202
|
+
* @example
|
|
203
|
+
* const action = await showChoice(dialog, {
|
|
204
|
+
* title: "Select Action",
|
|
205
|
+
* choices: [
|
|
206
|
+
* { key: "restart", label: "Restart Worker", description: "Graceful restart" },
|
|
207
|
+
* { key: "stop", label: "Stop Worker", description: "Immediate halt" },
|
|
208
|
+
* ],
|
|
209
|
+
* })
|
|
210
|
+
*/
|
|
211
|
+
export async function showChoice<K extends string>(
|
|
212
|
+
dialog: DialogHandle,
|
|
213
|
+
options: ChoiceDialogOptions<K>
|
|
214
|
+
): Promise<K | undefined> {
|
|
215
|
+
return dialog.choice<K>({
|
|
216
|
+
content: (ctx: ChoiceContext<K>) => (
|
|
217
|
+
<box flexDirection="column" padding={1} gap={1}>
|
|
218
|
+
{/* Header */}
|
|
219
|
+
<text fg={Colors.accent}>
|
|
220
|
+
<b>{options.title}</b>
|
|
221
|
+
</text>
|
|
222
|
+
<text dim fg={Colors.muted}>
|
|
223
|
+
Use ↑↓ to navigate, Enter to select, Esc to cancel
|
|
224
|
+
</text>
|
|
225
|
+
|
|
226
|
+
{/* Divider */}
|
|
227
|
+
<box height={1} />
|
|
228
|
+
|
|
229
|
+
{/* Keyboard-navigable choice list */}
|
|
230
|
+
<ChoiceList
|
|
231
|
+
choices={options.choices}
|
|
232
|
+
accentColor={Colors.accent}
|
|
233
|
+
foregroundColor={Colors.foreground}
|
|
234
|
+
mutedColor={Colors.muted}
|
|
235
|
+
highlightColor={Colors.card}
|
|
236
|
+
resolve={ctx.resolve}
|
|
237
|
+
dismiss={ctx.dismiss}
|
|
238
|
+
dialogId={ctx.dialogId}
|
|
239
|
+
/>
|
|
240
|
+
|
|
241
|
+
{/* Footer actions */}
|
|
242
|
+
<box flexDirection="row" justifyContent="flex-end" paddingTop={1}>
|
|
243
|
+
<text fg={Colors.muted} onMouseUp={ctx.dismiss}>
|
|
244
|
+
{" Cancel "}
|
|
245
|
+
</text>
|
|
246
|
+
</box>
|
|
247
|
+
</box>
|
|
248
|
+
),
|
|
249
|
+
fallback: options.fallback,
|
|
250
|
+
closeOnClickOutside: options.closeOnClickOutside ?? true,
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Show a non-interactive loading dialog (centered, dimmed backdrop).
|
|
256
|
+
* Returns a `close` function to dismiss the dialog when loading completes.
|
|
257
|
+
*
|
|
258
|
+
* @example
|
|
259
|
+
* const close = showLoading(dialog, "Deploying workers...")
|
|
260
|
+
* await deployAll()
|
|
261
|
+
* close()
|
|
262
|
+
*/
|
|
263
|
+
export function showLoading(dialog: DialogHandle, message: string): () => void {
|
|
264
|
+
const id = dialog.show({
|
|
265
|
+
content: () => (
|
|
266
|
+
<box
|
|
267
|
+
flexDirection="column"
|
|
268
|
+
padding={2}
|
|
269
|
+
gap={1}
|
|
270
|
+
justifyContent="center"
|
|
271
|
+
alignItems="center"
|
|
272
|
+
>
|
|
273
|
+
<text fg={Colors.accent}>{message}</text>
|
|
274
|
+
<text dim fg={Colors.muted}>
|
|
275
|
+
Please wait...
|
|
276
|
+
</text>
|
|
277
|
+
</box>
|
|
278
|
+
),
|
|
279
|
+
});
|
|
280
|
+
return () => dialog.close(id);
|
|
281
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
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
|
+
* SelectModal — a thin wrapper combining @opentui-ui/dialog modal behavior
|
|
9
|
+
* with keyboard-navigable option selection, styled with Hoox color tokens.
|
|
10
|
+
*
|
|
11
|
+
* Keyboard navigation:
|
|
12
|
+
* - Up/Down (or j/k): move selection
|
|
13
|
+
* - Enter: confirm selection
|
|
14
|
+
* - Escape: dismiss
|
|
15
|
+
*
|
|
16
|
+
* All colors use Colors tokens from @hoox-sh/hoox-shared (no hardcoded hex).
|
|
17
|
+
*/
|
|
18
|
+
import { useState } from "react";
|
|
19
|
+
import {
|
|
20
|
+
type DialogId,
|
|
21
|
+
type PromptContext,
|
|
22
|
+
useDialogKeyboard,
|
|
23
|
+
} from "@opentui-ui/dialog/react";
|
|
24
|
+
import { Colors } from "@hoox-sh/hoox-shared";
|
|
25
|
+
|
|
26
|
+
// ── Types ──────────────────────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
/** A single selectable option */
|
|
29
|
+
export interface SelectOption {
|
|
30
|
+
/** Unique key for the option */
|
|
31
|
+
key: string;
|
|
32
|
+
/** Display label shown in the list */
|
|
33
|
+
label: string;
|
|
34
|
+
/** Optional description rendered dimmed below or beside the label */
|
|
35
|
+
description?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Options for the SelectModal */
|
|
39
|
+
export interface SelectModalOptions {
|
|
40
|
+
/** Title displayed at the top of the modal (bold, accent-colored) */
|
|
41
|
+
title: string;
|
|
42
|
+
/** Array of selectable options */
|
|
43
|
+
options: SelectOption[];
|
|
44
|
+
/** Whether clicking outside the modal dismisses it (default: true) */
|
|
45
|
+
closeOnClickOutside?: boolean;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Dialog handle — the subset of useDialog() needed by SelectModal */
|
|
49
|
+
export interface SelectDialogHandle {
|
|
50
|
+
prompt<T>(options: {
|
|
51
|
+
content: (ctx: PromptContext<T>) => unknown;
|
|
52
|
+
fallback?: T;
|
|
53
|
+
closeOnClickOutside?: boolean;
|
|
54
|
+
}): Promise<T | undefined>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ── Internal: keyboard-navigable option list ───────────────────────────────
|
|
58
|
+
|
|
59
|
+
/** Props for the selectable option list rendered inside the dialog */
|
|
60
|
+
interface SelectListProps {
|
|
61
|
+
options: SelectOption[];
|
|
62
|
+
accentColor: string;
|
|
63
|
+
foregroundColor: string;
|
|
64
|
+
mutedColor: string;
|
|
65
|
+
highlightColor: string;
|
|
66
|
+
resolve: (value: string) => void;
|
|
67
|
+
dismiss: () => void;
|
|
68
|
+
dialogId: DialogId;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Internal component that renders a keyboard-navigable option list.
|
|
73
|
+
* Uses useDialogKeyboard scoped to the dialog so that only the topmost
|
|
74
|
+
* dialog receives keyboard events.
|
|
75
|
+
*/
|
|
76
|
+
function SelectList({
|
|
77
|
+
options,
|
|
78
|
+
accentColor,
|
|
79
|
+
foregroundColor,
|
|
80
|
+
mutedColor,
|
|
81
|
+
highlightColor,
|
|
82
|
+
resolve,
|
|
83
|
+
dismiss,
|
|
84
|
+
dialogId,
|
|
85
|
+
}: SelectListProps) {
|
|
86
|
+
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
87
|
+
|
|
88
|
+
useDialogKeyboard((key) => {
|
|
89
|
+
if (key.name === "down" || key.name === "j") {
|
|
90
|
+
setSelectedIndex((prev) => (prev < options.length - 1 ? prev + 1 : 0));
|
|
91
|
+
}
|
|
92
|
+
if (key.name === "up" || key.name === "k") {
|
|
93
|
+
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : options.length - 1));
|
|
94
|
+
}
|
|
95
|
+
if (key.name === "return") {
|
|
96
|
+
const selected = options[selectedIndex];
|
|
97
|
+
if (selected) resolve(selected.key);
|
|
98
|
+
}
|
|
99
|
+
if (key.name === "escape") {
|
|
100
|
+
dismiss();
|
|
101
|
+
}
|
|
102
|
+
}, dialogId);
|
|
103
|
+
|
|
104
|
+
return (
|
|
105
|
+
<box flexDirection="column" gap={0}>
|
|
106
|
+
{options.map((opt, idx) => (
|
|
107
|
+
<box flexDirection="row" gap={2} paddingLeft={1} paddingRight={1}>
|
|
108
|
+
{/* Selection indicator */}
|
|
109
|
+
<text fg={idx === selectedIndex ? accentColor : mutedColor}>
|
|
110
|
+
{idx === selectedIndex ? "▶" : " "}
|
|
111
|
+
</text>
|
|
112
|
+
{/* Option label */}
|
|
113
|
+
<text
|
|
114
|
+
fg={idx === selectedIndex ? foregroundColor : mutedColor}
|
|
115
|
+
bg={idx === selectedIndex ? highlightColor : undefined}
|
|
116
|
+
onMouseUp={() => resolve(opt.key)}
|
|
117
|
+
>
|
|
118
|
+
{opt.label}
|
|
119
|
+
</text>
|
|
120
|
+
{/* Optional description */}
|
|
121
|
+
{opt.description && (
|
|
122
|
+
<text dim fg={mutedColor}>
|
|
123
|
+
— {opt.description}
|
|
124
|
+
</text>
|
|
125
|
+
)}
|
|
126
|
+
</box>
|
|
127
|
+
))}
|
|
128
|
+
</box>
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ── Public API ─────────────────────────────────────────────────────────────
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Show a modal select dialog with keyboard navigation.
|
|
136
|
+
*
|
|
137
|
+
* Returns the selected option's `key`, or `undefined` if dismissed
|
|
138
|
+
* (via Escape, clicking outside, or pressing Cancel).
|
|
139
|
+
*
|
|
140
|
+
* Keyboard controls:
|
|
141
|
+
* - Up/Down or j/k: move selection highlight
|
|
142
|
+
* - Enter: confirm current selection
|
|
143
|
+
* - Escape: dismiss
|
|
144
|
+
*
|
|
145
|
+
* @example
|
|
146
|
+
* const chosen = await showSelectModal(dialog, {
|
|
147
|
+
* title: "Select Worker",
|
|
148
|
+
* options: [
|
|
149
|
+
* { key: "worker-a", label: "Alpha Worker", description: "US-East" },
|
|
150
|
+
* { key: "worker-b", label: "Beta Worker", description: "EU-West" },
|
|
151
|
+
* ],
|
|
152
|
+
* })
|
|
153
|
+
*/
|
|
154
|
+
export async function showSelectModal(
|
|
155
|
+
dialog: SelectDialogHandle,
|
|
156
|
+
opts: SelectModalOptions
|
|
157
|
+
): Promise<string | undefined> {
|
|
158
|
+
const { title, options, closeOnClickOutside = true } = opts;
|
|
159
|
+
|
|
160
|
+
return dialog.prompt<string>({
|
|
161
|
+
content: (ctx: PromptContext<string>) => (
|
|
162
|
+
<box flexDirection="column" padding={1} gap={1}>
|
|
163
|
+
{/* Header */}
|
|
164
|
+
<text fg={Colors.accent}>
|
|
165
|
+
<b>{title}</b>
|
|
166
|
+
</text>
|
|
167
|
+
<text dim fg={Colors.muted}>
|
|
168
|
+
Use ↑↓ to navigate, Enter to select, Esc to cancel
|
|
169
|
+
</text>
|
|
170
|
+
|
|
171
|
+
{/* Divider */}
|
|
172
|
+
<box height={1} />
|
|
173
|
+
|
|
174
|
+
{/* Selectable list */}
|
|
175
|
+
<SelectList
|
|
176
|
+
options={options}
|
|
177
|
+
accentColor={Colors.accent}
|
|
178
|
+
foregroundColor={Colors.foreground}
|
|
179
|
+
mutedColor={Colors.muted}
|
|
180
|
+
highlightColor={Colors.card}
|
|
181
|
+
resolve={ctx.resolve}
|
|
182
|
+
dismiss={ctx.dismiss}
|
|
183
|
+
dialogId={ctx.dialogId}
|
|
184
|
+
/>
|
|
185
|
+
|
|
186
|
+
{/* Footer actions */}
|
|
187
|
+
<box flexDirection="row" justifyContent="flex-end" paddingTop={1}>
|
|
188
|
+
<text fg={Colors.muted} onMouseUp={ctx.dismiss}>
|
|
189
|
+
{" Cancel "}
|
|
190
|
+
</text>
|
|
191
|
+
</box>
|
|
192
|
+
</box>
|
|
193
|
+
),
|
|
194
|
+
fallback: undefined,
|
|
195
|
+
closeOnClickOutside,
|
|
196
|
+
});
|
|
197
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2026 HOOX · HOOX · jango-blockchained (hoox-sh)
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Thin wrappers around @opentui-ui/toast with Hoox color token defaults.
|
|
8
|
+
* Provides toast helper functions (success, error, warning, info, loading)
|
|
9
|
+
* pre-configured with Hoox design system colors and durations.
|
|
10
|
+
*
|
|
11
|
+
* All helpers use the global toast singleton from @opentui-ui/toast.
|
|
12
|
+
* A ToasterRenderable must be initialized in the app root before calling
|
|
13
|
+
* these functions (see app.tsx).
|
|
14
|
+
*/
|
|
15
|
+
import { toast, TOAST_DURATION } from "@opentui-ui/toast";
|
|
16
|
+
import { Colors } from "@hoox-sh/hoox-shared";
|
|
17
|
+
|
|
18
|
+
// ── Types ──────────────────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
/** Options that can be passed to any toast helper */
|
|
21
|
+
export interface ToastOptions {
|
|
22
|
+
/** Additional description text below the message */
|
|
23
|
+
description?: string;
|
|
24
|
+
/** Auto-dismiss duration in ms (overrides the helper's default) */
|
|
25
|
+
duration?: number;
|
|
26
|
+
/** Whether the toast can be manually dismissed (default: true) */
|
|
27
|
+
dismissible?: boolean;
|
|
28
|
+
/** Action button with label and click handler */
|
|
29
|
+
action?: { label: string; onClick: () => void };
|
|
30
|
+
/** Show a close button on the toast (default: false) */
|
|
31
|
+
closeButton?: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Returned toast ID for programmatic control */
|
|
35
|
+
export type ToastId = string | number;
|
|
36
|
+
|
|
37
|
+
// ── Hoox-styled toast style overrides ──────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
/** Base style shared across all toast types */
|
|
40
|
+
const baseStyle = {
|
|
41
|
+
backgroundColor: Colors.card,
|
|
42
|
+
foregroundColor: Colors.foreground,
|
|
43
|
+
borderColor: Colors.border,
|
|
44
|
+
paddingX: 1,
|
|
45
|
+
paddingY: 0,
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/** Per-type border color overrides using Hoox design tokens */
|
|
49
|
+
const hooxTypeStyles = {
|
|
50
|
+
success: { borderColor: Colors.success },
|
|
51
|
+
error: { borderColor: Colors.error },
|
|
52
|
+
warning: { borderColor: Colors.warning },
|
|
53
|
+
info: { borderColor: Colors.highlight },
|
|
54
|
+
loading: { borderColor: Colors.dim },
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
// ── Toast helper functions ─────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Show a success toast notification.
|
|
61
|
+
* Uses green border (Colors.success) and 3s auto-dismiss.
|
|
62
|
+
*
|
|
63
|
+
* @example
|
|
64
|
+
* toastSuccess("Worker deployed successfully")
|
|
65
|
+
*/
|
|
66
|
+
export function toastSuccess(message: string, options?: ToastOptions): ToastId {
|
|
67
|
+
return toast.success(message, {
|
|
68
|
+
...options,
|
|
69
|
+
style: { ...baseStyle, ...hooxTypeStyles.success },
|
|
70
|
+
duration: options?.duration ?? TOAST_DURATION.SHORT,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Show an error toast notification.
|
|
76
|
+
* Uses red border (Colors.error) and 6s auto-dismiss.
|
|
77
|
+
*
|
|
78
|
+
* @example
|
|
79
|
+
* toastError("Failed to connect to API", { description: "Check your network" })
|
|
80
|
+
*/
|
|
81
|
+
export function toastError(message: string, options?: ToastOptions): ToastId {
|
|
82
|
+
return toast.error(message, {
|
|
83
|
+
...options,
|
|
84
|
+
style: { ...baseStyle, ...hooxTypeStyles.error },
|
|
85
|
+
duration: options?.duration ?? TOAST_DURATION.LONG,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Show a warning toast notification.
|
|
91
|
+
* Uses amber border (Colors.warning) and 6s auto-dismiss.
|
|
92
|
+
*
|
|
93
|
+
* @example
|
|
94
|
+
* toastWarning("Rate limit approaching", { description: "Slow down requests" })
|
|
95
|
+
*/
|
|
96
|
+
export function toastWarning(message: string, options?: ToastOptions): ToastId {
|
|
97
|
+
return toast.warning(message, {
|
|
98
|
+
...options,
|
|
99
|
+
style: { ...baseStyle, ...hooxTypeStyles.warning },
|
|
100
|
+
duration: options?.duration ?? TOAST_DURATION.LONG,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Show an info toast notification.
|
|
106
|
+
* Uses accent border (Colors.accent, orange) and 4s auto-dismiss.
|
|
107
|
+
*
|
|
108
|
+
* @example
|
|
109
|
+
* toastInfo("New version available", { action: { label: "Update", onClick: () => {} } })
|
|
110
|
+
*/
|
|
111
|
+
export function toastInfo(message: string, options?: ToastOptions): ToastId {
|
|
112
|
+
return toast.info(message, {
|
|
113
|
+
...options,
|
|
114
|
+
style: { ...baseStyle, ...hooxTypeStyles.info },
|
|
115
|
+
duration: options?.duration ?? TOAST_DURATION.DEFAULT,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Show a persistent loading toast notification.
|
|
121
|
+
* Uses muted border (Colors.muted) and does NOT auto-dismiss.
|
|
122
|
+
* Returns the toast ID so it can be updated to success/error later.
|
|
123
|
+
*
|
|
124
|
+
* @example
|
|
125
|
+
* const id = toastLoading("Deploying worker...")
|
|
126
|
+
* try {
|
|
127
|
+
* await deploy()
|
|
128
|
+
* toastSuccess("Deployed!", { id })
|
|
129
|
+
* } catch {
|
|
130
|
+
* toastError("Deploy failed", { id })
|
|
131
|
+
* }
|
|
132
|
+
*/
|
|
133
|
+
export function toastLoading(
|
|
134
|
+
message: string,
|
|
135
|
+
options?: Omit<ToastOptions, "duration">
|
|
136
|
+
): ToastId {
|
|
137
|
+
return toast.loading(message, {
|
|
138
|
+
...options,
|
|
139
|
+
style: { ...baseStyle, ...hooxTypeStyles.loading },
|
|
140
|
+
duration: TOAST_DURATION.PERSISTENT,
|
|
141
|
+
} as ToastOptions);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Dismiss a specific toast or all toasts.
|
|
146
|
+
* Thin wrapper around toast.dismiss().
|
|
147
|
+
*
|
|
148
|
+
* @example
|
|
149
|
+
* dismissToast(id) // dismiss specific
|
|
150
|
+
* dismissToast() // dismiss all
|
|
151
|
+
*/
|
|
152
|
+
export function dismissToast(id?: ToastId): void {
|
|
153
|
+
toast.dismiss(id as string | number | undefined);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ── Clipboard copy feedback ────────────────────────────────────────────────
|
|
157
|
+
|
|
158
|
+
const COPY_PREVIEW_MAX = 48;
|
|
159
|
+
|
|
160
|
+
/** Pure message for “copied” toast (testable). */
|
|
161
|
+
export function messageCopiedToClipboard(text: string): string {
|
|
162
|
+
const oneLine = text.replace(/\s+/g, " ").trim();
|
|
163
|
+
if (!oneLine) return "Copied to clipboard";
|
|
164
|
+
if (oneLine.length <= COPY_PREVIEW_MAX) {
|
|
165
|
+
return `Copied: “${oneLine}”`;
|
|
166
|
+
}
|
|
167
|
+
return `Copied: “${oneLine.slice(0, COPY_PREVIEW_MAX - 1)}…”`;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Toast after successful clipboard write (selection auto-copy, etc.). */
|
|
171
|
+
export function toastCopiedToClipboard(text: string): ToastId {
|
|
172
|
+
const lines = text.split("\n").filter((l) => l.length > 0).length;
|
|
173
|
+
return toastSuccess(messageCopiedToClipboard(text), {
|
|
174
|
+
description: lines > 1 ? `${lines} lines` : undefined,
|
|
175
|
+
duration: 2500,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Toast when clipboard write fails. */
|
|
180
|
+
export function toastCopyFailed(error: string): ToastId {
|
|
181
|
+
return toastError("Copy failed", {
|
|
182
|
+
description: error,
|
|
183
|
+
duration: 5000,
|
|
184
|
+
});
|
|
185
|
+
}
|