@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
package/src/app.tsx
ADDED
|
@@ -0,0 +1,696 @@
|
|
|
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
|
+
* App Root — Main entry point for the Hoox TUI dashboard.
|
|
9
|
+
*
|
|
10
|
+
* Responsibilities:
|
|
11
|
+
* 1. Restore session state from $HOME/.hoox/.tui-state/session.json on startup
|
|
12
|
+
* 2. Initialize ToasterRenderable for toast notifications
|
|
13
|
+
* 3. Render the layout shell (sidebar, tab bar, status bar, active view)
|
|
14
|
+
* 4. Register global keyboard shortcuts
|
|
15
|
+
* 5. Catch unhandled errors → crash screen
|
|
16
|
+
* 6. Save session state on clean shutdown
|
|
17
|
+
*
|
|
18
|
+
* Follows TUI Pattern 1 (FrameBuffer full-screen root) and Pattern 4 (Keyboard).
|
|
19
|
+
* Colors from design tokens via @hoox-sh/hoox-shared. No CSS, no DOM.
|
|
20
|
+
*/
|
|
21
|
+
import { useState, useEffect, useCallback } from "react";
|
|
22
|
+
import { useKeyboard } from "@opentui/react";
|
|
23
|
+
import { DialogProvider, useDialog } from "@opentui-ui/dialog/react";
|
|
24
|
+
import { useServiceStore, useUIStore, Colors } from "@hoox-sh/hoox-shared";
|
|
25
|
+
import { restoreSession, saveSession } from "@hoox-sh/hoox-shared";
|
|
26
|
+
import type { ViewId } from "@hoox-sh/hoox-shared";
|
|
27
|
+
|
|
28
|
+
import { cliBridge } from "./services/cli-bridge";
|
|
29
|
+
import { resolveTuiStatePath } from "./services/hoox-path-service";
|
|
30
|
+
import { redactSecretsInText, tuiDevLog } from "./services/dev-log";
|
|
31
|
+
import {
|
|
32
|
+
classifyConnectionError,
|
|
33
|
+
resolveTuiConnectionEnv,
|
|
34
|
+
} from "./services/tui-connection";
|
|
35
|
+
import {
|
|
36
|
+
toastAuthMissingRemote,
|
|
37
|
+
toastAuthRequiredMode,
|
|
38
|
+
toastConnectedMode,
|
|
39
|
+
toastConnectionLostMode,
|
|
40
|
+
toastOfflineStartup,
|
|
41
|
+
toastRateLimited,
|
|
42
|
+
toastReconnectedMode,
|
|
43
|
+
} from "./components/ui/connection-toasts";
|
|
44
|
+
import { getRendererRef } from "./hooks";
|
|
45
|
+
import type { DialogHandle } from "./components/ui/dialog";
|
|
46
|
+
import {
|
|
47
|
+
getViewFactory,
|
|
48
|
+
getViewShortcutMap,
|
|
49
|
+
getCtrlAltViewMap,
|
|
50
|
+
isRegisteredViewId,
|
|
51
|
+
ALL_PALETTE_COMMANDS,
|
|
52
|
+
} from "./view-registry";
|
|
53
|
+
import {
|
|
54
|
+
CrashScreen,
|
|
55
|
+
type CrashAction,
|
|
56
|
+
} from "./components/shared/crash-screen";
|
|
57
|
+
import { CommandPalette } from "./components/shared/command-palette";
|
|
58
|
+
import { QuitModal } from "./components/shared/quit-modal";
|
|
59
|
+
import { StatusBar } from "./components/layout/statusbar";
|
|
60
|
+
import { Sidebar } from "./components/layout/sidebar";
|
|
61
|
+
|
|
62
|
+
// ─── View keyboard shortcuts (derived from view-registry) ────────────────────
|
|
63
|
+
|
|
64
|
+
const VIEW_SHORTCUTS = getViewShortcutMap();
|
|
65
|
+
const CTRL_ALT_VIEWS = getCtrlAltViewMap();
|
|
66
|
+
|
|
67
|
+
// ─── Main App ────────────────────────────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* AppRoot — the top-level component.
|
|
71
|
+
*
|
|
72
|
+
* Renders the full layout: Sidebar | (TabBar + View) with StatusBar at bottom.
|
|
73
|
+
* Wraps the entire app in a try/catch-free zone; unhandled errors are caught
|
|
74
|
+
* by the platform-level crash handler (registered in the entry script).
|
|
75
|
+
*
|
|
76
|
+
* State flow:
|
|
77
|
+
* 1. On mount: restore session → set activeView and sidebarExpanded
|
|
78
|
+
* 2. On unmount (cleanup): save session to $HOME/.hoox/.tui-state/session.json
|
|
79
|
+
* 3. Crash: CrashScreen rendered with [Restart] [Safe Mode] [Report Bug]
|
|
80
|
+
*/
|
|
81
|
+
/** Cleanly shut down the TUI: destroy renderer (session save runs on destroy) then exit. */
|
|
82
|
+
function quitApp(): void {
|
|
83
|
+
try {
|
|
84
|
+
const renderer = getRendererRef();
|
|
85
|
+
renderer?.destroy();
|
|
86
|
+
} catch {
|
|
87
|
+
// destroy may throw if already torn down
|
|
88
|
+
}
|
|
89
|
+
process.exit(0);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* AppRoot — wraps the shell in DialogProvider so views can call
|
|
94
|
+
* `showConfirm` / `useDialog` for destructive actions.
|
|
95
|
+
*/
|
|
96
|
+
export function AppRoot({ safeMode = false }: { safeMode?: boolean }) {
|
|
97
|
+
return (
|
|
98
|
+
<DialogProvider
|
|
99
|
+
size="medium"
|
|
100
|
+
backdropColor={Colors.backdrop}
|
|
101
|
+
backdropOpacity={0.35}
|
|
102
|
+
>
|
|
103
|
+
<AppRootInner safeMode={safeMode} />
|
|
104
|
+
</DialogProvider>
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function AppRootInner({ safeMode = false }: { safeMode?: boolean }) {
|
|
109
|
+
// DialogProvider is required for useDialog; cast to our thin DialogHandle.
|
|
110
|
+
const dialog = useDialog() as unknown as DialogHandle;
|
|
111
|
+
const [restoring, setRestoring] = useState(true);
|
|
112
|
+
const activeView = useUIStore((s) => s.activeView);
|
|
113
|
+
const commandPaletteOpen = useUIStore((s) => s.commandPaletteOpen);
|
|
114
|
+
const modal = useUIStore((s) => s.modal);
|
|
115
|
+
const setView = useUIStore((s) => s.setView);
|
|
116
|
+
const toggleSidebar = useUIStore((s) => s.toggleSidebar);
|
|
117
|
+
const openPalette = useUIStore((s) => s.openPalette);
|
|
118
|
+
const closePalette = useUIStore((s) => s.closePalette);
|
|
119
|
+
const showModal = useUIStore((s) => s.showModal);
|
|
120
|
+
const dismissModal = useUIStore((s) => s.dismissModal);
|
|
121
|
+
|
|
122
|
+
const requestQuit = useCallback(() => {
|
|
123
|
+
closePalette();
|
|
124
|
+
showModal({
|
|
125
|
+
type: "confirm",
|
|
126
|
+
title: "Quit HOOX?",
|
|
127
|
+
message: "Exit the terminal operations center.",
|
|
128
|
+
onConfirm: quitApp,
|
|
129
|
+
onCancel: () => dismissModal(),
|
|
130
|
+
});
|
|
131
|
+
}, [closePalette, showModal, dismissModal]);
|
|
132
|
+
|
|
133
|
+
// ── Session restore on mount ────────────────────────────────────────────
|
|
134
|
+
// Shared restoreSession currently only accepts the original 9 ViewIds.
|
|
135
|
+
// Re-read session.json and accept any registry ViewId so newer views
|
|
136
|
+
// (queues, kv, secrets, …) actually restore after restart.
|
|
137
|
+
useEffect(() => {
|
|
138
|
+
let cancelled = false;
|
|
139
|
+
(async () => {
|
|
140
|
+
const session = await restoreSession();
|
|
141
|
+
if (cancelled) return;
|
|
142
|
+
|
|
143
|
+
let view: ViewId = session.activeView;
|
|
144
|
+
let expanded = session.sidebarExpanded;
|
|
145
|
+
|
|
146
|
+
try {
|
|
147
|
+
const rawPath = resolveTuiStatePath("session.json");
|
|
148
|
+
const file = Bun.file(rawPath);
|
|
149
|
+
if (await file.exists()) {
|
|
150
|
+
const raw = (await file.json()) as {
|
|
151
|
+
activeView?: unknown;
|
|
152
|
+
sidebarExpanded?: unknown;
|
|
153
|
+
};
|
|
154
|
+
if (isRegisteredViewId(raw.activeView)) {
|
|
155
|
+
view = raw.activeView;
|
|
156
|
+
}
|
|
157
|
+
if (typeof raw.sidebarExpanded === "boolean") {
|
|
158
|
+
expanded = raw.sidebarExpanded;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
} catch {
|
|
162
|
+
// Corrupt raw session — keep shared restore result
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (cancelled) return;
|
|
166
|
+
if (isRegisteredViewId(view)) {
|
|
167
|
+
setView(view);
|
|
168
|
+
}
|
|
169
|
+
// Align sidebar with saved state (toggle only when mismatched)
|
|
170
|
+
if (expanded !== useUIStore.getState().sidebarExpanded) {
|
|
171
|
+
toggleSidebar();
|
|
172
|
+
}
|
|
173
|
+
setRestoring(false);
|
|
174
|
+
})();
|
|
175
|
+
return () => {
|
|
176
|
+
cancelled = true;
|
|
177
|
+
};
|
|
178
|
+
}, [setView, toggleSidebar]);
|
|
179
|
+
|
|
180
|
+
// ── Startup data load: HTTP → (local only) CLI fallback ────────────────
|
|
181
|
+
// After session restore, try to fetch worker data. HTTP is tried first;
|
|
182
|
+
// if the local dev server is unreachable, fall back to the `hoox` CLI.
|
|
183
|
+
// REMOTE mode never uses CLI fallback — gateway HTTP is the source of truth.
|
|
184
|
+
// Also kick off SSE trade/log streams when the API is available.
|
|
185
|
+
// Safe mode skips all network/CLI/SSE to keep the shell usable after a crash.
|
|
186
|
+
useEffect(() => {
|
|
187
|
+
if (restoring) return;
|
|
188
|
+
if (safeMode) {
|
|
189
|
+
void tuiDevLog.info(
|
|
190
|
+
"connection",
|
|
191
|
+
"safe mode — skipping startup data load and SSE"
|
|
192
|
+
);
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
let cancelled = false;
|
|
196
|
+
|
|
197
|
+
(async () => {
|
|
198
|
+
const store = useServiceStore.getState();
|
|
199
|
+
const conn = resolveTuiConnectionEnv();
|
|
200
|
+
await tuiDevLog.info("connection", "startup data load begin", {
|
|
201
|
+
mode: conn.mode,
|
|
202
|
+
apiUrl: conn.apiUrl,
|
|
203
|
+
hasToken: conn.hasToken,
|
|
204
|
+
hasAuth: conn.hasAuth,
|
|
205
|
+
allowCliFallback: conn.allowCliFallback,
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
// Fail-closed when remote has neither Bearer nor Access service-token pair
|
|
209
|
+
if (conn.mode === "remote" && !conn.hasAuth) {
|
|
210
|
+
toastAuthMissingRemote(conn.apiHost);
|
|
211
|
+
await tuiDevLog.warn("connection", "remote without API credentials", {
|
|
212
|
+
host: conn.apiHost,
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// fetchWorkers swallows network errors into store state — inspect status.
|
|
217
|
+
await store.fetchWorkers();
|
|
218
|
+
if (cancelled) return;
|
|
219
|
+
const afterHttp = useServiceStore.getState();
|
|
220
|
+
const httpOk = afterHttp.connectionStatus === "connected";
|
|
221
|
+
if (httpOk) {
|
|
222
|
+
toastConnectedMode(conn.mode, conn.apiHost);
|
|
223
|
+
await tuiDevLog.info("connection", "HTTP fetchWorkers succeeded", {
|
|
224
|
+
mode: conn.mode,
|
|
225
|
+
apiUrl: conn.apiUrl,
|
|
226
|
+
workerCount: afterHttp.workers.length,
|
|
227
|
+
});
|
|
228
|
+
} else {
|
|
229
|
+
const kind = classifyConnectionError(afterHttp.lastError);
|
|
230
|
+
if (kind === "auth") {
|
|
231
|
+
toastAuthRequiredMode(conn.mode, conn.apiHost);
|
|
232
|
+
} else if (kind === "rate-limit") {
|
|
233
|
+
toastRateLimited();
|
|
234
|
+
} else {
|
|
235
|
+
toastOfflineStartup(conn.mode, conn.apiHost, kind);
|
|
236
|
+
}
|
|
237
|
+
await tuiDevLog.warn("connection", "HTTP fetchWorkers failed", {
|
|
238
|
+
mode: conn.mode,
|
|
239
|
+
apiUrl: conn.apiUrl,
|
|
240
|
+
connectionStatus: afterHttp.connectionStatus,
|
|
241
|
+
lastError: afterHttp.lastError,
|
|
242
|
+
errorKind: kind,
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// CLI fallback: local only (never mark REMOTE connected via local CLI)
|
|
247
|
+
if (!httpOk && conn.allowCliFallback) {
|
|
248
|
+
try {
|
|
249
|
+
await tuiDevLog.debug("connection", "CLI monitorStatus fallback");
|
|
250
|
+
const result = await cliBridge.monitorStatus();
|
|
251
|
+
if (cancelled) return;
|
|
252
|
+
if (result.success && result.data) {
|
|
253
|
+
const raw = result.data as Record<string, unknown>;
|
|
254
|
+
const rawWorkers = (raw.workers ?? raw.status ?? raw) as
|
|
255
|
+
| unknown[]
|
|
256
|
+
| Record<string, unknown>;
|
|
257
|
+
const parsed = Array.isArray(rawWorkers)
|
|
258
|
+
? rawWorkers
|
|
259
|
+
: typeof rawWorkers === "object" && rawWorkers !== null
|
|
260
|
+
? Object.values(rawWorkers)
|
|
261
|
+
: [];
|
|
262
|
+
if (parsed.length > 0) {
|
|
263
|
+
const workerInfo = (parsed as Record<string, unknown>[]).map(
|
|
264
|
+
(w, i) => {
|
|
265
|
+
const cliStatus = String(w.status ?? "healthy");
|
|
266
|
+
const status =
|
|
267
|
+
cliStatus === "healthy"
|
|
268
|
+
? "operational"
|
|
269
|
+
: cliStatus === "degraded"
|
|
270
|
+
? "degraded"
|
|
271
|
+
: "down";
|
|
272
|
+
return {
|
|
273
|
+
id: String(w.id ?? w.worker ?? `worker-${i}`),
|
|
274
|
+
name: String(w.worker ?? `worker-${i}`),
|
|
275
|
+
status: status as "operational" | "degraded" | "down",
|
|
276
|
+
uptime: Number(w.uptime ?? 0) || 0,
|
|
277
|
+
cpu: Number(w.cpu ?? 0) || 0,
|
|
278
|
+
memory: Number(w.memory ?? 0) || 0,
|
|
279
|
+
requests: Number(w.requests ?? 0) || 0,
|
|
280
|
+
durableObjectCount: Number(w.durableObjectCount ?? 0) || 0,
|
|
281
|
+
edgeCount: Number(w.edgeCount ?? 0) || 0,
|
|
282
|
+
version: String(w.version ?? ""),
|
|
283
|
+
lastDeployed: Number(w.lastDeployed ?? 0) || 0,
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
);
|
|
287
|
+
store.setWorkers(workerInfo);
|
|
288
|
+
store.setMetrics({
|
|
289
|
+
totalWorkers: workerInfo.length,
|
|
290
|
+
onlineWorkers: workerInfo.filter(
|
|
291
|
+
(x) => x.status === "operational"
|
|
292
|
+
).length,
|
|
293
|
+
totalPnl: 0,
|
|
294
|
+
activeStrategies: 0,
|
|
295
|
+
dailyTrades: 0,
|
|
296
|
+
aiCalls: 0,
|
|
297
|
+
uptime: 0,
|
|
298
|
+
lastUpdated: Date.now(),
|
|
299
|
+
});
|
|
300
|
+
store.handleConnectionSuccess();
|
|
301
|
+
toastConnectedMode(conn.mode, conn.apiHost);
|
|
302
|
+
await tuiDevLog.info("connection", "CLI fallback succeeded", {
|
|
303
|
+
workerCount: workerInfo.length,
|
|
304
|
+
});
|
|
305
|
+
} else {
|
|
306
|
+
await tuiDevLog.warn(
|
|
307
|
+
"connection",
|
|
308
|
+
"CLI fallback returned no workers",
|
|
309
|
+
{
|
|
310
|
+
success: result.success,
|
|
311
|
+
stderr: result.stderr || null,
|
|
312
|
+
}
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
} else {
|
|
316
|
+
await tuiDevLog.warn("connection", "CLI fallback failed", {
|
|
317
|
+
success: result.success,
|
|
318
|
+
stderr: result.stderr || null,
|
|
319
|
+
errorType: result.errorType ?? null,
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
} catch (err) {
|
|
323
|
+
await tuiDevLog.error(
|
|
324
|
+
"connection",
|
|
325
|
+
"HTTP and CLI unavailable — staying offline",
|
|
326
|
+
{
|
|
327
|
+
error: err instanceof Error ? err.message : String(err),
|
|
328
|
+
}
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
} else if (!httpOk && !conn.allowCliFallback) {
|
|
332
|
+
await tuiDevLog.info(
|
|
333
|
+
"connection",
|
|
334
|
+
"skipping CLI fallback in remote mode"
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
if (cancelled) return;
|
|
339
|
+
|
|
340
|
+
// Long-lived SSE streams (no-op when API offline; store handles errors)
|
|
341
|
+
await tuiDevLog.debug("connection", "starting SSE trade/log streams");
|
|
342
|
+
void store.streamTrades();
|
|
343
|
+
void store.streamLogs();
|
|
344
|
+
})();
|
|
345
|
+
|
|
346
|
+
return () => {
|
|
347
|
+
cancelled = true;
|
|
348
|
+
// Tear down long-lived SSE so remounts / safe-mode don't pile up callbacks
|
|
349
|
+
useServiceStore.getState().stopStreams();
|
|
350
|
+
};
|
|
351
|
+
}, [restoring, safeMode]);
|
|
352
|
+
|
|
353
|
+
// ── Connection status → mode-aware toasts ───────────────────────────────
|
|
354
|
+
useEffect(() => {
|
|
355
|
+
if (restoring) return;
|
|
356
|
+
const conn = resolveTuiConnectionEnv();
|
|
357
|
+
let prev = useServiceStore.getState().connectionStatus;
|
|
358
|
+
let prevDisconnectedAt = useServiceStore.getState().disconnectedAt;
|
|
359
|
+
|
|
360
|
+
const unsub = useServiceStore.subscribe((state) => {
|
|
361
|
+
const next = state.connectionStatus;
|
|
362
|
+
if (next === prev) return;
|
|
363
|
+
|
|
364
|
+
if (
|
|
365
|
+
next === "connected" &&
|
|
366
|
+
(prev === "reconnecting" || prev === "offline" || prev === "polling")
|
|
367
|
+
) {
|
|
368
|
+
if (prevDisconnectedAt != null && prevDisconnectedAt > 0) {
|
|
369
|
+
toastReconnectedMode(conn.mode, conn.apiHost, prevDisconnectedAt);
|
|
370
|
+
}
|
|
371
|
+
} else if (next === "offline" && prev !== "offline") {
|
|
372
|
+
const kind = classifyConnectionError(state.lastError);
|
|
373
|
+
if (kind === "auth") {
|
|
374
|
+
toastAuthRequiredMode(conn.mode, conn.apiHost);
|
|
375
|
+
} else if (kind === "rate-limit") {
|
|
376
|
+
toastRateLimited();
|
|
377
|
+
} else {
|
|
378
|
+
toastConnectionLostMode(conn.mode, conn.apiHost);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
prev = next;
|
|
383
|
+
prevDisconnectedAt = state.disconnectedAt;
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
return unsub;
|
|
387
|
+
}, [restoring]);
|
|
388
|
+
|
|
389
|
+
// ── Session save on unmount ─────────────────────────────────────────────
|
|
390
|
+
useEffect(() => {
|
|
391
|
+
return () => {
|
|
392
|
+
const lastUpdated = useServiceStore.getState().lastUpdated;
|
|
393
|
+
saveSession(
|
|
394
|
+
useUIStore.getState().activeView,
|
|
395
|
+
useUIStore.getState().sidebarExpanded,
|
|
396
|
+
{ cols: 80, rows: 24 }, // window size detected elsewhere
|
|
397
|
+
lastUpdated
|
|
398
|
+
).catch(() => {
|
|
399
|
+
// Non-fatal: session save failures are logged silently
|
|
400
|
+
});
|
|
401
|
+
};
|
|
402
|
+
}, []);
|
|
403
|
+
|
|
404
|
+
// ── Global CLI bridge error sink ────────────────────────────────────────
|
|
405
|
+
// Registers a single error sink at the app root so every `cliBridge.*`
|
|
406
|
+
// call (deploy, kill-switch, health check, etc.) — regardless of which
|
|
407
|
+
// view triggered it — propagates structured `CliErrorDetails` to the
|
|
408
|
+
// service store. The status bar subscribes to `lastErrorDetails` and
|
|
409
|
+
// surfaces the real diagnostic context (command, exit code, stderr)
|
|
410
|
+
// instead of a generic OFFLINE pill.
|
|
411
|
+
useEffect(() => {
|
|
412
|
+
const unsubscribe = cliBridge.onError((details) => {
|
|
413
|
+
useServiceStore.getState().setLastErrorDetails(details);
|
|
414
|
+
});
|
|
415
|
+
return unsubscribe;
|
|
416
|
+
}, []);
|
|
417
|
+
|
|
418
|
+
// ── Global keyboard shortcuts ───────────────────────────────────────────
|
|
419
|
+
// useCallback keeps handler identity stable across re-renders so OpenTUI
|
|
420
|
+
// does not thrash key listener registration.
|
|
421
|
+
const onGlobalKey = useCallback(
|
|
422
|
+
(key: { name?: string; ctrl?: boolean; alt?: boolean; meta?: boolean }) => {
|
|
423
|
+
const name = String(key.name ?? "").toLowerCase();
|
|
424
|
+
|
|
425
|
+
// Quit confirmation modal takes priority over other shortcuts
|
|
426
|
+
if (modal?.type === "confirm" && modal.title === "Quit HOOX?") {
|
|
427
|
+
if (name === "return" || name === "enter" || name === "y") {
|
|
428
|
+
dismissModal();
|
|
429
|
+
quitApp();
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
if (name === "escape" || name === "n") {
|
|
433
|
+
dismissModal();
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
return; // swallow other keys while confirming quit
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// While palette is open, leave typing/nav to CommandPalette;
|
|
440
|
+
// still allow Esc + a few global chords (view switch closes palette).
|
|
441
|
+
if (commandPaletteOpen) {
|
|
442
|
+
if (name === "escape") {
|
|
443
|
+
closePalette();
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
// Allow Ctrl+digit / Ctrl+Alt view jumps (setView closes palette)
|
|
447
|
+
if (key.ctrl && !key.alt && name && VIEW_SHORTCUTS[name]) {
|
|
448
|
+
setView(VIEW_SHORTCUTS[name]);
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
if (key.ctrl && key.alt && name && CTRL_ALT_VIEWS[name]) {
|
|
452
|
+
setView(CTRL_ALT_VIEWS[name]);
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// Ctrl+1-9 / Ctrl+0: switch views (not while alt is held)
|
|
459
|
+
if (key.ctrl && !key.alt && name && VIEW_SHORTCUTS[name]) {
|
|
460
|
+
setView(VIEW_SHORTCUTS[name]);
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// Ctrl+Alt+letter: switch to letter-chord views (kv, secrets, etc.)
|
|
465
|
+
if (key.ctrl && key.alt && name && CTRL_ALT_VIEWS[name]) {
|
|
466
|
+
setView(CTRL_ALT_VIEWS[name]);
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// Ctrl+B: toggle sidebar
|
|
471
|
+
if (key.ctrl && !key.alt && name === "b") {
|
|
472
|
+
toggleSidebar();
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// Ctrl+P: command palette
|
|
477
|
+
if (key.ctrl && !key.alt && name === "p") {
|
|
478
|
+
openPalette();
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// Ctrl+R: refresh worker data (no-op in safe mode network sense — still allowed)
|
|
483
|
+
if (key.ctrl && !key.alt && name === "r") {
|
|
484
|
+
if (!safeMode) {
|
|
485
|
+
void useServiceStore.getState().fetchWorkers();
|
|
486
|
+
}
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// Ctrl+Q: quit with confirmation (Ctrl+Alt+Q is db-query above)
|
|
491
|
+
if (key.ctrl && !key.alt && name === "q") {
|
|
492
|
+
requestQuit();
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// Escape: dismiss modal, then palette
|
|
497
|
+
if (name === "escape") {
|
|
498
|
+
if (modal) {
|
|
499
|
+
dismissModal();
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
closePalette();
|
|
503
|
+
}
|
|
504
|
+
},
|
|
505
|
+
[
|
|
506
|
+
modal,
|
|
507
|
+
commandPaletteOpen,
|
|
508
|
+
setView,
|
|
509
|
+
toggleSidebar,
|
|
510
|
+
openPalette,
|
|
511
|
+
closePalette,
|
|
512
|
+
dismissModal,
|
|
513
|
+
requestQuit,
|
|
514
|
+
safeMode,
|
|
515
|
+
]
|
|
516
|
+
);
|
|
517
|
+
|
|
518
|
+
useKeyboard(onGlobalKey);
|
|
519
|
+
|
|
520
|
+
// ── Loading state during session restore ────────────────────────────────
|
|
521
|
+
if (restoring) {
|
|
522
|
+
return (
|
|
523
|
+
<box
|
|
524
|
+
flexDirection="column"
|
|
525
|
+
width="100%"
|
|
526
|
+
height="100%"
|
|
527
|
+
justifyContent="center"
|
|
528
|
+
alignItems="center"
|
|
529
|
+
backgroundColor={Colors.background}
|
|
530
|
+
>
|
|
531
|
+
<text fg={Colors.highlight} bold>
|
|
532
|
+
┌ HOOX ┐
|
|
533
|
+
</text>
|
|
534
|
+
<text fg={Colors.muted} dim>
|
|
535
|
+
Restoring session…
|
|
536
|
+
</text>
|
|
537
|
+
</box>
|
|
538
|
+
);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// ── Active view component ───────────────────────────────────────────────
|
|
542
|
+
const renderView = getViewFactory(activeView);
|
|
543
|
+
|
|
544
|
+
return (
|
|
545
|
+
<box
|
|
546
|
+
flexDirection="column"
|
|
547
|
+
width="100%"
|
|
548
|
+
height="100%"
|
|
549
|
+
backgroundColor={Colors.background}
|
|
550
|
+
>
|
|
551
|
+
{/* Main area: Sidebar + Content */}
|
|
552
|
+
<box flexDirection="row" flexGrow={1}>
|
|
553
|
+
{/* Sidebar (left) */}
|
|
554
|
+
<Sidebar />
|
|
555
|
+
|
|
556
|
+
{/* Content area: View (fills remaining space) */}
|
|
557
|
+
<box flexDirection="column" flexGrow={1} padding={1}>
|
|
558
|
+
{renderView(dialog)}
|
|
559
|
+
</box>
|
|
560
|
+
</box>
|
|
561
|
+
|
|
562
|
+
{/* StatusBar (bottom, always visible) */}
|
|
563
|
+
<StatusBar />
|
|
564
|
+
|
|
565
|
+
{/* Command Palette overlay */}
|
|
566
|
+
{commandPaletteOpen && (
|
|
567
|
+
<CommandPalette
|
|
568
|
+
visible={commandPaletteOpen}
|
|
569
|
+
commands={ALL_PALETTE_COMMANDS}
|
|
570
|
+
onSelect={(selection) => {
|
|
571
|
+
if (selection.action === "setView" && selection.command.id) {
|
|
572
|
+
setView(selection.command.id as ViewId);
|
|
573
|
+
} else if (selection.command.id === "refresh") {
|
|
574
|
+
void useServiceStore.getState().fetchWorkers();
|
|
575
|
+
} else if (selection.command.id === "toggle-sidebar") {
|
|
576
|
+
toggleSidebar();
|
|
577
|
+
} else if (selection.command.id === "quit") {
|
|
578
|
+
closePalette();
|
|
579
|
+
requestQuit();
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
closePalette();
|
|
583
|
+
}}
|
|
584
|
+
onDismiss={() => closePalette()}
|
|
585
|
+
/>
|
|
586
|
+
)}
|
|
587
|
+
|
|
588
|
+
{/* Quit confirmation overlay */}
|
|
589
|
+
{modal?.type === "confirm" && modal.title === "Quit HOOX?" && (
|
|
590
|
+
<QuitModal
|
|
591
|
+
title={modal.title}
|
|
592
|
+
message={modal.message ?? "Exit the terminal operations center."}
|
|
593
|
+
onConfirm={() => {
|
|
594
|
+
dismissModal();
|
|
595
|
+
quitApp();
|
|
596
|
+
}}
|
|
597
|
+
onCancel={() => dismissModal()}
|
|
598
|
+
/>
|
|
599
|
+
)}
|
|
600
|
+
</box>
|
|
601
|
+
);
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// ─── Crash Recovery Wrapper ──────────────────────────────────────────────────
|
|
605
|
+
|
|
606
|
+
/**
|
|
607
|
+
* CrashRecoveryApp — wraps AppRoot with a crash boundary.
|
|
608
|
+
*
|
|
609
|
+
* When an unhandled error escapes the React tree:
|
|
610
|
+
* 1. The error is caught
|
|
611
|
+
* 2. CrashScreen is rendered with action buttons
|
|
612
|
+
* 3. [Restart] → re-mount AppRoot (clears React error state)
|
|
613
|
+
* 4. [Safe Mode] → re-mount with crashScreen.safeMode=true
|
|
614
|
+
* 5. [Report Bug] → write error details to $HOME/.hoox/.tui-state/crash.log
|
|
615
|
+
*/
|
|
616
|
+
export function CrashRecoveryApp() {
|
|
617
|
+
const [crash, setCrash] = useState<Error | null>(null);
|
|
618
|
+
const [safeMode, setSafeMode] = useState(false);
|
|
619
|
+
|
|
620
|
+
// React error boundary equivalent: catch errors in a wrapper
|
|
621
|
+
const handleCrashAction = useCallback(
|
|
622
|
+
(action: CrashAction) => {
|
|
623
|
+
switch (action) {
|
|
624
|
+
case "restart":
|
|
625
|
+
// Clear crash state → re-mount AppRoot
|
|
626
|
+
setCrash(null);
|
|
627
|
+
setSafeMode(false);
|
|
628
|
+
break;
|
|
629
|
+
|
|
630
|
+
case "safe-mode":
|
|
631
|
+
// Clear crash, enable safe mode (no network / SSE)
|
|
632
|
+
setCrash(null);
|
|
633
|
+
setSafeMode(true);
|
|
634
|
+
break;
|
|
635
|
+
|
|
636
|
+
case "report-bug":
|
|
637
|
+
// Write redacted crash details to $HOME/.hoox/.tui-state/crash.log
|
|
638
|
+
if (crash) {
|
|
639
|
+
const crashLog = [
|
|
640
|
+
`=== Hoox Crash Report ===`,
|
|
641
|
+
`Time: ${new Date().toISOString()}`,
|
|
642
|
+
`Error: ${redactSecretsInText(crash.message)}`,
|
|
643
|
+
`Stack: ${crash.stack ? redactSecretsInText(crash.stack) : "N/A"}`,
|
|
644
|
+
`Safe Mode: ${safeMode}`,
|
|
645
|
+
``,
|
|
646
|
+
].join("\n");
|
|
647
|
+
// Best-effort write (non-blocking) to $HOME/.hoox/.tui-state/crash.log
|
|
648
|
+
try {
|
|
649
|
+
Bun.write(resolveTuiStatePath("crash.log"), crashLog).catch(
|
|
650
|
+
() => {}
|
|
651
|
+
);
|
|
652
|
+
} catch {
|
|
653
|
+
// Silent — write failed
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
break;
|
|
657
|
+
}
|
|
658
|
+
},
|
|
659
|
+
[crash, safeMode]
|
|
660
|
+
);
|
|
661
|
+
|
|
662
|
+
// Register unhandled error handlers — remove only *our* listeners on cleanup
|
|
663
|
+
// (never process.removeAllListeners, which would strip other libraries).
|
|
664
|
+
useEffect(() => {
|
|
665
|
+
if (typeof process === "undefined") return;
|
|
666
|
+
|
|
667
|
+
const onUncaught = (error: Error) => {
|
|
668
|
+
setCrash(error);
|
|
669
|
+
};
|
|
670
|
+
const onRejection = (reason: unknown) => {
|
|
671
|
+
setCrash(reason instanceof Error ? reason : new Error(String(reason)));
|
|
672
|
+
};
|
|
673
|
+
|
|
674
|
+
process.on("uncaughtException", onUncaught);
|
|
675
|
+
process.on("unhandledRejection", onRejection);
|
|
676
|
+
|
|
677
|
+
return () => {
|
|
678
|
+
process.off("uncaughtException", onUncaught);
|
|
679
|
+
process.off("unhandledRejection", onRejection);
|
|
680
|
+
};
|
|
681
|
+
}, []);
|
|
682
|
+
|
|
683
|
+
// ── Crash screen ────────────────────────────────────────────────────────
|
|
684
|
+
if (crash) {
|
|
685
|
+
return (
|
|
686
|
+
<CrashScreen
|
|
687
|
+
error={crash}
|
|
688
|
+
safeMode={safeMode}
|
|
689
|
+
onAction={handleCrashAction}
|
|
690
|
+
/>
|
|
691
|
+
);
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
// ── Normal / safe mode ──────────────────────────────────────────────────
|
|
695
|
+
return <AppRoot safeMode={safeMode} />;
|
|
696
|
+
}
|