@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,102 @@
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
+ * ActionBar Component — Footer bar with action buttons
9
+ * and unsaved changes counter.
10
+ */
11
+ import { Colors } from "@hoox-sh/hoox-shared";
12
+
13
+ // ─── Types ────────────────────────────────────────────────────────────────────
14
+
15
+ interface ActionBarProps {
16
+ unsavedCount: number;
17
+ hasSelectedFile: boolean;
18
+ hasErrors: boolean;
19
+ onSave: () => void;
20
+ onValidate: () => void;
21
+ onDiff: () => void;
22
+ onFormat: () => void;
23
+ }
24
+
25
+ // ─── Component ────────────────────────────────────────────────────────────────
26
+
27
+ export function ActionBar({
28
+ unsavedCount,
29
+ hasSelectedFile,
30
+ hasErrors,
31
+ onSave,
32
+ onValidate,
33
+ onDiff,
34
+ onFormat,
35
+ }: ActionBarProps) {
36
+ const disabledColor = Colors.dim;
37
+ const enabledColor = Colors.foreground;
38
+ const accentColor = Colors.accent;
39
+ // Fail-closed UX: cannot click Save when local syntax errors exist.
40
+ const canSave = hasSelectedFile && unsavedCount > 0 && !hasErrors;
41
+
42
+ return (
43
+ <box
44
+ flexDirection="row"
45
+ justifyContent="space-between"
46
+ paddingTop={0}
47
+ paddingBottom={0}
48
+ paddingLeft={1}
49
+ paddingRight={1}
50
+ >
51
+ {/* Action buttons */}
52
+ <box flexDirection="row" gap={2}>
53
+ <text
54
+ fg={canSave ? accentColor : disabledColor}
55
+ bold={canSave}
56
+ dim={!canSave}
57
+ onMouseUp={canSave ? onSave : undefined}
58
+ >
59
+ [Save]
60
+ </text>
61
+ <text
62
+ fg={hasSelectedFile ? accentColor : disabledColor}
63
+ bold={hasSelectedFile}
64
+ dim={!hasSelectedFile}
65
+ onMouseUp={onValidate}
66
+ >
67
+ [Validate]
68
+ </text>
69
+ <text
70
+ fg={
71
+ hasSelectedFile && unsavedCount > 0 ? enabledColor : disabledColor
72
+ }
73
+ dim={!hasSelectedFile || unsavedCount === 0}
74
+ onMouseUp={onDiff}
75
+ >
76
+ [Diff]
77
+ </text>
78
+ <text
79
+ fg={hasSelectedFile ? enabledColor : disabledColor}
80
+ dim={!hasSelectedFile}
81
+ onMouseUp={onFormat}
82
+ >
83
+ [Format]
84
+ </text>
85
+ </box>
86
+
87
+ {/* Unsaved changes counter */}
88
+ <box flexDirection="row" gap={1}>
89
+ {unsavedCount > 0 && (
90
+ <text fg={Colors.warning} bold>
91
+ ⚡ {unsavedCount} unsaved change{unsavedCount > 1 ? "s" : ""}
92
+ </text>
93
+ )}
94
+ {unsavedCount === 0 && hasSelectedFile && (
95
+ <text fg={Colors.success} dim>
96
+ ✓ Saved
97
+ </text>
98
+ )}
99
+ </box>
100
+ </box>
101
+ );
102
+ }
@@ -0,0 +1,597 @@
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
+ * CodeEditor Component — Scrollable text editor with line numbers
9
+ * and basic TOML/JSON syntax highlighting via design tokens.
10
+ */
11
+ import { useMemo } from "react";
12
+ import { Colors } from "@hoox-sh/hoox-shared";
13
+ import { parse as parseToml, TomlError } from "smol-toml";
14
+ import type { SyntaxErrorEntry, TokenSpan, FileType } from "./types";
15
+
16
+ // ─── Syntax Highlighting — TOML ───────────────────────────────────────────────
17
+
18
+ /** Simple TOML token types recognized by the highlighter. */
19
+ type TomlTokenType =
20
+ | "comment"
21
+ | "section"
22
+ | "key"
23
+ | "equals"
24
+ | "value"
25
+ | "string"
26
+ | "number"
27
+ | "boolean"
28
+ | "whitespace"
29
+ | "error";
30
+
31
+ /** Color map for TOML token types. */
32
+ const TOML_COLORS: Record<TomlTokenType, { fg: string; bold: boolean }> = {
33
+ comment: { fg: Colors.muted, bold: false },
34
+ section: { fg: Colors.accent, bold: true },
35
+ key: { fg: Colors.accent, bold: false },
36
+ equals: { fg: Colors.foreground, bold: false },
37
+ value: { fg: Colors.foreground, bold: false },
38
+ string: { fg: Colors.info, bold: false },
39
+ number: { fg: Colors.foreground, bold: false },
40
+ boolean: { fg: Colors.info, bold: false },
41
+ whitespace: { fg: Colors.foreground, bold: false },
42
+ error: { fg: Colors.error, bold: true },
43
+ };
44
+
45
+ /**
46
+ * Tokenize a single line of TOML text into colored spans.
47
+ *
48
+ * Priority order:
49
+ * 1. Full-line comment (# at start or after whitespace)
50
+ * 2. Section header ([...])
51
+ * 3. Inline comment (trailing #)
52
+ * 4. key = value with string/number/boolean detection
53
+ */
54
+ export function tokenizeTomlLine(line: string): TokenSpan[] {
55
+ const spans: TokenSpan[] = [];
56
+ const trimmed = line.trimStart();
57
+ const leadingWsLen = line.length - trimmed.length;
58
+
59
+ // Leading whitespace
60
+ if (leadingWsLen > 0) {
61
+ spans.push({
62
+ text: line.slice(0, leadingWsLen),
63
+ color: TOML_COLORS.whitespace.fg,
64
+ bold: false,
65
+ });
66
+ }
67
+
68
+ if (trimmed.length === 0) return spans;
69
+
70
+ // Full-line comment
71
+ if (trimmed.startsWith("#")) {
72
+ spans.push({ text: trimmed, color: TOML_COLORS.comment.fg, bold: false });
73
+ return spans;
74
+ }
75
+
76
+ // Section header [section.name]
77
+ if (trimmed.startsWith("[")) {
78
+ const end = trimmed.indexOf("]");
79
+ if (end !== -1) {
80
+ spans.push({
81
+ text: trimmed.slice(0, end + 1),
82
+ color: TOML_COLORS.section.fg,
83
+ bold: true,
84
+ });
85
+ const rest = trimmed.slice(end + 1);
86
+ if (rest.length > 0) {
87
+ if (rest.trimStart().startsWith("#")) {
88
+ spans.push({
89
+ text: rest,
90
+ color: TOML_COLORS.comment.fg,
91
+ bold: false,
92
+ });
93
+ } else {
94
+ spans.push({
95
+ text: rest,
96
+ color: TOML_COLORS.whitespace.fg,
97
+ bold: false,
98
+ });
99
+ }
100
+ }
101
+ return spans;
102
+ }
103
+ }
104
+
105
+ // key = value
106
+ const eqIdx = trimmed.indexOf("=");
107
+ if (eqIdx !== -1) {
108
+ const keyPart = trimmed.slice(0, eqIdx).trimEnd();
109
+ // Check for trailing # comment after =
110
+ const postEq = trimmed.slice(eqIdx + 1);
111
+ const commentIdx = findCommentIndex(postEq);
112
+ const valuePart =
113
+ commentIdx !== -1 ? postEq.slice(0, commentIdx).trim() : postEq.trim();
114
+ const commentPart = commentIdx !== -1 ? postEq.slice(commentIdx) : "";
115
+
116
+ // Key
117
+ spans.push({ text: keyPart, color: TOML_COLORS.key.fg, bold: false });
118
+ // Equals
119
+ spans.push({ text: " = ", color: TOML_COLORS.equals.fg, bold: false });
120
+
121
+ // Value (detect type)
122
+ if (valuePart.startsWith('"') || valuePart.startsWith("'")) {
123
+ spans.push({
124
+ text: valuePart,
125
+ color: TOML_COLORS.string.fg,
126
+ bold: false,
127
+ });
128
+ } else if (valuePart === "true" || valuePart === "false") {
129
+ spans.push({
130
+ text: valuePart,
131
+ color: TOML_COLORS.boolean.fg,
132
+ bold: false,
133
+ });
134
+ } else if (/^-?\d+(\.\d+)?(e[+-]?\d+)?$/.test(valuePart)) {
135
+ spans.push({
136
+ text: valuePart,
137
+ color: TOML_COLORS.number.fg,
138
+ bold: false,
139
+ });
140
+ } else {
141
+ spans.push({ text: valuePart, color: TOML_COLORS.value.fg, bold: false });
142
+ }
143
+
144
+ if (commentPart) {
145
+ spans.push({
146
+ text: commentPart,
147
+ color: TOML_COLORS.comment.fg,
148
+ bold: false,
149
+ });
150
+ }
151
+ return spans;
152
+ }
153
+
154
+ // Fallback: plain text
155
+ spans.push({ text: trimmed, color: TOML_COLORS.value.fg, bold: false });
156
+ return spans;
157
+ }
158
+
159
+ // ─── Syntax Highlighting — JSON ───────────────────────────────────────────────
160
+
161
+ /** Color map for JSON token types. */
162
+ const JSON_COLORS: Record<string, { fg: string; bold: boolean }> = {
163
+ brace: { fg: Colors.foreground, bold: false },
164
+ key: { fg: Colors.accent, bold: false },
165
+ colon: { fg: Colors.foreground, bold: false },
166
+ string: { fg: Colors.info, bold: false },
167
+ number: { fg: Colors.foreground, bold: false },
168
+ boolean: { fg: Colors.info, bold: false },
169
+ null: { fg: Colors.muted, bold: false },
170
+ comma: { fg: Colors.foreground, bold: false },
171
+ whitespace: { fg: Colors.foreground, bold: false },
172
+ error: { fg: Colors.error, bold: true },
173
+ comment: { fg: Colors.muted, bold: false },
174
+ };
175
+
176
+ /**
177
+ * Tokenize a single line of JSON text into colored spans.
178
+ */
179
+ export function tokenizeJsonLine(line: string): TokenSpan[] {
180
+ const trimmed = line.trimStart();
181
+ const leadingWsLen = line.length - trimmed.length;
182
+ const spans: TokenSpan[] = [];
183
+
184
+ if (leadingWsLen > 0) {
185
+ spans.push({
186
+ text: line.slice(0, leadingWsLen),
187
+ color: JSON_COLORS.whitespace.fg,
188
+ bold: false,
189
+ });
190
+ }
191
+
192
+ if (trimmed.length === 0) return spans;
193
+
194
+ // // or /* comment */ style comments (non-standard JSON, but render them)
195
+ if (
196
+ trimmed.startsWith("//") ||
197
+ trimmed.startsWith("/*") ||
198
+ trimmed.startsWith("#")
199
+ ) {
200
+ spans.push({ text: trimmed, color: JSON_COLORS.comment.fg, bold: false });
201
+ return spans;
202
+ }
203
+
204
+ // Braces/brackets at start
205
+ if ("{]}[".includes(trimmed[0])) {
206
+ spans.push({ text: trimmed[0], color: JSON_COLORS.brace.fg, bold: true });
207
+ const rest = tokenizeJsonLine(trimmed.slice(1));
208
+ spans.push(...rest);
209
+ return spans;
210
+ }
211
+
212
+ // Trailing comma/brace
213
+ if (
214
+ trimmed.endsWith(",") ||
215
+ trimmed.endsWith("{") ||
216
+ trimmed.endsWith("}") ||
217
+ trimmed.endsWith("[") ||
218
+ trimmed.endsWith("]")
219
+ ) {
220
+ const last = trimmed[trimmed.length - 1];
221
+ const prefix = trimmed.slice(0, -1);
222
+ const prefixSpans = tokenizeJsonLine(prefix);
223
+ spans.push(...prefixSpans);
224
+ const isBrace = "{}[]".includes(last);
225
+ spans.push({
226
+ text: last,
227
+ color: isBrace ? JSON_COLORS.brace.fg : JSON_COLORS.comma.fg,
228
+ bold: isBrace,
229
+ });
230
+ return spans;
231
+ }
232
+
233
+ // "key": value pattern
234
+ const keyMatch = trimmed.match(/^"([^"\\]|\\.)*"\s*:/);
235
+ if (keyMatch) {
236
+ const keyStr = keyMatch[0];
237
+ spans.push({
238
+ text: keyStr.slice(0, keyStr.indexOf(":")),
239
+ color: JSON_COLORS.key.fg,
240
+ bold: false,
241
+ });
242
+ spans.push({
243
+ text: keyStr.slice(keyStr.indexOf(":")),
244
+ color: JSON_COLORS.colon.fg,
245
+ bold: false,
246
+ });
247
+ const rest = tokenizeJsonLine(trimmed.slice(keyMatch[0].length));
248
+ spans.push(...rest);
249
+ return spans;
250
+ }
251
+
252
+ // String value
253
+ if (trimmed.startsWith('"')) {
254
+ const endIdx = findStringEnd(trimmed, 0);
255
+ if (endIdx !== -1) {
256
+ spans.push({
257
+ text: trimmed.slice(0, endIdx + 1),
258
+ color: JSON_COLORS.string.fg,
259
+ bold: false,
260
+ });
261
+ const rest = tokenizeJsonLine(trimmed.slice(endIdx + 1));
262
+ spans.push(...rest);
263
+ } else {
264
+ spans.push({ text: trimmed, color: JSON_COLORS.string.fg, bold: false });
265
+ }
266
+ return spans;
267
+ }
268
+
269
+ // Number/boolean/null
270
+ const wordMatch = trimmed.match(
271
+ /^(-?\d+(\.\d+)?([eE][+-]?\d+)?|true|false|null)/
272
+ );
273
+ if (wordMatch) {
274
+ const word = wordMatch[0];
275
+ const color =
276
+ word === "null"
277
+ ? JSON_COLORS.null.fg
278
+ : word === "true" || word === "false"
279
+ ? JSON_COLORS.boolean.fg
280
+ : JSON_COLORS.number.fg;
281
+ spans.push({ text: word, color, bold: false });
282
+ const rest = tokenizeJsonLine(trimmed.slice(word.length));
283
+ spans.push(...rest);
284
+ return spans;
285
+ }
286
+
287
+ // Fallback
288
+ spans.push({ text: trimmed, color: JSON_COLORS.whitespace.fg, bold: false });
289
+ return spans;
290
+ }
291
+
292
+ // ─── Low-level helpers ───────────────────────────────────────────────────────
293
+
294
+ /** Find the start of an inline comment in TOML (# not inside a string). */
295
+ function findCommentIndex(s: string): number {
296
+ let inString: false | "'" | '"' = false;
297
+ for (let i = 0; i < s.length; i++) {
298
+ const ch = s[i];
299
+ if (inString) {
300
+ if (ch === "\\" && i + 1 < s.length) {
301
+ i++;
302
+ continue;
303
+ }
304
+ if (ch === inString) {
305
+ inString = false;
306
+ continue;
307
+ }
308
+ } else {
309
+ if (ch === '"' || ch === "'") {
310
+ inString = ch as "'" | '"';
311
+ continue;
312
+ }
313
+ if (ch === "#") return i;
314
+ }
315
+ }
316
+ return -1;
317
+ }
318
+
319
+ /** Find the end of a JSON string starting at idx (handles \"). */
320
+ function findStringEnd(s: string, startIdx: number): number {
321
+ const quote = s[startIdx];
322
+ for (let i = startIdx + 1; i < s.length; i++) {
323
+ if (s[i] === "\\" && i + 1 < s.length) {
324
+ i++;
325
+ continue;
326
+ }
327
+ if (s[i] === quote) return i;
328
+ }
329
+ return -1;
330
+ }
331
+
332
+ /** Detect file type from path extension. */
333
+ export function detectFileType(path: string): FileType {
334
+ const name = path.toLowerCase();
335
+ if (name.endsWith(".toml")) return "toml";
336
+ if (name.endsWith(".json")) return "json";
337
+ if (name.endsWith(".env") || name.startsWith(".")) return "env";
338
+ return "unknown";
339
+ }
340
+
341
+ /** Tokenize a line based on file type. */
342
+ function tokenizeLine(
343
+ line: string,
344
+ fileType: FileType,
345
+ _lineNum: number,
346
+ _syntaxErrors: SyntaxErrorEntry[]
347
+ ): TokenSpan[] {
348
+ if (fileType === "toml" || fileType === "env") {
349
+ return tokenizeTomlLine(line);
350
+ }
351
+ if (fileType === "json") {
352
+ return tokenizeJsonLine(line);
353
+ }
354
+ return [{ text: line, color: Colors.foreground, bold: false }];
355
+ }
356
+
357
+ // ─── Syntax Validation ───────────────────────────────────────────────────────
358
+
359
+ /**
360
+ * Map a smol-toml parser error to our SyntaxErrorEntry shape.
361
+ * Falls back to a generic 1/0 entry if the error is not a TomlError.
362
+ */
363
+ function tomlErrorToEntry(err: unknown): SyntaxErrorEntry {
364
+ if (err instanceof TomlError) {
365
+ return {
366
+ line: err.line,
367
+ column: err.column,
368
+ message: err.message,
369
+ };
370
+ }
371
+ const msg = err instanceof Error ? err.message : String(err);
372
+ return {
373
+ line: 1,
374
+ column: 0,
375
+ message: `TOML parser error: ${msg}`,
376
+ };
377
+ }
378
+
379
+ /**
380
+ * Validate TOML or JSON syntax and return an array of errors.
381
+ * For JSON: uses JSON.parse with detailed error extraction.
382
+ * For TOML: uses smol-toml (full TOML 1.1.0 parser) for accurate
383
+ * line/column errors. Multi-line strings, arrays, inline tables
384
+ * and every other TOML construct are handled by the parser — no
385
+ * hand-rolled bracket counting.
386
+ */
387
+ export function validateSyntax(
388
+ content: string,
389
+ fileType: FileType
390
+ ): SyntaxErrorEntry[] {
391
+ const errors: SyntaxErrorEntry[] = [];
392
+
393
+ if (fileType === "json") {
394
+ try {
395
+ JSON.parse(content);
396
+ } catch (e) {
397
+ const msg = e instanceof Error ? e.message : String(e);
398
+ // Try to extract line/column from parser error
399
+ const lineMatch = msg.match(/line\s+(\d+)/i);
400
+ const colMatch =
401
+ msg.match(/column\s+(\d+)/i) ?? msg.match(/position\s+(\d+)/i);
402
+ errors.push({
403
+ line: lineMatch ? parseInt(lineMatch[1]) : 1,
404
+ column: colMatch ? parseInt(colMatch[1]) : 0,
405
+ message: msg,
406
+ });
407
+ }
408
+ }
409
+
410
+ if (fileType === "toml") {
411
+ try {
412
+ // smol-toml is a full TOML 1.1.0 parser — supports multi-line
413
+ // strings ("""..."""), literal strings, arrays, inline tables,
414
+ // arrays of tables, dotted keys, dates/times, etc. Throws
415
+ // TomlError on the first invalid construct with 1-based
416
+ // line/column and a descriptive message.
417
+ parseToml(content);
418
+ } catch (e) {
419
+ errors.push(tomlErrorToEntry(e));
420
+ }
421
+ }
422
+
423
+ return errors;
424
+ }
425
+
426
+ /** Format TOML content (basic: normalize whitespace, sort sections). */
427
+ export function formatContent(content: string, fileType: FileType): string {
428
+ if (fileType === "json") {
429
+ try {
430
+ const parsed = JSON.parse(content);
431
+ return JSON.stringify(parsed, null, 2) + "\n";
432
+ } catch {
433
+ // If invalid JSON, return as-is
434
+ return content;
435
+ }
436
+ }
437
+ // For TOML: trim trailing whitespace, ensure single trailing newline
438
+ return (
439
+ content
440
+ .split("\n")
441
+ .map((l) => l.trimEnd())
442
+ .join("\n")
443
+ .trimEnd() + "\n"
444
+ );
445
+ }
446
+
447
+ // ─── CodeEditor Component ────────────────────────────────────────────────────
448
+
449
+ interface CodeEditorProps {
450
+ content: string;
451
+ fileType: FileType;
452
+ fileName: string | null;
453
+ syntaxErrors: SyntaxErrorEntry[];
454
+ scrollOffset: number;
455
+ /** Cap rendered lines (perf); remaining lines summarized at bottom. */
456
+ maxDisplayLines?: number;
457
+ }
458
+
459
+ export function CodeEditor({
460
+ content,
461
+ fileType,
462
+ fileName,
463
+ syntaxErrors,
464
+ scrollOffset: _scrollOffset,
465
+ maxDisplayLines = 5_000,
466
+ }: CodeEditorProps) {
467
+ const lines = useMemo(() => content.split("\n"), [content]);
468
+ const truncated = lines.length > maxDisplayLines;
469
+ const visibleLines = useMemo(
470
+ () => (truncated ? lines.slice(0, maxDisplayLines) : lines),
471
+ [lines, maxDisplayLines, truncated]
472
+ );
473
+
474
+ // Build a set of error line numbers for inline highlighting
475
+ const errorLineSet = useMemo(() => {
476
+ const set = new Set<number>();
477
+ for (const err of syntaxErrors) set.add(err.line);
478
+ return set;
479
+ }, [syntaxErrors]);
480
+
481
+ // Compute visible lines based on scroll offset
482
+ const maxLineWidth = String(lines.length).length;
483
+ const paddingLeft = maxLineWidth + 3; // " N │ "
484
+
485
+ return (
486
+ <box
487
+ flexDirection="column"
488
+ flexGrow={1}
489
+ border={true}
490
+ borderStyle="single"
491
+ borderColor={Colors.border}
492
+ paddingX={1}
493
+ paddingY={0}
494
+ >
495
+ {/* Header — file name (view-first: free typing not supported) */}
496
+ <box paddingLeft={1} paddingRight={1} paddingTop={0} paddingBottom={0}>
497
+ <text fg={Colors.foreground}>
498
+ {fileName
499
+ ? `Viewing: ${fileName}${truncated ? ` (${lines.length} lines, showing first ${maxDisplayLines})` : ""}`
500
+ : "No file selected"}
501
+ </text>
502
+ </box>
503
+
504
+ <text fg={Colors.border} dim>
505
+ {"─".repeat(60)}
506
+ </text>
507
+
508
+ {/* Editor content with line numbers */}
509
+ {fileName ? (
510
+ <scrollbox width="100%" flexGrow={1} border={false}>
511
+ {visibleLines.length === 0 ? (
512
+ <text fg={Colors.muted} dim>
513
+ {" ".repeat(paddingLeft)}Empty file
514
+ </text>
515
+ ) : (
516
+ <>
517
+ {visibleLines.map((line, idx) => {
518
+ const lineNum = idx + 1;
519
+ const lineNumStr = String(lineNum).padStart(maxLineWidth);
520
+ const hasError = errorLineSet.has(lineNum);
521
+ const tokens = tokenizeLine(line, fileType, lineNum, []);
522
+
523
+ return (
524
+ <box key={idx} flexDirection="row" gap={0}>
525
+ {/* Line number */}
526
+ <text
527
+ fg={hasError ? Colors.error : Colors.muted}
528
+ dim={!hasError}
529
+ >
530
+ {lineNumStr}
531
+ </text>
532
+ {/* Separator */}
533
+ <text fg={Colors.border} dim>
534
+ {" │ "}
535
+ </text>
536
+ {/* Tokenized line content */}
537
+ <text fg={Colors.foreground}>
538
+ {tokens.map((span, si) => (
539
+ <span key={si} fg={span.color}>
540
+ {span.text}
541
+ </span>
542
+ ))}
543
+ </text>
544
+ {/* Error indicator */}
545
+ {hasError && (
546
+ <text fg={Colors.error} bold>
547
+ {" ←"}
548
+ </text>
549
+ )}
550
+ </box>
551
+ );
552
+ })}
553
+ {truncated && (
554
+ <text fg={Colors.warning} dim>
555
+ … {lines.length - maxDisplayLines} more lines not rendered
556
+ (edit externally for full file)
557
+ </text>
558
+ )}
559
+ </>
560
+ )}
561
+ </scrollbox>
562
+ ) : (
563
+ <box flexDirection="column" flexGrow={1} padding={2} gap={1}>
564
+ <text fg={Colors.muted} dim>
565
+ Select a file from the tree to view (Format / Save available)
566
+ </text>
567
+ <text fg={Colors.dim} dim>
568
+ ↑↓ navigate · Enter select · Tab switch pane · Ctrl+S save
569
+ </text>
570
+ </box>
571
+ )}
572
+
573
+ {/* Syntax error summary (messages scrubbed of secret-like patterns) */}
574
+ {syntaxErrors.length > 0 && (
575
+ <box flexDirection="column" paddingTop={0} paddingLeft={1}>
576
+ <text fg={Colors.error} bold>
577
+ ⚠ {syntaxErrors.length} syntax error
578
+ {syntaxErrors.length > 1 ? "s" : ""}:
579
+ </text>
580
+ {syntaxErrors.slice(0, 3).map((err, i) => (
581
+ <text key={i} fg={Colors.error}>
582
+ Ln {err.line}, Col {err.column}:{" "}
583
+ {err.message.length > 120
584
+ ? err.message.slice(0, 117) + "…"
585
+ : err.message}
586
+ </text>
587
+ ))}
588
+ {syntaxErrors.length > 3 && (
589
+ <text fg={Colors.muted} dim>
590
+ ... and {syntaxErrors.length - 3} more
591
+ </text>
592
+ )}
593
+ </box>
594
+ )}
595
+ </box>
596
+ );
597
+ }