@hoox-sh/hoox-tui 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/README.md +247 -0
  2. package/bin/hoox-tui.js +35 -0
  3. package/dist/main.js +17594 -0
  4. package/package.json +72 -0
  5. package/src/app.tsx +696 -0
  6. package/src/cli-bridge-test-double.ts +212 -0
  7. package/src/components/layout/layout.test.tsx +205 -0
  8. package/src/components/layout/sidebar.tsx +69 -0
  9. package/src/components/layout/statusbar.test.tsx +423 -0
  10. package/src/components/layout/statusbar.tsx +348 -0
  11. package/src/components/shared/animated-border.tsx +58 -0
  12. package/src/components/shared/chrome.test.tsx +29 -0
  13. package/src/components/shared/command-palette.tsx +361 -0
  14. package/src/components/shared/cool-brackets.tsx +75 -0
  15. package/src/components/shared/crash-screen.tsx +167 -0
  16. package/src/components/shared/error-boundary.tsx +99 -0
  17. package/src/components/shared/keybinding-hint.tsx +37 -0
  18. package/src/components/shared/panel.tsx +61 -0
  19. package/src/components/shared/quit-modal.tsx +65 -0
  20. package/src/components/shared/shared.test.tsx +538 -0
  21. package/src/components/shared/spinner.tsx +85 -0
  22. package/src/components/shared/status-dot.tsx +54 -0
  23. package/src/components/shared/view-header.tsx +53 -0
  24. package/src/components/ui/connection-toasts.test.ts +50 -0
  25. package/src/components/ui/connection-toasts.ts +215 -0
  26. package/src/components/ui/dialog.tsx +281 -0
  27. package/src/components/ui/select.tsx +197 -0
  28. package/src/components/ui/toast.tsx +185 -0
  29. package/src/components/views/ai-chat.test.tsx +81 -0
  30. package/src/components/views/ai-chat.tsx +576 -0
  31. package/src/components/views/config-editor/action-bar.tsx +102 -0
  32. package/src/components/views/config-editor/code-editor.tsx +597 -0
  33. package/src/components/views/config-editor/file-tree.tsx +113 -0
  34. package/src/components/views/config-editor/types.ts +37 -0
  35. package/src/components/views/config-editor.test.tsx +465 -0
  36. package/src/components/views/config-editor.tsx +709 -0
  37. package/src/components/views/dashboard/alerts-panel.tsx +154 -0
  38. package/src/components/views/dashboard/auto-repair-panel.tsx +229 -0
  39. package/src/components/views/dashboard/model-health-section.tsx +276 -0
  40. package/src/components/views/dashboard/pyne-health-section.tsx +112 -0
  41. package/src/components/views/dashboard.test.tsx +347 -0
  42. package/src/components/views/dashboard.tsx +647 -0
  43. package/src/components/views/db-query.test.tsx +115 -0
  44. package/src/components/views/db-query.tsx +849 -0
  45. package/src/components/views/edge-topology.test.tsx +198 -0
  46. package/src/components/views/edge-topology.tsx +572 -0
  47. package/src/components/views/kv-viewer.test.tsx +241 -0
  48. package/src/components/views/kv-viewer.tsx +717 -0
  49. package/src/components/views/logs-viewer.test.tsx +273 -0
  50. package/src/components/views/logs-viewer.tsx +616 -0
  51. package/src/components/views/queue-depth.test.tsx +221 -0
  52. package/src/components/views/queue-depth.tsx +376 -0
  53. package/src/components/views/secrets-viewer.test.tsx +65 -0
  54. package/src/components/views/secrets-viewer.tsx +548 -0
  55. package/src/components/views/service-manager.test.tsx +358 -0
  56. package/src/components/views/service-manager.tsx +946 -0
  57. package/src/components/views/settings.test.tsx +648 -0
  58. package/src/components/views/settings.tsx +1497 -0
  59. package/src/components/views/setup-wizard.test.tsx +297 -0
  60. package/src/components/views/setup-wizard.tsx +1321 -0
  61. package/src/components/views/trade-monitor.test.tsx +592 -0
  62. package/src/components/views/trade-monitor.tsx +745 -0
  63. package/src/components/views/worker-detail.test.tsx +289 -0
  64. package/src/components/views/worker-detail.tsx +676 -0
  65. package/src/components/views/worker-settings.test.tsx +43 -0
  66. package/src/components/views/worker-settings.tsx +563 -0
  67. package/src/components/views/workers-overview.test.tsx +260 -0
  68. package/src/components/views/workers-overview.tsx +631 -0
  69. package/src/hooks/hooks.test.ts +72 -0
  70. package/src/hooks/index.ts +11 -0
  71. package/src/hooks/renderer-ref.ts +22 -0
  72. package/src/hooks/use-keyboard.ts +130 -0
  73. package/src/hooks/use-polling.ts +73 -0
  74. package/src/hooks/use-service-data.ts +18 -0
  75. package/src/main.tsx +117 -0
  76. package/src/network-test-double.ts +133 -0
  77. package/src/opentui-augment.d.ts +45 -0
  78. package/src/opentui-jsx.d.ts +70 -0
  79. package/src/services/cli-bridge/index.ts +1662 -0
  80. package/src/services/cli-bridge/standalone.ts +315 -0
  81. package/src/services/cli-bridge/types.ts +307 -0
  82. package/src/services/cli-bridge.test.ts +406 -0
  83. package/src/services/clipboard.test.ts +57 -0
  84. package/src/services/clipboard.ts +180 -0
  85. package/src/services/dashboard-settings-loader.ts +31 -0
  86. package/src/services/dev-log.test.ts +168 -0
  87. package/src/services/dev-log.ts +194 -0
  88. package/src/services/hoox-path-service.test.ts +183 -0
  89. package/src/services/hoox-path-service.ts +185 -0
  90. package/src/services/tui-connection.test.ts +212 -0
  91. package/src/services/tui-connection.ts +236 -0
  92. package/src/services/tui-storage.test.ts +92 -0
  93. package/src/services/tui-storage.ts +86 -0
  94. package/src/stores/config-store.test.ts +360 -0
  95. package/src/stores/service-store.test.ts +1026 -0
  96. package/src/stores/session-restore.test.ts +134 -0
  97. package/src/stores/ui-store.test.ts +276 -0
  98. package/src/test-setup.ts +265 -0
  99. package/src/test-utils.ts +179 -0
  100. package/src/types.ts +153 -0
  101. package/src/utils/colors.test.ts +326 -0
  102. package/src/utils/formatters.test.ts +236 -0
  103. package/src/view-registry.test.ts +132 -0
  104. package/src/view-registry.tsx +368 -0
@@ -0,0 +1,423 @@
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
+ * StatusBar Tests — connection state, error display, and click-to-expand.
9
+ *
10
+ * Validates:
11
+ * - Connection state pills (CONNECTED/POLLING/RECONNECTING/OFFLINE)
12
+ * - Last-updated timestamp rendering
13
+ * - One-line error summary appears in OFFLINE/RECONNECTING states
14
+ * - Click-to-expand shows full diagnostic panel (command, exit code,
15
+ * stderr, stdout, duration, timestamp, error type)
16
+ * - Line counts render next to stderr/stdout section headers
17
+ * - Recovery hint renders for each CliErrorType
18
+ * - Copy affordance footer is present
19
+ * - Pill is interactive only when there are details to show
20
+ *
21
+ * Uses the real renderer with a sufficient viewport to capture the
22
+ * expanded panel without truncation.
23
+ */
24
+ import { describe, it, expect, beforeEach, afterEach } from "bun:test";
25
+ import { testRender } from "@opentui/react/test-utils";
26
+ import { useServiceStore } from "@hoox-sh/hoox-shared/stores/service-store";
27
+ import type { CliErrorDetails } from "@hoox-sh/hoox-shared";
28
+
29
+ import {
30
+ StatusBar,
31
+ ExpandedErrorPanel,
32
+ resolveApiHostLabel,
33
+ } from "./statusbar";
34
+
35
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
36
+
37
+ /** Render the StatusBar and return the captured frame as a string. */
38
+ async function renderStatusBar(): Promise<string> {
39
+ const { captureCharFrame, renderOnce } = await testRender(<StatusBar />, {
40
+ width: 120,
41
+ height: 30,
42
+ exitOnCtrlC: false,
43
+ });
44
+ await renderOnce();
45
+ return captureCharFrame();
46
+ }
47
+
48
+ /** Render the expanded error panel and return the captured frame. */
49
+ async function renderPanel(details: CliErrorDetails): Promise<string> {
50
+ const { captureCharFrame, renderOnce } = await testRender(
51
+ <ExpandedErrorPanel details={details} />,
52
+ {
53
+ width: 120,
54
+ height: 30,
55
+ exitOnCtrlC: false,
56
+ }
57
+ );
58
+ await renderOnce();
59
+ return captureCharFrame();
60
+ }
61
+
62
+ function resetStore() {
63
+ useServiceStore.setState({
64
+ connectionStatus: "connected",
65
+ lastUpdated: 0,
66
+ lastError: null,
67
+ lastErrorDetails: null,
68
+ retryCount: 0,
69
+ reconnectDelay: 0,
70
+ disconnectedAt: null,
71
+ workers: [],
72
+ tradeStream: [],
73
+ alerts: [],
74
+ logs: [],
75
+ metrics: null,
76
+ selectedWorkerId: null,
77
+ lastSuccessfulFetch: 0,
78
+ });
79
+ }
80
+
81
+ const sampleError: CliErrorDetails = {
82
+ command: "/usr/local/bin/hoox check health",
83
+ exitCode: 1,
84
+ stderr: "Error: cloudflare credentials missing\nRun: hoox config env init",
85
+ stdout: "",
86
+ errorType: "non-zero-exit",
87
+ timestamp: 1717000000000,
88
+ duration: 1234,
89
+ };
90
+
91
+ // ─── StatusBar Tests ─────────────────────────────────────────────────────────
92
+
93
+ describe("StatusBar", () => {
94
+ beforeEach(() => {
95
+ resetStore();
96
+ });
97
+
98
+ // ── Connection state pills ──────────────────────────────────────────────
99
+
100
+ describe("connection state pills", () => {
101
+ it("shows CONNECTED when connection is healthy", async () => {
102
+ useServiceStore.setState({ connectionStatus: "connected" });
103
+ const output = await renderStatusBar();
104
+ expect(output).toContain("CONNECTED");
105
+ });
106
+
107
+ it("shows POLLING when in polling state", async () => {
108
+ useServiceStore.setState({ connectionStatus: "polling" });
109
+ const output = await renderStatusBar();
110
+ expect(output).toContain("POLLING");
111
+ });
112
+
113
+ it("shows RECONNECTING with retry count and backoff delay", async () => {
114
+ useServiceStore.setState({
115
+ connectionStatus: "reconnecting",
116
+ retryCount: 3,
117
+ reconnectDelay: 4000,
118
+ });
119
+ const output = await renderStatusBar();
120
+ expect(output).toContain("RECONNECTING");
121
+ expect(output).toContain("retry 3/5");
122
+ expect(output).toContain("4000ms");
123
+ });
124
+
125
+ it("shows OFFLINE when disconnected", async () => {
126
+ useServiceStore.setState({ connectionStatus: "offline" });
127
+ const output = await renderStatusBar();
128
+ expect(output).toContain("OFFLINE");
129
+ });
130
+ });
131
+
132
+ // ── Last-updated timestamp ─────────────────────────────────────────────
133
+
134
+ describe("timestamp display", () => {
135
+ it("shows em-dash placeholder when lastUpdated is 0", async () => {
136
+ useServiceStore.setState({
137
+ connectionStatus: "connected",
138
+ lastUpdated: 0,
139
+ });
140
+ const output = await renderStatusBar();
141
+ expect(output).toContain("Updated:");
142
+ expect(output).toContain("—");
143
+ });
144
+
145
+ it("prefixes with 'Last updated' in error state", async () => {
146
+ useServiceStore.setState({
147
+ connectionStatus: "offline",
148
+ lastUpdated: Date.now() - 60_000,
149
+ });
150
+ const output = await renderStatusBar();
151
+ expect(output).toContain("Last updated:");
152
+ });
153
+
154
+ it("prefixes with 'Updated' in healthy state", async () => {
155
+ useServiceStore.setState({
156
+ connectionStatus: "connected",
157
+ lastUpdated: Date.now() - 60_000,
158
+ });
159
+ const output = await renderStatusBar();
160
+ expect(output).toContain("Updated:");
161
+ expect(output).not.toContain("Last updated:");
162
+ });
163
+ });
164
+
165
+ // ── One-line error summary ─────────────────────────────────────────────
166
+
167
+ describe("one-line error summary", () => {
168
+ it("shows lastError in the one-line display when offline", async () => {
169
+ useServiceStore.setState({
170
+ connectionStatus: "offline",
171
+ lastError: "Connection refused",
172
+ });
173
+ const output = await renderStatusBar();
174
+ expect(output).toContain("Connection refused");
175
+ });
176
+
177
+ it("does not show lastError in the one-line display when connected", async () => {
178
+ useServiceStore.setState({
179
+ connectionStatus: "connected",
180
+ lastError: "stale error from earlier",
181
+ });
182
+ const output = await renderStatusBar();
183
+ // lastError should be cleared on success — assert it doesn't
184
+ // appear in the visible one-line summary
185
+ expect(output).not.toContain("stale error from earlier");
186
+ });
187
+ });
188
+
189
+ // ── Pill interactivity ─────────────────────────────────────────────────
190
+
191
+ describe("pill interactivity", () => {
192
+ it("shows the expand hint when there are error details to show", async () => {
193
+ useServiceStore.setState({
194
+ connectionStatus: "offline",
195
+ lastError: "fail",
196
+ lastErrorDetails: sampleError,
197
+ });
198
+ const output = await renderStatusBar();
199
+ // The expand hint is rendered when pillInteractive is true
200
+ expect(output).toContain("click for details");
201
+ });
202
+
203
+ it("does not show the expand hint when no error details are present", async () => {
204
+ useServiceStore.setState({
205
+ connectionStatus: "offline",
206
+ lastError: "fail",
207
+ lastErrorDetails: null,
208
+ });
209
+ const output = await renderStatusBar();
210
+ expect(output).not.toContain("click for details");
211
+ });
212
+
213
+ it("does not show the expand hint when connected (no error state)", async () => {
214
+ useServiceStore.setState({
215
+ connectionStatus: "connected",
216
+ lastError: null,
217
+ lastErrorDetails: null,
218
+ });
219
+ const output = await renderStatusBar();
220
+ expect(output).not.toContain("click for details");
221
+ });
222
+ });
223
+
224
+ // ── Keyboard hints ─────────────────────────────────────────────────────
225
+
226
+ describe("keyboard hints", () => {
227
+ it("shows ^P PALETTE · ^B SIDEBAR · ^Q QUIT hint", async () => {
228
+ const output = await renderStatusBar();
229
+ expect(output).toContain("PALETTE");
230
+ expect(output).toContain("SIDEBAR");
231
+ expect(output).toContain("QUIT");
232
+ });
233
+ });
234
+
235
+ // ── LOCAL / REMOTE mode pill ───────────────────────────────────────────
236
+
237
+ describe("mode indicator", () => {
238
+ const originalMode = process.env.HOOX_TUI_MODE;
239
+ const originalApi = process.env.HOOX_API_URL;
240
+
241
+ afterEach(() => {
242
+ if (originalMode === undefined) delete process.env.HOOX_TUI_MODE;
243
+ else process.env.HOOX_TUI_MODE = originalMode;
244
+ if (originalApi === undefined) delete process.env.HOOX_API_URL;
245
+ else process.env.HOOX_API_URL = originalApi;
246
+ });
247
+
248
+ it("shows [LOCAL] when HOOX_TUI_MODE is unset", async () => {
249
+ delete process.env.HOOX_TUI_MODE;
250
+ const output = await renderStatusBar();
251
+ expect(output).toContain("[LOCAL]");
252
+ expect(output).not.toContain("[REMOTE]");
253
+ });
254
+
255
+ it("shows [LOCAL] when HOOX_TUI_MODE=local", async () => {
256
+ process.env.HOOX_TUI_MODE = "local";
257
+ const output = await renderStatusBar();
258
+ expect(output).toContain("[LOCAL]");
259
+ });
260
+
261
+ it("shows [REMOTE] when HOOX_TUI_MODE=remote", async () => {
262
+ process.env.HOOX_TUI_MODE = "remote";
263
+ const output = await renderStatusBar();
264
+ expect(output).toContain("[REMOTE]");
265
+ expect(output).not.toContain("[LOCAL]");
266
+ });
267
+
268
+ it("shows the API host next to the mode pill", async () => {
269
+ process.env.HOOX_TUI_MODE = "remote";
270
+ process.env.HOOX_API_URL = "https://hoox.example.workers.dev";
271
+ process.env.HOOX_API_TOKEN = "test-token";
272
+ const output = await renderStatusBar();
273
+ expect(output).toContain("hoox.example.workers.dev");
274
+ expect(output).not.toContain("AUTH?");
275
+ });
276
+
277
+ it("shows AUTH? when remote without token", async () => {
278
+ process.env.HOOX_TUI_MODE = "remote";
279
+ process.env.HOOX_API_URL = "https://gw.test";
280
+ delete process.env.HOOX_API_TOKEN;
281
+ const output = await renderStatusBar();
282
+ expect(output).toContain("AUTH?");
283
+ expect(output).toContain("gw.test");
284
+ });
285
+ });
286
+ });
287
+
288
+ describe("resolveApiHostLabel", () => {
289
+ it("extracts host from https URLs", () => {
290
+ expect(resolveApiHostLabel("https://hoox.example.com/path")).toBe(
291
+ "hoox.example.com"
292
+ );
293
+ });
294
+
295
+ it("keeps port for localhost", () => {
296
+ expect(resolveApiHostLabel("http://localhost:8787")).toBe("localhost:8787");
297
+ });
298
+
299
+ it("tolerates bare hosts", () => {
300
+ expect(resolveApiHostLabel("not-a-url")).toBe("not-a-url");
301
+ });
302
+ });
303
+
304
+ // ─── ExpandedErrorPanel Tests ────────────────────────────────────────────────
305
+
306
+ describe("ExpandedErrorPanel", () => {
307
+ beforeEach(() => {
308
+ resetStore();
309
+ });
310
+
311
+ it("renders the error type label and recovery hint", async () => {
312
+ const output = await renderPanel(sampleError);
313
+ expect(output).toContain("Command failed");
314
+ expect(output).toContain("non-zero-exit");
315
+ expect(output).toContain("Inspect stderr");
316
+ });
317
+
318
+ it("renders the command string for reproduction", async () => {
319
+ const output = await renderPanel(sampleError);
320
+ expect(output).toContain("/usr/local/bin/hoox check health");
321
+ });
322
+
323
+ it("renders the exit code", async () => {
324
+ const output = await renderPanel(sampleError);
325
+ expect(output).toContain("Exit code:");
326
+ expect(output).toContain("1");
327
+ });
328
+
329
+ it("renders the duration in milliseconds", async () => {
330
+ const output = await renderPanel(sampleError);
331
+ expect(output).toContain("Duration:");
332
+ expect(output).toContain("1234ms");
333
+ });
334
+
335
+ it("renders the timestamp as a clock time", async () => {
336
+ const output = await renderPanel(sampleError);
337
+ expect(output).toContain("Time:");
338
+ // The timestamp 1717000000000 is around 2024-05-29 16:26:40 UTC.
339
+ // Just check the format pattern HH:MM:SS exists.
340
+ expect(output).toMatch(/\d{1,2}:\d{2}:\d{2}/);
341
+ });
342
+
343
+ it("renders stderr with a line count", async () => {
344
+ const output = await renderPanel(sampleError);
345
+ expect(output).toContain("stderr");
346
+ // 2 lines in the sample stderr
347
+ expect(output).toContain("2 lines");
348
+ });
349
+
350
+ it("uses singular 'line' when stderr is a single line", async () => {
351
+ const singleLineError: CliErrorDetails = {
352
+ ...sampleError,
353
+ stderr: "Single line error",
354
+ };
355
+ const output = await renderPanel(singleLineError);
356
+ expect(output).toContain("1 line");
357
+ });
358
+
359
+ it("renders stdout section only when it differs from stderr", async () => {
360
+ const withStdout: CliErrorDetails = {
361
+ ...sampleError,
362
+ stdout: "Warning: rate limit approaching",
363
+ };
364
+ const output = await renderPanel(withStdout);
365
+ expect(output).toContain("stdout");
366
+ expect(output).toContain("rate limit approaching");
367
+ });
368
+
369
+ it("hides the stdout section when it equals stderr", async () => {
370
+ const sameAsStderr: CliErrorDetails = {
371
+ ...sampleError,
372
+ stdout: sampleError.stderr, // identical
373
+ };
374
+ const output = await renderPanel(sameAsStderr);
375
+ // stdout section is omitted — only the stderr label appears once
376
+ const stdoutMatches = output.match(/stdout/g);
377
+ expect(stdoutMatches).toBeNull();
378
+ });
379
+
380
+ it("hides stderr section when stderr is empty", async () => {
381
+ const noStderr: CliErrorDetails = {
382
+ ...sampleError,
383
+ stderr: "",
384
+ };
385
+ const output = await renderPanel(noStderr);
386
+ expect(output).not.toContain("stderr (");
387
+ });
388
+
389
+ it("renders the copy affordance footer", async () => {
390
+ const output = await renderPanel(sampleError);
391
+ expect(output).toContain("Drag-select");
392
+ expect(output).toContain("copy");
393
+ });
394
+
395
+ it("shows different recovery hints for different error types", async () => {
396
+ const errorTypes = [
397
+ "binary-not-found",
398
+ "timeout",
399
+ "aborted",
400
+ "non-zero-exit",
401
+ "spawn-error",
402
+ ] as const;
403
+ for (const errorType of errorTypes) {
404
+ const details: CliErrorDetails = { ...sampleError, errorType };
405
+ const output = await renderPanel(details);
406
+ // Each error type has a unique recovery hint — verify it's present
407
+ const hints: Record<typeof errorType, string> = {
408
+ "binary-not-found": "HOOX_CLI",
409
+ timeout: "timeout",
410
+ aborted: "cancelled",
411
+ "non-zero-exit": "Inspect stderr",
412
+ "spawn-error": "permission or path",
413
+ };
414
+ expect(output).toContain(hints[errorType]);
415
+ }
416
+ });
417
+
418
+ it("falls back to '(unknown)' when command is empty", async () => {
419
+ const noCommand: CliErrorDetails = { ...sampleError, command: "" };
420
+ const output = await renderPanel(noCommand);
421
+ expect(output).toContain("unknown");
422
+ });
423
+ });