@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 +5 -4
- package/package.json +1 -2
- package/src/components/editor.ts +6 -42
- package/src/terminal.ts +9 -0
- package/src/tui.ts +65 -12
- package/src/utils.ts +4 -309
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.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
|
},
|
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>();
|
|
@@ -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
|
|
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
|
|
1015
|
-
const lineDiff =
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
548
|
+
const w = visibleWidth(segment);
|
|
854
549
|
|
|
855
550
|
if (currentCol < beforeEnd) {
|
|
856
551
|
if (pendingAnsiBefore) {
|