@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.
- package/CLAUDE.md +106 -1
- package/dist/actions/keyboardScope.d.ts +58 -0
- package/dist/actions/keyboardScope.js +110 -0
- package/dist/actions/listNav.d.ts +48 -3
- package/dist/actions/listNav.js +267 -77
- package/dist/actions/longpress.d.ts +37 -0
- package/dist/actions/longpress.js +105 -0
- package/dist/actions/stickToBottom.d.ts +66 -0
- package/dist/actions/stickToBottom.js +155 -0
- package/dist/actions/swipe.d.ts +64 -0
- package/dist/actions/swipe.js +179 -0
- package/dist/components/BottomSheet.svelte +280 -0
- package/dist/components/BottomSheet.svelte.d.ts +27 -0
- package/dist/components/ConfirmDialog.svelte +21 -0
- package/dist/components/ConfirmDialog.svelte.d.ts +11 -1
- package/dist/components/ConfirmModal.svelte +15 -0
- package/dist/components/ConfirmModal.svelte.d.ts +5 -0
- package/dist/components/Drawer.svelte +18 -1
- package/dist/components/Drawer.svelte.d.ts +9 -1
- package/dist/components/InputDialog.svelte +21 -0
- package/dist/components/InputDialog.svelte.d.ts +11 -1
- package/dist/components/Modal.svelte +18 -1
- package/dist/components/Modal.svelte.d.ts +8 -0
- package/dist/components/basic/Empty.svelte +173 -9
- package/dist/components/basic/Empty.svelte.d.ts +50 -1
- package/dist/components/basic/ErrorState.svelte +128 -0
- package/dist/components/basic/ErrorState.svelte.d.ts +46 -0
- package/dist/components/basic/JumpToLatest.svelte +60 -0
- package/dist/components/basic/JumpToLatest.svelte.d.ts +9 -0
- package/dist/components/basic/PageHeader.svelte +216 -0
- package/dist/components/basic/PageHeader.svelte.d.ts +52 -0
- package/dist/components/basic/ProgressButton.svelte +221 -0
- package/dist/components/basic/ProgressButton.svelte.d.ts +63 -0
- package/dist/components/graph/GraphApiBridge.svelte +36 -0
- package/dist/components/graph/GraphApiBridge.svelte.d.ts +9 -0
- package/dist/components/graph/GraphCanvas.svelte +212 -27
- package/dist/components/graph/GraphCanvas.svelte.d.ts +113 -19
- package/dist/components/graph/api.d.ts +52 -0
- package/dist/components/graph/api.js +66 -0
- package/dist/components/graph/index.d.ts +2 -0
- package/dist/components/graph/index.js +1 -0
- package/dist/components/graph/theme.js +13 -0
- package/dist/components/messaging/MessageItem.svelte +5 -0
- package/dist/components/messaging/MessageItem.svelte.d.ts +7 -0
- package/dist/components/table/DataTable.svelte +452 -78
- package/dist/components/table/DataTable.svelte.d.ts +2 -2
- package/dist/components/table/ManageColumnsDropdown.svelte +466 -0
- package/dist/components/table/ManageColumnsDropdown.svelte.d.ts +26 -0
- package/dist/components/table/columnStorage.d.ts +29 -0
- package/dist/components/table/columnStorage.js +61 -0
- package/dist/components/table/groupRows.d.ts +72 -0
- package/dist/components/table/groupRows.js +118 -0
- package/dist/components/table/index.d.ts +7 -1
- package/dist/components/table/index.js +3 -0
- package/dist/components/table/types.d.ts +121 -20
- package/dist/index.d.ts +25 -2
- package/dist/index.js +32 -1
- package/dist/utils/ansiToHtml.d.ts +83 -0
- package/dist/utils/ansiToHtml.js +543 -0
- package/dist/utils/keyboardShortcutRegistry.d.ts +332 -11
- package/dist/utils/keyboardShortcutRegistry.js +1070 -76
- package/dist/utils/pollBackoff.d.ts +83 -0
- package/dist/utils/pollBackoff.js +151 -0
- package/dist/utils/snapFlick.d.ts +59 -0
- package/dist/utils/snapFlick.js +66 -0
- package/dist/video/advisors/qr.d.ts +1 -1
- package/dist/virtual/createRowWindow.d.ts +75 -0
- package/dist/virtual/createRowWindow.js +129 -0
- package/dist/virtual/index.d.ts +4 -0
- package/dist/virtual/index.js +5 -0
- package/dist/virtual/rowWindowCore.d.ts +202 -0
- package/dist/virtual/rowWindowCore.js +510 -0
- 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, '&')
|
|
285
|
+
.replace(/</g, '<')
|
|
286
|
+
.replace(/>/g, '>')
|
|
287
|
+
.replace(/"/g, '"')
|
|
288
|
+
.replace(/'/g, ''');
|
|
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
|
+
}
|