@fleetagent/pi-tui 0.1.9 → 0.2.1

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 (109) hide show
  1. package/README.md +47 -7
  2. package/dist/autocomplete.d.ts +17 -23
  3. package/dist/autocomplete.d.ts.map +1 -1
  4. package/dist/autocomplete.js +129 -195
  5. package/dist/autocomplete.js.map +1 -1
  6. package/dist/components/alt-screen-flash.d.ts +13 -0
  7. package/dist/components/alt-screen-flash.d.ts.map +1 -0
  8. package/dist/components/alt-screen-flash.js +37 -0
  9. package/dist/components/alt-screen-flash.js.map +1 -0
  10. package/dist/components/editor.d.ts +47 -4
  11. package/dist/components/editor.d.ts.map +1 -1
  12. package/dist/components/editor.js +615 -585
  13. package/dist/components/editor.js.map +1 -1
  14. package/dist/components/h-stack.d.ts +7 -0
  15. package/dist/components/h-stack.d.ts.map +1 -0
  16. package/dist/components/h-stack.js +43 -0
  17. package/dist/components/h-stack.js.map +1 -0
  18. package/dist/components/image.d.ts +4 -0
  19. package/dist/components/image.d.ts.map +1 -1
  20. package/dist/components/image.js +38 -51
  21. package/dist/components/image.js.map +1 -1
  22. package/dist/components/input.d.ts +9 -0
  23. package/dist/components/input.d.ts.map +1 -1
  24. package/dist/components/input.js +121 -147
  25. package/dist/components/input.js.map +1 -1
  26. package/dist/components/markdown.d.ts +34 -0
  27. package/dist/components/markdown.d.ts.map +1 -1
  28. package/dist/components/markdown.js +491 -429
  29. package/dist/components/markdown.js.map +1 -1
  30. package/dist/components/scroll-view.d.ts +53 -0
  31. package/dist/components/scroll-view.d.ts.map +1 -0
  32. package/dist/components/scroll-view.js +173 -0
  33. package/dist/components/scroll-view.js.map +1 -0
  34. package/dist/components/select-list.d.ts.map +1 -1
  35. package/dist/components/select-list.js.map +1 -1
  36. package/dist/components/settings-list.d.ts +2 -0
  37. package/dist/components/settings-list.d.ts.map +1 -1
  38. package/dist/components/settings-list.js +31 -29
  39. package/dist/components/settings-list.js.map +1 -1
  40. package/dist/components/stack.d.ts +32 -0
  41. package/dist/components/stack.d.ts.map +1 -0
  42. package/dist/components/stack.js +122 -0
  43. package/dist/components/stack.js.map +1 -0
  44. package/dist/components/v-stack.d.ts +8 -0
  45. package/dist/components/v-stack.d.ts.map +1 -0
  46. package/dist/components/v-stack.js +27 -0
  47. package/dist/components/v-stack.js.map +1 -0
  48. package/dist/fuzzy.d.ts.map +1 -1
  49. package/dist/fuzzy.js +37 -46
  50. package/dist/fuzzy.js.map +1 -1
  51. package/dist/index.d.ts +10 -3
  52. package/dist/index.d.ts.map +1 -1
  53. package/dist/index.js +11 -2
  54. package/dist/index.js.map +1 -1
  55. package/dist/keybindings.d.ts +54 -4
  56. package/dist/keybindings.d.ts.map +1 -1
  57. package/dist/keybindings.js +39 -4
  58. package/dist/keybindings.js.map +1 -1
  59. package/dist/keys.d.ts.map +1 -1
  60. package/dist/keys.js +359 -479
  61. package/dist/keys.js.map +1 -1
  62. package/dist/kill-ring.d.ts +6 -4
  63. package/dist/kill-ring.d.ts.map +1 -1
  64. package/dist/kill-ring.js +0 -7
  65. package/dist/kill-ring.js.map +1 -1
  66. package/dist/latex.d.ts +10 -0
  67. package/dist/latex.d.ts.map +1 -0
  68. package/dist/latex.js +1406 -0
  69. package/dist/latex.js.map +1 -0
  70. package/dist/layout-node.d.ts +43 -0
  71. package/dist/layout-node.d.ts.map +1 -0
  72. package/dist/layout-node.js +6 -0
  73. package/dist/layout-node.js.map +1 -0
  74. package/dist/layout.d.ts +40 -0
  75. package/dist/layout.d.ts.map +1 -0
  76. package/dist/layout.js +386 -0
  77. package/dist/layout.js.map +1 -0
  78. package/dist/stdin-buffer.d.ts +8 -0
  79. package/dist/stdin-buffer.d.ts.map +1 -1
  80. package/dist/stdin-buffer.js +116 -159
  81. package/dist/stdin-buffer.js.map +1 -1
  82. package/dist/terminal-image.d.ts +41 -19
  83. package/dist/terminal-image.d.ts.map +1 -1
  84. package/dist/terminal-image.js +171 -32
  85. package/dist/terminal-image.js.map +1 -1
  86. package/dist/terminal.d.ts +3 -0
  87. package/dist/terminal.d.ts.map +1 -1
  88. package/dist/terminal.js +27 -23
  89. package/dist/terminal.js.map +1 -1
  90. package/dist/tui-alt-screen.d.ts +114 -0
  91. package/dist/tui-alt-screen.d.ts.map +1 -0
  92. package/dist/tui-alt-screen.js +950 -0
  93. package/dist/tui-alt-screen.js.map +1 -0
  94. package/dist/tui-main-screen.d.ts +52 -0
  95. package/dist/tui-main-screen.d.ts.map +1 -0
  96. package/dist/tui-main-screen.js +511 -0
  97. package/dist/tui-main-screen.js.map +1 -0
  98. package/dist/tui.d.ts +112 -44
  99. package/dist/tui.d.ts.map +1 -1
  100. package/dist/tui.js +489 -699
  101. package/dist/tui.js.map +1 -1
  102. package/dist/utils.d.ts +29 -17
  103. package/dist/utils.d.ts.map +1 -1
  104. package/dist/utils.js +605 -528
  105. package/dist/utils.js.map +1 -1
  106. package/dist/word-navigation.d.ts.map +1 -1
  107. package/dist/word-navigation.js +37 -39
  108. package/dist/word-navigation.js.map +1 -1
  109. package/package.json +1 -1
package/dist/utils.js CHANGED
@@ -33,6 +33,11 @@ function couldBeEmoji(segment) {
33
33
  // Regexes for character classification (same as string-width library)
34
34
  const zeroWidthRegex = /^(?:\p{Default_Ignorable_Code_Point}|\p{Control}|\p{Mark}|\p{Surrogate})+$/v;
35
35
  const leadingNonPrintingRegex = /^[\p{Default_Ignorable_Code_Point}\p{Control}\p{Format}\p{Mark}\p{Surrogate}]+/v;
36
+ const nonPrintingCharRegex = /^(?:\p{Default_Ignorable_Code_Point}|\p{Control}|\p{Format}|\p{Mark}|\p{Surrogate})$/v;
37
+ const markCharRegex = /^\p{Mark}$/v;
38
+ // Marks that terminals allocate cells for when attached to a base character.
39
+ // This includes Unicode spacing marks and non-spacing exceptions in legacy wcwidth tables.
40
+ const terminalSpacingMarkRegex = /^(?:[\p{Spacing_Mark}--[\u1734\u302E\u302F]]|[\u065F\u0F7F\u102B\u102C\u1031\u1033-\u1035\u1038\u103A-\u103E])+$/v;
36
41
  const rgiEmojiRegex = /^\p{RGI_Emoji}$/v;
37
42
  // Cache for non-ASCII strings
38
43
  const WIDTH_CACHE_SIZE = 512;
@@ -46,86 +51,76 @@ function isPrintableAscii(str) {
46
51
  }
47
52
  return true;
48
53
  }
49
- function truncateFragmentToWidth(text, maxWidth) {
50
- if (maxWidth <= 0 || text.length === 0) {
51
- return { text: "", width: 0 };
52
- }
53
- if (isPrintableAscii(text)) {
54
- const clipped = text.slice(0, maxWidth);
55
- return { text: clipped, width: clipped.length };
56
- }
57
- const hasAnsi = text.includes("\x1b");
58
- const hasTabs = text.includes("\t");
59
- if (!hasAnsi && !hasTabs) {
60
- let result = "";
61
- let width = 0;
62
- for (const { segment } of graphemeSegmenter.segment(text)) {
63
- const w = graphemeWidth(segment);
64
- if (width + w > maxWidth) {
65
- break;
66
- }
67
- result += segment;
68
- width += w;
69
- }
70
- return { text: result, width };
54
+ function appendPendingAnsi(state) {
55
+ if (!state.pendingAnsi)
56
+ return;
57
+ state.text += state.pendingAnsi;
58
+ state.pendingAnsi = "";
59
+ }
60
+ function appendVisibleFragment(state, fragment, fragmentWidth, maxWidth) {
61
+ if (state.width + fragmentWidth > maxWidth)
62
+ return false;
63
+ appendPendingAnsi(state);
64
+ state.text += fragment;
65
+ state.width += fragmentWidth;
66
+ return true;
67
+ }
68
+ function appendGraphemeRun(state, text, maxWidth) {
69
+ for (const { segment } of graphemeSegmenter.segment(text)) {
70
+ if (!appendVisibleFragment(state, segment, graphemeWidth(segment), maxWidth))
71
+ return false;
71
72
  }
72
- let result = "";
73
- let width = 0;
74
- let i = 0;
75
- let pendingAnsi = "";
76
- while (i < text.length) {
77
- const ansi = extractAnsiCode(text, i);
73
+ return true;
74
+ }
75
+ function truncateStyledFragment(state, text, maxWidth) {
76
+ let index = 0;
77
+ while (index < text.length) {
78
+ const ansi = extractAnsiCode(text, index);
78
79
  if (ansi) {
79
- pendingAnsi += ansi.code;
80
- i += ansi.length;
80
+ state.pendingAnsi += ansi.code;
81
+ index += ansi.length;
81
82
  continue;
82
83
  }
83
- if (text[i] === "\t") {
84
- if (width + 3 > maxWidth) {
85
- break;
86
- }
87
- if (pendingAnsi) {
88
- result += pendingAnsi;
89
- pendingAnsi = "";
90
- }
91
- result += "\t";
92
- width += 3;
93
- i++;
84
+ if (text[index] === "\t") {
85
+ if (!appendVisibleFragment(state, "\t", 3, maxWidth))
86
+ return;
87
+ index++;
94
88
  continue;
95
89
  }
96
- let end = i;
97
- while (end < text.length && text[end] !== "\t") {
98
- const nextAnsi = extractAnsiCode(text, end);
99
- if (nextAnsi) {
100
- break;
101
- }
90
+ let end = index;
91
+ while (end < text.length && text[end] !== "\t" && !extractAnsiCode(text, end))
102
92
  end++;
103
- }
104
- for (const { segment } of graphemeSegmenter.segment(text.slice(i, end))) {
105
- const w = graphemeWidth(segment);
106
- if (width + w > maxWidth) {
107
- return { text: result, width };
108
- }
109
- if (pendingAnsi) {
110
- result += pendingAnsi;
111
- pendingAnsi = "";
112
- }
113
- result += segment;
114
- width += w;
115
- }
116
- i = end;
93
+ if (!appendGraphemeRun(state, text.slice(index, end), maxWidth))
94
+ return;
95
+ index = end;
96
+ }
97
+ }
98
+ function truncateFragmentToWidth(text, maxWidth) {
99
+ if (maxWidth <= 0 || text.length === 0)
100
+ return { text: "", width: 0 };
101
+ if (isPrintableAscii(text)) {
102
+ const clipped = text.slice(0, maxWidth);
103
+ return { text: clipped, width: clipped.length };
104
+ }
105
+ const state = { text: "", width: 0, pendingAnsi: "" };
106
+ if (!text.includes("\x1b") && !text.includes("\t")) {
107
+ appendGraphemeRun(state, text, maxWidth);
117
108
  }
118
- return { text: result, width };
109
+ else {
110
+ truncateStyledFragment(state, text, maxWidth);
111
+ }
112
+ return { text: state.text, width: state.width };
119
113
  }
120
114
  function finalizeTruncatedResult(prefix, prefixWidth, ellipsis, ellipsisWidth, maxWidth, pad) {
121
115
  const reset = "\x1b[0m";
116
+ const hyperlinkClose = getActiveOsc8Close(prefix);
122
117
  const visibleWidth = prefixWidth + ellipsisWidth;
123
118
  let result;
124
119
  if (ellipsis.length > 0) {
125
- result = `${prefix}${reset}${ellipsis}${reset}`;
120
+ result = `${prefix}${hyperlinkClose}${reset}${ellipsis}${reset}`;
126
121
  }
127
122
  else {
128
- result = `${prefix}${reset}`;
123
+ result = `${prefix}${hyperlinkClose}${reset}`;
129
124
  }
130
125
  return pad ? result + " ".repeat(Math.max(0, maxWidth - visibleWidth)) : result;
131
126
  }
@@ -134,7 +129,45 @@ function finalizeTruncatedResult(prefix, prefixWidth, ellipsis, ellipsisWidth, m
134
129
  * Based on code from the string-width library, but includes a possible-emoji
135
130
  * check to avoid running the RGI_Emoji regex unnecessarily.
136
131
  */
132
+ function trailingCodePointWidth(codePoint, followsMark) {
133
+ if (followsMark || (codePoint >= 0xff00 && codePoint <= 0xffef)) {
134
+ // Indic consonants after marks and halfwidth/fullwidth forms.
135
+ return eastAsianWidth(codePoint);
136
+ }
137
+ if (codePoint === 0x0e33 || codePoint === 0x0eb3) {
138
+ // Thai/Lao AM vowels.
139
+ return 1;
140
+ }
141
+ return 0;
142
+ }
143
+ function trailingGraphemeWidth(base) {
144
+ let width = 0;
145
+ let followsMark = false;
146
+ for (const char of [...base].slice(1)) {
147
+ if (terminalSpacingMarkRegex.test(char)) {
148
+ width += 1;
149
+ followsMark = false;
150
+ continue;
151
+ }
152
+ if (markCharRegex.test(char)) {
153
+ followsMark = true;
154
+ continue;
155
+ }
156
+ if (nonPrintingCharRegex.test(char))
157
+ continue;
158
+ width += trailingCodePointWidth(char.codePointAt(0), followsMark);
159
+ followsMark = false;
160
+ }
161
+ return width;
162
+ }
137
163
  function graphemeWidth(segment) {
164
+ if (segment === "\t") {
165
+ return 3;
166
+ }
167
+ // Some marks occupy cells even without a base character.
168
+ if (terminalSpacingMarkRegex.test(segment)) {
169
+ return [...segment].length;
170
+ }
138
171
  // Zero-width clusters
139
172
  if (zeroWidthRegex.test(segment)) {
140
173
  return 0;
@@ -155,20 +188,10 @@ function graphemeWidth(segment) {
155
188
  if (cp >= 0x1f1e6 && cp <= 0x1f1ff) {
156
189
  return 2;
157
190
  }
158
- let width = eastAsianWidth(cp);
159
- // Trailing halfwidth/fullwidth forms and AM vowels that segment with a base.
160
- if (segment.length > 1) {
161
- for (const char of segment.slice(1)) {
162
- const c = char.codePointAt(0);
163
- if (c >= 0xff00 && c <= 0xffef) {
164
- width += eastAsianWidth(c);
165
- }
166
- else if (c === 0x0e33 || c === 0x0eb3) {
167
- width += 1;
168
- }
169
- }
170
- }
171
- return width;
191
+ // Intl.Segmenter can group multiple terminal-spacing code points into one
192
+ // grapheme. Count trailing visible code points that terminals may allocate
193
+ // cells for.
194
+ return eastAsianWidth(cp) + trailingGraphemeWidth(base);
172
195
  }
173
196
  /**
174
197
  * Calculate the visible width of a string in terminal columns.
@@ -223,63 +246,146 @@ export function visibleWidth(str) {
223
246
  widthCache.set(str, width);
224
247
  return width;
225
248
  }
249
+ /** Remove ANSI, OSC, and APC control sequences while preserving visible text. */
250
+ export function stripTerminalSequences(str) {
251
+ if (!str.includes("\x1b"))
252
+ return str;
253
+ let result = "";
254
+ let i = 0;
255
+ while (i < str.length) {
256
+ const ansi = extractAnsiCode(str, i);
257
+ if (ansi) {
258
+ i += ansi.length;
259
+ continue;
260
+ }
261
+ result += str[i];
262
+ i++;
263
+ }
264
+ return result;
265
+ }
266
+ function scanTerminalTextColumn(text, column, startColumn) {
267
+ let currentColumn = startColumn;
268
+ for (const { segment } of graphemeSegmenter.segment(text)) {
269
+ const width = segment === "\t" ? 3 : graphemeWidth(segment);
270
+ if (column >= currentColumn && column < currentColumn + width) {
271
+ return { containsColumn: true, nextColumn: currentColumn };
272
+ }
273
+ currentColumn += width;
274
+ }
275
+ return { containsColumn: false, nextColumn: currentColumn };
276
+ }
277
+ /** Return the terminal-cell range occupied by the grapheme at a visible column. */
278
+ export function getGraphemeCellRange(line, column) {
279
+ let currentCol = 0;
280
+ let i = 0;
281
+ while (i < line.length) {
282
+ const ansi = extractAnsiCode(line, i);
283
+ if (ansi) {
284
+ i += ansi.length;
285
+ continue;
286
+ }
287
+ let textEnd = i;
288
+ while (textEnd < line.length && !extractAnsiCode(line, textEnd))
289
+ textEnd++;
290
+ for (const { segment } of graphemeSegmenter.segment(line.slice(i, textEnd))) {
291
+ const width = graphemeWidth(segment);
292
+ if (width > 0 && column >= currentCol && column < currentCol + width) {
293
+ return { start: currentCol, end: currentCol + width };
294
+ }
295
+ currentCol += width;
296
+ }
297
+ i = textEnd;
298
+ }
299
+ return undefined;
300
+ }
301
+ /** Return the OSC 8 hyperlink covering a visible terminal column. */
302
+ export function getOsc8LinkAtColumn(line, column) {
303
+ let activeUrl;
304
+ let currentCol = 0;
305
+ let i = 0;
306
+ while (i < line.length) {
307
+ const ansi = extractAnsiCode(line, i);
308
+ if (ansi) {
309
+ const hyperlink = /^\x1b\]8;[^;]*;([^\x07\x1b]*)(?:\x07|\x1b\\)$/.exec(ansi.code);
310
+ if (hyperlink)
311
+ activeUrl = hyperlink[1] || undefined;
312
+ i += ansi.length;
313
+ continue;
314
+ }
315
+ let textEnd = i;
316
+ while (textEnd < line.length && !extractAnsiCode(line, textEnd))
317
+ textEnd++;
318
+ const scan = scanTerminalTextColumn(line.slice(i, textEnd), column, currentCol);
319
+ if (scan.containsColumn)
320
+ return activeUrl;
321
+ currentCol = scan.nextColumn;
322
+ i = textEnd;
323
+ }
324
+ return undefined;
325
+ }
226
326
  /**
227
327
  * Normalize text for terminal output without changing logical editor content.
228
328
  * Some terminals render precomposed Thai/Lao AM vowels inconsistently during
229
329
  * differential repaint. Their compatibility decompositions have the same cell
230
- * width but avoid stale-cell artifacts in terminal renderers.
330
+ * width but avoid stale-cell artifacts in terminal renderers. Visible tabs are
331
+ * expanded to the fixed width used by layout so terminal tab stops cannot wrap
332
+ * a logical line, while tabs inside terminal string sequences stay untouched.
231
333
  */
232
334
  const THAI_LAO_AM_REGEX = /[\u0e33\u0eb3]/;
233
335
  const THAI_LAO_AM_GLOBAL_REGEX = /[\u0e33\u0eb3]/g;
234
336
  export function normalizeTerminalOutput(str) {
235
- if (!THAI_LAO_AM_REGEX.test(str))
236
- return str;
237
- return str.replace(THAI_LAO_AM_GLOBAL_REGEX, (char) => (char === "\u0e33" ? "\u0e4d\u0e32" : "\u0ecd\u0eb2"));
238
- }
239
- /**
240
- * Extract ANSI escape sequences from a string at the given position.
241
- */
242
- export function extractAnsiCode(str, pos) {
243
- if (pos >= str.length || str[pos] !== "\x1b")
244
- return null;
245
- const next = str[pos + 1];
246
- // CSI sequence: ESC [ ... m/G/K/H/J
247
- if (next === "[") {
248
- let j = pos + 2;
249
- while (j < str.length && !/[mGKHJ]/.test(str[j]))
250
- j++;
251
- if (j < str.length)
252
- return { code: str.substring(pos, j + 1), length: j + 1 - pos };
253
- return null;
337
+ let normalized = str;
338
+ if (THAI_LAO_AM_REGEX.test(normalized)) {
339
+ normalized = normalized.replace(THAI_LAO_AM_GLOBAL_REGEX, (char) => char === "\u0e33" ? "\u0e4d\u0e32" : "\u0ecd\u0eb2");
254
340
  }
255
- // OSC sequence: ESC ] ... BEL or ESC ] ... ST (ESC \)
256
- // Used for hyperlinks (OSC 8), window titles, etc.
257
- if (next === "]") {
258
- let j = pos + 2;
259
- while (j < str.length) {
260
- if (str[j] === "\x07")
261
- return { code: str.substring(pos, j + 1), length: j + 1 - pos };
262
- if (str[j] === "\x1b" && str[j + 1] === "\\")
263
- return { code: str.substring(pos, j + 2), length: j + 2 - pos };
264
- j++;
341
+ if (!normalized.includes("\t"))
342
+ return normalized;
343
+ let result = "";
344
+ let i = 0;
345
+ while (i < normalized.length) {
346
+ const ansi = extractAnsiCode(normalized, i);
347
+ if (ansi) {
348
+ result += ansi.code;
349
+ i += ansi.length;
350
+ continue;
265
351
  }
266
- return null;
352
+ result += normalized[i] === "\t" ? " " : normalized[i];
353
+ i++;
267
354
  }
268
- // APC sequence: ESC _ ... BEL or ESC _ ... ST (ESC \)
269
- // Used for cursor marker and application-specific commands
270
- if (next === "_") {
271
- let j = pos + 2;
272
- while (j < str.length) {
273
- if (str[j] === "\x07")
274
- return { code: str.substring(pos, j + 1), length: j + 1 - pos };
275
- if (str[j] === "\x1b" && str[j + 1] === "\\")
276
- return { code: str.substring(pos, j + 2), length: j + 2 - pos };
277
- j++;
278
- }
355
+ return result;
356
+ }
357
+ function extractCsiSequence(str, pos) {
358
+ let end = pos + 2;
359
+ while (end < str.length && !/[mGKHJ]/.test(str[end]))
360
+ end++;
361
+ if (end >= str.length)
279
362
  return null;
363
+ return { code: str.substring(pos, end + 1), length: end + 1 - pos };
364
+ }
365
+ function extractTerminatedControlString(str, pos) {
366
+ let end = pos + 2;
367
+ while (end < str.length) {
368
+ if (str[end] === "\x07")
369
+ return { code: str.substring(pos, end + 1), length: end + 1 - pos };
370
+ if (str[end] === "\x1b" && str[end + 1] === "\\") {
371
+ return { code: str.substring(pos, end + 2), length: end + 2 - pos };
372
+ }
373
+ end++;
280
374
  }
281
375
  return null;
282
376
  }
377
+ /** Extract an ANSI escape sequence from a string at the given position. */
378
+ export function extractAnsiCode(str, pos) {
379
+ if (pos >= str.length || str[pos] !== "\x1b")
380
+ return null;
381
+ const introducer = str[pos + 1];
382
+ if (introducer === "[")
383
+ return extractCsiSequence(str, pos);
384
+ // OSC and APC sequences share BEL and ST termination rules.
385
+ if (introducer === "]" || introducer === "_")
386
+ return extractTerminatedControlString(str, pos);
387
+ return null;
388
+ }
283
389
  function parseOsc8Hyperlink(ansiCode) {
284
390
  if (!ansiCode.startsWith("\x1b]8;")) {
285
391
  return undefined;
@@ -303,6 +409,27 @@ function formatOsc8Hyperlink(hyperlink) {
303
409
  function formatOsc8Close(terminator) {
304
410
  return `\x1b]8;;${terminator}`;
305
411
  }
412
+ function getActiveOsc8Close(prefix) {
413
+ if (!prefix.includes("\x1b]8;")) {
414
+ return "";
415
+ }
416
+ let activeHyperlink = null;
417
+ let i = 0;
418
+ while (i < prefix.length) {
419
+ const ansi = extractAnsiCode(prefix, i);
420
+ if (ansi) {
421
+ const hyperlink = parseOsc8Hyperlink(ansi.code);
422
+ if (hyperlink !== undefined) {
423
+ activeHyperlink = hyperlink;
424
+ }
425
+ i += ansi.length;
426
+ }
427
+ else {
428
+ i++;
429
+ }
430
+ }
431
+ return activeHyperlink ? formatOsc8Close(activeHyperlink.terminator) : "";
432
+ }
306
433
  /**
307
434
  * Track active ANSI SGR codes to preserve styling across line breaks.
308
435
  */
@@ -319,135 +446,119 @@ class AnsiCodeTracker {
319
446
  fgColor = null; // Stores the full code like "31" or "38;5;240"
320
447
  bgColor = null; // Stores the full code like "41" or "48;5;240"
321
448
  activeHyperlink = null;
449
+ setColor(code, color) {
450
+ if (code === 38)
451
+ this.fgColor = color;
452
+ else
453
+ this.bgColor = color;
454
+ }
455
+ processExtendedColor(parts, index, code) {
456
+ if (code !== 38 && code !== 48)
457
+ return undefined;
458
+ if (parts[index + 1] === "5" && parts[index + 2] !== undefined) {
459
+ this.setColor(code, `${parts[index]};${parts[index + 1]};${parts[index + 2]}`);
460
+ return index + 3;
461
+ }
462
+ if (parts[index + 1] === "2" && parts[index + 4] !== undefined) {
463
+ this.setColor(code, `${parts[index]};${parts[index + 1]};${parts[index + 2]};${parts[index + 3]};${parts[index + 4]}`);
464
+ return index + 5;
465
+ }
466
+ return undefined;
467
+ }
468
+ processStandardSgrCode(code) {
469
+ switch (code) {
470
+ case 0:
471
+ this.reset();
472
+ break;
473
+ case 1:
474
+ this.bold = true;
475
+ break;
476
+ case 2:
477
+ this.dim = true;
478
+ break;
479
+ case 3:
480
+ this.italic = true;
481
+ break;
482
+ case 4:
483
+ this.underline = true;
484
+ break;
485
+ case 5:
486
+ this.blink = true;
487
+ break;
488
+ case 7:
489
+ this.inverse = true;
490
+ break;
491
+ case 8:
492
+ this.hidden = true;
493
+ break;
494
+ case 9:
495
+ this.strikethrough = true;
496
+ break;
497
+ case 21:
498
+ this.bold = false;
499
+ break;
500
+ case 22:
501
+ this.bold = false;
502
+ this.dim = false;
503
+ break;
504
+ case 23:
505
+ this.italic = false;
506
+ break;
507
+ case 24:
508
+ this.underline = false;
509
+ break;
510
+ case 25:
511
+ this.blink = false;
512
+ break;
513
+ case 27:
514
+ this.inverse = false;
515
+ break;
516
+ case 28:
517
+ this.hidden = false;
518
+ break;
519
+ case 29:
520
+ this.strikethrough = false;
521
+ break;
522
+ case 39:
523
+ this.fgColor = null;
524
+ break;
525
+ case 49:
526
+ this.bgColor = null;
527
+ break;
528
+ default:
529
+ if ((code >= 30 && code <= 37) || (code >= 90 && code <= 97))
530
+ this.fgColor = String(code);
531
+ else if ((code >= 40 && code <= 47) || (code >= 100 && code <= 107))
532
+ this.bgColor = String(code);
533
+ }
534
+ }
322
535
  process(ansiCode) {
323
- // OSC 8 hyperlink: \x1b]8;;<url>\x1b\\ (open) or \x1b]8;;\x1b\\ (close).
324
- // Preserve the original terminator because some terminals only make BEL-terminated
325
- // links clickable. OAuth login URLs use BEL, so reopening wrapped lines with ST
326
- // made only the first physical line clickable in those terminals.
327
536
  const hyperlink = parseOsc8Hyperlink(ansiCode);
328
537
  if (hyperlink !== undefined) {
329
538
  this.activeHyperlink = hyperlink;
330
539
  return;
331
540
  }
332
- if (!ansiCode.endsWith("m")) {
541
+ if (!ansiCode.endsWith("m"))
333
542
  return;
334
- }
335
- // Extract the parameters between \x1b[ and m
336
543
  const match = ansiCode.match(/\x1b\[([\d;]*)m/);
337
544
  if (!match)
338
545
  return;
339
546
  const params = match[1];
340
547
  if (params === "" || params === "0") {
341
- // Full reset
342
548
  this.reset();
343
549
  return;
344
550
  }
345
- // Parse parameters (can be semicolon-separated)
346
551
  const parts = params.split(";");
347
- let i = 0;
348
- while (i < parts.length) {
349
- const code = Number.parseInt(parts[i], 10);
350
- // Handle 256-color and RGB codes which consume multiple parameters
351
- if (code === 38 || code === 48) {
352
- // 38;5;N (256 color fg) or 38;2;R;G;B (RGB fg)
353
- // 48;5;N (256 color bg) or 48;2;R;G;B (RGB bg)
354
- if (parts[i + 1] === "5" && parts[i + 2] !== undefined) {
355
- // 256 color: 38;5;N or 48;5;N
356
- const colorCode = `${parts[i]};${parts[i + 1]};${parts[i + 2]}`;
357
- if (code === 38) {
358
- this.fgColor = colorCode;
359
- }
360
- else {
361
- this.bgColor = colorCode;
362
- }
363
- i += 3;
364
- continue;
365
- }
366
- else if (parts[i + 1] === "2" && parts[i + 4] !== undefined) {
367
- // RGB color: 38;2;R;G;B or 48;2;R;G;B
368
- const colorCode = `${parts[i]};${parts[i + 1]};${parts[i + 2]};${parts[i + 3]};${parts[i + 4]}`;
369
- if (code === 38) {
370
- this.fgColor = colorCode;
371
- }
372
- else {
373
- this.bgColor = colorCode;
374
- }
375
- i += 5;
376
- continue;
377
- }
378
- }
379
- // Standard SGR codes
380
- switch (code) {
381
- case 0:
382
- this.reset();
383
- break;
384
- case 1:
385
- this.bold = true;
386
- break;
387
- case 2:
388
- this.dim = true;
389
- break;
390
- case 3:
391
- this.italic = true;
392
- break;
393
- case 4:
394
- this.underline = true;
395
- break;
396
- case 5:
397
- this.blink = true;
398
- break;
399
- case 7:
400
- this.inverse = true;
401
- break;
402
- case 8:
403
- this.hidden = true;
404
- break;
405
- case 9:
406
- this.strikethrough = true;
407
- break;
408
- case 21:
409
- this.bold = false;
410
- break; // Some terminals
411
- case 22:
412
- this.bold = false;
413
- this.dim = false;
414
- break;
415
- case 23:
416
- this.italic = false;
417
- break;
418
- case 24:
419
- this.underline = false;
420
- break;
421
- case 25:
422
- this.blink = false;
423
- break;
424
- case 27:
425
- this.inverse = false;
426
- break;
427
- case 28:
428
- this.hidden = false;
429
- break;
430
- case 29:
431
- this.strikethrough = false;
432
- break;
433
- case 39:
434
- this.fgColor = null;
435
- break; // Default fg
436
- case 49:
437
- this.bgColor = null;
438
- break; // Default bg
439
- default:
440
- // Standard foreground colors 30-37, 90-97
441
- if ((code >= 30 && code <= 37) || (code >= 90 && code <= 97)) {
442
- this.fgColor = String(code);
443
- }
444
- // Standard background colors 40-47, 100-107
445
- else if ((code >= 40 && code <= 47) || (code >= 100 && code <= 107)) {
446
- this.bgColor = String(code);
447
- }
448
- break;
552
+ let index = 0;
553
+ while (index < parts.length) {
554
+ const code = Number.parseInt(parts[index], 10);
555
+ const nextIndex = this.processExtendedColor(parts, index, code);
556
+ if (nextIndex !== undefined) {
557
+ index = nextIndex;
558
+ continue;
449
559
  }
450
- i++;
560
+ this.processStandardSgrCode(code);
561
+ index++;
451
562
  }
452
563
  }
453
564
  reset() {
@@ -598,7 +709,7 @@ export function wrapTextWithAnsi(text, width) {
598
709
  }
599
710
  // Handle newlines by processing each line separately
600
711
  // Track ANSI state across lines so styles carry over after literal newlines
601
- const inputLines = text.split("\n");
712
+ const inputLines = text.split(/\r\n|\r|\n/);
602
713
  const result = [];
603
714
  const tracker = new AnsiCodeTracker();
604
715
  for (const inputLine of inputLines) {
@@ -613,76 +724,61 @@ export function wrapTextWithAnsi(text, width) {
613
724
  }
614
725
  return result.length > 0 ? result : [""];
615
726
  }
727
+ function appendLineEndReset(text, tracker) {
728
+ return text + tracker.getLineEndReset();
729
+ }
730
+ function appendOversizedWrapToken(token, context) {
731
+ if (context.current.text) {
732
+ context.lines.push(appendLineEndReset(context.current.text, context.tracker));
733
+ context.current.text = "";
734
+ context.current.visibleWidth = 0;
735
+ }
736
+ const broken = breakLongWord(token, context.maxWidth, context.tracker);
737
+ for (let index = 0; index < broken.length - 1; index++)
738
+ context.lines.push(broken[index]);
739
+ context.current.text = broken[broken.length - 1];
740
+ context.current.visibleWidth = visibleWidth(context.current.text);
741
+ }
742
+ function appendFittingWrapToken(token, tokenVisibleWidth, isWhitespace, context) {
743
+ const exceedsLine = context.current.visibleWidth + tokenVisibleWidth > context.maxWidth;
744
+ if (!exceedsLine || context.current.visibleWidth === 0) {
745
+ context.current.text += token;
746
+ context.current.visibleWidth += tokenVisibleWidth;
747
+ return;
748
+ }
749
+ context.lines.push(appendLineEndReset(context.current.text.trimEnd(), context.tracker));
750
+ if (isWhitespace) {
751
+ context.current.text = context.tracker.getActiveCodes();
752
+ context.current.visibleWidth = 0;
753
+ return;
754
+ }
755
+ context.current.text = context.tracker.getActiveCodes() + token;
756
+ context.current.visibleWidth = tokenVisibleWidth;
757
+ }
616
758
  function wrapSingleLine(line, width) {
617
- if (!line) {
759
+ if (!line)
618
760
  return [""];
619
- }
620
- const visibleLength = visibleWidth(line);
621
- if (visibleLength <= width) {
761
+ if (visibleWidth(line) <= width)
622
762
  return [line];
623
- }
624
- const wrapped = [];
625
- const tracker = new AnsiCodeTracker();
626
- const tokens = splitIntoTokensWithAnsi(line);
627
- let currentLine = "";
628
- let currentVisibleLength = 0;
629
- for (const token of tokens) {
630
- const tokenVisibleLength = visibleWidth(token);
763
+ const context = {
764
+ maxWidth: width,
765
+ lines: [],
766
+ tracker: new AnsiCodeTracker(),
767
+ current: { text: "", visibleWidth: 0 },
768
+ };
769
+ for (const token of splitIntoTokensWithAnsi(line)) {
770
+ const tokenVisibleWidth = visibleWidth(token);
631
771
  const isWhitespace = token.trim() === "";
632
- // Token itself is too long - break it character by character
633
- if (tokenVisibleLength > width && !isWhitespace) {
634
- if (currentLine) {
635
- // Add specific reset for underline only (preserves background)
636
- const lineEndReset = tracker.getLineEndReset();
637
- if (lineEndReset) {
638
- currentLine += lineEndReset;
639
- }
640
- wrapped.push(currentLine);
641
- currentLine = "";
642
- currentVisibleLength = 0;
643
- }
644
- // Break long token - breakLongWord handles its own resets
645
- const broken = breakLongWord(token, width, tracker);
646
- for (let i = 0; i < broken.length - 1; i++) {
647
- wrapped.push(broken[i]);
648
- }
649
- currentLine = broken[broken.length - 1];
650
- currentVisibleLength = visibleWidth(currentLine);
772
+ if (tokenVisibleWidth > width && !isWhitespace) {
773
+ appendOversizedWrapToken(token, context);
651
774
  continue;
652
775
  }
653
- // Check if adding this token would exceed width
654
- const totalNeeded = currentVisibleLength + tokenVisibleLength;
655
- if (totalNeeded > width && currentVisibleLength > 0) {
656
- // Trim trailing whitespace, then add underline reset (not full reset, to preserve background)
657
- let lineToWrap = currentLine.trimEnd();
658
- const lineEndReset = tracker.getLineEndReset();
659
- if (lineEndReset) {
660
- lineToWrap += lineEndReset;
661
- }
662
- wrapped.push(lineToWrap);
663
- if (isWhitespace) {
664
- // Don't start new line with whitespace
665
- currentLine = tracker.getActiveCodes();
666
- currentVisibleLength = 0;
667
- }
668
- else {
669
- currentLine = tracker.getActiveCodes() + token;
670
- currentVisibleLength = tokenVisibleLength;
671
- }
672
- }
673
- else {
674
- // Add to current line
675
- currentLine += token;
676
- currentVisibleLength += tokenVisibleLength;
677
- }
678
- updateTrackerFromText(token, tracker);
679
- }
680
- if (currentLine) {
681
- // No reset at end of final line - let caller handle it
682
- wrapped.push(currentLine);
776
+ appendFittingWrapToken(token, tokenVisibleWidth, isWhitespace, context);
777
+ updateTrackerFromText(token, context.tracker);
683
778
  }
684
- // Trailing whitespace can cause lines to exceed the requested width
685
- return wrapped.length > 0 ? wrapped.map((line) => line.trimEnd()) : [""];
779
+ if (context.current.text)
780
+ context.lines.push(context.current.text);
781
+ return context.lines.length > 0 ? context.lines.map((wrappedLine) => wrappedLine.trimEnd()) : [""];
686
782
  }
687
783
  export const PUNCTUATION_REGEX = /[(){}[\]<>.,;:'"!?+\-=*/\\|&%^$#@~`]/;
688
784
  /**
@@ -697,39 +793,33 @@ export function isWhitespaceChar(char) {
697
793
  export function isPunctuationChar(char) {
698
794
  return PUNCTUATION_REGEX.test(char);
699
795
  }
700
- function breakLongWord(word, width, tracker) {
701
- const lines = [];
702
- let currentLine = tracker.getActiveCodes();
703
- let currentWidth = 0;
704
- // First, separate ANSI codes from visible content
705
- // We need to handle ANSI codes specially since they're not graphemes
706
- let i = 0;
707
- const segments = [];
708
- while (i < word.length) {
709
- const ansiResult = extractAnsiCode(word, i);
796
+ function* iterateAnsiTextSegments(text) {
797
+ let index = 0;
798
+ while (index < text.length) {
799
+ const ansiResult = extractAnsiCode(text, index);
710
800
  if (ansiResult) {
711
- segments.push({ type: "ansi", value: ansiResult.code });
712
- i += ansiResult.length;
801
+ yield { type: "ansi", value: ansiResult.code };
802
+ index += ansiResult.length;
803
+ continue;
713
804
  }
714
- else {
715
- // Find the next ANSI code or end of string
716
- let end = i;
717
- while (end < word.length) {
718
- const nextAnsi = extractAnsiCode(word, end);
719
- if (nextAnsi)
720
- break;
721
- end++;
722
- }
723
- // Segment this non-ANSI portion into graphemes
724
- const textPortion = word.slice(i, end);
725
- for (const seg of graphemeSegmenter.segment(textPortion)) {
726
- segments.push({ type: "grapheme", value: seg.segment });
727
- }
728
- i = end;
805
+ let end = index;
806
+ while (end < text.length) {
807
+ if (extractAnsiCode(text, end))
808
+ break;
809
+ end++;
810
+ }
811
+ for (const segment of graphemeSegmenter.segment(text.slice(index, end))) {
812
+ yield { type: "grapheme", value: segment.segment };
729
813
  }
814
+ index = end;
730
815
  }
816
+ }
817
+ function breakLongWord(word, width, tracker) {
818
+ const lines = [];
819
+ let currentLine = tracker.getActiveCodes();
820
+ let currentWidth = 0;
731
821
  // Now process segments
732
- for (const seg of segments) {
822
+ for (const seg of iterateAnsiTextSegments(word)) {
733
823
  if (seg.type === "ansi") {
734
824
  currentLine += seg.value;
735
825
  tracker.process(seg.value);
@@ -776,6 +866,100 @@ export function applyBackgroundToLine(line, width, bgFn) {
776
866
  const withPadding = line + padding;
777
867
  return bgFn(withPadding);
778
868
  }
869
+ function createWidthTruncationScan() {
870
+ return {
871
+ prefix: "",
872
+ prefixWidth: 0,
873
+ pendingAnsi: "",
874
+ visibleWidth: 0,
875
+ keepingPrefix: true,
876
+ overflowed: false,
877
+ exhaustedInput: false,
878
+ };
879
+ }
880
+ function appendWidthTruncationFragment(state, fragment, fragmentWidth, targetWidth, maxWidth) {
881
+ if (state.keepingPrefix && state.prefixWidth + fragmentWidth <= targetWidth) {
882
+ state.prefix += state.pendingAnsi + fragment;
883
+ state.pendingAnsi = "";
884
+ state.prefixWidth += fragmentWidth;
885
+ }
886
+ else {
887
+ state.keepingPrefix = false;
888
+ state.pendingAnsi = "";
889
+ }
890
+ state.visibleWidth += fragmentWidth;
891
+ if (state.visibleWidth <= maxWidth)
892
+ return true;
893
+ state.overflowed = true;
894
+ return false;
895
+ }
896
+ function scanTruncationGraphemes(text, state, targetWidth, maxWidth) {
897
+ for (const { segment } of graphemeSegmenter.segment(text)) {
898
+ if (!appendWidthTruncationFragment(state, segment, graphemeWidth(segment), targetWidth, maxWidth))
899
+ return false;
900
+ }
901
+ return true;
902
+ }
903
+ function scanPlainTextForTruncation(text, targetWidth, maxWidth) {
904
+ const state = createWidthTruncationScan();
905
+ state.exhaustedInput = scanTruncationGraphemes(text, state, targetWidth, maxWidth);
906
+ return state;
907
+ }
908
+ function scanStyledTextForTruncation(text, targetWidth, maxWidth) {
909
+ const state = createWidthTruncationScan();
910
+ let index = 0;
911
+ while (index < text.length) {
912
+ const ansi = extractAnsiCode(text, index);
913
+ if (ansi) {
914
+ state.pendingAnsi += ansi.code;
915
+ index += ansi.length;
916
+ continue;
917
+ }
918
+ if (text[index] === "\t") {
919
+ if (!appendWidthTruncationFragment(state, "\t", 3, targetWidth, maxWidth))
920
+ break;
921
+ index++;
922
+ continue;
923
+ }
924
+ let end = index;
925
+ while (end < text.length && text[end] !== "\t" && !extractAnsiCode(text, end))
926
+ end++;
927
+ if (!scanTruncationGraphemes(text.slice(index, end), state, targetWidth, maxWidth))
928
+ break;
929
+ index = end;
930
+ }
931
+ state.exhaustedInput = index >= text.length;
932
+ return state;
933
+ }
934
+ function truncateWithOversizedEllipsis(text, maxWidth, ellipsis, ellipsisWidth, pad) {
935
+ if (ellipsisWidth < maxWidth)
936
+ return undefined;
937
+ const textWidth = visibleWidth(text);
938
+ if (textWidth <= maxWidth)
939
+ return pad ? text + " ".repeat(maxWidth - textWidth) : text;
940
+ const clippedEllipsis = truncateFragmentToWidth(ellipsis, maxWidth);
941
+ if (clippedEllipsis.width === 0)
942
+ return pad ? " ".repeat(maxWidth) : "";
943
+ return finalizeTruncatedResult("", 0, clippedEllipsis.text, clippedEllipsis.width, maxWidth, pad);
944
+ }
945
+ function truncatePrintableAscii(text, maxWidth, ellipsis, ellipsisWidth, pad) {
946
+ if (!isPrintableAscii(text))
947
+ return undefined;
948
+ if (text.length <= maxWidth)
949
+ return pad ? text + " ".repeat(maxWidth - text.length) : text;
950
+ const targetWidth = maxWidth - ellipsisWidth;
951
+ return finalizeTruncatedResult(text.slice(0, targetWidth), targetWidth, ellipsis, ellipsisWidth, maxWidth, pad);
952
+ }
953
+ function truncateMeasuredText(text, maxWidth, ellipsis, ellipsisWidth, pad) {
954
+ const targetWidth = maxWidth - ellipsisWidth;
955
+ const scan = text.includes("\x1b") || text.includes("\t")
956
+ ? scanStyledTextForTruncation(text, targetWidth, maxWidth)
957
+ : scanPlainTextForTruncation(text, targetWidth, maxWidth);
958
+ if (!scan.overflowed && scan.exhaustedInput) {
959
+ return pad ? text + " ".repeat(Math.max(0, maxWidth - scan.visibleWidth)) : text;
960
+ }
961
+ return finalizeTruncatedResult(scan.prefix, scan.prefixWidth, ellipsis, ellipsisWidth, maxWidth, pad);
962
+ }
779
963
  /**
780
964
  * Truncate text to fit within a maximum visible width, adding ellipsis if needed.
781
965
  * Optionally pad with spaces to reach exactly maxWidth.
@@ -788,128 +972,18 @@ export function applyBackgroundToLine(line, width, bgFn) {
788
972
  * @returns Truncated text, optionally padded to exactly maxWidth
789
973
  */
790
974
  export function truncateToWidth(text, maxWidth, ellipsis = "...", pad = false) {
791
- if (maxWidth <= 0) {
975
+ if (maxWidth <= 0)
792
976
  return "";
793
- }
794
- if (text.length === 0) {
977
+ if (text.length === 0)
795
978
  return pad ? " ".repeat(maxWidth) : "";
796
- }
797
979
  const ellipsisWidth = visibleWidth(ellipsis);
798
- if (ellipsisWidth >= maxWidth) {
799
- const textWidth = visibleWidth(text);
800
- if (textWidth <= maxWidth) {
801
- return pad ? text + " ".repeat(maxWidth - textWidth) : text;
802
- }
803
- const clippedEllipsis = truncateFragmentToWidth(ellipsis, maxWidth);
804
- if (clippedEllipsis.width === 0) {
805
- return pad ? " ".repeat(maxWidth) : "";
806
- }
807
- return finalizeTruncatedResult("", 0, clippedEllipsis.text, clippedEllipsis.width, maxWidth, pad);
808
- }
809
- if (isPrintableAscii(text)) {
810
- if (text.length <= maxWidth) {
811
- return pad ? text + " ".repeat(maxWidth - text.length) : text;
812
- }
813
- const targetWidth = maxWidth - ellipsisWidth;
814
- return finalizeTruncatedResult(text.slice(0, targetWidth), targetWidth, ellipsis, ellipsisWidth, maxWidth, pad);
815
- }
816
- const targetWidth = maxWidth - ellipsisWidth;
817
- let result = "";
818
- let pendingAnsi = "";
819
- let visibleSoFar = 0;
820
- let keptWidth = 0;
821
- let keepContiguousPrefix = true;
822
- let overflowed = false;
823
- let exhaustedInput = false;
824
- const hasAnsi = text.includes("\x1b");
825
- const hasTabs = text.includes("\t");
826
- if (!hasAnsi && !hasTabs) {
827
- for (const { segment } of graphemeSegmenter.segment(text)) {
828
- const width = graphemeWidth(segment);
829
- if (keepContiguousPrefix && keptWidth + width <= targetWidth) {
830
- result += segment;
831
- keptWidth += width;
832
- }
833
- else {
834
- keepContiguousPrefix = false;
835
- }
836
- visibleSoFar += width;
837
- if (visibleSoFar > maxWidth) {
838
- overflowed = true;
839
- break;
840
- }
841
- }
842
- exhaustedInput = !overflowed;
843
- }
844
- else {
845
- let i = 0;
846
- while (i < text.length) {
847
- const ansi = extractAnsiCode(text, i);
848
- if (ansi) {
849
- pendingAnsi += ansi.code;
850
- i += ansi.length;
851
- continue;
852
- }
853
- if (text[i] === "\t") {
854
- if (keepContiguousPrefix && keptWidth + 3 <= targetWidth) {
855
- if (pendingAnsi) {
856
- result += pendingAnsi;
857
- pendingAnsi = "";
858
- }
859
- result += "\t";
860
- keptWidth += 3;
861
- }
862
- else {
863
- keepContiguousPrefix = false;
864
- pendingAnsi = "";
865
- }
866
- visibleSoFar += 3;
867
- if (visibleSoFar > maxWidth) {
868
- overflowed = true;
869
- break;
870
- }
871
- i++;
872
- continue;
873
- }
874
- let end = i;
875
- while (end < text.length && text[end] !== "\t") {
876
- const nextAnsi = extractAnsiCode(text, end);
877
- if (nextAnsi) {
878
- break;
879
- }
880
- end++;
881
- }
882
- for (const { segment } of graphemeSegmenter.segment(text.slice(i, end))) {
883
- const width = graphemeWidth(segment);
884
- if (keepContiguousPrefix && keptWidth + width <= targetWidth) {
885
- if (pendingAnsi) {
886
- result += pendingAnsi;
887
- pendingAnsi = "";
888
- }
889
- result += segment;
890
- keptWidth += width;
891
- }
892
- else {
893
- keepContiguousPrefix = false;
894
- pendingAnsi = "";
895
- }
896
- visibleSoFar += width;
897
- if (visibleSoFar > maxWidth) {
898
- overflowed = true;
899
- break;
900
- }
901
- }
902
- if (overflowed) {
903
- break;
904
- }
905
- i = end;
906
- }
907
- exhaustedInput = i >= text.length;
908
- }
909
- if (!overflowed && exhaustedInput) {
910
- return pad ? text + " ".repeat(Math.max(0, maxWidth - visibleSoFar)) : text;
911
- }
912
- return finalizeTruncatedResult(result, keptWidth, ellipsis, ellipsisWidth, maxWidth, pad);
980
+ const oversizedEllipsis = truncateWithOversizedEllipsis(text, maxWidth, ellipsis, ellipsisWidth, pad);
981
+ if (oversizedEllipsis !== undefined)
982
+ return oversizedEllipsis;
983
+ const printableAscii = truncatePrintableAscii(text, maxWidth, ellipsis, ellipsisWidth, pad);
984
+ if (printableAscii !== undefined)
985
+ return printableAscii;
986
+ return truncateMeasuredText(text, maxWidth, ellipsis, ellipsisWidth, pad);
913
987
  }
914
988
  /**
915
989
  * Extract a range of visible columns from a line. Handles ANSI codes and wide chars.
@@ -918,112 +992,115 @@ export function truncateToWidth(text, maxWidth, ellipsis = "...", pad = false) {
918
992
  export function sliceByColumn(line, startCol, length, strict = false) {
919
993
  return sliceWithWidth(line, startCol, length, strict).text;
920
994
  }
995
+ function appendAnsiToColumnSlice(state, ansi, startColumn, endColumn) {
996
+ if (state.currentColumn >= startColumn && state.currentColumn < endColumn)
997
+ state.text += ansi;
998
+ else if (state.currentColumn < startColumn)
999
+ state.pendingAnsi += ansi;
1000
+ }
1001
+ function appendGraphemeToColumnSlice(state, grapheme, graphemeWidth, startColumn, endColumn, strict) {
1002
+ if (state.currentColumn < startColumn || state.currentColumn >= endColumn)
1003
+ return;
1004
+ if (strict && state.currentColumn + graphemeWidth > endColumn)
1005
+ return;
1006
+ if (state.pendingAnsi) {
1007
+ state.text += state.pendingAnsi;
1008
+ state.pendingAnsi = "";
1009
+ }
1010
+ state.text += grapheme;
1011
+ state.width += graphemeWidth;
1012
+ }
921
1013
  /** Like sliceByColumn but also returns the actual visible width of the result. */
922
1014
  export function sliceWithWidth(line, startCol, length, strict = false) {
923
1015
  if (length <= 0)
924
1016
  return { text: "", width: 0 };
925
- const endCol = startCol + length;
926
- let result = "", resultWidth = 0, currentCol = 0, i = 0, pendingAnsi = "";
927
- while (i < line.length) {
928
- const ansi = extractAnsiCode(line, i);
929
- if (ansi) {
930
- if (currentCol >= startCol && currentCol < endCol)
931
- result += ansi.code;
932
- else if (currentCol < startCol)
933
- pendingAnsi += ansi.code;
934
- i += ansi.length;
1017
+ const endColumn = startCol + length;
1018
+ const state = { text: "", width: 0, currentColumn: 0, pendingAnsi: "" };
1019
+ for (const segment of iterateAnsiTextSegments(line)) {
1020
+ if (segment.type === "ansi") {
1021
+ appendAnsiToColumnSlice(state, segment.value, startCol, endColumn);
935
1022
  continue;
936
1023
  }
937
- let textEnd = i;
938
- while (textEnd < line.length && !extractAnsiCode(line, textEnd))
939
- textEnd++;
940
- for (const { segment } of graphemeSegmenter.segment(line.slice(i, textEnd))) {
941
- const w = graphemeWidth(segment);
942
- const inRange = currentCol >= startCol && currentCol < endCol;
943
- const fits = !strict || currentCol + w <= endCol;
944
- if (inRange && fits) {
945
- if (pendingAnsi) {
946
- result += pendingAnsi;
947
- pendingAnsi = "";
948
- }
949
- result += segment;
950
- resultWidth += w;
951
- }
952
- currentCol += w;
953
- if (currentCol >= endCol)
954
- break;
955
- }
956
- i = textEnd;
957
- if (currentCol >= endCol)
1024
+ const width = graphemeWidth(segment.value);
1025
+ appendGraphemeToColumnSlice(state, segment.value, width, startCol, endColumn, strict);
1026
+ state.currentColumn += width;
1027
+ if (state.currentColumn >= endColumn)
958
1028
  break;
959
1029
  }
960
- return { text: result, width: resultWidth };
1030
+ return { text: state.text, width: state.width };
961
1031
  }
962
1032
  // Pooled tracker instance for extractSegments (avoids allocation per call)
963
1033
  const pooledStyleTracker = new AnsiCodeTracker();
1034
+ function appendAnsiToExtractedSegments(state, ansiCode, context) {
1035
+ pooledStyleTracker.process(ansiCode);
1036
+ if (state.currentColumn < context.beforeEnd) {
1037
+ state.pendingAnsiBefore += ansiCode;
1038
+ return;
1039
+ }
1040
+ if (state.currentColumn >= context.afterStart && state.currentColumn < context.afterEnd && state.afterStarted) {
1041
+ state.after += ansiCode;
1042
+ }
1043
+ }
1044
+ function appendGraphemeToExtractedSegments(state, grapheme, graphemeWidth, context) {
1045
+ if (state.currentColumn < context.beforeEnd && state.currentColumn + graphemeWidth <= context.beforeEnd) {
1046
+ state.before += state.pendingAnsiBefore + grapheme;
1047
+ state.pendingAnsiBefore = "";
1048
+ state.beforeWidth += graphemeWidth;
1049
+ return;
1050
+ }
1051
+ if (state.currentColumn < context.afterStart || state.currentColumn >= context.afterEnd)
1052
+ return;
1053
+ if (context.strictAfter && state.currentColumn + graphemeWidth > context.afterEnd)
1054
+ return;
1055
+ if (!state.afterStarted) {
1056
+ state.after += pooledStyleTracker.getActiveCodes();
1057
+ state.afterStarted = true;
1058
+ }
1059
+ state.after += grapheme;
1060
+ state.afterWidth += graphemeWidth;
1061
+ }
1062
+ function isLineSegmentExtractionComplete(state, context) {
1063
+ return context.afterLength <= 0 ? state.currentColumn >= context.beforeEnd : state.currentColumn >= context.afterEnd;
1064
+ }
964
1065
  /**
965
1066
  * Extract "before" and "after" segments from a line in a single pass.
966
1067
  * Used for overlay compositing where we need content before and after the overlay region.
967
1068
  * Preserves styling from before the overlay that should affect content after it.
968
1069
  */
969
1070
  export function extractSegments(line, beforeEnd, afterStart, afterLen, strictAfter = false) {
970
- let before = "", beforeWidth = 0, after = "", afterWidth = 0;
971
- let currentCol = 0, i = 0;
972
- let pendingAnsiBefore = "";
973
- let afterStarted = false;
974
- const afterEnd = afterStart + afterLen;
975
- // Track styling state so "after" inherits styling from before the overlay
1071
+ const context = {
1072
+ beforeEnd,
1073
+ afterStart,
1074
+ afterEnd: afterStart + afterLen,
1075
+ afterLength: afterLen,
1076
+ strictAfter,
1077
+ };
1078
+ const state = {
1079
+ before: "",
1080
+ beforeWidth: 0,
1081
+ after: "",
1082
+ afterWidth: 0,
1083
+ currentColumn: 0,
1084
+ pendingAnsiBefore: "",
1085
+ afterStarted: false,
1086
+ };
976
1087
  pooledStyleTracker.clear();
977
- while (i < line.length) {
978
- const ansi = extractAnsiCode(line, i);
979
- if (ansi) {
980
- // Track all SGR codes to know styling state at afterStart
981
- pooledStyleTracker.process(ansi.code);
982
- // Include ANSI codes in their respective segments
983
- if (currentCol < beforeEnd) {
984
- pendingAnsiBefore += ansi.code;
985
- }
986
- else if (currentCol >= afterStart && currentCol < afterEnd && afterStarted) {
987
- // Only include after we've started "after" (styling already prepended)
988
- after += ansi.code;
989
- }
990
- i += ansi.length;
1088
+ for (const segment of iterateAnsiTextSegments(line)) {
1089
+ if (segment.type === "ansi") {
1090
+ appendAnsiToExtractedSegments(state, segment.value, context);
991
1091
  continue;
992
1092
  }
993
- let textEnd = i;
994
- while (textEnd < line.length && !extractAnsiCode(line, textEnd))
995
- textEnd++;
996
- for (const { segment } of graphemeSegmenter.segment(line.slice(i, textEnd))) {
997
- const w = graphemeWidth(segment);
998
- if (currentCol < beforeEnd) {
999
- if (pendingAnsiBefore) {
1000
- before += pendingAnsiBefore;
1001
- pendingAnsiBefore = "";
1002
- }
1003
- before += segment;
1004
- beforeWidth += w;
1005
- }
1006
- else if (currentCol >= afterStart && currentCol < afterEnd) {
1007
- const fits = !strictAfter || currentCol + w <= afterEnd;
1008
- if (fits) {
1009
- // On first "after" grapheme, prepend inherited styling from before overlay
1010
- if (!afterStarted) {
1011
- after += pooledStyleTracker.getActiveCodes();
1012
- afterStarted = true;
1013
- }
1014
- after += segment;
1015
- afterWidth += w;
1016
- }
1017
- }
1018
- currentCol += w;
1019
- // Early exit: done with "before" only, or done with both segments
1020
- if (afterLen <= 0 ? currentCol >= beforeEnd : currentCol >= afterEnd)
1021
- break;
1022
- }
1023
- i = textEnd;
1024
- if (afterLen <= 0 ? currentCol >= beforeEnd : currentCol >= afterEnd)
1093
+ const width = graphemeWidth(segment.value);
1094
+ appendGraphemeToExtractedSegments(state, segment.value, width, context);
1095
+ state.currentColumn += width;
1096
+ if (isLineSegmentExtractionComplete(state, context))
1025
1097
  break;
1026
1098
  }
1027
- return { before, beforeWidth, after, afterWidth };
1099
+ return {
1100
+ before: state.before,
1101
+ beforeWidth: state.beforeWidth,
1102
+ after: state.after,
1103
+ afterWidth: state.afterWidth,
1104
+ };
1028
1105
  }
1029
1106
  //# sourceMappingURL=utils.js.map