@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,406 @@
1
+ /**
2
+ * Copyright (c) 2026 HOOX · HOOX · jango-blockchained (hoox-sh)
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Tests for validateReadOnlySql — the client-side read-only SQL validator.
8
+ *
9
+ * Validates the security guarantees of the db-query view:
10
+ * - SELECT / WITH / EXPLAIN are allowed
11
+ * - INSERT / UPDATE / DELETE / DROP / CREATE / ALTER / TRUNCATE / PRAGMA
12
+ * / ATTACH / DETACH / VACUUM / REINDEX are rejected
13
+ * - Multi-statement payloads (semicolons with content after) are rejected
14
+ * - Empty/whitespace-only input is rejected
15
+ * - Comment-only input is rejected
16
+ * - Mixed-case variants are handled case-insensitively
17
+ * - String literals and comments are stripped before keyword check
18
+ */
19
+ import { describe, it, expect } from "bun:test";
20
+ import {
21
+ validateReadOnlySql,
22
+ tryParseJsonLoose,
23
+ sanitizeCliArgsForLog,
24
+ type SqlValidationResult,
25
+ } from "./cli-bridge";
26
+
27
+ // ─── Helper ───────────────────────────────────────────────────────────────────
28
+
29
+ function isValid(sql: string): boolean {
30
+ return validateReadOnlySql(sql).readonly;
31
+ }
32
+
33
+ function rejectReason(sql: string): string {
34
+ const result = validateReadOnlySql(sql);
35
+ return result.readonly ? "" : (result as { reason: string }).reason;
36
+ }
37
+
38
+ // ─── Allowed entry points ─────────────────────────────────────────────────────
39
+
40
+ describe("validateReadOnlySql — allowed entry points", () => {
41
+ it("accepts simple SELECT", () => {
42
+ expect(isValid("SELECT * FROM users")).toBe(true);
43
+ });
44
+
45
+ it("accepts SELECT with WHERE clause", () => {
46
+ expect(isValid("SELECT id, name FROM users WHERE active = 1")).toBe(true);
47
+ });
48
+
49
+ it("accepts SELECT with JOIN", () => {
50
+ expect(
51
+ isValid(
52
+ "SELECT u.name, o.total FROM users u JOIN orders o ON u.id = o.user_id"
53
+ )
54
+ ).toBe(true);
55
+ });
56
+
57
+ it("accepts SELECT with subquery", () => {
58
+ expect(
59
+ isValid(
60
+ "SELECT * FROM (SELECT id, name FROM users WHERE active = 1) AS active_users"
61
+ )
62
+ ).toBe(true);
63
+ });
64
+
65
+ it("accepts WITH ... SELECT (CTE)", () => {
66
+ expect(
67
+ isValid(
68
+ "WITH active_users AS (SELECT * FROM users WHERE active = 1) SELECT * FROM active_users"
69
+ )
70
+ ).toBe(true);
71
+ });
72
+
73
+ it("accepts EXPLAIN SELECT", () => {
74
+ expect(isValid("EXPLAIN SELECT * FROM users")).toBe(true);
75
+ });
76
+
77
+ it("accepts EXPLAIN QUERY PLAN", () => {
78
+ expect(isValid("EXPLAIN QUERY PLAN SELECT * FROM users")).toBe(true);
79
+ });
80
+
81
+ it("accepts SELECT with LIMIT", () => {
82
+ expect(isValid("SELECT * FROM migrations ORDER BY id DESC LIMIT 5")).toBe(
83
+ true
84
+ );
85
+ });
86
+
87
+ it("accepts SELECT from sqlite_master", () => {
88
+ expect(
89
+ isValid(
90
+ "SELECT name, sql FROM sqlite_master WHERE type = 'table' ORDER BY name"
91
+ )
92
+ ).toBe(true);
93
+ });
94
+ });
95
+
96
+ // ─── Forbidden keywords ────────────────────────────────────────────────────────
97
+
98
+ describe("validateReadOnlySql — forbidden keywords", () => {
99
+ // INSERT
100
+ it("rejects INSERT", () => {
101
+ expect(isValid("INSERT INTO users (name) VALUES ('test')")).toBe(false);
102
+ });
103
+ it("rejects INSERT as first token (uppercase)", () => {
104
+ expect(isValid("INSERT INTO users VALUES (1)")).toBe(false);
105
+ });
106
+ it("rejects insert (lowercase)", () => {
107
+ expect(isValid("insert into users values (1)")).toBe(false);
108
+ });
109
+
110
+ // UPDATE
111
+ it("rejects UPDATE", () => {
112
+ expect(isValid("UPDATE users SET name = 'test' WHERE id = 1")).toBe(false);
113
+ });
114
+ it("rejects update (lowercase)", () => {
115
+ expect(isValid("update users set name = 'test' where id = 1")).toBe(false);
116
+ });
117
+
118
+ // DELETE
119
+ it("rejects DELETE", () => {
120
+ expect(isValid("DELETE FROM users WHERE id = 1")).toBe(false);
121
+ });
122
+ it("rejects delete (lowercase)", () => {
123
+ expect(isValid("delete from users where id = 1")).toBe(false);
124
+ });
125
+
126
+ // REPLACE
127
+ it("rejects REPLACE", () => {
128
+ expect(isValid("REPLACE INTO users (id, name) VALUES (1, 'test')")).toBe(
129
+ false
130
+ );
131
+ });
132
+
133
+ // DROP
134
+ it("rejects DROP TABLE", () => {
135
+ expect(isValid("DROP TABLE users")).toBe(false);
136
+ });
137
+ it("rejects DROP INDEX", () => {
138
+ expect(isValid("DROP INDEX idx_users")).toBe(false);
139
+ });
140
+
141
+ // CREATE
142
+ it("rejects CREATE TABLE", () => {
143
+ expect(isValid("CREATE TABLE test (id INTEGER PRIMARY KEY)")).toBe(false);
144
+ });
145
+ it("rejects CREATE INDEX", () => {
146
+ expect(isValid("CREATE INDEX idx ON users(name)")).toBe(false);
147
+ });
148
+ it("rejects CREATE VIEW", () => {
149
+ expect(isValid("CREATE VIEW v AS SELECT * FROM users")).toBe(false);
150
+ });
151
+
152
+ // ALTER
153
+ it("rejects ALTER TABLE", () => {
154
+ expect(isValid("ALTER TABLE users ADD COLUMN email TEXT")).toBe(false);
155
+ });
156
+
157
+ // TRUNCATE
158
+ it("rejects TRUNCATE", () => {
159
+ expect(isValid("TRUNCATE TABLE users")).toBe(false);
160
+ });
161
+
162
+ // PRAGMA
163
+ it("rejects PRAGMA", () => {
164
+ expect(isValid("PRAGMA journal_mode=WAL")).toBe(false);
165
+ });
166
+ it("rejects pragma (lowercase)", () => {
167
+ expect(isValid("pragma journal_mode")).toBe(false);
168
+ });
169
+
170
+ // ATTACH
171
+ it("rejects ATTACH", () => {
172
+ expect(isValid("ATTACH DATABASE '/tmp/test.db' AS test")).toBe(false);
173
+ });
174
+
175
+ // DETACH
176
+ it("rejects DETACH", () => {
177
+ expect(isValid("DETACH DATABASE test")).toBe(false);
178
+ });
179
+
180
+ // VACUUM
181
+ it("rejects VACUUM", () => {
182
+ expect(isValid("VACUUM")).toBe(false);
183
+ });
184
+
185
+ // REINDEX
186
+ it("rejects REINDEX", () => {
187
+ expect(isValid("REINDEX")).toBe(false);
188
+ });
189
+
190
+ // Forbidden keyword mixed into a valid-looking SELECT (injection attempt)
191
+ it("rejects SELECT with DROP in string literal", () => {
192
+ // The validator strips string literals before checking, so embedded
193
+ // keywords in string literals should not trigger rejection.
194
+ // However, the whole-statement check should still reject if the
195
+ // keyword appears outside strings/comments.
196
+ expect(isValid("SELECT 'DROP TABLE users'")).toBe(true); // keyword is inside string
197
+ });
198
+
199
+ it("rejects SELECT with forbidden keyword after string literal", () => {
200
+ // Keyword outside string → must be rejected
201
+ expect(isValid("SELECT 'hello' FROM users; DROP TABLE users")).toBe(false);
202
+ });
203
+ });
204
+
205
+ // ─── Comment stripping ────────────────────────────────────────────────────────
206
+
207
+ describe("validateReadOnlySql — comment stripping", () => {
208
+ it("accepts SELECT with trailing line comment", () => {
209
+ expect(isValid("SELECT * FROM users -- this is a comment")).toBe(true);
210
+ });
211
+
212
+ it("accepts SELECT with inline line comment", () => {
213
+ expect(isValid("SELECT * FROM users -- comment\nWHERE id = 1")).toBe(true);
214
+ });
215
+
216
+ it("accepts SELECT with block comment", () => {
217
+ expect(isValid("SELECT /* comment */ * FROM users")).toBe(true);
218
+ });
219
+
220
+ it("rejects INSERT hidden in block comment prefix", () => {
221
+ // The first significant token is read from the stripped+string-stripped
222
+ // input. Block comments are stripped first, so "INSERT /* ... */ INTO"
223
+ // should still be caught.
224
+ // Actual behaviour: "INSERT" is the first token after block comment
225
+ // stripping → rejected. This tests the defence-in-depth property.
226
+ expect(isValid("INSERT /* comment */ INTO users VALUES (1)")).toBe(false);
227
+ });
228
+
229
+ it("rejects comment-only input", () => {
230
+ expect(isValid("-- just a comment")).toBe(false);
231
+ expect(isValid("/* block comment */")).toBe(false);
232
+ });
233
+ });
234
+
235
+ // ─── String literal stripping ──────────────────────────────────────────────────
236
+
237
+ describe("validateReadOnlySql — string literal stripping", () => {
238
+ it("accepts SELECT with string literal containing keyword", () => {
239
+ // Keywords inside single-quoted strings should be stripped and not
240
+ // cause false positives.
241
+ expect(isValid("SELECT 'INSERT' FROM users")).toBe(true);
242
+ });
243
+
244
+ it("accepts SELECT with double-quoted identifier containing keyword", () => {
245
+ expect(isValid('SELECT "DROP" FROM users')).toBe(true);
246
+ });
247
+
248
+ it("rejects keyword that appears after string literal end", () => {
249
+ expect(isValid("SELECT 'x' FROM users; DROP TABLE users")).toBe(false);
250
+ });
251
+ });
252
+
253
+ // ─── Multi-statement rejection ─────────────────────────────────────────────────
254
+
255
+ describe("validateReadOnlySql — multi-statement rejection", () => {
256
+ it("rejects two SELECTs with semicolon", () => {
257
+ expect(isValid("SELECT 1; SELECT 2")).toBe(false);
258
+ });
259
+
260
+ it("rejects SELECT then DROP with semicolon", () => {
261
+ expect(isValid("SELECT * FROM users; DROP TABLE users")).toBe(false);
262
+ });
263
+
264
+ it("rejects SELECT with trailing semicolon (allowed)", () => {
265
+ expect(isValid("SELECT * FROM users;")).toBe(true);
266
+ });
267
+
268
+ it("rejects SELECT with semicolon and trailing whitespace (allowed)", () => {
269
+ expect(isValid("SELECT * FROM users; ")).toBe(true);
270
+ });
271
+
272
+ it("rejects SELECT with semicolon and extra statement after", () => {
273
+ expect(isValid("SELECT * FROM users; SELECT 2")).toBe(false);
274
+ });
275
+
276
+ it("rejects multiple semicolons", () => {
277
+ expect(isValid("SELECT 1;; SELECT 2")).toBe(false);
278
+ });
279
+ });
280
+
281
+ // ─── Empty / whitespace input ─────────────────────────────────────────────────
282
+
283
+ describe("validateReadOnlySql — empty and whitespace input", () => {
284
+ it("rejects empty string", () => {
285
+ expect(isValid("")).toBe(false);
286
+ });
287
+
288
+ it("rejects whitespace-only string", () => {
289
+ expect(isValid(" ")).toBe(false);
290
+ expect(isValid("\t\n")).toBe(false);
291
+ });
292
+
293
+ it("rejects newlines only", () => {
294
+ expect(isValid("\n\n")).toBe(false);
295
+ });
296
+ });
297
+
298
+ // ─── Result shape ─────────────────────────────────────────────────────────────
299
+
300
+ describe("validateReadOnlySql — result shape", () => {
301
+ it("returns {readonly: true} for valid SQL", () => {
302
+ const result = validateReadOnlySql("SELECT * FROM users");
303
+ expect(result.readonly).toBe(true);
304
+ });
305
+
306
+ it("returns {readonly: false, reason: string} for invalid SQL", () => {
307
+ const result = validateReadOnlySql("DROP TABLE users");
308
+ expect(result.readonly).toBe(false);
309
+ expect(typeof (result as { reason: string }).reason).toBe("string");
310
+ expect((result as { reason: string }).reason.length).toBeGreaterThan(0);
311
+ });
312
+
313
+ it("returns a non-empty reason for forbidden keyword", () => {
314
+ const result = validateReadOnlySql("INSERT INTO users VALUES (1)");
315
+ expect(result.readonly).toBe(false);
316
+ expect((result as { reason: string }).reason).toContain("INSERT");
317
+ });
318
+ });
319
+
320
+ // ─── Edge cases ───────────────────────────────────────────────────────────────
321
+
322
+ describe("validateReadOnlySql — edge cases", () => {
323
+ it("handles SQL with leading whitespace", () => {
324
+ expect(isValid(" SELECT * FROM users")).toBe(true);
325
+ });
326
+
327
+ it("handles SQL with leading parenthesis", () => {
328
+ expect(isValid("(SELECT * FROM users)")).toBe(true);
329
+ });
330
+
331
+ it("handles long valid SQL without issues", () => {
332
+ const longSql =
333
+ "SELECT u.id, u.name, o.total, o.created_at " +
334
+ "FROM users u " +
335
+ "JOIN orders o ON u.id = o.user_id " +
336
+ "WHERE u.active = 1 AND o.total > 100 " +
337
+ "ORDER BY o.created_at DESC LIMIT 50";
338
+ expect(isValid(longSql)).toBe(true);
339
+ });
340
+
341
+ it("handles unicode in SQL (emoji in string)", () => {
342
+ expect(isValid("SELECT '🚀' FROM users")).toBe(true);
343
+ });
344
+
345
+ it("handles backslash in string literal", () => {
346
+ expect(isValid("SELECT 'C:\\Users\\test' FROM users")).toBe(true);
347
+ });
348
+
349
+ it("handles escaped single quote in string", () => {
350
+ expect(isValid("SELECT name FROM users WHERE name = 'O''Brien'")).toBe(
351
+ true
352
+ );
353
+ });
354
+
355
+ it("handles doubled single quotes (SQL standard escape)", () => {
356
+ expect(isValid("SELECT 'it''s fine' FROM users")).toBe(true);
357
+ });
358
+ });
359
+
360
+ // ─── Robust JSON + secret redaction helpers ───────────────────────────────────
361
+
362
+ describe("tryParseJsonLoose", () => {
363
+ it("parses clean JSON", () => {
364
+ expect(tryParseJsonLoose('{"ok":true}')).toEqual({ ok: true });
365
+ expect(tryParseJsonLoose("[1,2]")).toEqual([1, 2]);
366
+ });
367
+
368
+ it("parses JSON after leading noise lines", () => {
369
+ const noisy = 'Fetching queues…\n{\n "queues": []\n}\n';
370
+ expect(tryParseJsonLoose(noisy)).toEqual({ queues: [] });
371
+ });
372
+
373
+ it("returns null on empty or garbage", () => {
374
+ expect(tryParseJsonLoose("")).toBeNull();
375
+ expect(tryParseJsonLoose("not json at all")).toBeNull();
376
+ });
377
+ });
378
+
379
+ describe("sanitizeCliArgsForLog", () => {
380
+ it("redacts values after set", () => {
381
+ const sanitized = sanitizeCliArgsForLog([
382
+ "config",
383
+ "kv",
384
+ "set",
385
+ "OPENAI_API_KEY",
386
+ "sk-live-super-secret",
387
+ ]);
388
+ expect(sanitized).toEqual([
389
+ "config",
390
+ "kv",
391
+ "set",
392
+ "OPENAI_API_KEY",
393
+ "[redacted]",
394
+ ]);
395
+ expect(sanitized.join(" ")).not.toContain("sk-live");
396
+ });
397
+
398
+ it("redacts Bearer tokens in free-form args", () => {
399
+ const sanitized = sanitizeCliArgsForLog([
400
+ "deploy",
401
+ "Authorization: Bearer abcdefghijklmnop",
402
+ ]);
403
+ expect(sanitized[1]).toContain("[redacted]");
404
+ expect(sanitized.join(" ")).not.toContain("abcdefghijklmnop");
405
+ });
406
+ });
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Copyright (c) 2026 HOOX · HOOX · jango-blockchained (hoox-sh)
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ import { describe, it, expect } from "bun:test";
7
+ import { copyToClipboard, copyViaSystemClipboard } from "./clipboard";
8
+ import { messageCopiedToClipboard } from "../components/ui/toast";
9
+
10
+ describe("clipboard", () => {
11
+ it("treats empty text as success without throwing", async () => {
12
+ const result = await copyToClipboard(" ", null);
13
+ expect(result.ok).toBe(true);
14
+ });
15
+
16
+ it("copyViaSystemClipboard returns a tool name or null", async () => {
17
+ const tool = await copyViaSystemClipboard("hoox-clipboard-test");
18
+ // CI may lack clipboard tools; null is acceptable
19
+ expect(tool === null || typeof tool === "string").toBe(true);
20
+ });
21
+
22
+ it("copyToClipboard falls back when no renderer is available", async () => {
23
+ const result = await copyToClipboard("hello from hoox tui", null);
24
+ // Success if system tool exists; otherwise structured failure
25
+ if (result.ok) {
26
+ expect(result.method === "osc52" || result.method === "system").toBe(
27
+ true
28
+ );
29
+ } else {
30
+ expect(result.error).toMatch(/clipboard/i);
31
+ }
32
+ });
33
+
34
+ it("accepts notify option without throwing", async () => {
35
+ // Toast singleton may no-op without a Toaster in unit tests
36
+ const result = await copyToClipboard("notify me", null, { notify: true });
37
+ expect(typeof result.ok).toBe("boolean");
38
+ });
39
+ });
40
+
41
+ describe("messageCopiedToClipboard", () => {
42
+ it("previews short text", () => {
43
+ expect(messageCopiedToClipboard("hello")).toBe("Copied: “hello”");
44
+ });
45
+
46
+ it("truncates long text", () => {
47
+ const long = "x".repeat(80);
48
+ const msg = messageCopiedToClipboard(long);
49
+ expect(msg.startsWith("Copied: “")).toBe(true);
50
+ expect(msg.endsWith("…”")).toBe(true);
51
+ expect(msg.length).toBeLessThan(long.length + 20);
52
+ });
53
+
54
+ it("collapses whitespace", () => {
55
+ expect(messageCopiedToClipboard("a\n\nb")).toBe("Copied: “a b”");
56
+ });
57
+ });
@@ -0,0 +1,180 @@
1
+ /**
2
+ * Copyright (c) 2026 HOOX · HOOX · jango-blockchained (hoox-sh)
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Clipboard helpers for the Hoox TUI.
8
+ *
9
+ * Strategy (first success wins):
10
+ * 1. OpenTUI OSC 52 via CliRenderer.copyToClipboardOSC52 (works over SSH
11
+ * when the terminal allows it)
12
+ * 2. Platform tools: wl-copy / xclip / xsel (Linux), pbcopy (macOS),
13
+ * clip (Windows)
14
+ *
15
+ * Used for auto-copy on mouse text selection.
16
+ */
17
+ import { getRendererRef } from "../hooks";
18
+ import {
19
+ toastCopiedToClipboard,
20
+ toastCopyFailed,
21
+ } from "../components/ui/toast";
22
+
23
+ /** Minimal surface we need from OpenTUI's CliRenderer (typed loosely for packages). */
24
+ export type ClipboardRenderer = {
25
+ copyToClipboardOSC52: (text: string) => boolean;
26
+ on: (event: string, listener: (...args: unknown[]) => void) => unknown;
27
+ off: (event: string, listener: (...args: unknown[]) => void) => unknown;
28
+ };
29
+
30
+ export type ClipboardResult =
31
+ | { ok: true; method: "osc52" | "system"; tool?: string }
32
+ | { ok: false; error: string };
33
+
34
+ const SYSTEM_CLIPBOARD_CANDIDATES: { bin: string; args: string[] }[] = [
35
+ { bin: "wl-copy", args: [] },
36
+ { bin: "xclip", args: ["-selection", "clipboard"] },
37
+ { bin: "xsel", args: ["--clipboard", "--input"] },
38
+ { bin: "pbcopy", args: [] },
39
+ { bin: "clip.exe", args: [] },
40
+ { bin: "clip", args: [] },
41
+ ];
42
+
43
+ /** Hang-guard for clipboard utilities (X11/Wayland can block forever). */
44
+ const CLIPBOARD_SPAWN_TIMEOUT_MS = 2_000;
45
+
46
+ /**
47
+ * Write text to the system clipboard via a known clipboard utility.
48
+ * Returns the tool name on success, or null if none worked.
49
+ * Each candidate is hard-killed after {@link CLIPBOARD_SPAWN_TIMEOUT_MS}.
50
+ */
51
+ export async function copyViaSystemClipboard(
52
+ text: string
53
+ ): Promise<string | null> {
54
+ for (const { bin, args } of SYSTEM_CLIPBOARD_CANDIDATES) {
55
+ if (!Bun.which(bin)) continue;
56
+ try {
57
+ const proc = Bun.spawn([bin, ...args], {
58
+ stdin: "pipe",
59
+ stdout: "ignore",
60
+ stderr: "ignore",
61
+ });
62
+ proc.stdin.write(text);
63
+ proc.stdin.end();
64
+
65
+ let timedOut = false;
66
+ const timer = setTimeout(() => {
67
+ timedOut = true;
68
+ try {
69
+ if (!proc.killed) proc.kill();
70
+ } catch {
71
+ /* already dead */
72
+ }
73
+ }, CLIPBOARD_SPAWN_TIMEOUT_MS);
74
+
75
+ const code = await proc.exited;
76
+ clearTimeout(timer);
77
+ if (!timedOut && code === 0) return bin;
78
+ } catch {
79
+ // try next tool
80
+ }
81
+ }
82
+ return null;
83
+ }
84
+
85
+ export interface CopyToClipboardOptions {
86
+ /**
87
+ * Show a success/error toast. Default false so generic callers stay quiet;
88
+ * auto-copy on selection opts in.
89
+ */
90
+ notify?: boolean;
91
+ }
92
+
93
+ /**
94
+ * Copy text using the best available method for the current environment.
95
+ *
96
+ * @param text - Content to copy (empty / whitespace-only is a no-op success)
97
+ * @param renderer - Optional renderer for OSC 52; falls back to getRendererRef()
98
+ * @param options - Optional `{ notify: true }` for toast feedback
99
+ */
100
+ export async function copyToClipboard(
101
+ text: string,
102
+ renderer?: ClipboardRenderer | null,
103
+ options?: CopyToClipboardOptions
104
+ ): Promise<ClipboardResult> {
105
+ if (!text || !text.trim()) {
106
+ return { ok: true, method: "osc52" };
107
+ }
108
+
109
+ const notify = options?.notify === true;
110
+
111
+ const r = (renderer ??
112
+ (getRendererRef() as ClipboardRenderer | null)) as ClipboardRenderer | null;
113
+ if (r && typeof r.copyToClipboardOSC52 === "function") {
114
+ try {
115
+ const ok = Boolean(r.copyToClipboardOSC52(text));
116
+ if (ok) {
117
+ // Still try system clipboard so local paste works even when OSC 52
118
+ // only reaches the remote terminal emulator.
119
+ void copyViaSystemClipboard(text);
120
+ if (notify) toastCopiedToClipboard(text);
121
+ return { ok: true, method: "osc52" };
122
+ }
123
+ } catch {
124
+ // fall through to system tools
125
+ }
126
+ }
127
+
128
+ const tool = await copyViaSystemClipboard(text);
129
+ if (tool) {
130
+ if (notify) toastCopiedToClipboard(text);
131
+ return { ok: true, method: "system", tool };
132
+ }
133
+
134
+ const error =
135
+ "No clipboard backend available (OSC 52 failed; install wl-copy/xclip/pbcopy)";
136
+ if (notify) toastCopyFailed(error);
137
+ return {
138
+ ok: false,
139
+ error,
140
+ };
141
+ }
142
+
143
+ /**
144
+ * Wire auto-copy when the user finishes a mouse text selection.
145
+ * Call once after createCliRenderer + setRendererRef.
146
+ *
147
+ * OpenTUI emits `"selection"` from finishSelection() with isDragging=false.
148
+ * We only copy non-empty selections and ignore pure clicks (empty text).
149
+ */
150
+ export function enableAutoCopyOnSelection(
151
+ renderer: ClipboardRenderer
152
+ ): () => void {
153
+ let lastCopied = "";
154
+ let lastAt = 0;
155
+
156
+ const onSelection = (...args: unknown[]) => {
157
+ const selection = args[0] as
158
+ | {
159
+ isDragging?: boolean;
160
+ getSelectedText?: () => string;
161
+ }
162
+ | undefined;
163
+ if (selection?.isDragging) return;
164
+ const text = selection?.getSelectedText?.() ?? "";
165
+ if (!text.trim()) return;
166
+
167
+ // Debounce identical rapid emissions
168
+ const now = Date.now();
169
+ if (text === lastCopied && now - lastAt < 400) return;
170
+ lastCopied = text;
171
+ lastAt = now;
172
+
173
+ void copyToClipboard(text, renderer, { notify: true });
174
+ };
175
+
176
+ renderer.on("selection", onSelection);
177
+ return () => {
178
+ renderer.off("selection", onSelection);
179
+ };
180
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Copyright (c) 2026 HOOX · HOOX · jango-blockchained (hoox-sh)
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Load worker dashboard.jsonc manifests for the TUI worker-settings view.
8
+ * Thin wrapper around shared filesystem discovery.
9
+ */
10
+ import {
11
+ loadDashboardManifestsFromRoot,
12
+ resolveHooxRuntimeRoot,
13
+ type WorkerDashboardManifest,
14
+ } from "@hoox-sh/hoox-shared";
15
+ import { join } from "node:path";
16
+
17
+ /**
18
+ * Discover and parse all dashboard settings manifests available in the
19
+ * current runtime monorepo (or empty list when not found).
20
+ */
21
+ export function loadDashboardSettingsManifests(
22
+ runtimeRoot?: string | null
23
+ ): WorkerDashboardManifest[] {
24
+ const root =
25
+ runtimeRoot ??
26
+ resolveHooxRuntimeRoot().root ??
27
+ // packages/tui/src/services → monorepo root
28
+ join(import.meta.dir, "..", "..", "..", "..");
29
+
30
+ return loadDashboardManifestsFromRoot(root);
31
+ }