linecraft 0.2.4 → 0.2.6

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 (79) hide show
  1. package/LICENSE +59 -21
  2. package/README.md +51 -8
  3. package/lib/components/code-debug.d.ts +2 -0
  4. package/lib/components/code-debug.d.ts.map +1 -1
  5. package/lib/components/code-debug.js +205 -266
  6. package/lib/components/code-debug.js.map +1 -1
  7. package/lib/components/code-debug.test.d.ts +2 -0
  8. package/lib/components/code-debug.test.d.ts.map +1 -0
  9. package/lib/components/code-debug.test.js +245 -0
  10. package/lib/components/code-debug.test.js.map +1 -0
  11. package/lib/components/fill.d.ts +2 -1
  12. package/lib/components/fill.d.ts.map +1 -1
  13. package/lib/components/fill.js.map +1 -1
  14. package/lib/components/progress-bar-grid.d.ts.map +1 -1
  15. package/lib/components/progress-bar-grid.js +5 -3
  16. package/lib/components/progress-bar-grid.js.map +1 -1
  17. package/lib/components/prompt.js +1 -1
  18. package/lib/components/prompt.js.map +1 -1
  19. package/lib/components/section.js +1 -1
  20. package/lib/components/section.js.map +1 -1
  21. package/lib/components/segments.js +4 -4
  22. package/lib/components/segments.js.map +1 -1
  23. package/lib/components/spinner.d.ts +2 -1
  24. package/lib/components/spinner.d.ts.map +1 -1
  25. package/lib/components/spinner.js +1 -1
  26. package/lib/components/spinner.js.map +1 -1
  27. package/lib/components/styled.d.ts +4 -2
  28. package/lib/components/styled.d.ts.map +1 -1
  29. package/lib/components/styled.js +37 -8
  30. package/lib/components/styled.js.map +1 -1
  31. package/lib/index.d.ts +2 -0
  32. package/lib/index.d.ts.map +1 -1
  33. package/lib/index.js +1 -0
  34. package/lib/index.js.map +1 -1
  35. package/lib/layout/grid.d.ts +3 -2
  36. package/lib/layout/grid.d.ts.map +1 -1
  37. package/lib/layout/grid.js +94 -47
  38. package/lib/layout/grid.js.map +1 -1
  39. package/lib/layout/grid.test.js +67 -0
  40. package/lib/layout/grid.test.js.map +1 -1
  41. package/lib/native/diff.test.js.map +1 -1
  42. package/lib/native/region-renderer.d.ts +1 -0
  43. package/lib/native/region-renderer.d.ts.map +1 -1
  44. package/lib/native/region-renderer.js +31 -1
  45. package/lib/native/region-renderer.js.map +1 -1
  46. package/lib/native/region.test.js +1 -1
  47. package/lib/native/region.test.js.map +1 -1
  48. package/lib/region-resize.test.js +3 -3
  49. package/lib/region-resize.test.js.map +1 -1
  50. package/lib/region.d.ts.map +1 -1
  51. package/lib/region.js +0 -46
  52. package/lib/region.js.map +1 -1
  53. package/lib/types.d.ts +6 -5
  54. package/lib/types.d.ts.map +1 -1
  55. package/lib/utils/colors.d.ts.map +1 -1
  56. package/lib/utils/colors.js +50 -8
  57. package/lib/utils/colors.js.map +1 -1
  58. package/lib/utils/colors.test.js.map +1 -1
  59. package/lib/utils/cursor-position.d.ts.map +1 -1
  60. package/lib/utils/cursor-position.js +4 -13
  61. package/lib/utils/cursor-position.js.map +1 -1
  62. package/lib/utils/debug-log.js +1 -1
  63. package/lib/utils/debug-log.js.map +1 -1
  64. package/lib/utils/terminal-theme.d.ts +17 -28
  65. package/lib/utils/terminal-theme.d.ts.map +1 -1
  66. package/lib/utils/terminal-theme.js +66 -38
  67. package/lib/utils/terminal-theme.js.map +1 -1
  68. package/lib/utils/text.d.ts +64 -2
  69. package/lib/utils/text.d.ts.map +1 -1
  70. package/lib/utils/text.js +619 -103
  71. package/lib/utils/text.js.map +1 -1
  72. package/lib/utils/text.test.d.ts +2 -0
  73. package/lib/utils/text.test.d.ts.map +1 -0
  74. package/lib/utils/text.test.js +237 -0
  75. package/lib/utils/text.test.js.map +1 -0
  76. package/lib/utils/wait-for-spacebar.d.ts.map +1 -1
  77. package/lib/utils/wait-for-spacebar.js +1 -8
  78. package/lib/utils/wait-for-spacebar.js.map +1 -1
  79. package/package.json +8 -2
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,302 @@ 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 ellipsisStartOffset = hasEllipsisStart ? 3 : 0;
305
+ // If we have range boundaries, use them for more accurate mapping
306
+ if (rangeStartCol !== undefined && rangeEndCol !== undefined) {
307
+ // Calculate how much of 'before' is shown (if truncated)
308
+ let shownBeforeChars = 0;
309
+ if (hasEllipsisStart && rangeStartCol > visibleStartCol) {
310
+ // The visible before portion is from visibleStartCol to (rangeStartCol - 1)
311
+ shownBeforeChars = (rangeStartCol - 1) - visibleStartCol + 1;
312
+ }
313
+ else if (!hasEllipsisStart && rangeStartCol > 1) {
314
+ // All of before is shown
315
+ shownBeforeChars = rangeStartCol - 1;
316
+ }
317
+ // If column is before the range, it's in the truncated before portion
318
+ if (originalCol < rangeStartCol) {
319
+ if (originalCol < visibleStartCol) {
320
+ return ellipsisStartOffset || 1;
321
+ }
322
+ // Column is in the visible before portion
323
+ const offsetInBefore = originalCol - visibleStartCol + 1;
324
+ return ellipsisStartOffset + offsetInBefore;
325
+ }
326
+ // If column is in the range, calculate position relative to range start
327
+ if (originalCol >= rangeStartCol && originalCol <= rangeEndCol) {
328
+ const offsetInRange = originalCol - rangeStartCol + 1;
329
+ // We need to count how many visible characters are in 'truncatedBefore'
330
+ const truncatedBeforeWidth = hasEllipsisStart ? (shownBeforeChars + 3) : shownBeforeChars;
331
+ return truncatedBeforeWidth + offsetInRange;
332
+ }
333
+ // If column is after the range, it's in the truncated after portion
334
+ if (originalCol > rangeEndCol) {
335
+ if (originalCol > visibleEndCol) {
336
+ return countVisibleChars(truncatedPlain);
337
+ }
338
+ // Column is in the visible after portion
339
+ const rangeWidth = rangeEndCol - rangeStartCol + 1;
340
+ const offsetInAfter = originalCol - rangeEndCol;
341
+ const truncatedBeforeWidth = hasEllipsisStart ? (shownBeforeChars + 3) : shownBeforeChars;
342
+ return truncatedBeforeWidth + rangeWidth + offsetInAfter;
343
+ }
344
+ }
345
+ // Fallback to simple calculation if range boundaries not provided
346
+ // If column is before visible range, point to start ellipsis or first character
347
+ if (originalCol < visibleStartCol) {
348
+ return ellipsisStartOffset || 1;
349
+ }
350
+ // If column is after visible range, point to end
351
+ if (originalCol > visibleEndCol) {
352
+ return countVisibleChars(truncatedPlain);
353
+ }
354
+ // Column is in visible range - calculate its position
355
+ // Position = ellipsis offset + (originalCol - visibleStartCol + 1)
356
+ return ellipsisStartOffset + (originalCol - visibleStartCol + 1);
357
+ }
358
+ /**
359
+ * Truncate text to show a specific column range, with ellipsis as needed
360
+ *
361
+ * This function ensures a target range (startCol to endCol) is visible in the output,
362
+ * adding ellipsis at the start, end, or both as needed to fit within maxWidth.
363
+ * All ANSI codes and OSC 8 hyperlinks are preserved.
364
+ *
365
+ * @param text - Text that may contain ANSI escape codes
366
+ * @param maxWidth - Maximum visual width (number of visible characters)
367
+ * @param targetStartCol - Start column of target range (1-based)
368
+ * @param targetEndCol - End column of target range (1-based, optional)
369
+ * @param maxColumn - Maximum column to show (optional, for limiting display)
370
+ * @returns Truncated text with target range visible, ANSI codes preserved, and visible range info
371
+ *
372
+ * @example
373
+ * truncateFocusRange('console.log("hello world");', 20, 1, 11)
374
+ * // Returns text showing columns 1-11 with ellipsis if needed
375
+ */
376
+ export function truncateFocusRange(text, maxWidth, targetStartCol, targetEndCol, maxColumn) {
377
+ const codeLength = countVisibleChars(text);
378
+ const targetEnd = targetEndCol ?? targetStartCol;
379
+ // If code fits, show everything
380
+ if (codeLength <= maxWidth) {
381
+ return {
382
+ text: truncateToWidth(text, maxWidth),
383
+ visibleStartCol: 1,
384
+ visibleEndCol: codeLength,
385
+ rangeStartCol: targetStartCol,
386
+ rangeEndCol: targetEnd,
387
+ };
388
+ }
389
+ const effectiveMaxCol = maxColumn ? Math.min(maxColumn, codeLength) : codeLength;
390
+ const targetWidth = targetEnd - targetStartCol + 1;
391
+ // If target doesn't fit, just show middle with ellipsis
392
+ if (targetWidth > maxWidth - 6) {
393
+ const midPoint = Math.floor((maxWidth - 6) / 2);
394
+ const startCol = Math.max(1, targetStartCol - midPoint);
395
+ const endCol = Math.min(effectiveMaxCol, startCol + maxWidth - 6);
396
+ const { range } = extractVisibleRange(text, startCol, endCol);
397
+ return {
398
+ text: '...' + truncateToWidth(range, maxWidth - 3) + '...',
399
+ visibleStartCol: startCol,
400
+ visibleEndCol: endCol,
401
+ rangeStartCol: startCol,
402
+ rangeEndCol: endCol,
403
+ };
404
+ }
405
+ // Calculate visible range to center target
406
+ const willNeedEllipsisStart = targetStartCol > 1 || (targetStartCol === 1 && codeLength > maxWidth);
407
+ const willNeedEllipsisEnd = targetEnd < codeLength || codeLength > maxWidth;
408
+ const ellipsisWidth = (willNeedEllipsisStart ? 3 : 0) + (willNeedEllipsisEnd ? 3 : 0);
409
+ const effectiveAvailableWidth = maxWidth - ellipsisWidth;
410
+ const padding = Math.floor((effectiveAvailableWidth - targetWidth) / 2);
411
+ let startCol = Math.max(1, targetStartCol - padding);
412
+ let endCol = Math.min(effectiveMaxCol, startCol + effectiveAvailableWidth - 1);
413
+ // Adjust if we hit boundaries
414
+ if (endCol - startCol + 1 > effectiveAvailableWidth) {
415
+ endCol = startCol + effectiveAvailableWidth - 1;
416
+ }
417
+ if (endCol > effectiveMaxCol) {
418
+ endCol = effectiveMaxCol;
419
+ startCol = Math.max(1, endCol - effectiveAvailableWidth + 1);
420
+ }
421
+ if (startCol < 1) {
422
+ startCol = 1;
423
+ endCol = Math.min(effectiveMaxCol, effectiveAvailableWidth);
424
+ }
425
+ // Extract the visible range using the helper
426
+ const { before, range, after } = extractVisibleRange(text, startCol, endCol);
427
+ // Use existing truncation utilities to add ellipsis
428
+ const hasEllipsisStart = startCol > 1;
429
+ const hasEllipsisEnd = endCol < codeLength;
430
+ const rangeWidth = countVisibleChars(range);
431
+ let truncatedText;
432
+ let actualVisibleStartCol = startCol;
433
+ let actualVisibleEndCol = endCol;
434
+ if (hasEllipsisStart && hasEllipsisEnd) {
435
+ const beforeWidth = maxWidth - rangeWidth - 3; // -3 for end ellipsis
436
+ const truncatedBefore = before ? truncateStart(before, Math.max(3, beforeWidth)) : '';
437
+ const afterWidth = maxWidth - countVisibleChars(truncatedBefore) - rangeWidth - 3; // -3 for start ellipsis
438
+ const truncatedAfter = after ? truncateEnd(after, Math.max(3, afterWidth)) : '';
439
+ truncatedText = truncatedBefore + range + truncatedAfter;
440
+ // Adjust visible range: if before was truncated, calculate actual start
441
+ // 'before' contains columns 1 to (startCol - 1)
442
+ // truncateStart shows the END of 'before', so if we show the last N chars of 'before',
443
+ // the first visible column from 'before' is: (startCol - 1) - N + 1 = startCol - N
444
+ if (truncatedBefore && before) {
445
+ const truncatedBeforePlain = stripAnsi(truncatedBefore);
446
+ if (truncatedBeforePlain.startsWith('...')) {
447
+ const shownBeforeChars = countVisibleChars(truncatedBeforePlain) - 3; // minus ellipsis
448
+ // 'before' contains columns 1 to (startCol - 1), so it has (startCol - 1) characters
449
+ // If we show the last N chars of 'before', the first visible column is: startCol - N
450
+ actualVisibleStartCol = Math.max(1, startCol - shownBeforeChars);
451
+ }
452
+ else {
453
+ // No ellipsis means we showed all of before, so visible start is 1
454
+ actualVisibleStartCol = 1;
455
+ }
456
+ }
457
+ else if (before && before.length > 0) {
458
+ // Before exists but wasn't truncated, so all of before is visible
459
+ actualVisibleStartCol = 1;
460
+ }
461
+ // Adjust visible range: if after was truncated, calculate actual end
462
+ // truncateEnd shows the START of 'after', so if we show N chars from the start of 'after',
463
+ // the actual visible end is: endCol + N
464
+ if (truncatedAfter && after) {
465
+ const truncatedAfterPlain = stripAnsi(truncatedAfter);
466
+ if (truncatedAfterPlain.endsWith('...')) {
467
+ const shownAfterChars = countVisibleChars(truncatedAfterPlain) - 3; // minus ellipsis
468
+ // 'after' starts at endCol + 1, so if we show the first N chars, visible end is endCol + N
469
+ actualVisibleEndCol = endCol + shownAfterChars;
470
+ }
471
+ else {
472
+ // No ellipsis means we showed all of after
473
+ actualVisibleEndCol = codeLength;
474
+ }
475
+ }
476
+ else if (after && after.length > 0) {
477
+ // After exists but wasn't truncated, so all of after is visible
478
+ actualVisibleEndCol = codeLength;
479
+ }
480
+ }
481
+ else if (hasEllipsisStart) {
482
+ const beforeWidth = maxWidth - rangeWidth;
483
+ const truncatedBefore = before ? truncateStart(before, beforeWidth) : '';
484
+ truncatedText = truncatedBefore + range + after;
485
+ // Adjust visible range: if before was truncated, calculate actual start
486
+ const truncatedBeforePlain = stripAnsi(truncatedBefore);
487
+ if (truncatedBefore && before && truncatedBeforePlain.startsWith('...')) {
488
+ const shownBeforeChars = countVisibleChars(truncatedBeforePlain) - 3;
489
+ actualVisibleStartCol = Math.max(1, startCol - shownBeforeChars);
490
+ }
491
+ else if (truncatedBefore && before) {
492
+ // No ellipsis means we showed all of before
493
+ actualVisibleStartCol = 1;
494
+ }
495
+ // After is fully shown
496
+ actualVisibleEndCol = codeLength;
497
+ }
498
+ else if (hasEllipsisEnd) {
499
+ const afterWidth = maxWidth - rangeWidth;
500
+ const truncatedAfter = after ? truncateEnd(after, afterWidth) : '';
501
+ truncatedText = before + range + truncatedAfter;
502
+ // Before is fully shown
503
+ actualVisibleStartCol = 1;
504
+ // Adjust visible range: if after was truncated, calculate actual end
505
+ const truncatedAfterPlain = stripAnsi(truncatedAfter);
506
+ if (truncatedAfter && after && truncatedAfterPlain.endsWith('...')) {
507
+ const shownAfterChars = countVisibleChars(truncatedAfterPlain) - 3;
508
+ actualVisibleEndCol = endCol + shownAfterChars;
509
+ }
510
+ else {
511
+ // No ellipsis means we showed all of after
512
+ actualVisibleEndCol = codeLength;
513
+ }
514
+ }
515
+ else {
516
+ // No ellipsis needed
517
+ truncatedText = truncateToWidth(text, maxWidth);
518
+ actualVisibleStartCol = 1;
519
+ actualVisibleEndCol = codeLength;
520
+ }
521
+ return {
522
+ text: truncatedText,
523
+ visibleStartCol: actualVisibleStartCol,
524
+ visibleEndCol: actualVisibleEndCol,
525
+ rangeStartCol: startCol,
526
+ rangeEndCol: endCol,
527
+ };
181
528
  }
182
529
  /**
183
530
  * Wrap text to fit within a width, breaking on spaces
531
+ * Never breaks words mid-word - if a word is too long, it will extend the line
532
+ * ANSI-aware: calculates width based on visible characters, not raw string length
184
533
  */
185
534
  export function wrapText(text, width) {
186
535
  if (!Number.isFinite(width) || width <= 0) {
@@ -189,6 +538,7 @@ export function wrapText(text, width) {
189
538
  const lines = [];
190
539
  const length = text.length;
191
540
  let index = 0;
541
+ let activeCodes = ''; // Track active ANSI codes across line breaks
192
542
  while (index < length) {
193
543
  // Skip leading spaces
194
544
  while (index < length && text[index] === ' ') {
@@ -197,27 +547,140 @@ export function wrapText(text, width) {
197
547
  if (index >= length) {
198
548
  break;
199
549
  }
200
- let end = Math.min(length, index + width);
201
- if (end === length) {
202
- lines.push(text.slice(index));
550
+ // Find the end position based on VISIBLE width (accounting for ANSI codes)
551
+ // We need to find where we've seen 'width' visible characters
552
+ // OR where we hit a newline (which should break the line)
553
+ let visibleCount = 0;
554
+ let end = index;
555
+ let foundNewline = false;
556
+ while (end < length && visibleCount < width) {
557
+ if (text[end] === '\x1b') {
558
+ // Found ANSI escape sequence - skip to the end
559
+ let ansiEnd = end + 1;
560
+ while (ansiEnd < length && text[ansiEnd] !== 'm') {
561
+ ansiEnd++;
562
+ }
563
+ if (ansiEnd < length) {
564
+ ansiEnd++; // Include the 'm'
565
+ }
566
+ end = ansiEnd;
567
+ }
568
+ else if (text[end] === '\n') {
569
+ // Found newline - break here (don't include the newline in the line)
570
+ foundNewline = true;
571
+ break;
572
+ }
573
+ else {
574
+ // Regular character - count it
575
+ end++;
576
+ visibleCount++;
577
+ }
578
+ }
579
+ // If we found a newline, break the line there
580
+ if (foundNewline) {
581
+ let line = text.slice(index, end); // Don't include the newline
582
+ // Prepend active codes from previous line if we have any
583
+ if (activeCodes) {
584
+ line = activeCodes + line;
585
+ }
586
+ // Extract active codes from this line for the next line
587
+ activeCodes = extractActiveAnsiCodes(line);
588
+ lines.push(line);
589
+ index = end + 1; // Skip past the newline
590
+ continue;
591
+ }
592
+ if (end >= length) {
593
+ // Reached end of text - take the rest
594
+ let finalLine = text.slice(index);
595
+ // Prepend active codes from previous line if we have any
596
+ if (activeCodes) {
597
+ finalLine = activeCodes + finalLine;
598
+ }
599
+ lines.push(finalLine);
203
600
  break;
204
601
  }
602
+ // Look for a break point (space or hyphen) going backwards from end
603
+ // But we need to search in the visible character space, not raw string position
205
604
  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;
605
+ let searchVisible = visibleCount;
606
+ let searchIdx = end;
607
+ // Search backwards for a space or hyphen
608
+ while (searchIdx > index && searchVisible > 0) {
609
+ // Move backwards, accounting for ANSI codes
610
+ if (text[searchIdx - 1] === '\x1b') {
611
+ // Found ANSI code - skip backwards past it
612
+ let ansiStart = searchIdx - 1;
613
+ while (ansiStart > index && text[ansiStart - 1] !== 'm') {
614
+ ansiStart--;
615
+ }
616
+ if (ansiStart > index && text[ansiStart - 1] === 'm') {
617
+ ansiStart--; // Include the 'm'
618
+ }
619
+ searchIdx = ansiStart;
211
620
  }
212
- if (char === '-') {
213
- breakPoint = i;
214
- break;
621
+ else {
622
+ const char = text[searchIdx - 1];
623
+ if (char === ' ') {
624
+ breakPoint = searchIdx - 1;
625
+ break;
626
+ }
627
+ if (char === '-') {
628
+ breakPoint = searchIdx;
629
+ break;
630
+ }
631
+ searchIdx--;
632
+ searchVisible--;
215
633
  }
216
634
  }
217
635
  if (breakPoint === -1) {
218
- breakPoint = end;
636
+ // No space found within the width - we're in the middle of a word
637
+ // Look for the NEXT space after 'end' to break there
638
+ // This ensures we never break mid-word
639
+ let nextSpace = -1;
640
+ let searchIdx = end;
641
+ while (searchIdx < length) {
642
+ if (text[searchIdx] === '\x1b') {
643
+ // Skip ANSI code
644
+ let ansiEnd = searchIdx + 1;
645
+ while (ansiEnd < length && text[ansiEnd] !== 'm') {
646
+ ansiEnd++;
647
+ }
648
+ if (ansiEnd < length) {
649
+ ansiEnd++;
650
+ }
651
+ searchIdx = ansiEnd;
652
+ }
653
+ else {
654
+ if (text[searchIdx] === ' ') {
655
+ nextSpace = searchIdx;
656
+ break;
657
+ }
658
+ searchIdx++;
659
+ }
660
+ }
661
+ if (nextSpace === -1) {
662
+ // No more spaces - take the rest (this is the last word/line)
663
+ let finalLine = text.slice(index);
664
+ // Prepend active codes from previous line if we have any
665
+ if (activeCodes) {
666
+ finalLine = activeCodes + finalLine;
667
+ }
668
+ lines.push(finalLine);
669
+ break;
670
+ }
671
+ else {
672
+ // Found a space after - break there (word extends beyond width)
673
+ // This means the word is longer than width, so we have to break after it
674
+ breakPoint = nextSpace;
675
+ }
219
676
  }
220
- const line = text.slice(index, breakPoint).trimEnd();
677
+ let line = text.slice(index, breakPoint).trimEnd();
678
+ // Prepend active codes from previous line if we have any
679
+ if (activeCodes) {
680
+ line = activeCodes + line;
681
+ }
682
+ // Extract active codes from this line for the next line
683
+ activeCodes = extractActiveAnsiCodes(line);
221
684
  lines.push(line);
222
685
  index = breakPoint;
223
686
  while (index < length && text[index] === ' ') {
@@ -248,17 +711,10 @@ export function countVisibleChars(text) {
248
711
  let count = 0;
249
712
  let idx = 0;
250
713
  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;
714
+ const nextIdx = skipEscapeSequence(text, idx);
715
+ if (nextIdx > idx) {
716
+ // We skipped an escape sequence
717
+ idx = nextIdx;
262
718
  }
263
719
  else {
264
720
  // Regular character - count it
@@ -269,15 +725,16 @@ export function countVisibleChars(text) {
269
725
  return count;
270
726
  }
271
727
  /**
272
- * Extract active ANSI codes from text (all codes that are active at the end)
728
+ * Extract active ANSI codes and OSC 8 hyperlinks from text (all codes that are active at the end)
273
729
  * Returns the ANSI escape sequences that should be applied to continue the styling
274
730
  */
275
731
  function extractActiveAnsiCodes(text) {
276
732
  const codes = [];
277
733
  let idx = 0;
734
+ let osc8Start = null; // Track OSC 8 hyperlink start sequence
278
735
  while (idx < text.length) {
279
736
  if (text[idx] === '\x1b' && idx + 1 < text.length && text[idx + 1] === '[') {
280
- // Found ANSI escape sequence
737
+ // Found ANSI SGR escape sequence
281
738
  let end = idx + 2;
282
739
  while (end < text.length && text[end] !== 'm') {
283
740
  end++;
@@ -286,7 +743,7 @@ function extractActiveAnsiCodes(text) {
286
743
  const code = text.substring(idx, end + 1);
287
744
  // Check if it's a reset (0m) or a style code
288
745
  if (code === '\x1b[0m') {
289
- // Reset - clear all previous codes
746
+ // Reset - clear all previous codes (but keep OSC 8)
290
747
  codes.length = 0;
291
748
  }
292
749
  else {
@@ -299,12 +756,76 @@ function extractActiveAnsiCodes(text) {
299
756
  idx++;
300
757
  }
301
758
  }
759
+ else if (text[idx] === '\x1b' && idx + 4 < text.length && text.substring(idx, idx + 4) === '\x1b]8;') {
760
+ // Found OSC 8 hyperlink sequence
761
+ let end = idx + 4;
762
+ // Check if this is a start sequence (has URL) or end sequence
763
+ let foundClose = false;
764
+ while (end < text.length - 1) {
765
+ if (text[end] === '\x1b' && text[end + 1] === '\\') {
766
+ // Found closing sequence
767
+ const sequence = text.substring(idx, end + 2);
768
+ if (end === idx + 4) {
769
+ // This is an end sequence (\x1b]8;;\x1b\\)
770
+ osc8Start = null; // Close the hyperlink
771
+ }
772
+ else {
773
+ // This is a start sequence (\x1b]8;;<url>\x1b\\)
774
+ osc8Start = sequence; // Store the start sequence
775
+ }
776
+ idx = end + 2;
777
+ foundClose = true;
778
+ break;
779
+ }
780
+ end++;
781
+ }
782
+ if (!foundClose) {
783
+ idx++;
784
+ }
785
+ }
302
786
  else {
303
787
  idx++;
304
788
  }
305
789
  }
306
- // Return all active codes combined
307
- return codes.join('');
790
+ // Combine ANSI codes and OSC 8 start (if hyperlink is still open)
791
+ const result = codes.join('');
792
+ return osc8Start ? result + osc8Start : result;
793
+ }
794
+ /**
795
+ * Close any open OSC 8 hyperlink at the end of text
796
+ * Returns the closing sequence if a hyperlink is open, empty string otherwise
797
+ */
798
+ function closeOsc8IfOpen(text) {
799
+ let idx = 0;
800
+ let osc8Start = null;
801
+ while (idx < text.length) {
802
+ if (text[idx] === '\x1b' && idx + 4 < text.length && text.substring(idx, idx + 4) === '\x1b]8;') {
803
+ // Found OSC 8 hyperlink sequence
804
+ let end = idx + 4;
805
+ while (end < text.length - 1) {
806
+ if (text[end] === '\x1b' && text[end + 1] === '\\') {
807
+ const sequence = text.substring(idx, end + 2);
808
+ if (end === idx + 4) {
809
+ // End sequence - close hyperlink
810
+ osc8Start = null;
811
+ }
812
+ else {
813
+ // Start sequence - open hyperlink
814
+ osc8Start = sequence;
815
+ }
816
+ idx = end + 2;
817
+ break;
818
+ }
819
+ end++;
820
+ }
821
+ if (idx === end + 2) {
822
+ continue;
823
+ }
824
+ }
825
+ idx++;
826
+ }
827
+ // If hyperlink is still open, return closing sequence
828
+ return osc8Start ? '\x1b]8;;\x1b\\' : '';
308
829
  }
309
830
  /**
310
831
  * Split text at a specific visible character position, preserving ANSI codes
@@ -329,17 +850,10 @@ export function splitAtVisiblePos(text, visiblePos) {
329
850
  let visual = 0; // Count of visible characters seen so far
330
851
  let idx = 0; // Current position in the string
331
852
  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;
853
+ const nextIdx = skipEscapeSequence(text, idx);
854
+ if (nextIdx > idx) {
855
+ // We skipped an escape sequence
856
+ idx = nextIdx;
343
857
  }
344
858
  else {
345
859
  // Regular character - count it and advance
@@ -349,17 +863,19 @@ export function splitAtVisiblePos(text, visiblePos) {
349
863
  }
350
864
  const before = text.substring(0, idx);
351
865
  const after = text.substring(idx);
352
- // Extract active ANSI codes from the "before" part
866
+ // Extract active ANSI codes and OSC 8 hyperlinks from the "before" part
353
867
  const activeCodes = extractActiveAnsiCodes(before);
868
+ // Close any open OSC 8 hyperlink in the "before" part
869
+ const beforeWithClosedOsc8 = before + closeOsc8IfOpen(before);
354
870
  // If there are active codes, we need to:
355
871
  // 1. Close them in the "before" part (unless it already ends with a reset)
356
872
  // 2. Re-apply them at the start of the "after" part
357
- if (activeCodes && !before.endsWith('\x1b[0m')) {
873
+ if (activeCodes && !beforeWithClosedOsc8.endsWith('\x1b[0m')) {
358
874
  return {
359
- before: before + '\x1b[0m',
875
+ before: beforeWithClosedOsc8 + '\x1b[0m',
360
876
  after: activeCodes + after
361
877
  };
362
878
  }
363
- return { before, after };
879
+ return { before: beforeWithClosedOsc8, after };
364
880
  }
365
881
  //# sourceMappingURL=text.js.map