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

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.8.8",
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>();
@@ -757,10 +770,13 @@ export class TUI extends Container {
757
770
  /**
758
771
  * Find and extract cursor position from rendered lines.
759
772
  * Searches for CURSOR_MARKER, calculates its position, and strips it from the output.
773
+ * @param lines - Rendered lines to search
774
+ * @param height - Terminal height (to calculate viewport)
760
775
  * @returns Cursor position { row, col } or null if no marker found
761
776
  */
762
- private extractCursorPosition(lines: string[]): { row: number; col: number } | null {
763
- for (let row = 0; row < lines.length; row++) {
777
+ private extractCursorPosition(lines: string[], height: number): { row: number; col: number } | null {
778
+ const viewportTop = Math.max(0, lines.length - height);
779
+ for (let row = lines.length - 1; row >= viewportTop; row--) {
764
780
  const line = lines[row];
765
781
  const markerIndex = line.indexOf(CURSOR_MARKER);
766
782
  if (markerIndex !== -1) {
@@ -882,6 +898,14 @@ export class TUI extends Container {
882
898
  // Capture terminal dimensions at start to ensure consistency throughout render
883
899
  const width = this.terminal.columns;
884
900
  const height = this.terminal.rows;
901
+ let viewportTop = Math.max(0, this.maxLinesRendered - height);
902
+ let prevViewportTop = this.previousViewportTop;
903
+ let hardwareCursorRow = this.hardwareCursorRow;
904
+ const computeLineDiff = (targetRow: number): number => {
905
+ const currentScreenRow = hardwareCursorRow - prevViewportTop;
906
+ const targetScreenRow = targetRow - viewportTop;
907
+ return targetScreenRow - currentScreenRow;
908
+ };
885
909
 
886
910
  // Render all components to get new lines
887
911
  let newLines = this.render(width);
@@ -892,7 +916,7 @@ export class TUI extends Container {
892
916
  }
893
917
 
894
918
  // Extract cursor position before applying line resets (marker must be found first)
895
- const cursorPos = this.extractCursorPosition(newLines);
919
+ const cursorPos = this.extractCursorPosition(newLines, height);
896
920
 
897
921
  newLines = this.applyLineResets(newLines);
898
922
 
@@ -901,6 +925,7 @@ export class TUI extends Container {
901
925
 
902
926
  // First render - just output everything without clearing (assumes clean screen)
903
927
  if (this.previousLines.length === 0 && !widthChanged) {
928
+ this.fullRedrawCount += 1;
904
929
  let buffer = "\x1b[?2026h"; // Begin synchronized output
905
930
  for (let i = 0; i < newLines.length; i++) {
906
931
  if (i > 0) buffer += "\r\n";
@@ -911,6 +936,8 @@ export class TUI extends Container {
911
936
  // After rendering N lines, cursor is at end of last line (clamp to 0 for empty)
912
937
  this.cursorRow = Math.max(0, newLines.length - 1);
913
938
  this.hardwareCursorRow = this.cursorRow;
939
+ this.maxLinesRendered = Math.max(this.maxLinesRendered, newLines.length);
940
+ this.previousViewportTop = Math.max(0, this.maxLinesRendered - height);
914
941
  this.positionHardwareCursor(cursorPos, newLines.length);
915
942
  this.previousLines = newLines;
916
943
  this.previousWidth = width;
@@ -919,6 +946,7 @@ export class TUI extends Container {
919
946
 
920
947
  // Width changed - full re-render
921
948
  if (widthChanged) {
949
+ this.fullRedrawCount += 1;
922
950
  let buffer = "\x1b[?2026h"; // Begin synchronized output
923
951
  buffer += "\x1b[3J\x1b[2J\x1b[H"; // Clear scrollback, screen, and home
924
952
  for (let i = 0; i < newLines.length; i++) {
@@ -929,6 +957,8 @@ export class TUI extends Container {
929
957
  this.terminal.write(buffer);
930
958
  this.cursorRow = Math.max(0, newLines.length - 1);
931
959
  this.hardwareCursorRow = this.cursorRow;
960
+ this.maxLinesRendered = Math.max(this.maxLinesRendered, newLines.length);
961
+ this.previousViewportTop = Math.max(0, this.maxLinesRendered - height);
932
962
  this.positionHardwareCursor(cursorPos, newLines.length);
933
963
  this.previousLines = newLines;
934
964
  this.previousWidth = width;
@@ -950,10 +980,19 @@ export class TUI extends Container {
950
980
  lastChanged = i;
951
981
  }
952
982
  }
983
+ const appendedLines = newLines.length > this.previousLines.length;
984
+ if (appendedLines) {
985
+ if (firstChanged === -1) {
986
+ firstChanged = this.previousLines.length;
987
+ }
988
+ lastChanged = newLines.length - 1;
989
+ }
990
+ const appendStart = appendedLines && firstChanged === this.previousLines.length && firstChanged > 0;
953
991
 
954
992
  // No changes - but still need to update hardware cursor position if it moved
955
993
  if (firstChanged === -1) {
956
994
  this.positionHardwareCursor(cursorPos, newLines.length);
995
+ this.previousViewportTop = Math.max(0, this.maxLinesRendered - height);
957
996
  return;
958
997
  }
959
998
 
@@ -963,7 +1002,7 @@ export class TUI extends Container {
963
1002
  let buffer = "\x1b[?2026h";
964
1003
  // Move to end of new content (clamp to 0 for empty content)
965
1004
  const targetRow = Math.max(0, newLines.length - 1);
966
- const lineDiff = targetRow - this.hardwareCursorRow;
1005
+ const lineDiff = computeLineDiff(targetRow);
967
1006
  if (lineDiff > 0) buffer += `\x1b[${lineDiff}B`;
968
1007
  else if (lineDiff < 0) buffer += `\x1b[${-lineDiff}A`;
969
1008
  buffer += "\r";
@@ -981,16 +1020,14 @@ export class TUI extends Container {
981
1020
  this.positionHardwareCursor(cursorPos, newLines.length);
982
1021
  this.previousLines = newLines;
983
1022
  this.previousWidth = width;
1023
+ this.previousViewportTop = Math.max(0, this.maxLinesRendered - height);
984
1024
  return;
985
1025
  }
986
1026
 
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;
1027
+ // Check if firstChanged is outside the viewport (based on maxLinesRendered)
992
1028
  if (firstChanged < viewportTop) {
993
1029
  // First change is above viewport - need full re-render
1030
+ this.fullRedrawCount += 1;
994
1031
  let buffer = "\x1b[?2026h"; // Begin synchronized output
995
1032
  buffer += "\x1b[3J\x1b[2J\x1b[H"; // Clear scrollback, screen, and home
996
1033
  for (let i = 0; i < newLines.length; i++) {
@@ -1001,6 +1038,8 @@ export class TUI extends Container {
1001
1038
  this.terminal.write(buffer);
1002
1039
  this.cursorRow = Math.max(0, newLines.length - 1);
1003
1040
  this.hardwareCursorRow = this.cursorRow;
1041
+ this.maxLinesRendered = Math.max(this.maxLinesRendered, newLines.length);
1042
+ this.previousViewportTop = Math.max(0, this.maxLinesRendered - height);
1004
1043
  this.positionHardwareCursor(cursorPos, newLines.length);
1005
1044
  this.previousLines = newLines;
1006
1045
  this.previousWidth = width;
@@ -1010,16 +1049,30 @@ export class TUI extends Container {
1010
1049
  // Render from first changed line to end
1011
1050
  // Build buffer with all updates wrapped in synchronized output
1012
1051
  let buffer = "\x1b[?2026h"; // Begin synchronized output
1052
+ const prevViewportBottom = prevViewportTop + height - 1;
1053
+ const moveTargetRow = appendStart ? firstChanged - 1 : firstChanged;
1054
+ if (moveTargetRow > prevViewportBottom) {
1055
+ const currentScreenRow = Math.max(0, Math.min(height - 1, hardwareCursorRow - prevViewportTop));
1056
+ const moveToBottom = height - 1 - currentScreenRow;
1057
+ if (moveToBottom > 0) {
1058
+ buffer += `\x1b[${moveToBottom}B`;
1059
+ }
1060
+ const scroll = moveTargetRow - prevViewportBottom;
1061
+ buffer += "\r\n".repeat(scroll);
1062
+ prevViewportTop += scroll;
1063
+ viewportTop += scroll;
1064
+ hardwareCursorRow = moveTargetRow;
1065
+ }
1013
1066
 
1014
- // Move cursor to first changed line (use hardwareCursorRow for actual position)
1015
- const lineDiff = firstChanged - this.hardwareCursorRow;
1067
+ // Move cursor to first changed line
1068
+ const lineDiff = computeLineDiff(moveTargetRow);
1016
1069
  if (lineDiff > 0) {
1017
1070
  buffer += `\x1b[${lineDiff}B`; // Move down
1018
1071
  } else if (lineDiff < 0) {
1019
1072
  buffer += `\x1b[${-lineDiff}A`; // Move up
1020
1073
  }
1021
1074
 
1022
- buffer += "\r"; // Move to column 0
1075
+ buffer += appendStart ? "\r\n" : "\r"; // Move to column 0
1023
1076
 
1024
1077
  // Only render changed lines (firstChanged to lastChanged), not all lines to end
1025
1078
  // This reduces flicker when only a single line changes (e.g., spinner animation)
@@ -1093,6 +1146,9 @@ export class TUI extends Container {
1093
1146
  // hardwareCursorRow tracks actual cursor position (may move to cursorPos below)
1094
1147
  this.cursorRow = Math.max(0, newLines.length - 1);
1095
1148
  this.hardwareCursorRow = finalCursorRow;
1149
+ // Track terminal's working area (grows but doesn't shrink unless cleared)
1150
+ this.maxLinesRendered = Math.max(this.maxLinesRendered, newLines.length);
1151
+ this.previousViewportTop = Math.max(0, this.maxLinesRendered - height);
1096
1152
 
1097
1153
  // Position hardware cursor for IME
1098
1154
  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,69 +303,7 @@ 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
- }
306
+ const WRAP_OPTIONS = { wordWrap: true, hard: true, trim: false } as const;
436
307
 
437
308
  /**
438
309
  * Wrap text with ANSI codes preserved.
@@ -446,116 +317,7 @@ function splitIntoTokensWithAnsi(text: string): string[] {
446
317
  * @returns Array of wrapped lines (NOT padded to width)
447
318
  */
448
319
  export function wrapTextWithAnsi(text: string, width: number): string[] {
449
- if (!text) {
450
- return [""];
451
- }
452
-
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()) : [""];
320
+ return Bun.wrapAnsi(text, width, WRAP_OPTIONS).split("\n");
559
321
  }
560
322
 
561
323
  const PUNCTUATION_REGEX = /[(){}[\]<>.,;:'"!?+\-=*/\\|&%^$#@~`]/;
@@ -574,75 +336,6 @@ export function isPunctuationChar(char: string): boolean {
574
336
  return PUNCTUATION_REGEX.test(char);
575
337
  }
576
338
 
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
339
  /**
647
340
  * Apply background color to a line, padding to full width.
648
341
  *
@@ -782,7 +475,7 @@ export function sliceWithWidth(
782
475
  while (textEnd < line.length && !extractAnsiCode(line, textEnd)) textEnd++;
783
476
 
784
477
  for (const { segment } of segmenter.segment(line.slice(i, textEnd))) {
785
- const w = graphemeWidth(segment);
478
+ const w = visibleWidth(segment);
786
479
  const inRange = currentCol >= startCol && currentCol < endCol;
787
480
  const fits = !strict || currentCol + w <= endCol;
788
481
  if (inRange && fits) {
@@ -850,7 +543,7 @@ export function extractSegments(
850
543
  while (textEnd < line.length && !extractAnsiCode(line, textEnd)) textEnd++;
851
544
 
852
545
  for (const { segment } of segmenter.segment(line.slice(i, textEnd))) {
853
- const w = graphemeWidth(segment);
546
+ const w = visibleWidth(segment);
854
547
 
855
548
  if (currentCol < beforeEnd) {
856
549
  if (pendingAnsiBefore) {