linecraft 0.2.4 → 0.2.5
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/README.md +41 -7
- package/lib/components/code-debug.d.ts +2 -0
- package/lib/components/code-debug.d.ts.map +1 -1
- package/lib/components/code-debug.js +224 -255
- package/lib/components/code-debug.js.map +1 -1
- package/lib/components/code-debug.test.d.ts +2 -0
- package/lib/components/code-debug.test.d.ts.map +1 -0
- package/lib/components/code-debug.test.js +253 -0
- package/lib/components/code-debug.test.js.map +1 -0
- package/lib/components/styled.d.ts +1 -0
- package/lib/components/styled.d.ts.map +1 -1
- package/lib/components/styled.js +36 -7
- package/lib/components/styled.js.map +1 -1
- package/lib/layout/grid.d.ts +3 -2
- package/lib/layout/grid.d.ts.map +1 -1
- package/lib/layout/grid.js +93 -45
- package/lib/layout/grid.js.map +1 -1
- package/lib/layout/grid.test.js +67 -0
- package/lib/layout/grid.test.js.map +1 -1
- package/lib/native/diff.test.js.map +1 -1
- package/lib/native/region-renderer.d.ts +1 -0
- package/lib/native/region-renderer.d.ts.map +1 -1
- package/lib/native/region-renderer.js +31 -1
- package/lib/native/region-renderer.js.map +1 -1
- package/lib/region.d.ts.map +1 -1
- package/lib/region.js +0 -46
- package/lib/region.js.map +1 -1
- package/lib/utils/cursor-position.d.ts.map +1 -1
- package/lib/utils/cursor-position.js +4 -13
- package/lib/utils/cursor-position.js.map +1 -1
- package/lib/utils/text.d.ts +64 -2
- package/lib/utils/text.d.ts.map +1 -1
- package/lib/utils/text.js +670 -102
- package/lib/utils/text.js.map +1 -1
- package/lib/utils/text.test.d.ts +2 -0
- package/lib/utils/text.test.d.ts.map +1 -0
- package/lib/utils/text.test.js +237 -0
- package/lib/utils/text.test.js.map +1 -0
- package/lib/utils/wait-for-spacebar.d.ts.map +1 -1
- package/lib/utils/wait-for-spacebar.js +0 -7
- package/lib/utils/wait-for-spacebar.js.map +1 -1
- package/package.json +1 -1
package/lib/utils/text.js
CHANGED
|
@@ -12,15 +12,85 @@
|
|
|
12
12
|
* stripAnsi('\x1b[31mHello\x1b[0m') // 'Hello'
|
|
13
13
|
*/
|
|
14
14
|
export function stripAnsi(text) {
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
// Remove all ANSI escape sequences (SGR and OSC) by iterating and skipping them
|
|
16
|
+
let result = '';
|
|
17
|
+
let idx = 0;
|
|
18
|
+
while (idx < text.length) {
|
|
19
|
+
if (text[idx] === '\x1b') {
|
|
20
|
+
// Skip the escape sequence
|
|
21
|
+
const nextIdx = skipEscapeSequence(text, idx);
|
|
22
|
+
idx = nextIdx;
|
|
23
|
+
}
|
|
24
|
+
else {
|
|
25
|
+
// Regular character, keep it
|
|
26
|
+
result += text[idx];
|
|
27
|
+
idx++;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return result;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Skip escape sequences (ANSI SGR, OSC 8, and other OSC sequences)
|
|
34
|
+
*
|
|
35
|
+
* @param text - Text that may contain escape sequences
|
|
36
|
+
* @param idx - Current index in the text
|
|
37
|
+
* @returns Next index after the escape sequence, or idx+1 if not an escape sequence
|
|
38
|
+
*/
|
|
39
|
+
function skipEscapeSequence(text, idx) {
|
|
40
|
+
if (idx >= text.length || text[idx] !== '\x1b') {
|
|
41
|
+
return idx; // Not an escape sequence, return same index so caller can count the character
|
|
42
|
+
}
|
|
43
|
+
if (idx + 1 < text.length && text[idx + 1] === '[') {
|
|
44
|
+
// ANSI SGR sequence: \x1b[<numbers>m
|
|
45
|
+
let end = idx + 2;
|
|
46
|
+
while (end < text.length && text[end] !== 'm') {
|
|
47
|
+
end++;
|
|
48
|
+
}
|
|
49
|
+
if (end < text.length) {
|
|
50
|
+
end++;
|
|
51
|
+
}
|
|
52
|
+
return end;
|
|
53
|
+
}
|
|
54
|
+
else if (idx + 1 < text.length && text[idx + 1] === ']') {
|
|
55
|
+
// OSC sequence: \x1b]8;;<url>\x1b\\ or \x1b]8;;\x1b\\
|
|
56
|
+
if (idx + 4 < text.length && text.substring(idx, idx + 4) === '\x1b]8;') {
|
|
57
|
+
// OSC 8 hyperlink sequence
|
|
58
|
+
let end = idx + 4;
|
|
59
|
+
while (end < text.length - 1) {
|
|
60
|
+
if (text[end] === '\x1b' && text[end + 1] === '\\') {
|
|
61
|
+
end += 2;
|
|
62
|
+
break;
|
|
63
|
+
}
|
|
64
|
+
end++;
|
|
65
|
+
}
|
|
66
|
+
return end;
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
// Other OSC sequence
|
|
70
|
+
let end = idx + 2;
|
|
71
|
+
while (end < text.length && text[end] !== '\x07' && text[end] !== '\x1b') {
|
|
72
|
+
end++;
|
|
73
|
+
}
|
|
74
|
+
if (end < text.length && text[end] === '\x07') {
|
|
75
|
+
end++;
|
|
76
|
+
}
|
|
77
|
+
else if (end < text.length && text[end] === '\x1b' && end + 1 < text.length && text[end + 1] === '\\') {
|
|
78
|
+
end += 2;
|
|
79
|
+
}
|
|
80
|
+
return end;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
// Unknown escape sequence, skip the escape character
|
|
85
|
+
return idx + 1;
|
|
86
|
+
}
|
|
17
87
|
}
|
|
18
88
|
/**
|
|
19
89
|
* Truncate text to a maximum visual width while preserving ANSI escape codes
|
|
20
90
|
*
|
|
21
91
|
* This function truncates text based on its visual width (ignoring ANSI codes),
|
|
22
|
-
* but preserves all ANSI escape sequences in the output.
|
|
23
|
-
*
|
|
92
|
+
* but preserves all ANSI escape sequences in the output. Active ANSI codes are
|
|
93
|
+
* preserved in the truncated result.
|
|
24
94
|
*
|
|
25
95
|
* @param text - Text that may contain ANSI escape codes
|
|
26
96
|
* @param maxWidth - Maximum visual width (number of visible characters)
|
|
@@ -38,23 +108,15 @@ export function truncateToWidth(text, maxWidth) {
|
|
|
38
108
|
if (plain.length <= maxWidth) {
|
|
39
109
|
return text;
|
|
40
110
|
}
|
|
41
|
-
// Truncate while preserving ANSI codes
|
|
42
|
-
// We iterate through the string, counting visual characters while skipping
|
|
111
|
+
// Truncate while preserving ANSI codes and OSC 8 sequences
|
|
112
|
+
// We iterate through the string, counting visual characters while skipping escape sequences
|
|
43
113
|
let visual = 0; // Count of visible characters seen so far
|
|
44
114
|
let idx = 0; // Current position in the string
|
|
45
115
|
while (idx < text.length && visual < maxWidth) {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
//
|
|
49
|
-
|
|
50
|
-
while (end < text.length && text[end] !== 'm') {
|
|
51
|
-
end++;
|
|
52
|
-
}
|
|
53
|
-
// Include the 'm' if found
|
|
54
|
-
if (end < text.length) {
|
|
55
|
-
end++;
|
|
56
|
-
}
|
|
57
|
-
idx = end;
|
|
116
|
+
const nextIdx = skipEscapeSequence(text, idx);
|
|
117
|
+
if (nextIdx > idx) {
|
|
118
|
+
// We skipped an escape sequence
|
|
119
|
+
idx = nextIdx;
|
|
58
120
|
}
|
|
59
121
|
else {
|
|
60
122
|
// Regular character - count it and advance
|
|
@@ -62,12 +124,17 @@ export function truncateToWidth(text, maxWidth) {
|
|
|
62
124
|
visual++;
|
|
63
125
|
}
|
|
64
126
|
}
|
|
65
|
-
// Return substring up to the truncation point
|
|
66
|
-
|
|
127
|
+
// Return substring up to the truncation point (preserves all ANSI codes up to that point)
|
|
128
|
+
const truncated = text.substring(0, idx);
|
|
129
|
+
// Close any open OSC 8 hyperlink
|
|
130
|
+
return truncated + closeOsc8IfOpen(truncated);
|
|
67
131
|
}
|
|
68
132
|
/**
|
|
69
133
|
* Truncate text with ellipsis at the end, preserving ANSI escape codes
|
|
70
134
|
*
|
|
135
|
+
* Active ANSI codes from the truncated portion are preserved, and the ellipsis
|
|
136
|
+
* is added after the truncated text (without ANSI codes).
|
|
137
|
+
*
|
|
71
138
|
* @param text - Text that may contain ANSI escape codes
|
|
72
139
|
* @param maxWidth - Maximum visual width (number of visible characters)
|
|
73
140
|
* @returns Truncated text with '...' at the end, ANSI codes preserved
|
|
@@ -83,13 +150,31 @@ export function truncateEnd(text, maxWidth) {
|
|
|
83
150
|
if (maxWidth <= 3) {
|
|
84
151
|
return '.'.repeat(maxWidth);
|
|
85
152
|
}
|
|
86
|
-
//
|
|
87
|
-
|
|
88
|
-
|
|
153
|
+
// 1. Extract raw portion before truncation
|
|
154
|
+
let visual = 0;
|
|
155
|
+
let idx = 0;
|
|
156
|
+
while (idx < text.length && visual < maxWidth - 3) {
|
|
157
|
+
const nextIdx = skipEscapeSequence(text, idx);
|
|
158
|
+
if (nextIdx > idx)
|
|
159
|
+
idx = nextIdx;
|
|
160
|
+
else {
|
|
161
|
+
idx++;
|
|
162
|
+
visual++;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
const rawTruncated = text.substring(0, idx);
|
|
166
|
+
const activeCodes = extractActiveAnsiCodes(rawTruncated);
|
|
167
|
+
// 2. Add ellipsis with the same active codes, then close everything
|
|
168
|
+
const ellipsis = activeCodes ? activeCodes + '...' : '...';
|
|
169
|
+
return rawTruncated + ellipsis + closeOsc8IfOpen(activeCodes) + '\x1b[0m';
|
|
89
170
|
}
|
|
90
171
|
/**
|
|
91
172
|
* Truncate text with ellipsis at the beginning, preserving ANSI escape codes
|
|
92
173
|
*
|
|
174
|
+
* When truncating from the start, we preserve ANSI codes that are active in the
|
|
175
|
+
* remaining portion. The ellipsis is added at the beginning (without ANSI codes),
|
|
176
|
+
* and active codes from the original text are re-applied to the remaining portion.
|
|
177
|
+
*
|
|
93
178
|
* @param text - Text that may contain ANSI escape codes
|
|
94
179
|
* @param maxWidth - Maximum visual width (number of visible characters)
|
|
95
180
|
* @returns Truncated text with '...' at the beginning, ANSI codes preserved
|
|
@@ -98,41 +183,42 @@ export function truncateEnd(text, maxWidth) {
|
|
|
98
183
|
* truncateStart('\x1b[31mHello World\x1b[0m', 8) // '...\x1b[31mWorld\x1b[0m'
|
|
99
184
|
*/
|
|
100
185
|
export function truncateStart(text, maxWidth) {
|
|
101
|
-
const
|
|
102
|
-
if (
|
|
186
|
+
const visibleWidth = countVisibleChars(text);
|
|
187
|
+
if (visibleWidth <= maxWidth) {
|
|
103
188
|
return text;
|
|
104
189
|
}
|
|
105
190
|
if (maxWidth <= 3) {
|
|
106
191
|
return '.'.repeat(maxWidth);
|
|
107
192
|
}
|
|
108
|
-
// We need to truncate from the start, preserving ANSI codes
|
|
109
|
-
// This is trickier - we need to work backwards
|
|
110
193
|
const availableWidth = maxWidth - 3;
|
|
111
|
-
const
|
|
112
|
-
// Find the actual start position in the original string (accounting for ANSI codes)
|
|
194
|
+
const startVisibleIdx = visibleWidth - availableWidth;
|
|
113
195
|
let visual = 0;
|
|
114
196
|
let idx = 0;
|
|
115
|
-
while (idx < text.length && visual <
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
end++;
|
|
120
|
-
}
|
|
121
|
-
if (end < text.length) {
|
|
122
|
-
end++;
|
|
123
|
-
}
|
|
124
|
-
idx = end;
|
|
125
|
-
}
|
|
197
|
+
while (idx < text.length && visual < startVisibleIdx) {
|
|
198
|
+
const nextIdx = skipEscapeSequence(text, idx);
|
|
199
|
+
if (nextIdx > idx)
|
|
200
|
+
idx = nextIdx;
|
|
126
201
|
else {
|
|
127
202
|
idx++;
|
|
128
203
|
visual++;
|
|
129
204
|
}
|
|
130
205
|
}
|
|
131
|
-
|
|
206
|
+
const remaining = text.substring(idx);
|
|
207
|
+
const beforeCut = text.substring(0, idx);
|
|
208
|
+
const activeCodes = extractActiveAnsiCodes(beforeCut);
|
|
209
|
+
// To ensure the hyperlink is preserved:
|
|
210
|
+
// 1. ellipsis starts with activeCodes
|
|
211
|
+
// 2. remaining is appended
|
|
212
|
+
// (remaining already has its own closing codes if it was originally linked)
|
|
213
|
+
const ellipsis = activeCodes ? activeCodes + '...' : '...';
|
|
214
|
+
return ellipsis + remaining;
|
|
132
215
|
}
|
|
133
216
|
/**
|
|
134
217
|
* Truncate text with ellipsis in the middle, preserving ANSI escape codes
|
|
135
218
|
*
|
|
219
|
+
* When truncating in the middle, we preserve ANSI codes from the start portion
|
|
220
|
+
* and re-apply them to the end portion to maintain consistent styling.
|
|
221
|
+
*
|
|
136
222
|
* @param text - Text that may contain ANSI escape codes
|
|
137
223
|
* @param maxWidth - Maximum visual width (number of visible characters)
|
|
138
224
|
* @returns Truncated text with '...' in the middle, ANSI codes preserved
|
|
@@ -148,39 +234,303 @@ export function truncateMiddle(text, maxWidth) {
|
|
|
148
234
|
if (maxWidth <= 3) {
|
|
149
235
|
return '.'.repeat(maxWidth);
|
|
150
236
|
}
|
|
151
|
-
|
|
152
|
-
// Total: start + 3 (ellipsis) + end = maxWidth
|
|
153
|
-
const availableChars = maxWidth - 3; // Characters available for text (excluding ellipsis)
|
|
237
|
+
const availableChars = maxWidth - 3;
|
|
154
238
|
const startChars = Math.floor(availableChars / 2);
|
|
155
239
|
const endChars = availableChars - startChars;
|
|
156
240
|
// Get start portion
|
|
157
|
-
const startPortion = truncateToWidth(text, startChars);
|
|
158
|
-
// Get end portion - work backwards from the end
|
|
159
|
-
const endStartIdx = plain.length - endChars;
|
|
160
|
-
// Find the actual start position in the original string (accounting for ANSI codes)
|
|
161
241
|
let visual = 0;
|
|
162
242
|
let idx = 0;
|
|
163
|
-
while (idx < text.length && visual <
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
end++;
|
|
171
|
-
}
|
|
172
|
-
idx = end;
|
|
243
|
+
while (idx < text.length && visual < startChars) {
|
|
244
|
+
const nextIdx = skipEscapeSequence(text, idx);
|
|
245
|
+
if (nextIdx > idx)
|
|
246
|
+
idx = nextIdx;
|
|
247
|
+
else {
|
|
248
|
+
idx++;
|
|
249
|
+
visual++;
|
|
173
250
|
}
|
|
251
|
+
}
|
|
252
|
+
const startPortion = text.substring(0, idx);
|
|
253
|
+
const activeCodes = extractActiveAnsiCodes(startPortion);
|
|
254
|
+
// Get end portion
|
|
255
|
+
const endStartIdx = plain.length - endChars;
|
|
256
|
+
visual = 0;
|
|
257
|
+
idx = 0;
|
|
258
|
+
while (idx < text.length && visual < endStartIdx) {
|
|
259
|
+
const nextIdx = skipEscapeSequence(text, idx);
|
|
260
|
+
if (nextIdx > idx)
|
|
261
|
+
idx = nextIdx;
|
|
174
262
|
else {
|
|
175
263
|
idx++;
|
|
176
264
|
visual++;
|
|
177
265
|
}
|
|
178
266
|
}
|
|
179
267
|
const endPortion = text.substring(idx);
|
|
180
|
-
|
|
268
|
+
// ellipsis and endPortion both need activeCodes
|
|
269
|
+
const ellipsis = activeCodes ? activeCodes + '...' : '...';
|
|
270
|
+
return startPortion + ellipsis + endPortion;
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* Extract a visible character range from text, preserving ANSI codes
|
|
274
|
+
*
|
|
275
|
+
* @param text - Text that may contain ANSI escape codes
|
|
276
|
+
* @param startCol - Start column (1-based)
|
|
277
|
+
* @param endCol - End column (1-based)
|
|
278
|
+
* @returns Object with { before: string, range: string, after: string }
|
|
279
|
+
*/
|
|
280
|
+
function extractVisibleRange(text, startCol, endCol) {
|
|
281
|
+
const { before, after: remainingAfterStart } = splitAtVisiblePos(text, startCol - 1);
|
|
282
|
+
const rangeLength = endCol - startCol + 1;
|
|
283
|
+
const { before: range, after } = splitAtVisiblePos(remainingAfterStart, rangeLength);
|
|
284
|
+
return { before, range, after };
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Map an original column position to its display position in truncated text
|
|
288
|
+
*
|
|
289
|
+
* This function takes the original text, the truncated result, and the visible range
|
|
290
|
+
* that was shown, and maps an original column to where it appears in the truncated text.
|
|
291
|
+
*
|
|
292
|
+
* @param originalText - The original text (plain, no ANSI)
|
|
293
|
+
* @param truncatedText - The truncated text result (may have ellipsis)
|
|
294
|
+
* @param visibleStartCol - The first column that was shown (1-based, includes truncated before)
|
|
295
|
+
* @param visibleEndCol - The last column that was shown (1-based, includes truncated after)
|
|
296
|
+
* @param originalCol - The original column to map (1-based)
|
|
297
|
+
* @param rangeStartCol - The start column of the main range (1-based, optional, for better accuracy)
|
|
298
|
+
* @param rangeEndCol - The end column of the main range (1-based, optional, for better accuracy)
|
|
299
|
+
* @returns The display position in the truncated text (1-based, visible characters)
|
|
300
|
+
*/
|
|
301
|
+
export function mapColumnToDisplay(originalText, truncatedText, visibleStartCol, visibleEndCol, originalCol, rangeStartCol, rangeEndCol) {
|
|
302
|
+
const truncatedPlain = stripAnsi(truncatedText);
|
|
303
|
+
const hasEllipsisStart = truncatedPlain.startsWith('...');
|
|
304
|
+
const hasEllipsisEnd = truncatedPlain.endsWith('...');
|
|
305
|
+
const ellipsisStartOffset = hasEllipsisStart ? 3 : 0;
|
|
306
|
+
// If we have range boundaries, use them for more accurate mapping
|
|
307
|
+
if (rangeStartCol !== undefined && rangeEndCol !== undefined) {
|
|
308
|
+
// Calculate how much of 'before' is shown (if truncated)
|
|
309
|
+
let shownBeforeChars = 0;
|
|
310
|
+
if (hasEllipsisStart && rangeStartCol > visibleStartCol) {
|
|
311
|
+
// The visible before portion is from visibleStartCol to (rangeStartCol - 1)
|
|
312
|
+
shownBeforeChars = (rangeStartCol - 1) - visibleStartCol + 1;
|
|
313
|
+
}
|
|
314
|
+
else if (!hasEllipsisStart && rangeStartCol > 1) {
|
|
315
|
+
// All of before is shown
|
|
316
|
+
shownBeforeChars = rangeStartCol - 1;
|
|
317
|
+
}
|
|
318
|
+
// If column is before the range, it's in the truncated before portion
|
|
319
|
+
if (originalCol < rangeStartCol) {
|
|
320
|
+
if (originalCol < visibleStartCol) {
|
|
321
|
+
return ellipsisStartOffset || 1;
|
|
322
|
+
}
|
|
323
|
+
// Column is in the visible before portion
|
|
324
|
+
const offsetInBefore = originalCol - visibleStartCol + 1;
|
|
325
|
+
return ellipsisStartOffset + offsetInBefore;
|
|
326
|
+
}
|
|
327
|
+
// If column is in the range, calculate position relative to range start
|
|
328
|
+
if (originalCol >= rangeStartCol && originalCol <= rangeEndCol) {
|
|
329
|
+
const offsetInRange = originalCol - rangeStartCol + 1;
|
|
330
|
+
// We need to count how many visible characters are in 'truncatedBefore'
|
|
331
|
+
const truncatedBeforeWidth = hasEllipsisStart ? (shownBeforeChars + 3) : shownBeforeChars;
|
|
332
|
+
return truncatedBeforeWidth + offsetInRange;
|
|
333
|
+
}
|
|
334
|
+
// If column is after the range, it's in the truncated after portion
|
|
335
|
+
if (originalCol > rangeEndCol) {
|
|
336
|
+
if (originalCol > visibleEndCol) {
|
|
337
|
+
return countVisibleChars(truncatedPlain);
|
|
338
|
+
}
|
|
339
|
+
// Column is in the visible after portion
|
|
340
|
+
const rangeWidth = rangeEndCol - rangeStartCol + 1;
|
|
341
|
+
const offsetInAfter = originalCol - rangeEndCol;
|
|
342
|
+
const truncatedBeforeWidth = hasEllipsisStart ? (shownBeforeChars + 3) : shownBeforeChars;
|
|
343
|
+
return truncatedBeforeWidth + rangeWidth + offsetInAfter;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
// Fallback to simple calculation if range boundaries not provided
|
|
347
|
+
// If column is before visible range, point to start ellipsis or first character
|
|
348
|
+
if (originalCol < visibleStartCol) {
|
|
349
|
+
return ellipsisStartOffset || 1;
|
|
350
|
+
}
|
|
351
|
+
// If column is after visible range, point to end
|
|
352
|
+
if (originalCol > visibleEndCol) {
|
|
353
|
+
return countVisibleChars(truncatedPlain);
|
|
354
|
+
}
|
|
355
|
+
// Column is in visible range - calculate its position
|
|
356
|
+
// Position = ellipsis offset + (originalCol - visibleStartCol + 1)
|
|
357
|
+
return ellipsisStartOffset + (originalCol - visibleStartCol + 1);
|
|
358
|
+
}
|
|
359
|
+
/**
|
|
360
|
+
* Truncate text to show a specific column range, with ellipsis as needed
|
|
361
|
+
*
|
|
362
|
+
* This function ensures a target range (startCol to endCol) is visible in the output,
|
|
363
|
+
* adding ellipsis at the start, end, or both as needed to fit within maxWidth.
|
|
364
|
+
* All ANSI codes and OSC 8 hyperlinks are preserved.
|
|
365
|
+
*
|
|
366
|
+
* @param text - Text that may contain ANSI escape codes
|
|
367
|
+
* @param maxWidth - Maximum visual width (number of visible characters)
|
|
368
|
+
* @param targetStartCol - Start column of target range (1-based)
|
|
369
|
+
* @param targetEndCol - End column of target range (1-based, optional)
|
|
370
|
+
* @param maxColumn - Maximum column to show (optional, for limiting display)
|
|
371
|
+
* @returns Truncated text with target range visible, ANSI codes preserved, and visible range info
|
|
372
|
+
*
|
|
373
|
+
* @example
|
|
374
|
+
* truncateFocusRange('console.log("hello world");', 20, 1, 11)
|
|
375
|
+
* // Returns text showing columns 1-11 with ellipsis if needed
|
|
376
|
+
*/
|
|
377
|
+
export function truncateFocusRange(text, maxWidth, targetStartCol, targetEndCol, maxColumn) {
|
|
378
|
+
const codeLength = countVisibleChars(text);
|
|
379
|
+
const targetEnd = targetEndCol ?? targetStartCol;
|
|
380
|
+
// If code fits, show everything
|
|
381
|
+
if (codeLength <= maxWidth) {
|
|
382
|
+
return {
|
|
383
|
+
text: truncateToWidth(text, maxWidth),
|
|
384
|
+
visibleStartCol: 1,
|
|
385
|
+
visibleEndCol: codeLength,
|
|
386
|
+
rangeStartCol: targetStartCol,
|
|
387
|
+
rangeEndCol: targetEnd,
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
const effectiveMaxCol = maxColumn ? Math.min(maxColumn, codeLength) : codeLength;
|
|
391
|
+
const targetWidth = targetEnd - targetStartCol + 1;
|
|
392
|
+
// If target doesn't fit, just show middle with ellipsis
|
|
393
|
+
if (targetWidth > maxWidth - 6) {
|
|
394
|
+
const midPoint = Math.floor((maxWidth - 6) / 2);
|
|
395
|
+
const startCol = Math.max(1, targetStartCol - midPoint);
|
|
396
|
+
const endCol = Math.min(effectiveMaxCol, startCol + maxWidth - 6);
|
|
397
|
+
const { range } = extractVisibleRange(text, startCol, endCol);
|
|
398
|
+
return {
|
|
399
|
+
text: '...' + truncateToWidth(range, maxWidth - 3) + '...',
|
|
400
|
+
visibleStartCol: startCol,
|
|
401
|
+
visibleEndCol: endCol,
|
|
402
|
+
rangeStartCol: startCol,
|
|
403
|
+
rangeEndCol: endCol,
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
// Calculate visible range to center target
|
|
407
|
+
const willNeedEllipsisStart = targetStartCol > 1 || (targetStartCol === 1 && codeLength > maxWidth);
|
|
408
|
+
const willNeedEllipsisEnd = targetEnd < codeLength || codeLength > maxWidth;
|
|
409
|
+
const ellipsisWidth = (willNeedEllipsisStart ? 3 : 0) + (willNeedEllipsisEnd ? 3 : 0);
|
|
410
|
+
const effectiveAvailableWidth = maxWidth - ellipsisWidth;
|
|
411
|
+
const padding = Math.floor((effectiveAvailableWidth - targetWidth) / 2);
|
|
412
|
+
let startCol = Math.max(1, targetStartCol - padding);
|
|
413
|
+
let endCol = Math.min(effectiveMaxCol, startCol + effectiveAvailableWidth - 1);
|
|
414
|
+
// Adjust if we hit boundaries
|
|
415
|
+
if (endCol - startCol + 1 > effectiveAvailableWidth) {
|
|
416
|
+
endCol = startCol + effectiveAvailableWidth - 1;
|
|
417
|
+
}
|
|
418
|
+
if (endCol > effectiveMaxCol) {
|
|
419
|
+
endCol = effectiveMaxCol;
|
|
420
|
+
startCol = Math.max(1, endCol - effectiveAvailableWidth + 1);
|
|
421
|
+
}
|
|
422
|
+
if (startCol < 1) {
|
|
423
|
+
startCol = 1;
|
|
424
|
+
endCol = Math.min(effectiveMaxCol, effectiveAvailableWidth);
|
|
425
|
+
}
|
|
426
|
+
// Extract the visible range using the helper
|
|
427
|
+
const { before, range, after } = extractVisibleRange(text, startCol, endCol);
|
|
428
|
+
// Use existing truncation utilities to add ellipsis
|
|
429
|
+
const hasEllipsisStart = startCol > 1;
|
|
430
|
+
const hasEllipsisEnd = endCol < codeLength;
|
|
431
|
+
const rangeWidth = countVisibleChars(range);
|
|
432
|
+
let truncatedText;
|
|
433
|
+
let actualVisibleStartCol = startCol;
|
|
434
|
+
let actualVisibleEndCol = endCol;
|
|
435
|
+
if (hasEllipsisStart && hasEllipsisEnd) {
|
|
436
|
+
const beforeWidth = maxWidth - rangeWidth - 3; // -3 for end ellipsis
|
|
437
|
+
const truncatedBefore = before ? truncateStart(before, Math.max(3, beforeWidth)) : '';
|
|
438
|
+
const afterWidth = maxWidth - countVisibleChars(truncatedBefore) - rangeWidth - 3; // -3 for start ellipsis
|
|
439
|
+
const truncatedAfter = after ? truncateEnd(after, Math.max(3, afterWidth)) : '';
|
|
440
|
+
truncatedText = truncatedBefore + range + truncatedAfter;
|
|
441
|
+
// Adjust visible range: if before was truncated, calculate actual start
|
|
442
|
+
// 'before' contains columns 1 to (startCol - 1)
|
|
443
|
+
// truncateStart shows the END of 'before', so if we show the last N chars of 'before',
|
|
444
|
+
// the first visible column from 'before' is: (startCol - 1) - N + 1 = startCol - N
|
|
445
|
+
if (truncatedBefore && before) {
|
|
446
|
+
const truncatedBeforePlain = stripAnsi(truncatedBefore);
|
|
447
|
+
if (truncatedBeforePlain.startsWith('...')) {
|
|
448
|
+
const shownBeforeChars = countVisibleChars(truncatedBeforePlain) - 3; // minus ellipsis
|
|
449
|
+
// 'before' contains columns 1 to (startCol - 1), so it has (startCol - 1) characters
|
|
450
|
+
// If we show the last N chars of 'before', the first visible column is: startCol - N
|
|
451
|
+
actualVisibleStartCol = Math.max(1, startCol - shownBeforeChars);
|
|
452
|
+
}
|
|
453
|
+
else {
|
|
454
|
+
// No ellipsis means we showed all of before, so visible start is 1
|
|
455
|
+
actualVisibleStartCol = 1;
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
else if (before && before.length > 0) {
|
|
459
|
+
// Before exists but wasn't truncated, so all of before is visible
|
|
460
|
+
actualVisibleStartCol = 1;
|
|
461
|
+
}
|
|
462
|
+
// Adjust visible range: if after was truncated, calculate actual end
|
|
463
|
+
// truncateEnd shows the START of 'after', so if we show N chars from the start of 'after',
|
|
464
|
+
// the actual visible end is: endCol + N
|
|
465
|
+
if (truncatedAfter && after) {
|
|
466
|
+
const truncatedAfterPlain = stripAnsi(truncatedAfter);
|
|
467
|
+
if (truncatedAfterPlain.endsWith('...')) {
|
|
468
|
+
const shownAfterChars = countVisibleChars(truncatedAfterPlain) - 3; // minus ellipsis
|
|
469
|
+
// 'after' starts at endCol + 1, so if we show the first N chars, visible end is endCol + N
|
|
470
|
+
actualVisibleEndCol = endCol + shownAfterChars;
|
|
471
|
+
}
|
|
472
|
+
else {
|
|
473
|
+
// No ellipsis means we showed all of after
|
|
474
|
+
actualVisibleEndCol = codeLength;
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
else if (after && after.length > 0) {
|
|
478
|
+
// After exists but wasn't truncated, so all of after is visible
|
|
479
|
+
actualVisibleEndCol = codeLength;
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
else if (hasEllipsisStart) {
|
|
483
|
+
const beforeWidth = maxWidth - rangeWidth;
|
|
484
|
+
const truncatedBefore = before ? truncateStart(before, beforeWidth) : '';
|
|
485
|
+
truncatedText = truncatedBefore + range + after;
|
|
486
|
+
// Adjust visible range: if before was truncated, calculate actual start
|
|
487
|
+
const truncatedBeforePlain = stripAnsi(truncatedBefore);
|
|
488
|
+
if (truncatedBefore && before && truncatedBeforePlain.startsWith('...')) {
|
|
489
|
+
const shownBeforeChars = countVisibleChars(truncatedBeforePlain) - 3;
|
|
490
|
+
actualVisibleStartCol = Math.max(1, startCol - shownBeforeChars);
|
|
491
|
+
}
|
|
492
|
+
else if (truncatedBefore && before) {
|
|
493
|
+
// No ellipsis means we showed all of before
|
|
494
|
+
actualVisibleStartCol = 1;
|
|
495
|
+
}
|
|
496
|
+
// After is fully shown
|
|
497
|
+
actualVisibleEndCol = codeLength;
|
|
498
|
+
}
|
|
499
|
+
else if (hasEllipsisEnd) {
|
|
500
|
+
const afterWidth = maxWidth - rangeWidth;
|
|
501
|
+
const truncatedAfter = after ? truncateEnd(after, afterWidth) : '';
|
|
502
|
+
truncatedText = before + range + truncatedAfter;
|
|
503
|
+
// Before is fully shown
|
|
504
|
+
actualVisibleStartCol = 1;
|
|
505
|
+
// Adjust visible range: if after was truncated, calculate actual end
|
|
506
|
+
const truncatedAfterPlain = stripAnsi(truncatedAfter);
|
|
507
|
+
if (truncatedAfter && after && truncatedAfterPlain.endsWith('...')) {
|
|
508
|
+
const shownAfterChars = countVisibleChars(truncatedAfterPlain) - 3;
|
|
509
|
+
actualVisibleEndCol = endCol + shownAfterChars;
|
|
510
|
+
}
|
|
511
|
+
else {
|
|
512
|
+
// No ellipsis means we showed all of after
|
|
513
|
+
actualVisibleEndCol = codeLength;
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
else {
|
|
517
|
+
// No ellipsis needed
|
|
518
|
+
truncatedText = truncateToWidth(text, maxWidth);
|
|
519
|
+
actualVisibleStartCol = 1;
|
|
520
|
+
actualVisibleEndCol = codeLength;
|
|
521
|
+
}
|
|
522
|
+
return {
|
|
523
|
+
text: truncatedText,
|
|
524
|
+
visibleStartCol: actualVisibleStartCol,
|
|
525
|
+
visibleEndCol: actualVisibleEndCol,
|
|
526
|
+
rangeStartCol: startCol,
|
|
527
|
+
rangeEndCol: endCol,
|
|
528
|
+
};
|
|
181
529
|
}
|
|
182
530
|
/**
|
|
183
531
|
* Wrap text to fit within a width, breaking on spaces
|
|
532
|
+
* Never breaks words mid-word - if a word is too long, it will extend the line
|
|
533
|
+
* ANSI-aware: calculates width based on visible characters, not raw string length
|
|
184
534
|
*/
|
|
185
535
|
export function wrapText(text, width) {
|
|
186
536
|
if (!Number.isFinite(width) || width <= 0) {
|
|
@@ -189,6 +539,7 @@ export function wrapText(text, width) {
|
|
|
189
539
|
const lines = [];
|
|
190
540
|
const length = text.length;
|
|
191
541
|
let index = 0;
|
|
542
|
+
let activeCodes = ''; // Track active ANSI codes across line breaks
|
|
192
543
|
while (index < length) {
|
|
193
544
|
// Skip leading spaces
|
|
194
545
|
while (index < length && text[index] === ' ') {
|
|
@@ -197,27 +548,140 @@ export function wrapText(text, width) {
|
|
|
197
548
|
if (index >= length) {
|
|
198
549
|
break;
|
|
199
550
|
}
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
551
|
+
// Find the end position based on VISIBLE width (accounting for ANSI codes)
|
|
552
|
+
// We need to find where we've seen 'width' visible characters
|
|
553
|
+
// OR where we hit a newline (which should break the line)
|
|
554
|
+
let visibleCount = 0;
|
|
555
|
+
let end = index;
|
|
556
|
+
let foundNewline = false;
|
|
557
|
+
while (end < length && visibleCount < width) {
|
|
558
|
+
if (text[end] === '\x1b') {
|
|
559
|
+
// Found ANSI escape sequence - skip to the end
|
|
560
|
+
let ansiEnd = end + 1;
|
|
561
|
+
while (ansiEnd < length && text[ansiEnd] !== 'm') {
|
|
562
|
+
ansiEnd++;
|
|
563
|
+
}
|
|
564
|
+
if (ansiEnd < length) {
|
|
565
|
+
ansiEnd++; // Include the 'm'
|
|
566
|
+
}
|
|
567
|
+
end = ansiEnd;
|
|
568
|
+
}
|
|
569
|
+
else if (text[end] === '\n') {
|
|
570
|
+
// Found newline - break here (don't include the newline in the line)
|
|
571
|
+
foundNewline = true;
|
|
572
|
+
break;
|
|
573
|
+
}
|
|
574
|
+
else {
|
|
575
|
+
// Regular character - count it
|
|
576
|
+
end++;
|
|
577
|
+
visibleCount++;
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
// If we found a newline, break the line there
|
|
581
|
+
if (foundNewline) {
|
|
582
|
+
let line = text.slice(index, end); // Don't include the newline
|
|
583
|
+
// Prepend active codes from previous line if we have any
|
|
584
|
+
if (activeCodes) {
|
|
585
|
+
line = activeCodes + line;
|
|
586
|
+
}
|
|
587
|
+
// Extract active codes from this line for the next line
|
|
588
|
+
activeCodes = extractActiveAnsiCodes(line);
|
|
589
|
+
lines.push(line);
|
|
590
|
+
index = end + 1; // Skip past the newline
|
|
591
|
+
continue;
|
|
592
|
+
}
|
|
593
|
+
if (end >= length) {
|
|
594
|
+
// Reached end of text - take the rest
|
|
595
|
+
let finalLine = text.slice(index);
|
|
596
|
+
// Prepend active codes from previous line if we have any
|
|
597
|
+
if (activeCodes) {
|
|
598
|
+
finalLine = activeCodes + finalLine;
|
|
599
|
+
}
|
|
600
|
+
lines.push(finalLine);
|
|
203
601
|
break;
|
|
204
602
|
}
|
|
603
|
+
// Look for a break point (space or hyphen) going backwards from end
|
|
604
|
+
// But we need to search in the visible character space, not raw string position
|
|
205
605
|
let breakPoint = -1;
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
606
|
+
let searchVisible = visibleCount;
|
|
607
|
+
let searchIdx = end;
|
|
608
|
+
// Search backwards for a space or hyphen
|
|
609
|
+
while (searchIdx > index && searchVisible > 0) {
|
|
610
|
+
// Move backwards, accounting for ANSI codes
|
|
611
|
+
if (text[searchIdx - 1] === '\x1b') {
|
|
612
|
+
// Found ANSI code - skip backwards past it
|
|
613
|
+
let ansiStart = searchIdx - 1;
|
|
614
|
+
while (ansiStart > index && text[ansiStart - 1] !== 'm') {
|
|
615
|
+
ansiStart--;
|
|
616
|
+
}
|
|
617
|
+
if (ansiStart > index && text[ansiStart - 1] === 'm') {
|
|
618
|
+
ansiStart--; // Include the 'm'
|
|
619
|
+
}
|
|
620
|
+
searchIdx = ansiStart;
|
|
211
621
|
}
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
622
|
+
else {
|
|
623
|
+
const char = text[searchIdx - 1];
|
|
624
|
+
if (char === ' ') {
|
|
625
|
+
breakPoint = searchIdx - 1;
|
|
626
|
+
break;
|
|
627
|
+
}
|
|
628
|
+
if (char === '-') {
|
|
629
|
+
breakPoint = searchIdx;
|
|
630
|
+
break;
|
|
631
|
+
}
|
|
632
|
+
searchIdx--;
|
|
633
|
+
searchVisible--;
|
|
215
634
|
}
|
|
216
635
|
}
|
|
217
636
|
if (breakPoint === -1) {
|
|
218
|
-
|
|
637
|
+
// No space found within the width - we're in the middle of a word
|
|
638
|
+
// Look for the NEXT space after 'end' to break there
|
|
639
|
+
// This ensures we never break mid-word
|
|
640
|
+
let nextSpace = -1;
|
|
641
|
+
let searchIdx = end;
|
|
642
|
+
while (searchIdx < length) {
|
|
643
|
+
if (text[searchIdx] === '\x1b') {
|
|
644
|
+
// Skip ANSI code
|
|
645
|
+
let ansiEnd = searchIdx + 1;
|
|
646
|
+
while (ansiEnd < length && text[ansiEnd] !== 'm') {
|
|
647
|
+
ansiEnd++;
|
|
648
|
+
}
|
|
649
|
+
if (ansiEnd < length) {
|
|
650
|
+
ansiEnd++;
|
|
651
|
+
}
|
|
652
|
+
searchIdx = ansiEnd;
|
|
653
|
+
}
|
|
654
|
+
else {
|
|
655
|
+
if (text[searchIdx] === ' ') {
|
|
656
|
+
nextSpace = searchIdx;
|
|
657
|
+
break;
|
|
658
|
+
}
|
|
659
|
+
searchIdx++;
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
if (nextSpace === -1) {
|
|
663
|
+
// No more spaces - take the rest (this is the last word/line)
|
|
664
|
+
let finalLine = text.slice(index);
|
|
665
|
+
// Prepend active codes from previous line if we have any
|
|
666
|
+
if (activeCodes) {
|
|
667
|
+
finalLine = activeCodes + finalLine;
|
|
668
|
+
}
|
|
669
|
+
lines.push(finalLine);
|
|
670
|
+
break;
|
|
671
|
+
}
|
|
672
|
+
else {
|
|
673
|
+
// Found a space after - break there (word extends beyond width)
|
|
674
|
+
// This means the word is longer than width, so we have to break after it
|
|
675
|
+
breakPoint = nextSpace;
|
|
676
|
+
}
|
|
219
677
|
}
|
|
220
|
-
|
|
678
|
+
let line = text.slice(index, breakPoint).trimEnd();
|
|
679
|
+
// Prepend active codes from previous line if we have any
|
|
680
|
+
if (activeCodes) {
|
|
681
|
+
line = activeCodes + line;
|
|
682
|
+
}
|
|
683
|
+
// Extract active codes from this line for the next line
|
|
684
|
+
activeCodes = extractActiveAnsiCodes(line);
|
|
221
685
|
lines.push(line);
|
|
222
686
|
index = breakPoint;
|
|
223
687
|
while (index < length && text[index] === ' ') {
|
|
@@ -248,17 +712,10 @@ export function countVisibleChars(text) {
|
|
|
248
712
|
let count = 0;
|
|
249
713
|
let idx = 0;
|
|
250
714
|
while (idx < text.length) {
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
end++;
|
|
256
|
-
}
|
|
257
|
-
// Include the 'm' if found
|
|
258
|
-
if (end < text.length) {
|
|
259
|
-
end++;
|
|
260
|
-
}
|
|
261
|
-
idx = end;
|
|
715
|
+
const nextIdx = skipEscapeSequence(text, idx);
|
|
716
|
+
if (nextIdx > idx) {
|
|
717
|
+
// We skipped an escape sequence
|
|
718
|
+
idx = nextIdx;
|
|
262
719
|
}
|
|
263
720
|
else {
|
|
264
721
|
// Regular character - count it
|
|
@@ -269,15 +726,19 @@ export function countVisibleChars(text) {
|
|
|
269
726
|
return count;
|
|
270
727
|
}
|
|
271
728
|
/**
|
|
272
|
-
* Extract active ANSI codes from text (all codes that are active at the end)
|
|
729
|
+
* Extract active ANSI codes and OSC 8 hyperlinks from text (all codes that are active at the end)
|
|
273
730
|
* Returns the ANSI escape sequences that should be applied to continue the styling
|
|
274
731
|
*/
|
|
275
|
-
|
|
732
|
+
/**
|
|
733
|
+
* Extract only SGR (color/style) codes, excluding OSC 8 hyperlinks
|
|
734
|
+
* Used for ellipsis styling where we want color but not hyperlinks
|
|
735
|
+
*/
|
|
736
|
+
function extractActiveSgrCodes(text) {
|
|
276
737
|
const codes = [];
|
|
277
738
|
let idx = 0;
|
|
278
739
|
while (idx < text.length) {
|
|
279
740
|
if (text[idx] === '\x1b' && idx + 1 < text.length && text[idx + 1] === '[') {
|
|
280
|
-
// Found ANSI escape sequence
|
|
741
|
+
// Found ANSI SGR escape sequence
|
|
281
742
|
let end = idx + 2;
|
|
282
743
|
while (end < text.length && text[end] !== 'm') {
|
|
283
744
|
end++;
|
|
@@ -299,13 +760,125 @@ function extractActiveAnsiCodes(text) {
|
|
|
299
760
|
idx++;
|
|
300
761
|
}
|
|
301
762
|
}
|
|
763
|
+
else if (text[idx] === '\x1b' && idx + 4 < text.length && text.substring(idx, idx + 4) === '\x1b]8;') {
|
|
764
|
+
// Skip OSC 8 hyperlink sequences (don't include in result)
|
|
765
|
+
let end = idx + 4;
|
|
766
|
+
while (end < text.length - 1) {
|
|
767
|
+
if (text[end] === '\x1b' && text[end + 1] === '\\') {
|
|
768
|
+
idx = end + 2;
|
|
769
|
+
break;
|
|
770
|
+
}
|
|
771
|
+
end++;
|
|
772
|
+
}
|
|
773
|
+
if (end >= text.length - 1) {
|
|
774
|
+
idx++;
|
|
775
|
+
}
|
|
776
|
+
}
|
|
302
777
|
else {
|
|
303
778
|
idx++;
|
|
304
779
|
}
|
|
305
780
|
}
|
|
306
|
-
// Return all active codes combined
|
|
307
781
|
return codes.join('');
|
|
308
782
|
}
|
|
783
|
+
function extractActiveAnsiCodes(text) {
|
|
784
|
+
const codes = [];
|
|
785
|
+
let idx = 0;
|
|
786
|
+
let osc8Start = null; // Track OSC 8 hyperlink start sequence
|
|
787
|
+
while (idx < text.length) {
|
|
788
|
+
if (text[idx] === '\x1b' && idx + 1 < text.length && text[idx + 1] === '[') {
|
|
789
|
+
// Found ANSI SGR escape sequence
|
|
790
|
+
let end = idx + 2;
|
|
791
|
+
while (end < text.length && text[end] !== 'm') {
|
|
792
|
+
end++;
|
|
793
|
+
}
|
|
794
|
+
if (end < text.length) {
|
|
795
|
+
const code = text.substring(idx, end + 1);
|
|
796
|
+
// Check if it's a reset (0m) or a style code
|
|
797
|
+
if (code === '\x1b[0m') {
|
|
798
|
+
// Reset - clear all previous codes (but keep OSC 8)
|
|
799
|
+
codes.length = 0;
|
|
800
|
+
}
|
|
801
|
+
else {
|
|
802
|
+
// Style code - add it
|
|
803
|
+
codes.push(code);
|
|
804
|
+
}
|
|
805
|
+
idx = end + 1;
|
|
806
|
+
}
|
|
807
|
+
else {
|
|
808
|
+
idx++;
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
else if (text[idx] === '\x1b' && idx + 4 < text.length && text.substring(idx, idx + 4) === '\x1b]8;') {
|
|
812
|
+
// Found OSC 8 hyperlink sequence
|
|
813
|
+
let end = idx + 4;
|
|
814
|
+
// Check if this is a start sequence (has URL) or end sequence
|
|
815
|
+
let foundClose = false;
|
|
816
|
+
while (end < text.length - 1) {
|
|
817
|
+
if (text[end] === '\x1b' && text[end + 1] === '\\') {
|
|
818
|
+
// Found closing sequence
|
|
819
|
+
const sequence = text.substring(idx, end + 2);
|
|
820
|
+
if (end === idx + 4) {
|
|
821
|
+
// This is an end sequence (\x1b]8;;\x1b\\)
|
|
822
|
+
osc8Start = null; // Close the hyperlink
|
|
823
|
+
}
|
|
824
|
+
else {
|
|
825
|
+
// This is a start sequence (\x1b]8;;<url>\x1b\\)
|
|
826
|
+
osc8Start = sequence; // Store the start sequence
|
|
827
|
+
}
|
|
828
|
+
idx = end + 2;
|
|
829
|
+
foundClose = true;
|
|
830
|
+
break;
|
|
831
|
+
}
|
|
832
|
+
end++;
|
|
833
|
+
}
|
|
834
|
+
if (!foundClose) {
|
|
835
|
+
idx++;
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
else {
|
|
839
|
+
idx++;
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
// Combine ANSI codes and OSC 8 start (if hyperlink is still open)
|
|
843
|
+
const result = codes.join('');
|
|
844
|
+
return osc8Start ? result + osc8Start : result;
|
|
845
|
+
}
|
|
846
|
+
/**
|
|
847
|
+
* Close any open OSC 8 hyperlink at the end of text
|
|
848
|
+
* Returns the closing sequence if a hyperlink is open, empty string otherwise
|
|
849
|
+
*/
|
|
850
|
+
function closeOsc8IfOpen(text) {
|
|
851
|
+
let idx = 0;
|
|
852
|
+
let osc8Start = null;
|
|
853
|
+
while (idx < text.length) {
|
|
854
|
+
if (text[idx] === '\x1b' && idx + 4 < text.length && text.substring(idx, idx + 4) === '\x1b]8;') {
|
|
855
|
+
// Found OSC 8 hyperlink sequence
|
|
856
|
+
let end = idx + 4;
|
|
857
|
+
while (end < text.length - 1) {
|
|
858
|
+
if (text[end] === '\x1b' && text[end + 1] === '\\') {
|
|
859
|
+
const sequence = text.substring(idx, end + 2);
|
|
860
|
+
if (end === idx + 4) {
|
|
861
|
+
// End sequence - close hyperlink
|
|
862
|
+
osc8Start = null;
|
|
863
|
+
}
|
|
864
|
+
else {
|
|
865
|
+
// Start sequence - open hyperlink
|
|
866
|
+
osc8Start = sequence;
|
|
867
|
+
}
|
|
868
|
+
idx = end + 2;
|
|
869
|
+
break;
|
|
870
|
+
}
|
|
871
|
+
end++;
|
|
872
|
+
}
|
|
873
|
+
if (idx === end + 2) {
|
|
874
|
+
continue;
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
idx++;
|
|
878
|
+
}
|
|
879
|
+
// If hyperlink is still open, return closing sequence
|
|
880
|
+
return osc8Start ? '\x1b]8;;\x1b\\' : '';
|
|
881
|
+
}
|
|
309
882
|
/**
|
|
310
883
|
* Split text at a specific visible character position, preserving ANSI codes
|
|
311
884
|
*
|
|
@@ -329,17 +902,10 @@ export function splitAtVisiblePos(text, visiblePos) {
|
|
|
329
902
|
let visual = 0; // Count of visible characters seen so far
|
|
330
903
|
let idx = 0; // Current position in the string
|
|
331
904
|
while (idx < text.length && visual < visiblePos) {
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
end++;
|
|
337
|
-
}
|
|
338
|
-
// Include the 'm' if found
|
|
339
|
-
if (end < text.length) {
|
|
340
|
-
end++;
|
|
341
|
-
}
|
|
342
|
-
idx = end;
|
|
905
|
+
const nextIdx = skipEscapeSequence(text, idx);
|
|
906
|
+
if (nextIdx > idx) {
|
|
907
|
+
// We skipped an escape sequence
|
|
908
|
+
idx = nextIdx;
|
|
343
909
|
}
|
|
344
910
|
else {
|
|
345
911
|
// Regular character - count it and advance
|
|
@@ -349,17 +915,19 @@ export function splitAtVisiblePos(text, visiblePos) {
|
|
|
349
915
|
}
|
|
350
916
|
const before = text.substring(0, idx);
|
|
351
917
|
const after = text.substring(idx);
|
|
352
|
-
// Extract active ANSI codes from the "before" part
|
|
918
|
+
// Extract active ANSI codes and OSC 8 hyperlinks from the "before" part
|
|
353
919
|
const activeCodes = extractActiveAnsiCodes(before);
|
|
920
|
+
// Close any open OSC 8 hyperlink in the "before" part
|
|
921
|
+
const beforeWithClosedOsc8 = before + closeOsc8IfOpen(before);
|
|
354
922
|
// If there are active codes, we need to:
|
|
355
923
|
// 1. Close them in the "before" part (unless it already ends with a reset)
|
|
356
924
|
// 2. Re-apply them at the start of the "after" part
|
|
357
|
-
if (activeCodes && !
|
|
925
|
+
if (activeCodes && !beforeWithClosedOsc8.endsWith('\x1b[0m')) {
|
|
358
926
|
return {
|
|
359
|
-
before:
|
|
927
|
+
before: beforeWithClosedOsc8 + '\x1b[0m',
|
|
360
928
|
after: activeCodes + after
|
|
361
929
|
};
|
|
362
930
|
}
|
|
363
|
-
return { before, after };
|
|
931
|
+
return { before: beforeWithClosedOsc8, after };
|
|
364
932
|
}
|
|
365
933
|
//# sourceMappingURL=text.js.map
|