@joewinke/jatui 0.26.0 → 0.27.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 (73) hide show
  1. package/CLAUDE.md +106 -1
  2. package/dist/actions/keyboardScope.d.ts +58 -0
  3. package/dist/actions/keyboardScope.js +110 -0
  4. package/dist/actions/listNav.d.ts +48 -3
  5. package/dist/actions/listNav.js +267 -77
  6. package/dist/actions/longpress.d.ts +37 -0
  7. package/dist/actions/longpress.js +105 -0
  8. package/dist/actions/stickToBottom.d.ts +66 -0
  9. package/dist/actions/stickToBottom.js +155 -0
  10. package/dist/actions/swipe.d.ts +64 -0
  11. package/dist/actions/swipe.js +179 -0
  12. package/dist/components/BottomSheet.svelte +280 -0
  13. package/dist/components/BottomSheet.svelte.d.ts +27 -0
  14. package/dist/components/ConfirmDialog.svelte +21 -0
  15. package/dist/components/ConfirmDialog.svelte.d.ts +11 -1
  16. package/dist/components/ConfirmModal.svelte +15 -0
  17. package/dist/components/ConfirmModal.svelte.d.ts +5 -0
  18. package/dist/components/Drawer.svelte +18 -1
  19. package/dist/components/Drawer.svelte.d.ts +9 -1
  20. package/dist/components/InputDialog.svelte +21 -0
  21. package/dist/components/InputDialog.svelte.d.ts +11 -1
  22. package/dist/components/Modal.svelte +18 -1
  23. package/dist/components/Modal.svelte.d.ts +8 -0
  24. package/dist/components/basic/Empty.svelte +173 -9
  25. package/dist/components/basic/Empty.svelte.d.ts +50 -1
  26. package/dist/components/basic/ErrorState.svelte +128 -0
  27. package/dist/components/basic/ErrorState.svelte.d.ts +46 -0
  28. package/dist/components/basic/JumpToLatest.svelte +60 -0
  29. package/dist/components/basic/JumpToLatest.svelte.d.ts +9 -0
  30. package/dist/components/basic/PageHeader.svelte +216 -0
  31. package/dist/components/basic/PageHeader.svelte.d.ts +52 -0
  32. package/dist/components/basic/ProgressButton.svelte +221 -0
  33. package/dist/components/basic/ProgressButton.svelte.d.ts +63 -0
  34. package/dist/components/graph/GraphApiBridge.svelte +36 -0
  35. package/dist/components/graph/GraphApiBridge.svelte.d.ts +9 -0
  36. package/dist/components/graph/GraphCanvas.svelte +212 -27
  37. package/dist/components/graph/GraphCanvas.svelte.d.ts +113 -19
  38. package/dist/components/graph/api.d.ts +52 -0
  39. package/dist/components/graph/api.js +66 -0
  40. package/dist/components/graph/index.d.ts +2 -0
  41. package/dist/components/graph/index.js +1 -0
  42. package/dist/components/graph/theme.js +13 -0
  43. package/dist/components/messaging/MessageItem.svelte +5 -0
  44. package/dist/components/messaging/MessageItem.svelte.d.ts +7 -0
  45. package/dist/components/table/DataTable.svelte +452 -78
  46. package/dist/components/table/DataTable.svelte.d.ts +2 -2
  47. package/dist/components/table/ManageColumnsDropdown.svelte +466 -0
  48. package/dist/components/table/ManageColumnsDropdown.svelte.d.ts +26 -0
  49. package/dist/components/table/columnStorage.d.ts +29 -0
  50. package/dist/components/table/columnStorage.js +61 -0
  51. package/dist/components/table/groupRows.d.ts +72 -0
  52. package/dist/components/table/groupRows.js +118 -0
  53. package/dist/components/table/index.d.ts +7 -1
  54. package/dist/components/table/index.js +3 -0
  55. package/dist/components/table/types.d.ts +121 -20
  56. package/dist/index.d.ts +25 -2
  57. package/dist/index.js +32 -1
  58. package/dist/utils/ansiToHtml.d.ts +83 -0
  59. package/dist/utils/ansiToHtml.js +543 -0
  60. package/dist/utils/keyboardShortcutRegistry.d.ts +332 -11
  61. package/dist/utils/keyboardShortcutRegistry.js +1070 -76
  62. package/dist/utils/pollBackoff.d.ts +83 -0
  63. package/dist/utils/pollBackoff.js +151 -0
  64. package/dist/utils/snapFlick.d.ts +59 -0
  65. package/dist/utils/snapFlick.js +66 -0
  66. package/dist/video/advisors/qr.d.ts +1 -1
  67. package/dist/virtual/createRowWindow.d.ts +75 -0
  68. package/dist/virtual/createRowWindow.js +129 -0
  69. package/dist/virtual/index.d.ts +4 -0
  70. package/dist/virtual/index.js +5 -0
  71. package/dist/virtual/rowWindowCore.d.ts +202 -0
  72. package/dist/virtual/rowWindowCore.js +510 -0
  73. package/package.json +11 -1
@@ -0,0 +1,543 @@
1
+ /**
2
+ * Convert ANSI escape codes to HTML spans with inline styles
3
+ * Supports common color codes used by Claude Code / terminal output
4
+ * Includes automatic contrast adjustment for background colors
5
+ */
6
+ // ANSI color code to CSS color mapping
7
+ const ANSI_COLORS = {
8
+ // Standard colors (30-37)
9
+ 30: '#1a1a1a', // Black
10
+ 31: '#e74c3c', // Red
11
+ 32: '#2ecc71', // Green
12
+ 33: '#f39c12', // Yellow
13
+ 34: '#3498db', // Blue
14
+ 35: '#9b59b6', // Magenta
15
+ 36: '#00bcd4', // Cyan
16
+ 37: '#ecf0f1', // White
17
+ // Bright colors (90-97)
18
+ 90: '#636e72', // Bright Black (Gray)
19
+ 91: '#ff6b6b', // Bright Red
20
+ 92: '#55efc4', // Bright Green
21
+ 93: '#ffeaa7', // Bright Yellow
22
+ 94: '#74b9ff', // Bright Blue
23
+ 95: '#fd79a8', // Bright Magenta
24
+ 96: '#81ecec', // Bright Cyan
25
+ 97: '#ffffff', // Bright White
26
+ };
27
+ // Background colors (40-47, 100-107)
28
+ const ANSI_BG_COLORS = {
29
+ 40: '#1a1a1a',
30
+ 41: '#e74c3c',
31
+ 42: '#2ecc71',
32
+ 43: '#f39c12',
33
+ 44: '#3498db',
34
+ 45: '#9b59b6',
35
+ 46: '#00bcd4',
36
+ 47: '#ecf0f1',
37
+ 100: '#636e72',
38
+ 101: '#ff6b6b',
39
+ 102: '#55efc4',
40
+ 103: '#ffeaa7',
41
+ 104: '#74b9ff',
42
+ 105: '#fd79a8',
43
+ 106: '#81ecec',
44
+ 107: '#ffffff',
45
+ };
46
+ /**
47
+ * Parse a hex color string to RGB values
48
+ */
49
+ function hexToRgb(hex) {
50
+ const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
51
+ return result
52
+ ? {
53
+ r: parseInt(result[1], 16),
54
+ g: parseInt(result[2], 16),
55
+ b: parseInt(result[3], 16),
56
+ }
57
+ : null;
58
+ }
59
+ /**
60
+ * Calculate relative luminance of a color (WCAG formula)
61
+ * Returns value between 0 (black) and 1 (white)
62
+ */
63
+ function getLuminance(r, g, b) {
64
+ const [rs, gs, bs] = [r, g, b].map((c) => {
65
+ c = c / 255;
66
+ return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
67
+ });
68
+ return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
69
+ }
70
+ /**
71
+ * Calculate contrast ratio between two colors (WCAG formula)
72
+ * Returns ratio from 1 (same color) to 21 (black vs white)
73
+ */
74
+ function getContrastRatio(fg, bg) {
75
+ const fgLum = getLuminance(fg.r, fg.g, fg.b);
76
+ const bgLum = getLuminance(bg.r, bg.g, bg.b);
77
+ const lighter = Math.max(fgLum, bgLum);
78
+ const darker = Math.min(fgLum, bgLum);
79
+ return (lighter + 0.05) / (darker + 0.05);
80
+ }
81
+ /**
82
+ * Get a contrasting foreground color for a given background
83
+ * Uses WCAG 4.5:1 contrast ratio as minimum threshold
84
+ */
85
+ function getContrastingForeground(bgColor, currentFg) {
86
+ const bg = hexToRgb(bgColor);
87
+ if (!bg)
88
+ return currentFg;
89
+ // If we have a foreground color, check if it has sufficient contrast
90
+ if (currentFg) {
91
+ const fg = hexToRgb(currentFg);
92
+ if (fg && getContrastRatio(fg, bg) >= 4.5) {
93
+ return currentFg; // Current foreground is fine
94
+ }
95
+ }
96
+ // Calculate background luminance to determine if we need light or dark text
97
+ const bgLum = getLuminance(bg.r, bg.g, bg.b);
98
+ // Dark backgrounds (luminance < 0.5) need light text
99
+ // Light backgrounds (luminance >= 0.5) need dark text
100
+ // Using a slightly lower threshold (0.4) to be more aggressive about dark text on mid-tones
101
+ if (bgLum < 0.4) {
102
+ return '#ffffff'; // White text on dark backgrounds
103
+ }
104
+ else {
105
+ return '#1a1a1a'; // Near-black text on light backgrounds
106
+ }
107
+ }
108
+ export function createDefaultAnsiState() {
109
+ return { bold: false, dim: false, italic: false, underline: false, reverse: false, color: null, bgColor: null };
110
+ }
111
+ /**
112
+ * Parse ANSI codes and update state
113
+ * Handles both single codes and extended color sequences (38;2;R;G;B and 48;2;R;G;B)
114
+ * @param codes - Array of code strings from the escape sequence
115
+ * @param state - Current ANSI state to update
116
+ */
117
+ function parseAnsiCodes(codes, state) {
118
+ let i = 0;
119
+ while (i < codes.length) {
120
+ const num = parseInt(codes[i], 10);
121
+ // Check for 24-bit color codes: 38;2;R;G;B (foreground) or 48;2;R;G;B (background)
122
+ if ((num === 38 || num === 48) && codes[i + 1] === '2' && i + 4 < codes.length) {
123
+ const r = parseInt(codes[i + 2], 10);
124
+ const g = parseInt(codes[i + 3], 10);
125
+ const b = parseInt(codes[i + 4], 10);
126
+ if (!isNaN(r) && !isNaN(g) && !isNaN(b)) {
127
+ const color = `rgb(${r},${g},${b})`;
128
+ if (num === 38) {
129
+ state.color = color;
130
+ }
131
+ else {
132
+ state.bgColor = color;
133
+ }
134
+ i += 5; // Skip 38/48, 2, R, G, B
135
+ continue;
136
+ }
137
+ }
138
+ // Check for 256-color codes: 38;5;N (foreground) or 48;5;N (background)
139
+ if ((num === 38 || num === 48) && codes[i + 1] === '5' && i + 2 < codes.length) {
140
+ const colorIndex = parseInt(codes[i + 2], 10);
141
+ if (!isNaN(colorIndex)) {
142
+ const color = get256Color(colorIndex);
143
+ if (num === 38) {
144
+ state.color = color;
145
+ }
146
+ else {
147
+ state.bgColor = color;
148
+ }
149
+ i += 3; // Skip 38/48, 5, N
150
+ continue;
151
+ }
152
+ }
153
+ // Standard single-code handling
154
+ if (num === 0) {
155
+ // Reset all
156
+ state.bold = false;
157
+ state.dim = false;
158
+ state.italic = false;
159
+ state.underline = false;
160
+ state.reverse = false;
161
+ state.color = null;
162
+ state.bgColor = null;
163
+ }
164
+ else if (num === 1) {
165
+ state.bold = true;
166
+ }
167
+ else if (num === 2) {
168
+ state.dim = true;
169
+ }
170
+ else if (num === 3) {
171
+ state.italic = true;
172
+ }
173
+ else if (num === 4) {
174
+ state.underline = true;
175
+ }
176
+ else if (num === 7) {
177
+ state.reverse = true;
178
+ }
179
+ else if (num === 22) {
180
+ state.bold = false;
181
+ state.dim = false;
182
+ }
183
+ else if (num === 23) {
184
+ state.italic = false;
185
+ }
186
+ else if (num === 24) {
187
+ state.underline = false;
188
+ }
189
+ else if (num === 27) {
190
+ state.reverse = false;
191
+ }
192
+ else if (num >= 30 && num <= 37) {
193
+ state.color = ANSI_COLORS[num];
194
+ }
195
+ else if (num === 39) {
196
+ state.color = null; // Default color
197
+ }
198
+ else if (num >= 40 && num <= 47) {
199
+ state.bgColor = ANSI_BG_COLORS[num];
200
+ }
201
+ else if (num === 49) {
202
+ state.bgColor = null; // Default background
203
+ }
204
+ else if (num >= 90 && num <= 97) {
205
+ state.color = ANSI_COLORS[num];
206
+ }
207
+ else if (num >= 100 && num <= 107) {
208
+ state.bgColor = ANSI_BG_COLORS[num];
209
+ }
210
+ i++;
211
+ }
212
+ }
213
+ /**
214
+ * Convert 256-color palette index to CSS color
215
+ * 0-7: Standard colors, 8-15: Bright colors
216
+ * 16-231: 6x6x6 color cube, 232-255: Grayscale
217
+ */
218
+ function get256Color(index) {
219
+ if (index < 8) {
220
+ // Standard colors
221
+ const colors = ['#000000', '#cd0000', '#00cd00', '#cdcd00', '#0000cd', '#cd00cd', '#00cdcd', '#e5e5e5'];
222
+ return colors[index];
223
+ }
224
+ else if (index < 16) {
225
+ // Bright colors
226
+ const colors = ['#7f7f7f', '#ff0000', '#00ff00', '#ffff00', '#5c5cff', '#ff00ff', '#00ffff', '#ffffff'];
227
+ return colors[index - 8];
228
+ }
229
+ else if (index < 232) {
230
+ // 6x6x6 color cube
231
+ const i = index - 16;
232
+ const r = Math.floor(i / 36);
233
+ const g = Math.floor((i % 36) / 6);
234
+ const b = i % 6;
235
+ const toHex = (n) => (n === 0 ? 0 : 55 + n * 40);
236
+ return `rgb(${toHex(r)},${toHex(g)},${toHex(b)})`;
237
+ }
238
+ else {
239
+ // Grayscale (232-255 -> 24 shades)
240
+ const gray = (index - 232) * 10 + 8;
241
+ return `rgb(${gray},${gray},${gray})`;
242
+ }
243
+ }
244
+ function stateToStyle(state) {
245
+ const styles = [];
246
+ // Handle reverse video by swapping foreground and background
247
+ let fgColor = state.color;
248
+ let bgColor = state.bgColor;
249
+ if (state.reverse) {
250
+ // Swap fg and bg colors
251
+ const temp = fgColor;
252
+ fgColor = bgColor || '#1a1a1a'; // Default bg if none set
253
+ bgColor = temp || '#ecf0f1'; // Default fg if none set
254
+ }
255
+ // When background color is set, ensure foreground has sufficient contrast
256
+ if (bgColor) {
257
+ styles.push(`background-color:${bgColor}`);
258
+ // Get a contrasting foreground color (either the current one if sufficient, or auto-selected)
259
+ const contrastingFg = getContrastingForeground(bgColor, fgColor);
260
+ if (contrastingFg) {
261
+ styles.push(`color:${contrastingFg}`);
262
+ }
263
+ }
264
+ else if (fgColor) {
265
+ // No background, just use the specified foreground color
266
+ styles.push(`color:${fgColor}`);
267
+ }
268
+ if (state.bold) {
269
+ styles.push('font-weight:bold');
270
+ }
271
+ if (state.dim) {
272
+ styles.push('opacity:0.6');
273
+ }
274
+ if (state.italic) {
275
+ styles.push('font-style:italic');
276
+ }
277
+ if (state.underline) {
278
+ styles.push('text-decoration:underline');
279
+ }
280
+ return styles.join(';');
281
+ }
282
+ function escapeHtml(text) {
283
+ return text
284
+ .replace(/&/g, '&amp;')
285
+ .replace(/</g, '&lt;')
286
+ .replace(/>/g, '&gt;')
287
+ .replace(/"/g, '&quot;')
288
+ .replace(/'/g, '&#039;');
289
+ }
290
+ /**
291
+ * Convert a string containing ANSI escape codes to HTML
292
+ */
293
+ export function ansiToHtml(input) {
294
+ // First, strip cursor show/hide sequences (ESC[?25l and ESC[?25h)
295
+ // These are output by chafa and other terminal tools but not needed in HTML
296
+ input = input.replace(/\x1b\[\?25[lh]/g, '');
297
+ // Match ANSI escape sequences: ESC[ followed by numbers and 'm'
298
+ const ansiRegex = /\x1b\[([0-9;]*)m/g;
299
+ const state = {
300
+ bold: false,
301
+ dim: false,
302
+ italic: false,
303
+ underline: false,
304
+ reverse: false,
305
+ color: null,
306
+ bgColor: null,
307
+ };
308
+ let result = '';
309
+ let lastIndex = 0;
310
+ let inSpan = false;
311
+ let match;
312
+ while ((match = ansiRegex.exec(input)) !== null) {
313
+ // Add text before this escape code
314
+ const textBefore = input.slice(lastIndex, match.index);
315
+ if (textBefore) {
316
+ result += escapeHtml(textBefore);
317
+ }
318
+ // Close previous span if open
319
+ if (inSpan) {
320
+ result += '</span>';
321
+ inSpan = false;
322
+ }
323
+ // Parse the ANSI codes (can be multiple separated by ;)
324
+ // Uses parseAnsiCodes which handles extended color sequences (24-bit RGB, 256-color)
325
+ const codes = match[1].split(';').filter(c => c !== '');
326
+ parseAnsiCodes(codes, state);
327
+ // Open new span if we have styles
328
+ const style = stateToStyle(state);
329
+ if (style) {
330
+ result += `<span style="${style}">`;
331
+ inSpan = true;
332
+ }
333
+ lastIndex = match.index + match[0].length;
334
+ }
335
+ // Add remaining text
336
+ const remaining = input.slice(lastIndex);
337
+ if (remaining) {
338
+ result += escapeHtml(remaining);
339
+ }
340
+ // Close final span if open
341
+ if (inSpan) {
342
+ result += '</span>';
343
+ }
344
+ return result;
345
+ }
346
+ /** Strip ANSI codes from a string (for plain text).
347
+ *
348
+ * THE one ANSI stripper for the IDE (jat-69f8v dedup): every consumer that
349
+ * needs plain text without escape codes imports this — do not write a local
350
+ * copy (scripts/grep-gate-helper-dedup.sh refuses new definitions).
351
+ *
352
+ * Bare-escape handling is a strict ECMA-48 parse (jat-ybwwn): the old rule
353
+ * \x1b[^\[]\S* ate the escape AND every following non-space character, so
354
+ * "\x1b7hello" (DECSC + text) lost "hello". Escapes are now matched
355
+ * sequence-by-sequence; text after a two-byte escape survives.
356
+ */
357
+ export function stripAnsi(input) {
358
+ return input
359
+ // SGR sequences: \x1b[...m (colors, bold, etc.)
360
+ .replace(/\x1b\[[0-9;]*m/g, '')
361
+ // CSI sequences: canonical ECMA-48 CSI — ESC [ params(0x30-0x3F)
362
+ // intermediates(0x20-0x2F) final(0x40-0x7E). Covers cursor moves, erase,
363
+ // private modes (\x1b[?25h), bracketed paste (\x1b[200~), etc.
364
+ .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '')
365
+ // OSC sequences (terminated): \x1b]...BEL or \x1b]...\x1b\\
366
+ .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '')
367
+ // OSC fragments left unterminated by a chunk/delta split (e.g. an OSC 8
368
+ // hyperlink cut mid-URL): eat ESC] plus the following run of non-space —
369
+ // it stops at the first whitespace so real text after the fragment
370
+ // survives.
371
+ .replace(/\x1b\]\S*/g, '')
372
+ // Remaining ECMA-48 escapes: ESC + optional intermediate bytes (0x20-0x2F,
373
+ // which makes charset designators \x1b(B / \x1b)B / \x1b#8 match here) +
374
+ // ONE final byte (0x30-0x7E, minus '[' so a CSI intro can never be eaten
375
+ // by this rule — CSI matched above). Covers DECSC (\x1b7), DECRC (\x1b8),
376
+ // RIS (\x1bc), DECALN (\x1b#8)…
377
+ .replace(/\x1b[\x20-\x2f]*[\x30-\x39\x3c-\x5a\x5c-\x7e]/g, '')
378
+ // Orphaned CSI fragments from delta splits (e.g., "[39m", "[1m")
379
+ .replace(/\[[\d;]*m/g, '')
380
+ // A CSI intro truncated at end-of-string (chunk split cut right after
381
+ // "ESC[" or a lone "ESC"): drop the dangling intro, keep the text before it.
382
+ .replace(/\x1b\[?$/, '')
383
+ // Any ESC that survived the sequence rules is a dangling fragment
384
+ // (truncated at a chunk boundary, or followed by whitespace/control
385
+ // bytes). Drop the bare ESC itself; whatever text follows it stays.
386
+ .replace(/\x1b/g, '')
387
+ // CR: tmux captures carry \r (line-end and progress-bar overwrites);
388
+ // plain-text consumers never want it. Safe for the HTML renderer too —
389
+ // HTML collapses it anyway.
390
+ .replace(/\r/g, '');
391
+ }
392
+ /**
393
+ * URL regex pattern for detecting clickable links
394
+ * Matches http://, https://, and localhost URLs
395
+ */
396
+ const URL_PATTERN = /(?:https?:\/\/|localhost:\d+)(?:[^\s<>"')\]]*)/gi;
397
+ /**
398
+ * Convert URLs in HTML to clickable links
399
+ * Must be called AFTER ansiToHtml to avoid breaking span tags
400
+ */
401
+ export function linkifyUrls(html) {
402
+ // Don't linkify inside existing HTML tags
403
+ // Split by tags, only linkify text content
404
+ const parts = html.split(/(<[^>]+>)/);
405
+ return parts.map(part => {
406
+ // Skip HTML tags
407
+ if (part.startsWith('<')) {
408
+ return part;
409
+ }
410
+ // Linkify text content
411
+ return part.replace(URL_PATTERN, (url) => {
412
+ // Ensure URL has protocol for the href
413
+ const href = url.startsWith('localhost') ? `http://${url}` : url;
414
+ return `<a href="${href}" target="_blank" rel="noopener noreferrer" class="terminal-link">${url}</a>`;
415
+ });
416
+ }).join('');
417
+ }
418
+ /**
419
+ * Convert ANSI to HTML and make URLs clickable
420
+ */
421
+ export function ansiToHtmlWithLinks(input) {
422
+ const html = ansiToHtml(input);
423
+ return linkifyUrls(html);
424
+ }
425
+ /**
426
+ * Convert ANSI multi-line output to HTML with per-line hierarchy classes.
427
+ * Wraps each line in <span class="tl-{kind}"> so CSS can mute tool calls
428
+ * and brighten prose without per-line DOM nodes.
429
+ *
430
+ * Intended for <pre>-based surfaces that render the whole output string
431
+ * at once (OverlayTerminal) where individual div wrappers aren't available.
432
+ */
433
+ export function ansiToHtmlWithHierarchy(input) {
434
+ const rawLines = input.split('\n');
435
+ const startState = createDefaultAnsiState();
436
+ const parsed = parseOutputLines(rawLines, startState);
437
+ return parsed
438
+ .map((line) => {
439
+ const cls = line.lineClass || 'prose';
440
+ return `<span class="tl-${cls}">${line.html}</span>`;
441
+ })
442
+ .join('\n');
443
+ }
444
+ /**
445
+ * Convert ANSI to HTML starting from a given ANSI state, returning the end state.
446
+ * Enables incremental parsing: call with the end state of the previous segment to
447
+ * continue rendering without re-processing already-seen content.
448
+ */
449
+ export function ansiToHtmlStateful(input, startState) {
450
+ input = input.replace(/\x1b\[\?25[lh]/g, '');
451
+ const ansiRegex = /\x1b\[([0-9;]*)m/g;
452
+ const state = { ...startState };
453
+ let result = '';
454
+ let lastIndex = 0;
455
+ let inSpan = false;
456
+ let match;
457
+ // If startState has active styles, open an initial span
458
+ const initialStyle = stateToStyle(state);
459
+ if (initialStyle) {
460
+ result += `<span style="${initialStyle}">`;
461
+ inSpan = true;
462
+ }
463
+ while ((match = ansiRegex.exec(input)) !== null) {
464
+ const textBefore = input.slice(lastIndex, match.index);
465
+ if (textBefore) {
466
+ result += escapeHtml(textBefore);
467
+ }
468
+ if (inSpan) {
469
+ result += '</span>';
470
+ inSpan = false;
471
+ }
472
+ const codes = match[1].split(';').filter(c => c !== '');
473
+ parseAnsiCodes(codes, state);
474
+ const style = stateToStyle(state);
475
+ if (style) {
476
+ result += `<span style="${style}">`;
477
+ inSpan = true;
478
+ }
479
+ lastIndex = match.index + match[0].length;
480
+ }
481
+ const remaining = input.slice(lastIndex);
482
+ if (remaining) {
483
+ result += escapeHtml(remaining);
484
+ }
485
+ if (inSpan) {
486
+ result += '</span>';
487
+ }
488
+ return { html: result, endState: { ...state } };
489
+ }
490
+ /**
491
+ * Classify a raw terminal line for visual hierarchy rendering.
492
+ *
493
+ * Claude Code tool calls follow a predictable ANSI pattern:
494
+ * - Tool call header: ANSI-colored ● (U+25CF) or ⏺ (U+23FA) followed by bold tool name
495
+ * - Tool result: " ⎿ " prefix (2 spaces + U+23BF)
496
+ * - Tool detail: 5-space indent + dim content (folded result body)
497
+ * - Separator: Line of ─ or ━ box-drawing characters
498
+ *
499
+ * Returns a short CSS class suffix: 'tc' | 'tr' | 'td' | 'sep' | '' (prose)
500
+ */
501
+ export function classifyLine(raw) {
502
+ // Strip leading ANSI escape sequences to inspect the visible start
503
+ const stripped = raw.replace(/^\x1b\[[0-9;]*m/, '');
504
+ const first = stripped.charAt(0);
505
+ // Tool-call header: ⏺ (U+23FA) only — the recording circle Claude Code uses for tool calls.
506
+ // ● (U+25CF) is the agent's prose/thinking text and must NOT be dimmed.
507
+ if (first === '\u23fa')
508
+ return 'tc';
509
+ // Also handle when the raw line starts with ESC[ directly before the circle
510
+ if (raw.startsWith('\u23fa'))
511
+ return 'tc';
512
+ // Tool result: " ⎿" (2 spaces + U+23BF hook)
513
+ if (raw.startsWith(' \u23bf'))
514
+ return 'tr';
515
+ // Tool detail: 5-space indent (folded result body lines), OR
516
+ // dim-prefixed result continuation lines
517
+ if (raw.startsWith(' ') && raw.length > 5)
518
+ return 'td';
519
+ // Separator lines (box-drawing horizontal rules)
520
+ const trimmed = raw.replace(/\x1b\[[0-9;]*m/g, '').trim();
521
+ if (trimmed.length > 10 && /^[─━═─\-─]+$/.test(trimmed))
522
+ return 'sep';
523
+ return '';
524
+ }
525
+ /**
526
+ * Parse an array of raw line strings into ParsedLine records.
527
+ * Each line's ANSI end state is passed as the start state for the next line,
528
+ * so color/bold/etc. carry across line boundaries correctly.
529
+ */
530
+ export function parseOutputLines(rawLines, startState) {
531
+ const result = [];
532
+ let currentState = { ...startState };
533
+ for (const line of rawLines) {
534
+ // Reset dim at each line boundary. Real terminals don't carry dim across
535
+ // newlines visibly — only the IDE's opacity:0.6 makes bleed-through noticeable.
536
+ // Colors, bold, italic, etc. legitimately persist (e.g. a coloured block of output).
537
+ const lineStartState = { ...currentState, dim: false };
538
+ const { html, endState } = ansiToHtmlStateful(line, lineStartState);
539
+ result.push({ content: line, html: linkifyUrls(html), endState: { ...endState }, lineClass: classifyLine(line) });
540
+ currentState = endState;
541
+ }
542
+ return result;
543
+ }