@hoox-sh/hoox-tui 0.2.0 → 0.3.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 +50 -31
- package/dist/main.js +4238 -1711
- package/package.json +2 -2
- package/src/app.tsx +45 -30
- package/src/cli-bridge-test-double.ts +48 -1
- package/src/components/layout/sidebar.tsx +27 -11
- package/src/components/layout/statusbar.test.tsx +19 -5
- package/src/components/layout/statusbar.tsx +34 -18
- package/src/components/shared/crash-screen.tsx +24 -2
- package/src/components/shared/error-boundary.tsx +56 -33
- package/src/components/ui/connection-toasts.test.ts +58 -1
- package/src/components/views/config-editor.test.tsx +4 -2
- package/src/components/views/config-editor.tsx +31 -3
- package/src/components/views/dashboard/alerts-panel.tsx +36 -8
- package/src/components/views/dashboard/auto-repair-helpers.test.ts +23 -0
- package/src/components/views/dashboard.test.tsx +2 -1
- package/src/components/views/dashboard.tsx +126 -41
- package/src/components/views/db-query.test.tsx +7 -0
- package/src/components/views/db-query.tsx +5 -4
- package/src/components/views/edge-topology.tsx +3 -2
- package/src/components/views/kv-viewer.test.tsx +28 -0
- package/src/components/views/kv-viewer.tsx +4 -3
- package/src/components/views/logs-viewer.tsx +46 -19
- package/src/components/views/queue-depth.test.tsx +39 -0
- package/src/components/views/queue-depth.tsx +3 -2
- package/src/components/views/secrets-viewer.test.tsx +2 -2
- package/src/components/views/secrets-viewer.tsx +4 -3
- package/src/components/views/service-manager.tsx +147 -53
- package/src/components/views/settings.tsx +50 -10
- package/src/components/views/setup-wizard.test.tsx +13 -0
- package/src/components/views/setup-wizard.tsx +53 -22
- package/src/components/views/trade-monitor.tsx +20 -13
- package/src/components/views/worker-detail.tsx +27 -12
- package/src/components/views/worker-settings.test.tsx +5 -6
- package/src/components/views/worker-settings.tsx +13 -11
- package/src/components/views/workers-overview.tsx +27 -6
- package/src/hooks/hooks.test.ts +303 -9
- package/src/hooks/index.ts +7 -1
- package/src/hooks/shell-overlay.ts +41 -0
- package/src/hooks/use-polling.ts +91 -35
- package/src/hooks/use-service-data.ts +21 -4
- package/src/main.tsx +15 -0
- package/src/services/cli-bridge/index.ts +28 -22
- package/src/services/cli-bridge/standalone.ts +29 -2
- package/src/services/cli-bridge.test.ts +848 -1
- package/src/services/clipboard.test.ts +217 -3
- package/src/services/dashboard-settings-loader.test.ts +50 -0
- package/src/services/tui-connection.test.ts +21 -0
- package/src/stores/service-store.test.ts +89 -3
- package/src/stores/ui-store.test.ts +23 -0
- package/src/test-setup.ts +11 -2
- package/src/types.ts +1 -0
- package/src/view-registry.test.ts +3 -1
- package/src/view-registry.tsx +14 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hoox-sh/hoox-tui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Hoox algorithmic trading terminal operations center — OpenTUI dashboard",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/main.js",
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
"prepublishOnly": "bun run typecheck && bun run build"
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"@hoox-sh/hoox-shared": "^1.
|
|
35
|
+
"@hoox-sh/hoox-shared": "^1.3.0",
|
|
36
36
|
"@opentui-ui/dialog": "^0.1.2",
|
|
37
37
|
"@opentui-ui/toast": "^0.0.5",
|
|
38
38
|
"@opentui/core": "^0.5.1",
|
package/src/app.tsx
CHANGED
|
@@ -131,38 +131,16 @@ function AppRootInner({ safeMode = false }: { safeMode?: boolean }) {
|
|
|
131
131
|
}, [closePalette, showModal, dismissModal]);
|
|
132
132
|
|
|
133
133
|
// ── Session restore on mount ────────────────────────────────────────────
|
|
134
|
-
// Shared restoreSession
|
|
135
|
-
// Re-read session.json and accept any registry ViewId so newer views
|
|
136
|
-
// (queues, kv, secrets, …) actually restore after restart.
|
|
134
|
+
// Shared restoreSession validates all 16 ViewIds (unknown → dashboard).
|
|
137
135
|
useEffect(() => {
|
|
138
136
|
let cancelled = false;
|
|
139
137
|
(async () => {
|
|
140
138
|
const session = await restoreSession();
|
|
141
139
|
if (cancelled) return;
|
|
142
140
|
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
}
|
|
141
|
+
const view: ViewId = session.activeView;
|
|
142
|
+
const expanded = session.sidebarExpanded;
|
|
164
143
|
|
|
165
|
-
if (cancelled) return;
|
|
166
144
|
if (isRegisteredViewId(view)) {
|
|
167
145
|
setView(view);
|
|
168
146
|
}
|
|
@@ -479,10 +457,15 @@ function AppRootInner({ safeMode = false }: { safeMode?: boolean }) {
|
|
|
479
457
|
return;
|
|
480
458
|
}
|
|
481
459
|
|
|
482
|
-
// Ctrl+R: refresh worker data
|
|
460
|
+
// Ctrl+R: refresh worker data + force reconnect from offline
|
|
483
461
|
if (key.ctrl && !key.alt && name === "r") {
|
|
484
462
|
if (!safeMode) {
|
|
485
|
-
|
|
463
|
+
const store = useServiceStore.getState();
|
|
464
|
+
if (store.connectionStatus === "offline") {
|
|
465
|
+
store.forceRetry();
|
|
466
|
+
} else {
|
|
467
|
+
void store.fetchWorkers();
|
|
468
|
+
}
|
|
486
469
|
}
|
|
487
470
|
return;
|
|
488
471
|
}
|
|
@@ -493,13 +476,20 @@ function AppRootInner({ safeMode = false }: { safeMode?: boolean }) {
|
|
|
493
476
|
return;
|
|
494
477
|
}
|
|
495
478
|
|
|
496
|
-
// Escape:
|
|
479
|
+
// Escape: modal → palette → goBack (when previousView set)
|
|
497
480
|
if (name === "escape") {
|
|
498
481
|
if (modal) {
|
|
499
482
|
dismissModal();
|
|
500
483
|
return;
|
|
501
484
|
}
|
|
502
|
-
|
|
485
|
+
if (commandPaletteOpen) {
|
|
486
|
+
closePalette();
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
const prev = useUIStore.getState().previousView;
|
|
490
|
+
if (prev) {
|
|
491
|
+
useUIStore.getState().goBack();
|
|
492
|
+
}
|
|
503
493
|
}
|
|
504
494
|
},
|
|
505
495
|
[
|
|
@@ -555,6 +545,22 @@ function AppRootInner({ safeMode = false }: { safeMode?: boolean }) {
|
|
|
555
545
|
|
|
556
546
|
{/* Content area: View (fills remaining space) */}
|
|
557
547
|
<box flexDirection="column" flexGrow={1} padding={1}>
|
|
548
|
+
{safeMode ? (
|
|
549
|
+
<box
|
|
550
|
+
flexDirection="row"
|
|
551
|
+
paddingLeft={1}
|
|
552
|
+
paddingRight={1}
|
|
553
|
+
backgroundColor={Colors.card}
|
|
554
|
+
>
|
|
555
|
+
<text fg={Colors.warning} bold>
|
|
556
|
+
SAFE MODE
|
|
557
|
+
</text>
|
|
558
|
+
<text fg={Colors.muted} dim>
|
|
559
|
+
{" "}
|
|
560
|
+
— network / CLI / SSE disabled · restart for full ops
|
|
561
|
+
</text>
|
|
562
|
+
</box>
|
|
563
|
+
) : null}
|
|
558
564
|
{renderView(dialog)}
|
|
559
565
|
</box>
|
|
560
566
|
</box>
|
|
@@ -571,7 +577,16 @@ function AppRootInner({ safeMode = false }: { safeMode?: boolean }) {
|
|
|
571
577
|
if (selection.action === "setView" && selection.command.id) {
|
|
572
578
|
setView(selection.command.id as ViewId);
|
|
573
579
|
} else if (selection.command.id === "refresh") {
|
|
574
|
-
|
|
580
|
+
const store = useServiceStore.getState();
|
|
581
|
+
if (store.connectionStatus === "offline") {
|
|
582
|
+
store.forceRetry();
|
|
583
|
+
} else {
|
|
584
|
+
void store.fetchWorkers();
|
|
585
|
+
}
|
|
586
|
+
} else if (selection.command.id === "force-retry") {
|
|
587
|
+
useServiceStore.getState().forceRetry();
|
|
588
|
+
} else if (selection.command.id === "expand-error") {
|
|
589
|
+
useUIStore.getState().toggleStatusErrorExpanded();
|
|
575
590
|
} else if (selection.command.id === "toggle-sidebar") {
|
|
576
591
|
toggleSidebar();
|
|
577
592
|
} else if (selection.command.id === "quit") {
|
|
@@ -188,13 +188,48 @@ export function resetCliBridgeDouble(): void {
|
|
|
188
188
|
applyDefaults();
|
|
189
189
|
}
|
|
190
190
|
|
|
191
|
+
/**
|
|
192
|
+
* Real CliBridgeImpl singleton loaded before mock.module installs the double.
|
|
193
|
+
* Used by method-level unit tests that stub `exec` without spawning CLI.
|
|
194
|
+
*/
|
|
195
|
+
export let realCliBridge: {
|
|
196
|
+
exec: (...args: unknown[]) => Promise<unknown>;
|
|
197
|
+
dbQuery: (sql: string) => Promise<unknown>;
|
|
198
|
+
monitorQueueDepth: () => Promise<unknown>;
|
|
199
|
+
monitorKillSwitch: (action: string) => Promise<unknown>;
|
|
200
|
+
configKvList: () => Promise<unknown>;
|
|
201
|
+
configKvGet: (key: string) => Promise<unknown>;
|
|
202
|
+
configKvSet: (key: string, value: string) => Promise<unknown>;
|
|
203
|
+
configSecretsList: () => Promise<unknown>;
|
|
204
|
+
agentHealthCheck: () => Promise<unknown>;
|
|
205
|
+
pyneHealthCheck: () => Promise<unknown>;
|
|
206
|
+
deployAll: (...args: unknown[]) => Promise<unknown>;
|
|
207
|
+
deployWorker: (...args: unknown[]) => Promise<unknown>;
|
|
208
|
+
checkHealth: () => Promise<unknown>;
|
|
209
|
+
checkHealthFix: () => Promise<unknown>;
|
|
210
|
+
checkFix: () => Promise<unknown>;
|
|
211
|
+
workerLogs: (name: string) => Promise<unknown>;
|
|
212
|
+
configShow: () => Promise<unknown>;
|
|
213
|
+
configValidate: () => Promise<unknown>;
|
|
214
|
+
monitorStatus: () => Promise<unknown>;
|
|
215
|
+
rebuild: (...args: unknown[]) => Promise<unknown>;
|
|
216
|
+
repairWorker: (...args: unknown[]) => Promise<unknown>;
|
|
217
|
+
checkSetup: () => Promise<unknown>;
|
|
218
|
+
resolveBinary: () => Promise<string>;
|
|
219
|
+
invalidateCache: () => void;
|
|
220
|
+
onError: (sink: (details: unknown) => void) => () => void;
|
|
221
|
+
abort: (tag: string) => void;
|
|
222
|
+
dispose: () => void;
|
|
223
|
+
} | null = null;
|
|
224
|
+
|
|
191
225
|
/** Factory for mock.module — re-exports real standalone + index helpers. */
|
|
192
226
|
export async function createCliBridgeModuleMock(): Promise<
|
|
193
227
|
Record<string, unknown>
|
|
194
228
|
> {
|
|
195
229
|
// Load real module exports *before* mock.module is installed so tests that
|
|
196
|
-
// import
|
|
230
|
+
// import pure parsers / sanitize helpers still get the real fns.
|
|
197
231
|
const realBridge = await import("./services/cli-bridge/index");
|
|
232
|
+
realCliBridge = realBridge.cliBridge as typeof realCliBridge;
|
|
198
233
|
const standalone = await import("./services/cli-bridge/standalone");
|
|
199
234
|
const types = await import("./services/cli-bridge/types");
|
|
200
235
|
return {
|
|
@@ -204,6 +239,18 @@ export async function createCliBridgeModuleMock(): Promise<
|
|
|
204
239
|
AI_MODEL_OPTIONS: standalone.AI_MODEL_OPTIONS,
|
|
205
240
|
tryParseJsonLoose: realBridge.tryParseJsonLoose,
|
|
206
241
|
sanitizeCliArgsForLog: realBridge.sanitizeCliArgsForLog,
|
|
242
|
+
parseDbQueryResult: realBridge.parseDbQueryResult,
|
|
243
|
+
deriveQueueDepthStatus: realBridge.deriveQueueDepthStatus,
|
|
244
|
+
parseQueueDepths: realBridge.parseQueueDepths,
|
|
245
|
+
parseSecretsList: realBridge.parseSecretsList,
|
|
246
|
+
inferSecretType: realBridge.inferSecretType,
|
|
247
|
+
parseKvList: realBridge.parseKvList,
|
|
248
|
+
parseKvManifest: realBridge.parseKvManifest,
|
|
249
|
+
stripAnsi: realBridge.stripAnsi,
|
|
250
|
+
parseKillSwitchStatus: realBridge.parseKillSwitchStatus,
|
|
251
|
+
parsePyneHealth: realBridge.parsePyneHealth,
|
|
252
|
+
parseAgentHealth: realBridge.parseAgentHealth,
|
|
253
|
+
extractNamespaceId: realBridge.extractNamespaceId,
|
|
207
254
|
// Type re-exports are erased at runtime; keep values if any exist
|
|
208
255
|
...Object.fromEntries(
|
|
209
256
|
Object.entries(types).filter(([, v]) => typeof v !== "undefined")
|
|
@@ -42,27 +42,43 @@ export function Sidebar() {
|
|
|
42
42
|
</CoolBrackets>
|
|
43
43
|
<text fg={Colors.dim}>─────────────────</text>
|
|
44
44
|
|
|
45
|
-
{/* Navigation items (registry order) */}
|
|
45
|
+
{/* Navigation items (registry order) — shortcut from registry */}
|
|
46
46
|
{SIDEBAR_ITEMS.map((item) => {
|
|
47
47
|
const isActive = item.id === activeView;
|
|
48
|
+
// Pad label so shortcut sits right-aligned in the fixed-width rail
|
|
49
|
+
const shortcutHint = item.shortcut;
|
|
48
50
|
return (
|
|
49
|
-
<box
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
>
|
|
56
|
-
{
|
|
51
|
+
<box
|
|
52
|
+
flexDirection="row"
|
|
53
|
+
gap={1}
|
|
54
|
+
key={item.id}
|
|
55
|
+
justifyContent="space-between"
|
|
56
|
+
>
|
|
57
|
+
<box flexDirection="row" gap={1} flexGrow={1}>
|
|
58
|
+
{isActive ? (
|
|
59
|
+
<CoolGlyph char="▸" />
|
|
60
|
+
) : (
|
|
61
|
+
<text fg={Colors.dim}> </text>
|
|
62
|
+
)}
|
|
63
|
+
<text
|
|
64
|
+
fg={isActive ? Colors.accent : Colors.muted}
|
|
65
|
+
bold={isActive}
|
|
66
|
+
onMouseUp={() => setView(item.id)}
|
|
67
|
+
>
|
|
68
|
+
{item.label}
|
|
69
|
+
</text>
|
|
70
|
+
</box>
|
|
71
|
+
<text fg={Colors.dim} dim>
|
|
72
|
+
{shortcutHint}
|
|
57
73
|
</text>
|
|
58
74
|
</box>
|
|
59
75
|
);
|
|
60
76
|
})}
|
|
61
77
|
|
|
62
|
-
{/* Shortcut
|
|
78
|
+
{/* Shortcut legend — digits are Ctrl; ^X chords are Ctrl+Alt */}
|
|
63
79
|
<box flexGrow={1} />
|
|
64
80
|
<text fg={Colors.dim} dim>
|
|
65
|
-
|
|
81
|
+
1-9 Ctrl · ^X Ctrl+Alt
|
|
66
82
|
</text>
|
|
67
83
|
</box>
|
|
68
84
|
);
|
|
@@ -197,7 +197,7 @@ describe("StatusBar", () => {
|
|
|
197
197
|
});
|
|
198
198
|
const output = await renderStatusBar();
|
|
199
199
|
// The expand hint is rendered when pillInteractive is true
|
|
200
|
-
expect(output).toContain("
|
|
200
|
+
expect(output).toContain("details");
|
|
201
201
|
});
|
|
202
202
|
|
|
203
203
|
it("does not show the expand hint when no error details are present", async () => {
|
|
@@ -224,11 +224,12 @@ describe("StatusBar", () => {
|
|
|
224
224
|
// ── Keyboard hints ─────────────────────────────────────────────────────
|
|
225
225
|
|
|
226
226
|
describe("keyboard hints", () => {
|
|
227
|
-
it("shows
|
|
227
|
+
it("shows global shortcut hints including diagnostics chord", async () => {
|
|
228
228
|
const output = await renderStatusBar();
|
|
229
|
-
expect(output).toContain("
|
|
230
|
-
expect(output).toContain("
|
|
231
|
-
expect(output).toContain("
|
|
229
|
+
expect(output).toContain("^P");
|
|
230
|
+
expect(output).toContain("^B");
|
|
231
|
+
expect(output).toContain("^Q");
|
|
232
|
+
expect(output).toContain("^⇧D");
|
|
232
233
|
});
|
|
233
234
|
});
|
|
234
235
|
|
|
@@ -278,10 +279,23 @@ describe("StatusBar", () => {
|
|
|
278
279
|
process.env.HOOX_TUI_MODE = "remote";
|
|
279
280
|
process.env.HOOX_API_URL = "https://gw.test";
|
|
280
281
|
delete process.env.HOOX_API_TOKEN;
|
|
282
|
+
delete process.env.CF_ACCESS_CLIENT_ID;
|
|
283
|
+
delete process.env.CF_ACCESS_CLIENT_SECRET;
|
|
281
284
|
const output = await renderStatusBar();
|
|
282
285
|
expect(output).toContain("AUTH?");
|
|
283
286
|
expect(output).toContain("gw.test");
|
|
284
287
|
});
|
|
288
|
+
|
|
289
|
+
it("does not show AUTH? for Access-only remote credentials", async () => {
|
|
290
|
+
process.env.HOOX_TUI_MODE = "remote";
|
|
291
|
+
process.env.HOOX_API_URL = "https://gw.test";
|
|
292
|
+
delete process.env.HOOX_API_TOKEN;
|
|
293
|
+
process.env.CF_ACCESS_CLIENT_ID = "access-id";
|
|
294
|
+
process.env.CF_ACCESS_CLIENT_SECRET = "access-secret";
|
|
295
|
+
const output = await renderStatusBar();
|
|
296
|
+
expect(output).not.toContain("AUTH?");
|
|
297
|
+
expect(output).toContain("gw.test");
|
|
298
|
+
});
|
|
285
299
|
});
|
|
286
300
|
});
|
|
287
301
|
|
|
@@ -20,22 +20,24 @@
|
|
|
20
20
|
* known error types so the user gets a suggested next step, not just
|
|
21
21
|
* the raw error.
|
|
22
22
|
*/
|
|
23
|
-
import {
|
|
23
|
+
import { useCallback, memo } from "react";
|
|
24
|
+
import { useKeyboard } from "@opentui/react";
|
|
24
25
|
import {
|
|
25
26
|
Colors,
|
|
26
27
|
ConnectionStatusColor,
|
|
27
28
|
useServiceStore,
|
|
29
|
+
useUIStore,
|
|
28
30
|
formatRelativeTimeFromTime as formatRelativeTime,
|
|
29
31
|
type CliErrorType,
|
|
30
32
|
type CliErrorDetails,
|
|
31
33
|
} from "@hoox-sh/hoox-shared";
|
|
32
34
|
import {
|
|
33
35
|
classifyConnectionError,
|
|
34
|
-
hasApiToken,
|
|
35
36
|
resolveTuiConnectionEnv,
|
|
36
37
|
} from "../../services/tui-connection";
|
|
37
38
|
import { redactSecretsInText } from "../../services/dev-log";
|
|
38
39
|
import { CoolGlyph } from "../shared/cool-brackets";
|
|
40
|
+
import { isShellOverlayOpen } from "../../hooks/shell-overlay";
|
|
39
41
|
|
|
40
42
|
/** Map each CliErrorType to a short, human-readable label. */
|
|
41
43
|
const ERROR_TYPE_LABELS: Record<CliErrorType, string> = {
|
|
@@ -231,26 +233,40 @@ export function StatusBar() {
|
|
|
231
233
|
const lastErrorDetails = useServiceStore((s) => s.lastErrorDetails);
|
|
232
234
|
const retryCount = useServiceStore((s) => s.retryCount);
|
|
233
235
|
const reconnectDelay = useServiceStore((s) => s.reconnectDelay);
|
|
236
|
+
const errorExpanded = useUIStore((s) => s.statusErrorExpanded);
|
|
237
|
+
const toggleStatusErrorExpanded = useUIStore(
|
|
238
|
+
(s) => s.toggleStatusErrorExpanded
|
|
239
|
+
);
|
|
234
240
|
|
|
235
241
|
const conn = resolveTuiConnectionEnv();
|
|
236
242
|
const tuiMode = conn.mode;
|
|
237
243
|
const apiHost = conn.apiHost;
|
|
238
|
-
|
|
244
|
+
// Fail-closed readiness: Bearer and/or Access pair (not token alone)
|
|
245
|
+
const authReady = conn.hasAuth;
|
|
239
246
|
const errorKind = classifyConnectionError(lastError);
|
|
240
|
-
// Compact AUTH cue: missing
|
|
247
|
+
// Compact AUTH cue: missing any remote credential, or auth error
|
|
241
248
|
const showAuthHint =
|
|
242
249
|
tuiMode === "remote" &&
|
|
243
|
-
(!
|
|
244
|
-
errorKind === "auth" ||
|
|
245
|
-
Boolean(lastError?.includes("401")));
|
|
246
|
-
|
|
247
|
-
// Local UI state — expansion is purely a presentation concern, so it
|
|
248
|
-
// lives in component state rather than the global store.
|
|
249
|
-
const [errorExpanded, setErrorExpanded] = useState<boolean>(false);
|
|
250
|
+
(!authReady || errorKind === "auth" || Boolean(lastError?.includes("401")));
|
|
250
251
|
|
|
251
252
|
const toggleError = useCallback(() => {
|
|
252
|
-
|
|
253
|
-
}, []);
|
|
253
|
+
toggleStatusErrorExpanded();
|
|
254
|
+
}, [toggleStatusErrorExpanded]);
|
|
255
|
+
|
|
256
|
+
// Keyboard expand diagnostics: Ctrl+Shift+D (does not steal view chords)
|
|
257
|
+
useKeyboard((key) => {
|
|
258
|
+
if (isShellOverlayOpen()) return;
|
|
259
|
+
const name = String(key.name ?? "").toLowerCase();
|
|
260
|
+
if (key.ctrl && key.shift && name === "d") {
|
|
261
|
+
const details = useServiceStore.getState().lastErrorDetails;
|
|
262
|
+
const status = useServiceStore.getState().connectionStatus;
|
|
263
|
+
const canExpand =
|
|
264
|
+
details !== null && (status === "offline" || status === "reconnecting");
|
|
265
|
+
if (canExpand) {
|
|
266
|
+
useUIStore.getState().toggleStatusErrorExpanded();
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
});
|
|
254
270
|
|
|
255
271
|
const statusLabel: Record<string, string> = {
|
|
256
272
|
connected: "CONNECTED",
|
|
@@ -285,7 +301,7 @@ export function StatusBar() {
|
|
|
285
301
|
);
|
|
286
302
|
|
|
287
303
|
if (showAuthHint) {
|
|
288
|
-
parts.push(!
|
|
304
|
+
parts.push(!authReady ? "| AUTH?" : "| AUTH!");
|
|
289
305
|
}
|
|
290
306
|
|
|
291
307
|
if (lastError && isErrorState && errorKind !== "auth") {
|
|
@@ -294,11 +310,11 @@ export function StatusBar() {
|
|
|
294
310
|
}
|
|
295
311
|
|
|
296
312
|
// The expand hint appears whenever a clickable error is available so
|
|
297
|
-
// users know they can drill in.
|
|
313
|
+
// users know they can drill in (mouse or Ctrl+Shift+D).
|
|
298
314
|
const expandHint = pillInteractive
|
|
299
315
|
? errorExpanded
|
|
300
|
-
? "▾
|
|
301
|
-
: "▸
|
|
316
|
+
? "▾ collapse (^⇧D)"
|
|
317
|
+
: "▸ details (^⇧D)"
|
|
302
318
|
: "";
|
|
303
319
|
|
|
304
320
|
return (
|
|
@@ -335,7 +351,7 @@ export function StatusBar() {
|
|
|
335
351
|
<CoolGlyph char="┐" />
|
|
336
352
|
</box>
|
|
337
353
|
<text fg={Colors.dim} dim>
|
|
338
|
-
^P
|
|
354
|
+
^P · ^B · ^Q · ^⇧D err
|
|
339
355
|
</text>
|
|
340
356
|
</box>
|
|
341
357
|
|
|
@@ -11,11 +11,11 @@
|
|
|
11
11
|
* - "Something went wrong" banner
|
|
12
12
|
* - Error message (first line)
|
|
13
13
|
* - Three action buttons: [Restart] [Safe Mode] [Report Bug]
|
|
14
|
+
* - Keyboard: R restart · S safe mode · B report bug
|
|
14
15
|
*
|
|
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
16
|
* Colors use Hoox design tokens via @hoox-sh/hoox-shared.
|
|
18
17
|
*/
|
|
18
|
+
import { useKeyboard } from "@opentui/react";
|
|
19
19
|
import { Colors } from "@hoox-sh/hoox-shared";
|
|
20
20
|
import { redactSecretsInText } from "../../services/dev-log";
|
|
21
21
|
|
|
@@ -56,6 +56,22 @@ export function CrashScreen({
|
|
|
56
56
|
? redactSecretsInText(secondRaw).slice(0, 80)
|
|
57
57
|
: undefined;
|
|
58
58
|
|
|
59
|
+
// Keyboard recovery (mouse optional / TUI_MOUSE=0)
|
|
60
|
+
useKeyboard((key) => {
|
|
61
|
+
const name = String(key.name ?? "").toLowerCase();
|
|
62
|
+
if (name === "r" || name === "1") {
|
|
63
|
+
onAction("restart");
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
if (!safeMode && (name === "s" || name === "2")) {
|
|
67
|
+
onAction("safe-mode");
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
if (name === "b" || name === "3") {
|
|
71
|
+
onAction("report-bug");
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
|
|
59
75
|
return (
|
|
60
76
|
<box
|
|
61
77
|
flexDirection="column"
|
|
@@ -162,6 +178,12 @@ export function CrashScreen({
|
|
|
162
178
|
</text>
|
|
163
179
|
</box>
|
|
164
180
|
</box>
|
|
181
|
+
|
|
182
|
+
<text fg={Colors.dim} dim>
|
|
183
|
+
{safeMode
|
|
184
|
+
? "Keys: R restart · B report bug"
|
|
185
|
+
: "Keys: R restart · S safe mode · B report bug"}
|
|
186
|
+
</text>
|
|
165
187
|
</box>
|
|
166
188
|
);
|
|
167
189
|
}
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
* Colors use Hoox design tokens — no hardcoded hex.
|
|
13
13
|
*/
|
|
14
14
|
import { Component, type ReactNode } from "react";
|
|
15
|
+
import { useKeyboard } from "@opentui/react";
|
|
15
16
|
import { Colors } from "@hoox-sh/hoox-shared";
|
|
16
17
|
import { redactSecretsInText } from "../../services/dev-log";
|
|
17
18
|
|
|
@@ -37,6 +38,56 @@ export function formatBoundaryErrorMessage(error: Error, maxLen = 160): string {
|
|
|
37
38
|
: scrubbed;
|
|
38
39
|
}
|
|
39
40
|
|
|
41
|
+
/** Keyboard + mouse retry chrome (hooks require a function component). */
|
|
42
|
+
function ErrorRecoveryPanel({
|
|
43
|
+
viewName,
|
|
44
|
+
message,
|
|
45
|
+
onRetry,
|
|
46
|
+
}: {
|
|
47
|
+
viewName: string;
|
|
48
|
+
message: string;
|
|
49
|
+
onRetry: () => void;
|
|
50
|
+
}) {
|
|
51
|
+
useKeyboard((key) => {
|
|
52
|
+
const name = String(key.name ?? "").toLowerCase();
|
|
53
|
+
if (name === "return" || name === "enter" || name === "r") {
|
|
54
|
+
onRetry();
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<box
|
|
60
|
+
flexDirection="column"
|
|
61
|
+
padding={2}
|
|
62
|
+
gap={1}
|
|
63
|
+
border={true}
|
|
64
|
+
borderStyle="single"
|
|
65
|
+
borderColor={Colors.border}
|
|
66
|
+
backgroundColor={Colors.card}
|
|
67
|
+
>
|
|
68
|
+
<box flexDirection="row" gap={1}>
|
|
69
|
+
<text fg={Colors.error} bold>
|
|
70
|
+
⚠
|
|
71
|
+
</text>
|
|
72
|
+
<text fg={Colors.error} bold>
|
|
73
|
+
Failed to load {viewName}
|
|
74
|
+
</text>
|
|
75
|
+
</box>
|
|
76
|
+
|
|
77
|
+
<text fg={Colors.muted}>{message}</text>
|
|
78
|
+
|
|
79
|
+
<box paddingTop={1} flexDirection="row" gap={2}>
|
|
80
|
+
<text fg={Colors.accent} bg={Colors.card} onMouseUp={onRetry}>
|
|
81
|
+
{" [Retry] "}
|
|
82
|
+
</text>
|
|
83
|
+
<text fg={Colors.dim} dim>
|
|
84
|
+
Enter / R
|
|
85
|
+
</text>
|
|
86
|
+
</box>
|
|
87
|
+
</box>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
40
91
|
// ── Component ──────────────────────────────────────────────────────────────
|
|
41
92
|
|
|
42
93
|
export class ErrorBoundary extends Component<
|
|
@@ -58,39 +109,11 @@ export class ErrorBoundary extends Component<
|
|
|
58
109
|
if (this.state.error) {
|
|
59
110
|
const message = formatBoundaryErrorMessage(this.state.error);
|
|
60
111
|
return (
|
|
61
|
-
<
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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>
|
|
112
|
+
<ErrorRecoveryPanel
|
|
113
|
+
viewName={this.props.viewName}
|
|
114
|
+
message={message}
|
|
115
|
+
onRetry={this.handleRetry}
|
|
116
|
+
/>
|
|
94
117
|
);
|
|
95
118
|
}
|
|
96
119
|
|
|
@@ -11,6 +11,19 @@ import {
|
|
|
11
11
|
messageConnectionLost,
|
|
12
12
|
messageOfflineStartup,
|
|
13
13
|
messageReconnected,
|
|
14
|
+
toastReconnected,
|
|
15
|
+
toastReconnectedMode,
|
|
16
|
+
toastRateLimited,
|
|
17
|
+
toastConfigSaved,
|
|
18
|
+
toastDeployStarted,
|
|
19
|
+
toastDeployCompleted,
|
|
20
|
+
toastDeployFailed,
|
|
21
|
+
toastConnectionLost,
|
|
22
|
+
toastConnectionLostMode,
|
|
23
|
+
toastConnectedMode,
|
|
24
|
+
toastAuthRequiredMode,
|
|
25
|
+
toastAuthMissingRemote,
|
|
26
|
+
toastOfflineStartup,
|
|
14
27
|
} from "./connection-toasts";
|
|
15
28
|
|
|
16
29
|
describe("connection-toasts messages", () => {
|
|
@@ -36,15 +49,59 @@ describe("connection-toasts messages", () => {
|
|
|
36
49
|
expect(msg).toContain("downtime");
|
|
37
50
|
});
|
|
38
51
|
|
|
52
|
+
it("reconnected clamps negative downtime to zero", () => {
|
|
53
|
+
const msg = messageReconnected("local", "h", 10_000, 1_000);
|
|
54
|
+
expect(msg).toContain("downtime");
|
|
55
|
+
});
|
|
56
|
+
|
|
39
57
|
it("auth messages are explicit", () => {
|
|
40
58
|
expect(messageAuthRequired("remote", "gw")).toContain("Auth failed");
|
|
59
|
+
expect(messageAuthRequired("local", "localhost")).toContain("LOCAL");
|
|
41
60
|
expect(messageAuthMissing("gw")).toContain("No API credentials");
|
|
42
61
|
});
|
|
43
62
|
|
|
44
|
-
it("offline startup distinguishes
|
|
63
|
+
it("offline startup distinguishes kinds", () => {
|
|
45
64
|
expect(messageOfflineStartup("remote", "gw", "auth")).toContain("(auth)");
|
|
46
65
|
expect(
|
|
47
66
|
messageOfflineStartup("local", "localhost:8787", "network")
|
|
48
67
|
).not.toContain("(auth)");
|
|
68
|
+
expect(messageOfflineStartup("remote", "gw", "rate-limit")).toContain(
|
|
69
|
+
"(rate limited)"
|
|
70
|
+
);
|
|
71
|
+
expect(messageOfflineStartup("local", "h", "unknown")).toBe(
|
|
72
|
+
"Could not connect · LOCAL · h"
|
|
73
|
+
);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe("connection-toasts factory functions", () => {
|
|
78
|
+
// Toast singleton may no-op without a mounted Toaster; factories must not throw.
|
|
79
|
+
it("toast factories are callable without a mounted toaster", () => {
|
|
80
|
+
expect(() => toastReconnected(Date.now() - 5_000)).not.toThrow();
|
|
81
|
+
expect(() =>
|
|
82
|
+
toastReconnectedMode("remote", "gw.test", Date.now() - 2_000)
|
|
83
|
+
).not.toThrow();
|
|
84
|
+
expect(() => toastRateLimited()).not.toThrow();
|
|
85
|
+
expect(() => toastConfigSaved()).not.toThrow();
|
|
86
|
+
expect(() => toastDeployStarted("trade-worker")).not.toThrow();
|
|
87
|
+
expect(() => toastDeployCompleted("trade-worker")).not.toThrow();
|
|
88
|
+
expect(() => toastDeployFailed("trade-worker", "timeout")).not.toThrow();
|
|
89
|
+
expect(() => toastConnectionLost()).not.toThrow();
|
|
90
|
+
expect(() =>
|
|
91
|
+
toastConnectionLostMode("local", "localhost:8787")
|
|
92
|
+
).not.toThrow();
|
|
93
|
+
expect(() => toastConnectionLostMode("remote", "gw")).not.toThrow();
|
|
94
|
+
expect(() => toastConnectedMode("local", "localhost:8787")).not.toThrow();
|
|
95
|
+
expect(() => toastAuthRequiredMode("remote", "gw")).not.toThrow();
|
|
96
|
+
expect(() => toastAuthRequiredMode("local", "localhost")).not.toThrow();
|
|
97
|
+
expect(() => toastAuthMissingRemote("gw")).not.toThrow();
|
|
98
|
+
expect(() => toastOfflineStartup("remote", "gw", "auth")).not.toThrow();
|
|
99
|
+
expect(() => toastOfflineStartup("remote", "gw", "network")).not.toThrow();
|
|
100
|
+
expect(() =>
|
|
101
|
+
toastOfflineStartup("local", "localhost", "rate-limit")
|
|
102
|
+
).not.toThrow();
|
|
103
|
+
expect(() =>
|
|
104
|
+
toastOfflineStartup("local", "localhost", "unknown")
|
|
105
|
+
).not.toThrow();
|
|
49
106
|
});
|
|
50
107
|
});
|