linecraft 0.5.3 → 0.5.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.
Files changed (42) hide show
  1. package/README.md +41 -7
  2. package/lib/components/code-debug.d.ts +2 -0
  3. package/lib/components/code-debug.d.ts.map +1 -1
  4. package/lib/components/code-debug.js +224 -255
  5. package/lib/components/code-debug.js.map +1 -1
  6. package/lib/components/code-debug.test.d.ts +2 -0
  7. package/lib/components/code-debug.test.d.ts.map +1 -0
  8. package/lib/components/code-debug.test.js +253 -0
  9. package/lib/components/code-debug.test.js.map +1 -0
  10. package/lib/components/styled.d.ts +1 -0
  11. package/lib/components/styled.d.ts.map +1 -1
  12. package/lib/components/styled.js +36 -7
  13. package/lib/components/styled.js.map +1 -1
  14. package/lib/layout/grid.d.ts +3 -2
  15. package/lib/layout/grid.d.ts.map +1 -1
  16. package/lib/layout/grid.js +93 -45
  17. package/lib/layout/grid.js.map +1 -1
  18. package/lib/layout/grid.test.js +67 -0
  19. package/lib/layout/grid.test.js.map +1 -1
  20. package/lib/native/diff.test.js.map +1 -1
  21. package/lib/native/region-renderer.d.ts +1 -0
  22. package/lib/native/region-renderer.d.ts.map +1 -1
  23. package/lib/native/region-renderer.js +31 -1
  24. package/lib/native/region-renderer.js.map +1 -1
  25. package/lib/region.d.ts.map +1 -1
  26. package/lib/region.js +0 -46
  27. package/lib/region.js.map +1 -1
  28. package/lib/utils/cursor-position.d.ts.map +1 -1
  29. package/lib/utils/cursor-position.js +4 -13
  30. package/lib/utils/cursor-position.js.map +1 -1
  31. package/lib/utils/text.d.ts +64 -2
  32. package/lib/utils/text.d.ts.map +1 -1
  33. package/lib/utils/text.js +670 -102
  34. package/lib/utils/text.js.map +1 -1
  35. package/lib/utils/text.test.d.ts +2 -0
  36. package/lib/utils/text.test.d.ts.map +1 -0
  37. package/lib/utils/text.test.js +237 -0
  38. package/lib/utils/text.test.js.map +1 -0
  39. package/lib/utils/wait-for-spacebar.d.ts.map +1 -1
  40. package/lib/utils/wait-for-spacebar.js +0 -7
  41. package/lib/utils/wait-for-spacebar.js.map +1 -1
  42. 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
- const ansiRegex = /\x1b\[[0-9;]*m/g;
16
- return text.replace(ansiRegex, '');
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. This is the base function
23
- * used by all other truncate functions.
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 ANSI sequences
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
- if (text[idx] === '\x1b') {
47
- // Found start of ANSI escape sequence - skip to the end
48
- // ANSI codes follow pattern: \x1b[...m where ... can contain numbers, semicolons
49
- let end = idx + 1;
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
- return text.substring(0, idx);
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
- // Truncate to make room for ellipsis, then add ellipsis
87
- const truncated = truncateToWidth(text, maxWidth - 3);
88
- return truncated + '...';
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 plain = stripAnsi(text);
102
- if (plain.length <= maxWidth) {
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 startIdx = plain.length - availableWidth;
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 < startIdx) {
116
- if (text[idx] === '\x1b') {
117
- let end = idx + 1;
118
- while (end < text.length && text[end] !== 'm') {
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
- return '...' + text.substring(idx);
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
- // Calculate how many characters we can show on each side
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 < endStartIdx) {
164
- if (text[idx] === '\x1b') {
165
- let end = idx + 1;
166
- while (end < text.length && text[end] !== 'm') {
167
- end++;
168
- }
169
- if (end < text.length) {
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
- return startPortion + '...' + endPortion;
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
- let end = Math.min(length, index + width);
201
- if (end === length) {
202
- lines.push(text.slice(index));
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
- for (let i = end; i > index; i--) {
207
- const char = text[i - 1];
208
- if (char === ' ') {
209
- breakPoint = i - 1;
210
- break;
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
- if (char === '-') {
213
- breakPoint = i;
214
- break;
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
- breakPoint = end;
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
- const line = text.slice(index, breakPoint).trimEnd();
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
- if (text[idx] === '\x1b') {
252
- // Found start of ANSI escape sequence - skip to the end
253
- let end = idx + 1;
254
- while (end < text.length && text[end] !== 'm') {
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
- function extractActiveAnsiCodes(text) {
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
- if (text[idx] === '\x1b') {
333
- // Found start of ANSI escape sequence - skip to the end
334
- let end = idx + 1;
335
- while (end < text.length && text[end] !== 'm') {
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 && !before.endsWith('\x1b[0m')) {
925
+ if (activeCodes && !beforeWithClosedOsc8.endsWith('\x1b[0m')) {
358
926
  return {
359
- before: before + '\x1b[0m',
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