@oh-my-pi/pi-tui 8.5.0 → 8.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -544,7 +544,7 @@ interface Terminal {
544
544
  ```typescript
545
545
  import { visibleWidth, truncateToWidth, wrapTextWithAnsi } from "@oh-my-pi/pi-tui";
546
546
 
547
- // Get visible width of string (ignoring ANSI codes)
547
+ // Get visible width of string (ignoring ANSI codes, uses Bun.stringWidth)
548
548
  const width = visibleWidth("\x1b[31mHello\x1b[0m"); // 5
549
549
 
550
550
  // Truncate string to width (preserving ANSI codes, adds ellipsis)
@@ -553,7 +553,7 @@ const truncated = truncateToWidth("Hello World", 8); // "Hello..."
553
553
  // Truncate without ellipsis
554
554
  const truncatedNoEllipsis = truncateToWidth("Hello World", 8, ""); // "Hello Wo"
555
555
 
556
- // Wrap text to width (preserving ANSI codes across line breaks)
556
+ // Wrap text to width (Bun.wrapAnsi word wrap, trims line ends, preserves ANSI)
557
557
  const lines = wrapTextWithAnsi("This is a long line that needs wrapping", 20);
558
558
  // ["This is a long line", "that needs wrapping"]
559
559
  ```
@@ -631,10 +631,11 @@ class MyComponent implements Component {
631
631
 
632
632
  ### ANSI Code Considerations
633
633
 
634
- Both `visibleWidth()` and `truncateToWidth()` correctly handle ANSI escape codes:
634
+ `visibleWidth()`, `truncateToWidth()`, and `wrapTextWithAnsi()` correctly handle ANSI escape codes:
635
635
 
636
- - `visibleWidth()` ignores ANSI codes when calculating width
636
+ - `visibleWidth()` ignores ANSI codes when calculating width (via `Bun.stringWidth`)
637
637
  - `truncateToWidth()` preserves ANSI codes and properly closes them when truncating
638
+ - `wrapTextWithAnsi()` preserves ANSI codes while word-wrapping and trimming line ends
638
639
 
639
640
  ```typescript
640
641
  import chalk from "chalk";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-my-pi/pi-tui",
3
- "version": "8.5.0",
3
+ "version": "8.6.0",
4
4
  "description": "Terminal User Interface library with differential rendering for efficient text-based applications",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -49,7 +49,6 @@
49
49
  "dependencies": {
50
50
  "@types/mime-types": "^3.0.1",
51
51
  "chalk": "^5.5.0",
52
- "get-east-asian-width": "^1.3.0",
53
52
  "marked": "^17.0.1",
54
53
  "mime-types": "^3.0.1"
55
54
  },
@@ -1195,51 +1195,15 @@ export class Editor implements Component, Focusable {
1195
1195
  }
1196
1196
 
1197
1197
  if (pastedLines.length === 1) {
1198
- const text = pastedLines[0] || "";
1199
- this.insertTextAtCursor(text);
1198
+ // Single line - insert character by character to trigger autocomplete
1199
+ for (const char of filteredText) {
1200
+ this.insertCharacter(char);
1201
+ }
1200
1202
  return;
1201
1203
  }
1202
1204
 
1203
- // Multi-line paste - be very careful with array manipulation
1204
- const currentLine = this.state.lines[this.state.cursorLine] || "";
1205
- const beforeCursor = currentLine.slice(0, this.state.cursorCol);
1206
- const afterCursor = currentLine.slice(this.state.cursorCol);
1207
-
1208
- // Build the new lines array step by step
1209
- const newLines: string[] = [];
1210
-
1211
- // Add all lines before current line
1212
- for (let i = 0; i < this.state.cursorLine; i++) {
1213
- newLines.push(this.state.lines[i] || "");
1214
- }
1215
-
1216
- // Add the first pasted line merged with before cursor text
1217
- newLines.push(beforeCursor + (pastedLines[0] || ""));
1218
-
1219
- // Add all middle pasted lines
1220
- for (let i = 1; i < pastedLines.length - 1; i++) {
1221
- newLines.push(pastedLines[i] || "");
1222
- }
1223
-
1224
- // Add the last pasted line with after cursor text
1225
- newLines.push((pastedLines[pastedLines.length - 1] || "") + afterCursor);
1226
-
1227
- // Add all lines after current line
1228
- for (let i = this.state.cursorLine + 1; i < this.state.lines.length; i++) {
1229
- newLines.push(this.state.lines[i] || "");
1230
- }
1231
-
1232
- // Replace the entire lines array
1233
- this.state.lines = newLines;
1234
-
1235
- // Update cursor position to end of pasted content
1236
- this.state.cursorLine += pastedLines.length - 1;
1237
- this.state.cursorCol = (pastedLines[pastedLines.length - 1] || "").length;
1238
-
1239
- // Notify of change
1240
- if (this.onChange) {
1241
- this.onChange(this.getText());
1242
- }
1205
+ // Multi-line paste - use insertTextAtCursor for proper handling
1206
+ this.insertTextAtCursor(filteredText);
1243
1207
  });
1244
1208
  }
1245
1209
 
package/src/terminal.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import * as fs from "node:fs";
1
2
  import { setKittyProtocolActive } from "./keys";
2
3
  import { StdinBuffer } from "./stdin-buffer";
3
4
 
@@ -77,6 +78,7 @@ export class ProcessTerminal implements Terminal {
77
78
  private stdinBuffer?: StdinBuffer;
78
79
  private stdinDataHandler?: (data: string) => void;
79
80
  private dead = false;
81
+ private writeLogPath = process.env.OMP_TUI_WRITE_LOG || "";
80
82
 
81
83
  get kittyProtocolActive(): boolean {
82
84
  return this._kittyProtocolActive;
@@ -221,6 +223,13 @@ export class ProcessTerminal implements Terminal {
221
223
 
222
224
  write(data: string): void {
223
225
  this.safeWrite(data);
226
+ if (this.writeLogPath) {
227
+ try {
228
+ fs.appendFileSync(this.writeLogPath, data, { encoding: "utf8" });
229
+ } catch {
230
+ // Ignore logging errors
231
+ }
232
+ }
224
233
  }
225
234
 
226
235
  private safeWrite(data: string): void {
package/src/tui.ts CHANGED
@@ -227,6 +227,9 @@ export class TUI extends Container {
227
227
  private inputBuffer = ""; // Buffer for parsing terminal responses
228
228
  private cellSizeQueryPending = false;
229
229
  private showHardwareCursor = process.env.OMP_HARDWARE_CURSOR === "1";
230
+ private maxLinesRendered = 0; // Track terminal's working area (max lines ever rendered)
231
+ private previousViewportTop = 0; // Track previous viewport top for resize-aware cursor moves
232
+ private fullRedrawCount = 0;
230
233
 
231
234
  // Overlay stack for modal components rendered on top of base content
232
235
  private overlayStack: {
@@ -245,6 +248,10 @@ export class TUI extends Container {
245
248
  }
246
249
  }
247
250
 
251
+ get fullRedraws(): number {
252
+ return this.fullRedrawCount;
253
+ }
254
+
248
255
  getShowHardwareCursor(): boolean {
249
256
  return this.showHardwareCursor;
250
257
  }
@@ -411,6 +418,8 @@ export class TUI extends Container {
411
418
  this.previousWidth = -1; // -1 triggers widthChanged, forcing a full clear
412
419
  this.cursorRow = 0;
413
420
  this.hardwareCursorRow = 0;
421
+ this.maxLinesRendered = 0;
422
+ this.previousViewportTop = 0;
414
423
  }
415
424
  if (this.renderRequested) return;
416
425
  this.renderRequested = true;
@@ -708,12 +717,16 @@ export class TUI extends Container {
708
717
  minLinesNeeded = Math.max(minLinesNeeded, row + overlayLines.length);
709
718
  }
710
719
 
711
- // Extend result with empty lines if content is too short for overlay placement
712
- while (result.length < minLinesNeeded) {
720
+ // Ensure result covers the terminal working area to keep overlay positioning stable across resizes.
721
+ // maxLinesRendered can exceed current content length after a shrink; pad to keep viewportStart consistent.
722
+ const workingHeight = Math.max(this.maxLinesRendered, minLinesNeeded);
723
+
724
+ // Extend result with empty lines if content is too short for overlay placement or working area
725
+ while (result.length < workingHeight) {
713
726
  result.push("");
714
727
  }
715
728
 
716
- const viewportStart = Math.max(0, result.length - termHeight);
729
+ const viewportStart = Math.max(0, workingHeight - termHeight);
717
730
 
718
731
  // Track which lines were modified for final verification
719
732
  const modifiedLines = new Set<number>();
@@ -882,6 +895,14 @@ export class TUI extends Container {
882
895
  // Capture terminal dimensions at start to ensure consistency throughout render
883
896
  const width = this.terminal.columns;
884
897
  const height = this.terminal.rows;
898
+ let viewportTop = Math.max(0, this.maxLinesRendered - height);
899
+ let prevViewportTop = this.previousViewportTop;
900
+ let hardwareCursorRow = this.hardwareCursorRow;
901
+ const computeLineDiff = (targetRow: number): number => {
902
+ const currentScreenRow = hardwareCursorRow - prevViewportTop;
903
+ const targetScreenRow = targetRow - viewportTop;
904
+ return targetScreenRow - currentScreenRow;
905
+ };
885
906
 
886
907
  // Render all components to get new lines
887
908
  let newLines = this.render(width);
@@ -901,6 +922,7 @@ export class TUI extends Container {
901
922
 
902
923
  // First render - just output everything without clearing (assumes clean screen)
903
924
  if (this.previousLines.length === 0 && !widthChanged) {
925
+ this.fullRedrawCount += 1;
904
926
  let buffer = "\x1b[?2026h"; // Begin synchronized output
905
927
  for (let i = 0; i < newLines.length; i++) {
906
928
  if (i > 0) buffer += "\r\n";
@@ -911,6 +933,8 @@ export class TUI extends Container {
911
933
  // After rendering N lines, cursor is at end of last line (clamp to 0 for empty)
912
934
  this.cursorRow = Math.max(0, newLines.length - 1);
913
935
  this.hardwareCursorRow = this.cursorRow;
936
+ this.maxLinesRendered = Math.max(this.maxLinesRendered, newLines.length);
937
+ this.previousViewportTop = Math.max(0, this.maxLinesRendered - height);
914
938
  this.positionHardwareCursor(cursorPos, newLines.length);
915
939
  this.previousLines = newLines;
916
940
  this.previousWidth = width;
@@ -919,6 +943,7 @@ export class TUI extends Container {
919
943
 
920
944
  // Width changed - full re-render
921
945
  if (widthChanged) {
946
+ this.fullRedrawCount += 1;
922
947
  let buffer = "\x1b[?2026h"; // Begin synchronized output
923
948
  buffer += "\x1b[3J\x1b[2J\x1b[H"; // Clear scrollback, screen, and home
924
949
  for (let i = 0; i < newLines.length; i++) {
@@ -929,6 +954,8 @@ export class TUI extends Container {
929
954
  this.terminal.write(buffer);
930
955
  this.cursorRow = Math.max(0, newLines.length - 1);
931
956
  this.hardwareCursorRow = this.cursorRow;
957
+ this.maxLinesRendered = Math.max(this.maxLinesRendered, newLines.length);
958
+ this.previousViewportTop = Math.max(0, this.maxLinesRendered - height);
932
959
  this.positionHardwareCursor(cursorPos, newLines.length);
933
960
  this.previousLines = newLines;
934
961
  this.previousWidth = width;
@@ -950,10 +977,19 @@ export class TUI extends Container {
950
977
  lastChanged = i;
951
978
  }
952
979
  }
980
+ const appendedLines = newLines.length > this.previousLines.length;
981
+ if (appendedLines) {
982
+ if (firstChanged === -1) {
983
+ firstChanged = this.previousLines.length;
984
+ }
985
+ lastChanged = newLines.length - 1;
986
+ }
987
+ const appendStart = appendedLines && firstChanged === this.previousLines.length && firstChanged > 0;
953
988
 
954
989
  // No changes - but still need to update hardware cursor position if it moved
955
990
  if (firstChanged === -1) {
956
991
  this.positionHardwareCursor(cursorPos, newLines.length);
992
+ this.previousViewportTop = Math.max(0, this.maxLinesRendered - height);
957
993
  return;
958
994
  }
959
995
 
@@ -963,7 +999,7 @@ export class TUI extends Container {
963
999
  let buffer = "\x1b[?2026h";
964
1000
  // Move to end of new content (clamp to 0 for empty content)
965
1001
  const targetRow = Math.max(0, newLines.length - 1);
966
- const lineDiff = targetRow - this.hardwareCursorRow;
1002
+ const lineDiff = computeLineDiff(targetRow);
967
1003
  if (lineDiff > 0) buffer += `\x1b[${lineDiff}B`;
968
1004
  else if (lineDiff < 0) buffer += `\x1b[${-lineDiff}A`;
969
1005
  buffer += "\r";
@@ -981,16 +1017,14 @@ export class TUI extends Container {
981
1017
  this.positionHardwareCursor(cursorPos, newLines.length);
982
1018
  this.previousLines = newLines;
983
1019
  this.previousWidth = width;
1020
+ this.previousViewportTop = Math.max(0, this.maxLinesRendered - height);
984
1021
  return;
985
1022
  }
986
1023
 
987
- // Check if firstChanged is outside the viewport
988
- // cursorRow is the line where cursor is (0-indexed)
989
- // Viewport shows lines from (cursorRow - height + 1) to cursorRow
990
- // If firstChanged < viewportTop, we need full re-render
991
- const viewportTop = this.cursorRow - height + 1;
1024
+ // Check if firstChanged is outside the viewport (based on maxLinesRendered)
992
1025
  if (firstChanged < viewportTop) {
993
1026
  // First change is above viewport - need full re-render
1027
+ this.fullRedrawCount += 1;
994
1028
  let buffer = "\x1b[?2026h"; // Begin synchronized output
995
1029
  buffer += "\x1b[3J\x1b[2J\x1b[H"; // Clear scrollback, screen, and home
996
1030
  for (let i = 0; i < newLines.length; i++) {
@@ -1001,6 +1035,8 @@ export class TUI extends Container {
1001
1035
  this.terminal.write(buffer);
1002
1036
  this.cursorRow = Math.max(0, newLines.length - 1);
1003
1037
  this.hardwareCursorRow = this.cursorRow;
1038
+ this.maxLinesRendered = Math.max(this.maxLinesRendered, newLines.length);
1039
+ this.previousViewportTop = Math.max(0, this.maxLinesRendered - height);
1004
1040
  this.positionHardwareCursor(cursorPos, newLines.length);
1005
1041
  this.previousLines = newLines;
1006
1042
  this.previousWidth = width;
@@ -1010,16 +1046,30 @@ export class TUI extends Container {
1010
1046
  // Render from first changed line to end
1011
1047
  // Build buffer with all updates wrapped in synchronized output
1012
1048
  let buffer = "\x1b[?2026h"; // Begin synchronized output
1049
+ const prevViewportBottom = prevViewportTop + height - 1;
1050
+ const moveTargetRow = appendStart ? firstChanged - 1 : firstChanged;
1051
+ if (moveTargetRow > prevViewportBottom) {
1052
+ const currentScreenRow = Math.max(0, Math.min(height - 1, hardwareCursorRow - prevViewportTop));
1053
+ const moveToBottom = height - 1 - currentScreenRow;
1054
+ if (moveToBottom > 0) {
1055
+ buffer += `\x1b[${moveToBottom}B`;
1056
+ }
1057
+ const scroll = moveTargetRow - prevViewportBottom;
1058
+ buffer += "\r\n".repeat(scroll);
1059
+ prevViewportTop += scroll;
1060
+ viewportTop += scroll;
1061
+ hardwareCursorRow = moveTargetRow;
1062
+ }
1013
1063
 
1014
- // Move cursor to first changed line (use hardwareCursorRow for actual position)
1015
- const lineDiff = firstChanged - this.hardwareCursorRow;
1064
+ // Move cursor to first changed line
1065
+ const lineDiff = computeLineDiff(moveTargetRow);
1016
1066
  if (lineDiff > 0) {
1017
1067
  buffer += `\x1b[${lineDiff}B`; // Move down
1018
1068
  } else if (lineDiff < 0) {
1019
1069
  buffer += `\x1b[${-lineDiff}A`; // Move up
1020
1070
  }
1021
1071
 
1022
- buffer += "\r"; // Move to column 0
1072
+ buffer += appendStart ? "\r\n" : "\r"; // Move to column 0
1023
1073
 
1024
1074
  // Only render changed lines (firstChanged to lastChanged), not all lines to end
1025
1075
  // This reduces flicker when only a single line changes (e.g., spinner animation)
@@ -1093,6 +1143,9 @@ export class TUI extends Container {
1093
1143
  // hardwareCursorRow tracks actual cursor position (may move to cursorPos below)
1094
1144
  this.cursorRow = Math.max(0, newLines.length - 1);
1095
1145
  this.hardwareCursorRow = finalCursorRow;
1146
+ // Track terminal's working area (grows but doesn't shrink unless cleared)
1147
+ this.maxLinesRendered = Math.max(this.maxLinesRendered, newLines.length);
1148
+ this.previousViewportTop = Math.max(0, this.maxLinesRendered - height);
1096
1149
 
1097
1150
  // Position hardware cursor for IME
1098
1151
  this.positionHardwareCursor(cursorPos, newLines.length);
package/src/utils.ts CHANGED
@@ -1,5 +1,3 @@
1
- import { eastAsianWidth } from "get-east-asian-width";
2
-
3
1
  // Grapheme segmenter (shared instance)
4
2
  const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
5
3
 
@@ -10,71 +8,10 @@ export function getSegmenter(): Intl.Segmenter {
10
8
  return segmenter;
11
9
  }
12
10
 
13
- /**
14
- * Check if a grapheme cluster (after segmentation) could possibly be an RGI emoji.
15
- * This is a fast heuristic to avoid the expensive rgiEmojiRegex test.
16
- * The tested Unicode blocks are deliberately broad to account for future
17
- * Unicode additions.
18
- */
19
- function couldBeEmoji(segment: string): boolean {
20
- const cp = segment.codePointAt(0)!;
21
- return (
22
- (cp >= 0x1f000 && cp <= 0x1fbff) || // Emoji and Pictograph
23
- (cp >= 0x2300 && cp <= 0x23ff) || // Misc technical
24
- (cp >= 0x2600 && cp <= 0x27bf) || // Misc symbols, dingbats
25
- (cp >= 0x2b50 && cp <= 0x2b55) || // Specific stars/circles
26
- segment.includes("\uFE0F") || // Contains VS16 (emoji presentation selector)
27
- segment.length > 2 // Multi-codepoint sequences (ZWJ, skin tones, etc.)
28
- );
29
- }
30
-
31
- // Regexes for character classification (same as string-width library)
32
- const zeroWidthRegex = /^(?:\p{Default_Ignorable_Code_Point}|\p{Control}|\p{Mark}|\p{Surrogate})+$/v;
33
- const leadingNonPrintingRegex = /^[\p{Default_Ignorable_Code_Point}\p{Control}\p{Format}\p{Mark}\p{Surrogate}]+/v;
34
- const rgiEmojiRegex = /^\p{RGI_Emoji}$/v;
35
-
36
11
  // Cache for non-ASCII strings
37
12
  const WIDTH_CACHE_SIZE = 512;
38
13
  const widthCache = new Map<string, number>();
39
14
 
40
- /**
41
- * Calculate the terminal width of a single grapheme cluster.
42
- * Based on code from the string-width library, but includes a possible-emoji
43
- * check to avoid running the RGI_Emoji regex unnecessarily.
44
- */
45
- function graphemeWidth(segment: string): number {
46
- // Zero-width clusters
47
- if (zeroWidthRegex.test(segment)) {
48
- return 0;
49
- }
50
-
51
- // Emoji check with pre-filter
52
- if (couldBeEmoji(segment) && rgiEmojiRegex.test(segment)) {
53
- return 2;
54
- }
55
-
56
- // Get base visible codepoint
57
- const base = segment.replace(leadingNonPrintingRegex, "");
58
- const cp = base.codePointAt(0);
59
- if (cp === undefined) {
60
- return 0;
61
- }
62
-
63
- let width = eastAsianWidth(cp);
64
-
65
- // Trailing halfwidth/fullwidth forms
66
- if (segment.length > 1) {
67
- for (const char of segment.slice(1)) {
68
- const c = char.codePointAt(0)!;
69
- if (c >= 0xff00 && c <= 0xffef) {
70
- width += eastAsianWidth(c);
71
- }
72
- }
73
- }
74
-
75
- return width;
76
- }
77
-
78
15
  /**
79
16
  * Calculate the visible width of a string in terminal columns.
80
17
  */
@@ -114,11 +51,7 @@ export function visibleWidth(str: string): number {
114
51
  clean = clean.replace(/\x1b\]8;;[^\x07]*\x07/g, "");
115
52
  }
116
53
 
117
- // Calculate width
118
- let width = 0;
119
- for (const { segment } of segmenter.segment(clean)) {
120
- width += graphemeWidth(segment);
121
- }
54
+ const width = Bun.stringWidth(clean);
122
55
 
123
56
  // Cache result
124
57
  if (widthCache.size >= WIDTH_CACHE_SIZE) {
@@ -370,70 +303,6 @@ class AnsiCodeTracker {
370
303
  }
371
304
  }
372
305
 
373
- function updateTrackerFromText(text: string, tracker: AnsiCodeTracker): void {
374
- let i = 0;
375
- while (i < text.length) {
376
- const ansiResult = extractAnsiCode(text, i);
377
- if (ansiResult) {
378
- tracker.process(ansiResult.code);
379
- i += ansiResult.length;
380
- } else {
381
- i++;
382
- }
383
- }
384
- }
385
-
386
- /**
387
- * Split text into words while keeping ANSI codes attached.
388
- */
389
- function splitIntoTokensWithAnsi(text: string): string[] {
390
- const tokens: string[] = [];
391
- let current = "";
392
- let pendingAnsi = ""; // ANSI codes waiting to be attached to next visible content
393
- let inWhitespace = false;
394
- let i = 0;
395
-
396
- while (i < text.length) {
397
- const ansiResult = extractAnsiCode(text, i);
398
- if (ansiResult) {
399
- // Hold ANSI codes separately - they'll be attached to the next visible char
400
- pendingAnsi += ansiResult.code;
401
- i += ansiResult.length;
402
- continue;
403
- }
404
-
405
- const char = text[i];
406
- const charIsSpace = char === " ";
407
-
408
- if (charIsSpace !== inWhitespace && current) {
409
- // Switching between whitespace and non-whitespace, push current token
410
- tokens.push(current);
411
- current = "";
412
- }
413
-
414
- // Attach any pending ANSI codes to this visible character
415
- if (pendingAnsi) {
416
- current += pendingAnsi;
417
- pendingAnsi = "";
418
- }
419
-
420
- inWhitespace = charIsSpace;
421
- current += char;
422
- i++;
423
- }
424
-
425
- // Handle any remaining pending ANSI codes (attach to last token)
426
- if (pendingAnsi) {
427
- current += pendingAnsi;
428
- }
429
-
430
- if (current) {
431
- tokens.push(current);
432
- }
433
-
434
- return tokens;
435
- }
436
-
437
306
  /**
438
307
  * Wrap text with ANSI codes preserved.
439
308
  *
@@ -450,112 +319,7 @@ export function wrapTextWithAnsi(text: string, width: number): string[] {
450
319
  return [""];
451
320
  }
452
321
 
453
- // Handle newlines by processing each line separately
454
- // Track ANSI state across lines so styles carry over after literal newlines
455
- const inputLines = text.split("\n");
456
- const result: string[] = [];
457
- const tracker = new AnsiCodeTracker();
458
-
459
- for (const inputLine of inputLines) {
460
- // Prepend active ANSI codes from previous lines (except for first line)
461
- const prefix = result.length > 0 ? tracker.getActiveCodes() : "";
462
- result.push(...wrapSingleLine(prefix + inputLine, width));
463
- // Update tracker with codes from this line for next iteration
464
- updateTrackerFromText(inputLine, tracker);
465
- }
466
-
467
- return result.length > 0 ? result : [""];
468
- }
469
-
470
- function wrapSingleLine(line: string, width: number): string[] {
471
- if (!line) {
472
- return [""];
473
- }
474
-
475
- const visibleLength = visibleWidth(line);
476
- if (visibleLength <= width) {
477
- return [line];
478
- }
479
-
480
- const wrapped: string[] = [];
481
- const tracker = new AnsiCodeTracker();
482
- const tokens = splitIntoTokensWithAnsi(line);
483
-
484
- let currentLine = "";
485
- let currentVisibleLength = 0;
486
-
487
- for (const token of tokens) {
488
- const tokenVisibleLength = visibleWidth(token);
489
- const isWhitespace = token.trim() === "";
490
-
491
- // Token itself is too long - break it character by character
492
- // For whitespace tokens exceeding width, truncate to width instead of breaking
493
- if (tokenVisibleLength > width && isWhitespace) {
494
- // Truncate long whitespace to fit width
495
- const truncated = token.substring(0, width - currentVisibleLength);
496
- if (truncated) {
497
- currentLine += truncated;
498
- currentVisibleLength += visibleWidth(truncated);
499
- }
500
- updateTrackerFromText(token, tracker);
501
- continue;
502
- }
503
-
504
- if (tokenVisibleLength > width && !isWhitespace) {
505
- if (currentLine) {
506
- // Add specific reset for underline only (preserves background)
507
- const lineEndReset = tracker.getLineEndReset();
508
- if (lineEndReset) {
509
- currentLine += lineEndReset;
510
- }
511
- wrapped.push(currentLine);
512
- currentLine = "";
513
- currentVisibleLength = 0;
514
- }
515
-
516
- // Break long token - breakLongWord handles its own resets
517
- const broken = breakLongWord(token, width, tracker);
518
- wrapped.push(...broken.slice(0, -1));
519
- currentLine = broken[broken.length - 1];
520
- currentVisibleLength = visibleWidth(currentLine);
521
- continue;
522
- }
523
-
524
- // Check if adding this token would exceed width
525
- const totalNeeded = currentVisibleLength + tokenVisibleLength;
526
-
527
- if (totalNeeded > width && currentVisibleLength > 0) {
528
- // Trim trailing whitespace, then add underline reset (not full reset, to preserve background)
529
- let lineToWrap = currentLine.trimEnd();
530
- const lineEndReset = tracker.getLineEndReset();
531
- if (lineEndReset) {
532
- lineToWrap += lineEndReset;
533
- }
534
- wrapped.push(lineToWrap);
535
- if (isWhitespace) {
536
- // Don't start new line with whitespace
537
- currentLine = tracker.getActiveCodes();
538
- currentVisibleLength = 0;
539
- } else {
540
- currentLine = tracker.getActiveCodes() + token;
541
- currentVisibleLength = tokenVisibleLength;
542
- }
543
- } else {
544
- // Add to current line
545
- currentLine += token;
546
- currentVisibleLength += tokenVisibleLength;
547
- }
548
-
549
- updateTrackerFromText(token, tracker);
550
- }
551
-
552
- if (currentLine) {
553
- // No reset at end of final line - let caller handle it
554
- wrapped.push(currentLine);
555
- }
556
-
557
- // Trailing whitespace can cause lines to exceed the requested width
558
- return wrapped.length > 0 ? wrapped.map(line => line.trimEnd()) : [""];
322
+ return Bun.wrapAnsi(text, width, { wordWrap: true, hard: true, trim: false }).split("\n");
559
323
  }
560
324
 
561
325
  const PUNCTUATION_REGEX = /[(){}[\]<>.,;:'"!?+\-=*/\\|&%^$#@~`]/;
@@ -574,75 +338,6 @@ export function isPunctuationChar(char: string): boolean {
574
338
  return PUNCTUATION_REGEX.test(char);
575
339
  }
576
340
 
577
- function breakLongWord(word: string, width: number, tracker: AnsiCodeTracker): string[] {
578
- const lines: string[] = [];
579
- let currentLine = tracker.getActiveCodes();
580
- let currentWidth = 0;
581
-
582
- // First, separate ANSI codes from visible content
583
- // We need to handle ANSI codes specially since they're not graphemes
584
- let i = 0;
585
- const segments: Array<{ type: "ansi" | "grapheme"; value: string }> = [];
586
-
587
- while (i < word.length) {
588
- const ansiResult = extractAnsiCode(word, i);
589
- if (ansiResult) {
590
- segments.push({ type: "ansi", value: ansiResult.code });
591
- i += ansiResult.length;
592
- } else {
593
- // Find the next ANSI code or end of string
594
- let end = i;
595
- while (end < word.length) {
596
- const nextAnsi = extractAnsiCode(word, end);
597
- if (nextAnsi) break;
598
- end++;
599
- }
600
- // Segment this non-ANSI portion into graphemes
601
- const textPortion = word.slice(i, end);
602
- for (const seg of segmenter.segment(textPortion)) {
603
- segments.push({ type: "grapheme", value: seg.segment });
604
- }
605
- i = end;
606
- }
607
- }
608
-
609
- // Now process segments
610
- for (const seg of segments) {
611
- if (seg.type === "ansi") {
612
- currentLine += seg.value;
613
- tracker.process(seg.value);
614
- continue;
615
- }
616
-
617
- const grapheme = seg.value;
618
- // Skip empty graphemes to avoid issues with string-width calculation
619
- if (!grapheme) continue;
620
-
621
- const graphemeWidth = visibleWidth(grapheme);
622
-
623
- if (currentWidth + graphemeWidth > width) {
624
- // Add specific reset for underline only (preserves background)
625
- const lineEndReset = tracker.getLineEndReset();
626
- if (lineEndReset) {
627
- currentLine += lineEndReset;
628
- }
629
- lines.push(currentLine);
630
- currentLine = tracker.getActiveCodes();
631
- currentWidth = 0;
632
- }
633
-
634
- currentLine += grapheme;
635
- currentWidth += graphemeWidth;
636
- }
637
-
638
- if (currentLine) {
639
- // No reset at end of final segment - caller handles continuation
640
- lines.push(currentLine);
641
- }
642
-
643
- return lines.length > 0 ? lines : [""];
644
- }
645
-
646
341
  /**
647
342
  * Apply background color to a line, padding to full width.
648
343
  *
@@ -782,7 +477,7 @@ export function sliceWithWidth(
782
477
  while (textEnd < line.length && !extractAnsiCode(line, textEnd)) textEnd++;
783
478
 
784
479
  for (const { segment } of segmenter.segment(line.slice(i, textEnd))) {
785
- const w = graphemeWidth(segment);
480
+ const w = visibleWidth(segment);
786
481
  const inRange = currentCol >= startCol && currentCol < endCol;
787
482
  const fits = !strict || currentCol + w <= endCol;
788
483
  if (inRange && fits) {
@@ -850,7 +545,7 @@ export function extractSegments(
850
545
  while (textEnd < line.length && !extractAnsiCode(line, textEnd)) textEnd++;
851
546
 
852
547
  for (const { segment } of segmenter.segment(line.slice(i, textEnd))) {
853
- const w = graphemeWidth(segment);
548
+ const w = visibleWidth(segment);
854
549
 
855
550
  if (currentCol < beforeEnd) {
856
551
  if (pendingAnsiBefore) {