@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 +5 -4
- package/package.json +1 -2
- package/src/components/editor.ts +6 -42
- package/src/terminal.ts +9 -0
- package/src/tui.ts +71 -15
- package/src/utils.ts +5 -312
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 (
|
|
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
|
-
|
|
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.
|
|
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
|
},
|
package/src/components/editor.ts
CHANGED
|
@@ -1195,51 +1195,15 @@ export class Editor implements Component, Focusable {
|
|
|
1195
1195
|
}
|
|
1196
1196
|
|
|
1197
1197
|
if (pastedLines.length === 1) {
|
|
1198
|
-
|
|
1199
|
-
|
|
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 -
|
|
1204
|
-
|
|
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
|
-
//
|
|
712
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
|
1015
|
-
const lineDiff =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
546
|
+
const w = visibleWidth(segment);
|
|
854
547
|
|
|
855
548
|
if (currentCol < beforeEnd) {
|
|
856
549
|
if (pendingAnsiBefore) {
|