@greppleai/grip-tui 0.0.0-stage → 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +818 -2
- package/dist/autocomplete.d.ts +48 -0
- package/dist/autocomplete.d.ts.map +1 -0
- package/dist/autocomplete.js +566 -0
- package/dist/autocomplete.js.map +1 -0
- package/dist/components/alt-screen-flash.d.ts +13 -0
- package/dist/components/alt-screen-flash.d.ts.map +1 -0
- package/dist/components/alt-screen-flash.js +37 -0
- package/dist/components/alt-screen-flash.js.map +1 -0
- package/dist/components/box.d.ts +22 -0
- package/dist/components/box.d.ts.map +1 -0
- package/dist/components/box.js +104 -0
- package/dist/components/box.js.map +1 -0
- package/dist/components/cancellable-loader.d.ts +22 -0
- package/dist/components/cancellable-loader.d.ts.map +1 -0
- package/dist/components/cancellable-loader.js +35 -0
- package/dist/components/cancellable-loader.js.map +1 -0
- package/dist/components/editor.d.ts +292 -0
- package/dist/components/editor.d.ts.map +1 -0
- package/dist/components/editor.js +1887 -0
- package/dist/components/editor.js.map +1 -0
- package/dist/components/h-stack.d.ts +7 -0
- package/dist/components/h-stack.d.ts.map +1 -0
- package/dist/components/h-stack.js +43 -0
- package/dist/components/h-stack.js.map +1 -0
- package/dist/components/image.d.ts +32 -0
- package/dist/components/image.d.ts.map +1 -0
- package/dist/components/image.js +76 -0
- package/dist/components/image.js.map +1 -0
- package/dist/components/input.d.ts +46 -0
- package/dist/components/input.d.ts.map +1 -0
- package/dist/components/input.js +352 -0
- package/dist/components/input.js.map +1 -0
- package/dist/components/loader.d.ts +32 -0
- package/dist/components/loader.d.ts.map +1 -0
- package/dist/components/loader.js +80 -0
- package/dist/components/loader.js.map +1 -0
- package/dist/components/markdown.d.ts +132 -0
- package/dist/components/markdown.d.ts.map +1 -0
- package/dist/components/markdown.js +717 -0
- package/dist/components/markdown.js.map +1 -0
- package/dist/components/scroll-view.d.ts +53 -0
- package/dist/components/scroll-view.d.ts.map +1 -0
- package/dist/components/scroll-view.js +173 -0
- package/dist/components/scroll-view.js.map +1 -0
- package/dist/components/select-list.d.ts +50 -0
- package/dist/components/select-list.d.ts.map +1 -0
- package/dist/components/select-list.js +159 -0
- package/dist/components/select-list.js.map +1 -0
- package/dist/components/settings-list.d.ts +52 -0
- package/dist/components/settings-list.d.ts.map +1 -0
- package/dist/components/settings-list.js +187 -0
- package/dist/components/settings-list.js.map +1 -0
- package/dist/components/spacer.d.ts +12 -0
- package/dist/components/spacer.d.ts.map +1 -0
- package/dist/components/spacer.js +23 -0
- package/dist/components/spacer.js.map +1 -0
- package/dist/components/stack.d.ts +32 -0
- package/dist/components/stack.d.ts.map +1 -0
- package/dist/components/stack.js +122 -0
- package/dist/components/stack.js.map +1 -0
- package/dist/components/text.d.ts +19 -0
- package/dist/components/text.d.ts.map +1 -0
- package/dist/components/text.js +89 -0
- package/dist/components/text.js.map +1 -0
- package/dist/components/truncated-text.d.ts +13 -0
- package/dist/components/truncated-text.d.ts.map +1 -0
- package/dist/components/truncated-text.js +51 -0
- package/dist/components/truncated-text.js.map +1 -0
- package/dist/components/v-stack.d.ts +8 -0
- package/dist/components/v-stack.d.ts.map +1 -0
- package/dist/components/v-stack.js +27 -0
- package/dist/components/v-stack.js.map +1 -0
- package/dist/editor-component.d.ts +39 -0
- package/dist/editor-component.d.ts.map +1 -0
- package/dist/editor-component.js +2 -0
- package/dist/editor-component.js.map +1 -0
- package/dist/fuzzy.d.ts +16 -0
- package/dist/fuzzy.d.ts.map +1 -0
- package/dist/fuzzy.js +101 -0
- package/dist/fuzzy.js.map +1 -0
- package/dist/index.d.ts +30 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +41 -0
- package/dist/index.js.map +1 -0
- package/dist/keybindings.d.ts +243 -0
- package/dist/keybindings.d.ts.map +1 -0
- package/dist/keybindings.js +209 -0
- package/dist/keybindings.js.map +1 -0
- package/dist/keys.d.ts +184 -0
- package/dist/keys.d.ts.map +1 -0
- package/dist/keys.js +1053 -0
- package/dist/keys.js.map +1 -0
- package/dist/kill-ring.d.ts +30 -0
- package/dist/kill-ring.d.ts.map +1 -0
- package/dist/kill-ring.js +37 -0
- package/dist/kill-ring.js.map +1 -0
- package/dist/latex.d.ts +10 -0
- package/dist/latex.d.ts.map +1 -0
- package/dist/latex.js +1406 -0
- package/dist/latex.js.map +1 -0
- package/dist/layout-node.d.ts +43 -0
- package/dist/layout-node.d.ts.map +1 -0
- package/dist/layout-node.js +6 -0
- package/dist/layout-node.js.map +1 -0
- package/dist/layout.d.ts +40 -0
- package/dist/layout.d.ts.map +1 -0
- package/dist/layout.js +386 -0
- package/dist/layout.js.map +1 -0
- package/dist/native-modifiers.d.ts +3 -0
- package/dist/native-modifiers.d.ts.map +1 -0
- package/dist/native-modifiers.js +53 -0
- package/dist/native-modifiers.js.map +1 -0
- package/dist/stdin-buffer.d.ts +58 -0
- package/dist/stdin-buffer.d.ts.map +1 -0
- package/dist/stdin-buffer.js +318 -0
- package/dist/stdin-buffer.js.map +1 -0
- package/dist/terminal-control.d.ts +5 -0
- package/dist/terminal-control.d.ts.map +1 -0
- package/dist/terminal-control.js +26 -0
- package/dist/terminal-control.js.map +1 -0
- package/dist/terminal-image.d.ts +112 -0
- package/dist/terminal-image.d.ts.map +1 -0
- package/dist/terminal-image.js +508 -0
- package/dist/terminal-image.js.map +1 -0
- package/dist/terminal.d.ts +117 -0
- package/dist/terminal.d.ts.map +1 -0
- package/dist/terminal.js +490 -0
- package/dist/terminal.js.map +1 -0
- package/dist/tui-alt-screen.d.ts +114 -0
- package/dist/tui-alt-screen.d.ts.map +1 -0
- package/dist/tui-alt-screen.js +950 -0
- package/dist/tui-alt-screen.js.map +1 -0
- package/dist/tui-main-screen.d.ts +52 -0
- package/dist/tui-main-screen.d.ts.map +1 -0
- package/dist/tui-main-screen.js +511 -0
- package/dist/tui-main-screen.js.map +1 -0
- package/dist/tui.d.ts +295 -0
- package/dist/tui.d.ts.map +1 -0
- package/dist/tui.js +868 -0
- package/dist/tui.js.map +1 -0
- package/dist/undo-stack.d.ts +17 -0
- package/dist/undo-stack.d.ts.map +1 -0
- package/dist/undo-stack.js +25 -0
- package/dist/undo-stack.js.map +1 -0
- package/dist/utils.d.ts +96 -0
- package/dist/utils.d.ts.map +1 -0
- package/dist/utils.js +1106 -0
- package/dist/utils.js.map +1 -0
- package/dist/word-navigation.d.ts +25 -0
- package/dist/word-navigation.d.ts.map +1 -0
- package/dist/word-navigation.js +94 -0
- package/dist/word-navigation.js.map +1 -0
- package/native/darwin/prebuilds/darwin-arm64/darwin-modifiers.node +0 -0
- package/native/darwin/prebuilds/darwin-x64/darwin-modifiers.node +0 -0
- package/native/win32/prebuilds/win32-arm64/win32-console-mode.node +0 -0
- package/native/win32/prebuilds/win32-x64/win32-console-mode.node +0 -0
- package/package.json +47 -5
package/dist/utils.js
ADDED
|
@@ -0,0 +1,1106 @@
|
|
|
1
|
+
import { eastAsianWidth } from "get-east-asian-width";
|
|
2
|
+
// segmenters (shared instance)
|
|
3
|
+
const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
|
4
|
+
const wordSegmenter = new Intl.Segmenter(undefined, { granularity: "word" });
|
|
5
|
+
/**
|
|
6
|
+
* Get the shared grapheme segmenter instance.
|
|
7
|
+
*/
|
|
8
|
+
export function getGraphemeSegmenter() {
|
|
9
|
+
return graphemeSegmenter;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Get the shared word segmenter instance.
|
|
13
|
+
*/
|
|
14
|
+
export function getWordSegmenter() {
|
|
15
|
+
return wordSegmenter;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Check if a grapheme cluster (after segmentation) could possibly be an RGI emoji.
|
|
19
|
+
* This is a fast heuristic to avoid the expensive rgiEmojiRegex test.
|
|
20
|
+
* The tested Unicode blocks are deliberately broad to account for future
|
|
21
|
+
* Unicode additions.
|
|
22
|
+
*/
|
|
23
|
+
function couldBeEmoji(segment) {
|
|
24
|
+
const cp = segment.codePointAt(0);
|
|
25
|
+
return ((cp >= 0x1f000 && cp <= 0x1fbff) || // Emoji and Pictograph
|
|
26
|
+
(cp >= 0x2300 && cp <= 0x23ff) || // Misc technical
|
|
27
|
+
(cp >= 0x2600 && cp <= 0x27bf) || // Misc symbols, dingbats
|
|
28
|
+
(cp >= 0x2b50 && cp <= 0x2b55) || // Specific stars/circles
|
|
29
|
+
segment.includes("\uFE0F") || // Contains VS16 (emoji presentation selector)
|
|
30
|
+
segment.length > 2 // Multi-codepoint sequences (ZWJ, skin tones, etc.)
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
// Regexes for character classification (same as string-width library)
|
|
34
|
+
const zeroWidthRegex = /^(?:\p{Default_Ignorable_Code_Point}|\p{Control}|\p{Mark}|\p{Surrogate})+$/v;
|
|
35
|
+
const leadingNonPrintingRegex = /^[\p{Default_Ignorable_Code_Point}\p{Control}\p{Format}\p{Mark}\p{Surrogate}]+/v;
|
|
36
|
+
const nonPrintingCharRegex = /^(?:\p{Default_Ignorable_Code_Point}|\p{Control}|\p{Format}|\p{Mark}|\p{Surrogate})$/v;
|
|
37
|
+
const markCharRegex = /^\p{Mark}$/v;
|
|
38
|
+
// Marks that terminals allocate cells for when attached to a base character.
|
|
39
|
+
// This includes Unicode spacing marks and non-spacing exceptions in legacy wcwidth tables.
|
|
40
|
+
const terminalSpacingMarkRegex = /^(?:[\p{Spacing_Mark}--[\u1734\u302E\u302F]]|[\u065F\u0F7F\u102B\u102C\u1031\u1033-\u1035\u1038\u103A-\u103E])+$/v;
|
|
41
|
+
const rgiEmojiRegex = /^\p{RGI_Emoji}$/v;
|
|
42
|
+
// Cache for non-ASCII strings
|
|
43
|
+
const WIDTH_CACHE_SIZE = 512;
|
|
44
|
+
const widthCache = new Map();
|
|
45
|
+
function isPrintableAscii(str) {
|
|
46
|
+
for (let i = 0; i < str.length; i++) {
|
|
47
|
+
const code = str.charCodeAt(i);
|
|
48
|
+
if (code < 0x20 || code > 0x7e) {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
function appendPendingAnsi(state) {
|
|
55
|
+
if (!state.pendingAnsi)
|
|
56
|
+
return;
|
|
57
|
+
state.text += state.pendingAnsi;
|
|
58
|
+
state.pendingAnsi = "";
|
|
59
|
+
}
|
|
60
|
+
function appendVisibleFragment(state, fragment, fragmentWidth, maxWidth) {
|
|
61
|
+
if (state.width + fragmentWidth > maxWidth)
|
|
62
|
+
return false;
|
|
63
|
+
appendPendingAnsi(state);
|
|
64
|
+
state.text += fragment;
|
|
65
|
+
state.width += fragmentWidth;
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
function appendGraphemeRun(state, text, maxWidth) {
|
|
69
|
+
for (const { segment } of graphemeSegmenter.segment(text)) {
|
|
70
|
+
if (!appendVisibleFragment(state, segment, graphemeWidth(segment), maxWidth))
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
function truncateStyledFragment(state, text, maxWidth) {
|
|
76
|
+
let index = 0;
|
|
77
|
+
while (index < text.length) {
|
|
78
|
+
const ansi = extractAnsiCode(text, index);
|
|
79
|
+
if (ansi) {
|
|
80
|
+
state.pendingAnsi += ansi.code;
|
|
81
|
+
index += ansi.length;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (text[index] === "\t") {
|
|
85
|
+
if (!appendVisibleFragment(state, "\t", 3, maxWidth))
|
|
86
|
+
return;
|
|
87
|
+
index++;
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
let end = index;
|
|
91
|
+
while (end < text.length && text[end] !== "\t" && !extractAnsiCode(text, end))
|
|
92
|
+
end++;
|
|
93
|
+
if (!appendGraphemeRun(state, text.slice(index, end), maxWidth))
|
|
94
|
+
return;
|
|
95
|
+
index = end;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
function truncateFragmentToWidth(text, maxWidth) {
|
|
99
|
+
if (maxWidth <= 0 || text.length === 0)
|
|
100
|
+
return { text: "", width: 0 };
|
|
101
|
+
if (isPrintableAscii(text)) {
|
|
102
|
+
const clipped = text.slice(0, maxWidth);
|
|
103
|
+
return { text: clipped, width: clipped.length };
|
|
104
|
+
}
|
|
105
|
+
const state = { text: "", width: 0, pendingAnsi: "" };
|
|
106
|
+
if (!text.includes("\x1b") && !text.includes("\t")) {
|
|
107
|
+
appendGraphemeRun(state, text, maxWidth);
|
|
108
|
+
}
|
|
109
|
+
else {
|
|
110
|
+
truncateStyledFragment(state, text, maxWidth);
|
|
111
|
+
}
|
|
112
|
+
return { text: state.text, width: state.width };
|
|
113
|
+
}
|
|
114
|
+
function finalizeTruncatedResult(prefix, prefixWidth, ellipsis, ellipsisWidth, maxWidth, pad) {
|
|
115
|
+
const reset = "\x1b[0m";
|
|
116
|
+
const hyperlinkClose = getActiveOsc8Close(prefix);
|
|
117
|
+
const visibleWidth = prefixWidth + ellipsisWidth;
|
|
118
|
+
let result;
|
|
119
|
+
if (ellipsis.length > 0) {
|
|
120
|
+
result = `${prefix}${hyperlinkClose}${reset}${ellipsis}${reset}`;
|
|
121
|
+
}
|
|
122
|
+
else {
|
|
123
|
+
result = `${prefix}${hyperlinkClose}${reset}`;
|
|
124
|
+
}
|
|
125
|
+
return pad ? result + " ".repeat(Math.max(0, maxWidth - visibleWidth)) : result;
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Calculate the terminal width of a single grapheme cluster.
|
|
129
|
+
* Based on code from the string-width library, but includes a possible-emoji
|
|
130
|
+
* check to avoid running the RGI_Emoji regex unnecessarily.
|
|
131
|
+
*/
|
|
132
|
+
function trailingCodePointWidth(codePoint, followsMark) {
|
|
133
|
+
if (followsMark || (codePoint >= 0xff00 && codePoint <= 0xffef)) {
|
|
134
|
+
// Indic consonants after marks and halfwidth/fullwidth forms.
|
|
135
|
+
return eastAsianWidth(codePoint);
|
|
136
|
+
}
|
|
137
|
+
if (codePoint === 0x0e33 || codePoint === 0x0eb3) {
|
|
138
|
+
// Thai/Lao AM vowels.
|
|
139
|
+
return 1;
|
|
140
|
+
}
|
|
141
|
+
return 0;
|
|
142
|
+
}
|
|
143
|
+
function trailingGraphemeWidth(base) {
|
|
144
|
+
let width = 0;
|
|
145
|
+
let followsMark = false;
|
|
146
|
+
for (const char of [...base].slice(1)) {
|
|
147
|
+
if (terminalSpacingMarkRegex.test(char)) {
|
|
148
|
+
width += 1;
|
|
149
|
+
followsMark = false;
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
if (markCharRegex.test(char)) {
|
|
153
|
+
followsMark = true;
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
if (nonPrintingCharRegex.test(char))
|
|
157
|
+
continue;
|
|
158
|
+
width += trailingCodePointWidth(char.codePointAt(0), followsMark);
|
|
159
|
+
followsMark = false;
|
|
160
|
+
}
|
|
161
|
+
return width;
|
|
162
|
+
}
|
|
163
|
+
function graphemeWidth(segment) {
|
|
164
|
+
if (segment === "\t") {
|
|
165
|
+
return 3;
|
|
166
|
+
}
|
|
167
|
+
// Some marks occupy cells even without a base character.
|
|
168
|
+
if (terminalSpacingMarkRegex.test(segment)) {
|
|
169
|
+
return [...segment].length;
|
|
170
|
+
}
|
|
171
|
+
// Zero-width clusters
|
|
172
|
+
if (zeroWidthRegex.test(segment)) {
|
|
173
|
+
return 0;
|
|
174
|
+
}
|
|
175
|
+
// Emoji check with pre-filter
|
|
176
|
+
if (couldBeEmoji(segment) && rgiEmojiRegex.test(segment)) {
|
|
177
|
+
return 2;
|
|
178
|
+
}
|
|
179
|
+
// Get base visible codepoint
|
|
180
|
+
const base = segment.replace(leadingNonPrintingRegex, "");
|
|
181
|
+
const cp = base.codePointAt(0);
|
|
182
|
+
if (cp === undefined) {
|
|
183
|
+
return 0;
|
|
184
|
+
}
|
|
185
|
+
// Regional indicator symbols (U+1F1E6..U+1F1FF) are often rendered as
|
|
186
|
+
// full-width emoji in terminals, even when isolated during streaming.
|
|
187
|
+
// Keep width conservative (2) to avoid terminal auto-wrap drift artifacts.
|
|
188
|
+
if (cp >= 0x1f1e6 && cp <= 0x1f1ff) {
|
|
189
|
+
return 2;
|
|
190
|
+
}
|
|
191
|
+
// Intl.Segmenter can group multiple terminal-spacing code points into one
|
|
192
|
+
// grapheme. Count trailing visible code points that terminals may allocate
|
|
193
|
+
// cells for.
|
|
194
|
+
return eastAsianWidth(cp) + trailingGraphemeWidth(base);
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Calculate the visible width of a string in terminal columns.
|
|
198
|
+
*/
|
|
199
|
+
export function visibleWidth(str) {
|
|
200
|
+
if (str.length === 0) {
|
|
201
|
+
return 0;
|
|
202
|
+
}
|
|
203
|
+
// Fast path: pure ASCII printable
|
|
204
|
+
if (isPrintableAscii(str)) {
|
|
205
|
+
return str.length;
|
|
206
|
+
}
|
|
207
|
+
// Check cache
|
|
208
|
+
const cached = widthCache.get(str);
|
|
209
|
+
if (cached !== undefined) {
|
|
210
|
+
return cached;
|
|
211
|
+
}
|
|
212
|
+
// Normalize: tabs to 3 spaces, strip ANSI escape codes
|
|
213
|
+
let clean = str;
|
|
214
|
+
if (str.includes("\t")) {
|
|
215
|
+
clean = clean.replace(/\t/g, " ");
|
|
216
|
+
}
|
|
217
|
+
if (clean.includes("\x1b")) {
|
|
218
|
+
// Strip supported ANSI/OSC/APC escape sequences in one pass.
|
|
219
|
+
// This covers CSI styling/cursor codes, OSC hyperlinks and prompt markers,
|
|
220
|
+
// and APC sequences like CURSOR_MARKER.
|
|
221
|
+
let stripped = "";
|
|
222
|
+
let i = 0;
|
|
223
|
+
while (i < clean.length) {
|
|
224
|
+
const ansi = extractAnsiCode(clean, i);
|
|
225
|
+
if (ansi) {
|
|
226
|
+
i += ansi.length;
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
stripped += clean[i];
|
|
230
|
+
i++;
|
|
231
|
+
}
|
|
232
|
+
clean = stripped;
|
|
233
|
+
}
|
|
234
|
+
// Calculate width
|
|
235
|
+
let width = 0;
|
|
236
|
+
for (const { segment } of graphemeSegmenter.segment(clean)) {
|
|
237
|
+
width += graphemeWidth(segment);
|
|
238
|
+
}
|
|
239
|
+
// Cache result
|
|
240
|
+
if (widthCache.size >= WIDTH_CACHE_SIZE) {
|
|
241
|
+
const firstKey = widthCache.keys().next().value;
|
|
242
|
+
if (firstKey !== undefined) {
|
|
243
|
+
widthCache.delete(firstKey);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
widthCache.set(str, width);
|
|
247
|
+
return width;
|
|
248
|
+
}
|
|
249
|
+
/** Remove ANSI, OSC, and APC control sequences while preserving visible text. */
|
|
250
|
+
export function stripTerminalSequences(str) {
|
|
251
|
+
if (!str.includes("\x1b"))
|
|
252
|
+
return str;
|
|
253
|
+
let result = "";
|
|
254
|
+
let i = 0;
|
|
255
|
+
while (i < str.length) {
|
|
256
|
+
const ansi = extractAnsiCode(str, i);
|
|
257
|
+
if (ansi) {
|
|
258
|
+
i += ansi.length;
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
result += str[i];
|
|
262
|
+
i++;
|
|
263
|
+
}
|
|
264
|
+
return result;
|
|
265
|
+
}
|
|
266
|
+
function scanTerminalTextColumn(text, column, startColumn) {
|
|
267
|
+
let currentColumn = startColumn;
|
|
268
|
+
for (const { segment } of graphemeSegmenter.segment(text)) {
|
|
269
|
+
const width = segment === "\t" ? 3 : graphemeWidth(segment);
|
|
270
|
+
if (column >= currentColumn && column < currentColumn + width) {
|
|
271
|
+
return { containsColumn: true, nextColumn: currentColumn };
|
|
272
|
+
}
|
|
273
|
+
currentColumn += width;
|
|
274
|
+
}
|
|
275
|
+
return { containsColumn: false, nextColumn: currentColumn };
|
|
276
|
+
}
|
|
277
|
+
/** Return the terminal-cell range occupied by the grapheme at a visible column. */
|
|
278
|
+
export function getGraphemeCellRange(line, column) {
|
|
279
|
+
let currentCol = 0;
|
|
280
|
+
let i = 0;
|
|
281
|
+
while (i < line.length) {
|
|
282
|
+
const ansi = extractAnsiCode(line, i);
|
|
283
|
+
if (ansi) {
|
|
284
|
+
i += ansi.length;
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
let textEnd = i;
|
|
288
|
+
while (textEnd < line.length && !extractAnsiCode(line, textEnd))
|
|
289
|
+
textEnd++;
|
|
290
|
+
for (const { segment } of graphemeSegmenter.segment(line.slice(i, textEnd))) {
|
|
291
|
+
const width = graphemeWidth(segment);
|
|
292
|
+
if (width > 0 && column >= currentCol && column < currentCol + width) {
|
|
293
|
+
return { start: currentCol, end: currentCol + width };
|
|
294
|
+
}
|
|
295
|
+
currentCol += width;
|
|
296
|
+
}
|
|
297
|
+
i = textEnd;
|
|
298
|
+
}
|
|
299
|
+
return undefined;
|
|
300
|
+
}
|
|
301
|
+
/** Return the OSC 8 hyperlink covering a visible terminal column. */
|
|
302
|
+
export function getOsc8LinkAtColumn(line, column) {
|
|
303
|
+
let activeUrl;
|
|
304
|
+
let currentCol = 0;
|
|
305
|
+
let i = 0;
|
|
306
|
+
while (i < line.length) {
|
|
307
|
+
const ansi = extractAnsiCode(line, i);
|
|
308
|
+
if (ansi) {
|
|
309
|
+
const hyperlink = /^\x1b\]8;[^;]*;([^\x07\x1b]*)(?:\x07|\x1b\\)$/.exec(ansi.code);
|
|
310
|
+
if (hyperlink)
|
|
311
|
+
activeUrl = hyperlink[1] || undefined;
|
|
312
|
+
i += ansi.length;
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
let textEnd = i;
|
|
316
|
+
while (textEnd < line.length && !extractAnsiCode(line, textEnd))
|
|
317
|
+
textEnd++;
|
|
318
|
+
const scan = scanTerminalTextColumn(line.slice(i, textEnd), column, currentCol);
|
|
319
|
+
if (scan.containsColumn)
|
|
320
|
+
return activeUrl;
|
|
321
|
+
currentCol = scan.nextColumn;
|
|
322
|
+
i = textEnd;
|
|
323
|
+
}
|
|
324
|
+
return undefined;
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* Normalize text for terminal output without changing logical editor content.
|
|
328
|
+
* Some terminals render precomposed Thai/Lao AM vowels inconsistently during
|
|
329
|
+
* differential repaint. Their compatibility decompositions have the same cell
|
|
330
|
+
* width but avoid stale-cell artifacts in terminal renderers. Visible tabs are
|
|
331
|
+
* expanded to the fixed width used by layout so terminal tab stops cannot wrap
|
|
332
|
+
* a logical line, while tabs inside terminal string sequences stay untouched.
|
|
333
|
+
*/
|
|
334
|
+
const THAI_LAO_AM_REGEX = /[\u0e33\u0eb3]/;
|
|
335
|
+
const THAI_LAO_AM_GLOBAL_REGEX = /[\u0e33\u0eb3]/g;
|
|
336
|
+
export function normalizeTerminalOutput(str) {
|
|
337
|
+
let normalized = str;
|
|
338
|
+
if (THAI_LAO_AM_REGEX.test(normalized)) {
|
|
339
|
+
normalized = normalized.replace(THAI_LAO_AM_GLOBAL_REGEX, (char) => char === "\u0e33" ? "\u0e4d\u0e32" : "\u0ecd\u0eb2");
|
|
340
|
+
}
|
|
341
|
+
if (!normalized.includes("\t"))
|
|
342
|
+
return normalized;
|
|
343
|
+
let result = "";
|
|
344
|
+
let i = 0;
|
|
345
|
+
while (i < normalized.length) {
|
|
346
|
+
const ansi = extractAnsiCode(normalized, i);
|
|
347
|
+
if (ansi) {
|
|
348
|
+
result += ansi.code;
|
|
349
|
+
i += ansi.length;
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
352
|
+
result += normalized[i] === "\t" ? " " : normalized[i];
|
|
353
|
+
i++;
|
|
354
|
+
}
|
|
355
|
+
return result;
|
|
356
|
+
}
|
|
357
|
+
function extractCsiSequence(str, pos) {
|
|
358
|
+
let end = pos + 2;
|
|
359
|
+
while (end < str.length && !/[mGKHJ]/.test(str[end]))
|
|
360
|
+
end++;
|
|
361
|
+
if (end >= str.length)
|
|
362
|
+
return null;
|
|
363
|
+
return { code: str.substring(pos, end + 1), length: end + 1 - pos };
|
|
364
|
+
}
|
|
365
|
+
function extractTerminatedControlString(str, pos) {
|
|
366
|
+
let end = pos + 2;
|
|
367
|
+
while (end < str.length) {
|
|
368
|
+
if (str[end] === "\x07")
|
|
369
|
+
return { code: str.substring(pos, end + 1), length: end + 1 - pos };
|
|
370
|
+
if (str[end] === "\x1b" && str[end + 1] === "\\") {
|
|
371
|
+
return { code: str.substring(pos, end + 2), length: end + 2 - pos };
|
|
372
|
+
}
|
|
373
|
+
end++;
|
|
374
|
+
}
|
|
375
|
+
return null;
|
|
376
|
+
}
|
|
377
|
+
/** Extract an ANSI escape sequence from a string at the given position. */
|
|
378
|
+
export function extractAnsiCode(str, pos) {
|
|
379
|
+
if (pos >= str.length || str[pos] !== "\x1b")
|
|
380
|
+
return null;
|
|
381
|
+
const introducer = str[pos + 1];
|
|
382
|
+
if (introducer === "[")
|
|
383
|
+
return extractCsiSequence(str, pos);
|
|
384
|
+
// OSC and APC sequences share BEL and ST termination rules.
|
|
385
|
+
if (introducer === "]" || introducer === "_")
|
|
386
|
+
return extractTerminatedControlString(str, pos);
|
|
387
|
+
return null;
|
|
388
|
+
}
|
|
389
|
+
function parseOsc8Hyperlink(ansiCode) {
|
|
390
|
+
if (!ansiCode.startsWith("\x1b]8;")) {
|
|
391
|
+
return undefined;
|
|
392
|
+
}
|
|
393
|
+
const terminator = ansiCode.endsWith("\x07") ? "\x07" : "\x1b\\";
|
|
394
|
+
const body = ansiCode.slice(4, terminator === "\x07" ? -1 : -2);
|
|
395
|
+
const separatorIndex = body.indexOf(";");
|
|
396
|
+
if (separatorIndex === -1) {
|
|
397
|
+
return undefined;
|
|
398
|
+
}
|
|
399
|
+
const params = body.slice(0, separatorIndex);
|
|
400
|
+
const url = body.slice(separatorIndex + 1);
|
|
401
|
+
if (!url) {
|
|
402
|
+
return null;
|
|
403
|
+
}
|
|
404
|
+
return { params, url, terminator };
|
|
405
|
+
}
|
|
406
|
+
function formatOsc8Hyperlink(hyperlink) {
|
|
407
|
+
return `\x1b]8;${hyperlink.params};${hyperlink.url}${hyperlink.terminator}`;
|
|
408
|
+
}
|
|
409
|
+
function formatOsc8Close(terminator) {
|
|
410
|
+
return `\x1b]8;;${terminator}`;
|
|
411
|
+
}
|
|
412
|
+
function getActiveOsc8Close(prefix) {
|
|
413
|
+
if (!prefix.includes("\x1b]8;")) {
|
|
414
|
+
return "";
|
|
415
|
+
}
|
|
416
|
+
let activeHyperlink = null;
|
|
417
|
+
let i = 0;
|
|
418
|
+
while (i < prefix.length) {
|
|
419
|
+
const ansi = extractAnsiCode(prefix, i);
|
|
420
|
+
if (ansi) {
|
|
421
|
+
const hyperlink = parseOsc8Hyperlink(ansi.code);
|
|
422
|
+
if (hyperlink !== undefined) {
|
|
423
|
+
activeHyperlink = hyperlink;
|
|
424
|
+
}
|
|
425
|
+
i += ansi.length;
|
|
426
|
+
}
|
|
427
|
+
else {
|
|
428
|
+
i++;
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
return activeHyperlink ? formatOsc8Close(activeHyperlink.terminator) : "";
|
|
432
|
+
}
|
|
433
|
+
/**
|
|
434
|
+
* Track active ANSI SGR codes to preserve styling across line breaks.
|
|
435
|
+
*/
|
|
436
|
+
class AnsiCodeTracker {
|
|
437
|
+
// Track individual attributes separately so we can reset them specifically
|
|
438
|
+
bold = false;
|
|
439
|
+
dim = false;
|
|
440
|
+
italic = false;
|
|
441
|
+
underline = false;
|
|
442
|
+
blink = false;
|
|
443
|
+
inverse = false;
|
|
444
|
+
hidden = false;
|
|
445
|
+
strikethrough = false;
|
|
446
|
+
fgColor = null; // Stores the full code like "31" or "38;5;240"
|
|
447
|
+
bgColor = null; // Stores the full code like "41" or "48;5;240"
|
|
448
|
+
activeHyperlink = null;
|
|
449
|
+
setColor(code, color) {
|
|
450
|
+
if (code === 38)
|
|
451
|
+
this.fgColor = color;
|
|
452
|
+
else
|
|
453
|
+
this.bgColor = color;
|
|
454
|
+
}
|
|
455
|
+
processExtendedColor(parts, index, code) {
|
|
456
|
+
if (code !== 38 && code !== 48)
|
|
457
|
+
return undefined;
|
|
458
|
+
if (parts[index + 1] === "5" && parts[index + 2] !== undefined) {
|
|
459
|
+
this.setColor(code, `${parts[index]};${parts[index + 1]};${parts[index + 2]}`);
|
|
460
|
+
return index + 3;
|
|
461
|
+
}
|
|
462
|
+
if (parts[index + 1] === "2" && parts[index + 4] !== undefined) {
|
|
463
|
+
this.setColor(code, `${parts[index]};${parts[index + 1]};${parts[index + 2]};${parts[index + 3]};${parts[index + 4]}`);
|
|
464
|
+
return index + 5;
|
|
465
|
+
}
|
|
466
|
+
return undefined;
|
|
467
|
+
}
|
|
468
|
+
processStandardSgrCode(code) {
|
|
469
|
+
switch (code) {
|
|
470
|
+
case 0:
|
|
471
|
+
this.reset();
|
|
472
|
+
break;
|
|
473
|
+
case 1:
|
|
474
|
+
this.bold = true;
|
|
475
|
+
break;
|
|
476
|
+
case 2:
|
|
477
|
+
this.dim = true;
|
|
478
|
+
break;
|
|
479
|
+
case 3:
|
|
480
|
+
this.italic = true;
|
|
481
|
+
break;
|
|
482
|
+
case 4:
|
|
483
|
+
this.underline = true;
|
|
484
|
+
break;
|
|
485
|
+
case 5:
|
|
486
|
+
this.blink = true;
|
|
487
|
+
break;
|
|
488
|
+
case 7:
|
|
489
|
+
this.inverse = true;
|
|
490
|
+
break;
|
|
491
|
+
case 8:
|
|
492
|
+
this.hidden = true;
|
|
493
|
+
break;
|
|
494
|
+
case 9:
|
|
495
|
+
this.strikethrough = true;
|
|
496
|
+
break;
|
|
497
|
+
case 21:
|
|
498
|
+
this.bold = false;
|
|
499
|
+
break;
|
|
500
|
+
case 22:
|
|
501
|
+
this.bold = false;
|
|
502
|
+
this.dim = false;
|
|
503
|
+
break;
|
|
504
|
+
case 23:
|
|
505
|
+
this.italic = false;
|
|
506
|
+
break;
|
|
507
|
+
case 24:
|
|
508
|
+
this.underline = false;
|
|
509
|
+
break;
|
|
510
|
+
case 25:
|
|
511
|
+
this.blink = false;
|
|
512
|
+
break;
|
|
513
|
+
case 27:
|
|
514
|
+
this.inverse = false;
|
|
515
|
+
break;
|
|
516
|
+
case 28:
|
|
517
|
+
this.hidden = false;
|
|
518
|
+
break;
|
|
519
|
+
case 29:
|
|
520
|
+
this.strikethrough = false;
|
|
521
|
+
break;
|
|
522
|
+
case 39:
|
|
523
|
+
this.fgColor = null;
|
|
524
|
+
break;
|
|
525
|
+
case 49:
|
|
526
|
+
this.bgColor = null;
|
|
527
|
+
break;
|
|
528
|
+
default:
|
|
529
|
+
if ((code >= 30 && code <= 37) || (code >= 90 && code <= 97))
|
|
530
|
+
this.fgColor = String(code);
|
|
531
|
+
else if ((code >= 40 && code <= 47) || (code >= 100 && code <= 107))
|
|
532
|
+
this.bgColor = String(code);
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
process(ansiCode) {
|
|
536
|
+
const hyperlink = parseOsc8Hyperlink(ansiCode);
|
|
537
|
+
if (hyperlink !== undefined) {
|
|
538
|
+
this.activeHyperlink = hyperlink;
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
if (!ansiCode.endsWith("m"))
|
|
542
|
+
return;
|
|
543
|
+
const match = ansiCode.match(/\x1b\[([\d;]*)m/);
|
|
544
|
+
if (!match)
|
|
545
|
+
return;
|
|
546
|
+
const params = match[1];
|
|
547
|
+
if (params === "" || params === "0") {
|
|
548
|
+
this.reset();
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
const parts = params.split(";");
|
|
552
|
+
let index = 0;
|
|
553
|
+
while (index < parts.length) {
|
|
554
|
+
const code = Number.parseInt(parts[index], 10);
|
|
555
|
+
const nextIndex = this.processExtendedColor(parts, index, code);
|
|
556
|
+
if (nextIndex !== undefined) {
|
|
557
|
+
index = nextIndex;
|
|
558
|
+
continue;
|
|
559
|
+
}
|
|
560
|
+
this.processStandardSgrCode(code);
|
|
561
|
+
index++;
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
reset() {
|
|
565
|
+
this.bold = false;
|
|
566
|
+
this.dim = false;
|
|
567
|
+
this.italic = false;
|
|
568
|
+
this.underline = false;
|
|
569
|
+
this.blink = false;
|
|
570
|
+
this.inverse = false;
|
|
571
|
+
this.hidden = false;
|
|
572
|
+
this.strikethrough = false;
|
|
573
|
+
this.fgColor = null;
|
|
574
|
+
this.bgColor = null;
|
|
575
|
+
// SGR reset does not affect OSC 8 hyperlink state
|
|
576
|
+
}
|
|
577
|
+
/** Clear all state for reuse. */
|
|
578
|
+
clear() {
|
|
579
|
+
this.reset();
|
|
580
|
+
this.activeHyperlink = null;
|
|
581
|
+
}
|
|
582
|
+
getActiveCodes() {
|
|
583
|
+
const codes = [];
|
|
584
|
+
if (this.bold)
|
|
585
|
+
codes.push("1");
|
|
586
|
+
if (this.dim)
|
|
587
|
+
codes.push("2");
|
|
588
|
+
if (this.italic)
|
|
589
|
+
codes.push("3");
|
|
590
|
+
if (this.underline)
|
|
591
|
+
codes.push("4");
|
|
592
|
+
if (this.blink)
|
|
593
|
+
codes.push("5");
|
|
594
|
+
if (this.inverse)
|
|
595
|
+
codes.push("7");
|
|
596
|
+
if (this.hidden)
|
|
597
|
+
codes.push("8");
|
|
598
|
+
if (this.strikethrough)
|
|
599
|
+
codes.push("9");
|
|
600
|
+
if (this.fgColor)
|
|
601
|
+
codes.push(this.fgColor);
|
|
602
|
+
if (this.bgColor)
|
|
603
|
+
codes.push(this.bgColor);
|
|
604
|
+
let result = codes.length > 0 ? `\x1b[${codes.join(";")}m` : "";
|
|
605
|
+
if (this.activeHyperlink) {
|
|
606
|
+
result += formatOsc8Hyperlink(this.activeHyperlink);
|
|
607
|
+
}
|
|
608
|
+
return result;
|
|
609
|
+
}
|
|
610
|
+
hasActiveCodes() {
|
|
611
|
+
return (this.bold ||
|
|
612
|
+
this.dim ||
|
|
613
|
+
this.italic ||
|
|
614
|
+
this.underline ||
|
|
615
|
+
this.blink ||
|
|
616
|
+
this.inverse ||
|
|
617
|
+
this.hidden ||
|
|
618
|
+
this.strikethrough ||
|
|
619
|
+
this.fgColor !== null ||
|
|
620
|
+
this.bgColor !== null ||
|
|
621
|
+
this.activeHyperlink !== null);
|
|
622
|
+
}
|
|
623
|
+
/**
|
|
624
|
+
* Get reset codes for attributes that need to be turned off at line end.
|
|
625
|
+
* Underline must be closed to prevent bleeding into padding.
|
|
626
|
+
* Active OSC 8 hyperlinks must be closed and re-opened on the next line.
|
|
627
|
+
* Returns empty string if no attributes need closing.
|
|
628
|
+
*/
|
|
629
|
+
getLineEndReset() {
|
|
630
|
+
let result = "";
|
|
631
|
+
if (this.underline) {
|
|
632
|
+
result += "\x1b[24m"; // Underline off only
|
|
633
|
+
}
|
|
634
|
+
if (this.activeHyperlink) {
|
|
635
|
+
result += formatOsc8Close(this.activeHyperlink.terminator); // Re-opened at line start via getActiveCodes()
|
|
636
|
+
}
|
|
637
|
+
return result;
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
function updateTrackerFromText(text, tracker) {
|
|
641
|
+
let i = 0;
|
|
642
|
+
while (i < text.length) {
|
|
643
|
+
const ansiResult = extractAnsiCode(text, i);
|
|
644
|
+
if (ansiResult) {
|
|
645
|
+
tracker.process(ansiResult.code);
|
|
646
|
+
i += ansiResult.length;
|
|
647
|
+
}
|
|
648
|
+
else {
|
|
649
|
+
i++;
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
/**
|
|
654
|
+
* Split text into words while keeping ANSI codes attached.
|
|
655
|
+
*/
|
|
656
|
+
function splitIntoTokensWithAnsi(text) {
|
|
657
|
+
const tokens = [];
|
|
658
|
+
let current = "";
|
|
659
|
+
let pendingAnsi = ""; // ANSI codes waiting to be attached to next visible content
|
|
660
|
+
let inWhitespace = false;
|
|
661
|
+
let i = 0;
|
|
662
|
+
while (i < text.length) {
|
|
663
|
+
const ansiResult = extractAnsiCode(text, i);
|
|
664
|
+
if (ansiResult) {
|
|
665
|
+
// Hold ANSI codes separately - they'll be attached to the next visible char
|
|
666
|
+
pendingAnsi += ansiResult.code;
|
|
667
|
+
i += ansiResult.length;
|
|
668
|
+
continue;
|
|
669
|
+
}
|
|
670
|
+
const char = text[i];
|
|
671
|
+
const charIsSpace = char === " ";
|
|
672
|
+
if (charIsSpace !== inWhitespace && current) {
|
|
673
|
+
// Switching between whitespace and non-whitespace, push current token
|
|
674
|
+
tokens.push(current);
|
|
675
|
+
current = "";
|
|
676
|
+
}
|
|
677
|
+
// Attach any pending ANSI codes to this visible character
|
|
678
|
+
if (pendingAnsi) {
|
|
679
|
+
current += pendingAnsi;
|
|
680
|
+
pendingAnsi = "";
|
|
681
|
+
}
|
|
682
|
+
inWhitespace = charIsSpace;
|
|
683
|
+
current += char;
|
|
684
|
+
i++;
|
|
685
|
+
}
|
|
686
|
+
// Handle any remaining pending ANSI codes (attach to last token)
|
|
687
|
+
if (pendingAnsi) {
|
|
688
|
+
current += pendingAnsi;
|
|
689
|
+
}
|
|
690
|
+
if (current) {
|
|
691
|
+
tokens.push(current);
|
|
692
|
+
}
|
|
693
|
+
return tokens;
|
|
694
|
+
}
|
|
695
|
+
/**
|
|
696
|
+
* Wrap text with ANSI codes preserved.
|
|
697
|
+
*
|
|
698
|
+
* ONLY does word wrapping - NO padding, NO background colors.
|
|
699
|
+
* Returns lines where each line is <= width visible chars.
|
|
700
|
+
* Active ANSI codes are preserved across line breaks.
|
|
701
|
+
*
|
|
702
|
+
* @param text - Text to wrap (may contain ANSI codes and newlines)
|
|
703
|
+
* @param width - Maximum visible width per line
|
|
704
|
+
* @returns Array of wrapped lines (NOT padded to width)
|
|
705
|
+
*/
|
|
706
|
+
export function wrapTextWithAnsi(text, width) {
|
|
707
|
+
if (!text) {
|
|
708
|
+
return [""];
|
|
709
|
+
}
|
|
710
|
+
// Handle newlines by processing each line separately
|
|
711
|
+
// Track ANSI state across lines so styles carry over after literal newlines
|
|
712
|
+
const inputLines = text.split(/\r\n|\r|\n/);
|
|
713
|
+
const result = [];
|
|
714
|
+
const tracker = new AnsiCodeTracker();
|
|
715
|
+
for (const inputLine of inputLines) {
|
|
716
|
+
// Prepend active ANSI codes from previous lines (except for first line)
|
|
717
|
+
const prefix = result.length > 0 ? tracker.getActiveCodes() : "";
|
|
718
|
+
const wrappedLines = wrapSingleLine(prefix + inputLine, width);
|
|
719
|
+
for (const wrappedLine of wrappedLines) {
|
|
720
|
+
result.push(wrappedLine);
|
|
721
|
+
}
|
|
722
|
+
// Update tracker with codes from this line for next iteration
|
|
723
|
+
updateTrackerFromText(inputLine, tracker);
|
|
724
|
+
}
|
|
725
|
+
return result.length > 0 ? result : [""];
|
|
726
|
+
}
|
|
727
|
+
function appendLineEndReset(text, tracker) {
|
|
728
|
+
return text + tracker.getLineEndReset();
|
|
729
|
+
}
|
|
730
|
+
function appendOversizedWrapToken(token, context) {
|
|
731
|
+
if (context.current.text) {
|
|
732
|
+
context.lines.push(appendLineEndReset(context.current.text, context.tracker));
|
|
733
|
+
context.current.text = "";
|
|
734
|
+
context.current.visibleWidth = 0;
|
|
735
|
+
}
|
|
736
|
+
const broken = breakLongWord(token, context.maxWidth, context.tracker);
|
|
737
|
+
for (let index = 0; index < broken.length - 1; index++)
|
|
738
|
+
context.lines.push(broken[index]);
|
|
739
|
+
context.current.text = broken[broken.length - 1];
|
|
740
|
+
context.current.visibleWidth = visibleWidth(context.current.text);
|
|
741
|
+
}
|
|
742
|
+
function appendFittingWrapToken(token, tokenVisibleWidth, isWhitespace, context) {
|
|
743
|
+
const exceedsLine = context.current.visibleWidth + tokenVisibleWidth > context.maxWidth;
|
|
744
|
+
if (!exceedsLine || context.current.visibleWidth === 0) {
|
|
745
|
+
context.current.text += token;
|
|
746
|
+
context.current.visibleWidth += tokenVisibleWidth;
|
|
747
|
+
return;
|
|
748
|
+
}
|
|
749
|
+
context.lines.push(appendLineEndReset(context.current.text.trimEnd(), context.tracker));
|
|
750
|
+
if (isWhitespace) {
|
|
751
|
+
context.current.text = context.tracker.getActiveCodes();
|
|
752
|
+
context.current.visibleWidth = 0;
|
|
753
|
+
return;
|
|
754
|
+
}
|
|
755
|
+
context.current.text = context.tracker.getActiveCodes() + token;
|
|
756
|
+
context.current.visibleWidth = tokenVisibleWidth;
|
|
757
|
+
}
|
|
758
|
+
function wrapSingleLine(line, width) {
|
|
759
|
+
if (!line)
|
|
760
|
+
return [""];
|
|
761
|
+
if (visibleWidth(line) <= width)
|
|
762
|
+
return [line];
|
|
763
|
+
const context = {
|
|
764
|
+
maxWidth: width,
|
|
765
|
+
lines: [],
|
|
766
|
+
tracker: new AnsiCodeTracker(),
|
|
767
|
+
current: { text: "", visibleWidth: 0 },
|
|
768
|
+
};
|
|
769
|
+
for (const token of splitIntoTokensWithAnsi(line)) {
|
|
770
|
+
const tokenVisibleWidth = visibleWidth(token);
|
|
771
|
+
const isWhitespace = token.trim() === "";
|
|
772
|
+
if (tokenVisibleWidth > width && !isWhitespace) {
|
|
773
|
+
appendOversizedWrapToken(token, context);
|
|
774
|
+
continue;
|
|
775
|
+
}
|
|
776
|
+
appendFittingWrapToken(token, tokenVisibleWidth, isWhitespace, context);
|
|
777
|
+
updateTrackerFromText(token, context.tracker);
|
|
778
|
+
}
|
|
779
|
+
if (context.current.text)
|
|
780
|
+
context.lines.push(context.current.text);
|
|
781
|
+
return context.lines.length > 0 ? context.lines.map((wrappedLine) => wrappedLine.trimEnd()) : [""];
|
|
782
|
+
}
|
|
783
|
+
export const PUNCTUATION_REGEX = /[(){}[\]<>.,;:'"!?+\-=*/\\|&%^$#@~`]/;
|
|
784
|
+
/**
|
|
785
|
+
* Check if a character is whitespace.
|
|
786
|
+
*/
|
|
787
|
+
export function isWhitespaceChar(char) {
|
|
788
|
+
return /\s/.test(char);
|
|
789
|
+
}
|
|
790
|
+
/**
|
|
791
|
+
* Check if a character is punctuation.
|
|
792
|
+
*/
|
|
793
|
+
export function isPunctuationChar(char) {
|
|
794
|
+
return PUNCTUATION_REGEX.test(char);
|
|
795
|
+
}
|
|
796
|
+
function* iterateAnsiTextSegments(text) {
|
|
797
|
+
let index = 0;
|
|
798
|
+
while (index < text.length) {
|
|
799
|
+
const ansiResult = extractAnsiCode(text, index);
|
|
800
|
+
if (ansiResult) {
|
|
801
|
+
yield { type: "ansi", value: ansiResult.code };
|
|
802
|
+
index += ansiResult.length;
|
|
803
|
+
continue;
|
|
804
|
+
}
|
|
805
|
+
let end = index;
|
|
806
|
+
while (end < text.length) {
|
|
807
|
+
if (extractAnsiCode(text, end))
|
|
808
|
+
break;
|
|
809
|
+
end++;
|
|
810
|
+
}
|
|
811
|
+
for (const segment of graphemeSegmenter.segment(text.slice(index, end))) {
|
|
812
|
+
yield { type: "grapheme", value: segment.segment };
|
|
813
|
+
}
|
|
814
|
+
index = end;
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
function breakLongWord(word, width, tracker) {
|
|
818
|
+
const lines = [];
|
|
819
|
+
let currentLine = tracker.getActiveCodes();
|
|
820
|
+
let currentWidth = 0;
|
|
821
|
+
// Now process segments
|
|
822
|
+
for (const seg of iterateAnsiTextSegments(word)) {
|
|
823
|
+
if (seg.type === "ansi") {
|
|
824
|
+
currentLine += seg.value;
|
|
825
|
+
tracker.process(seg.value);
|
|
826
|
+
continue;
|
|
827
|
+
}
|
|
828
|
+
const grapheme = seg.value;
|
|
829
|
+
// Skip empty graphemes to avoid issues with string-width calculation
|
|
830
|
+
if (!grapheme)
|
|
831
|
+
continue;
|
|
832
|
+
const graphemeWidth = visibleWidth(grapheme);
|
|
833
|
+
if (currentWidth + graphemeWidth > width) {
|
|
834
|
+
// Add specific reset for underline only (preserves background)
|
|
835
|
+
const lineEndReset = tracker.getLineEndReset();
|
|
836
|
+
if (lineEndReset) {
|
|
837
|
+
currentLine += lineEndReset;
|
|
838
|
+
}
|
|
839
|
+
lines.push(currentLine);
|
|
840
|
+
currentLine = tracker.getActiveCodes();
|
|
841
|
+
currentWidth = 0;
|
|
842
|
+
}
|
|
843
|
+
currentLine += grapheme;
|
|
844
|
+
currentWidth += graphemeWidth;
|
|
845
|
+
}
|
|
846
|
+
if (currentLine) {
|
|
847
|
+
// No reset at end of final segment - caller handles continuation
|
|
848
|
+
lines.push(currentLine);
|
|
849
|
+
}
|
|
850
|
+
return lines.length > 0 ? lines : [""];
|
|
851
|
+
}
|
|
852
|
+
/**
|
|
853
|
+
* Apply background color to a line, padding to full width.
|
|
854
|
+
*
|
|
855
|
+
* @param line - Line of text (may contain ANSI codes)
|
|
856
|
+
* @param width - Total width to pad to
|
|
857
|
+
* @param bgFn - Background color function
|
|
858
|
+
* @returns Line with background applied and padded to width
|
|
859
|
+
*/
|
|
860
|
+
export function applyBackgroundToLine(line, width, bgFn) {
|
|
861
|
+
// Calculate padding needed
|
|
862
|
+
const visibleLen = visibleWidth(line);
|
|
863
|
+
const paddingNeeded = Math.max(0, width - visibleLen);
|
|
864
|
+
const padding = " ".repeat(paddingNeeded);
|
|
865
|
+
// Apply background to content + padding
|
|
866
|
+
const withPadding = line + padding;
|
|
867
|
+
return bgFn(withPadding);
|
|
868
|
+
}
|
|
869
|
+
function createWidthTruncationScan() {
|
|
870
|
+
return {
|
|
871
|
+
prefix: "",
|
|
872
|
+
prefixWidth: 0,
|
|
873
|
+
pendingAnsi: "",
|
|
874
|
+
visibleWidth: 0,
|
|
875
|
+
keepingPrefix: true,
|
|
876
|
+
overflowed: false,
|
|
877
|
+
exhaustedInput: false,
|
|
878
|
+
};
|
|
879
|
+
}
|
|
880
|
+
function appendWidthTruncationFragment(state, fragment, fragmentWidth, targetWidth, maxWidth) {
|
|
881
|
+
if (state.keepingPrefix && state.prefixWidth + fragmentWidth <= targetWidth) {
|
|
882
|
+
state.prefix += state.pendingAnsi + fragment;
|
|
883
|
+
state.pendingAnsi = "";
|
|
884
|
+
state.prefixWidth += fragmentWidth;
|
|
885
|
+
}
|
|
886
|
+
else {
|
|
887
|
+
state.keepingPrefix = false;
|
|
888
|
+
state.pendingAnsi = "";
|
|
889
|
+
}
|
|
890
|
+
state.visibleWidth += fragmentWidth;
|
|
891
|
+
if (state.visibleWidth <= maxWidth)
|
|
892
|
+
return true;
|
|
893
|
+
state.overflowed = true;
|
|
894
|
+
return false;
|
|
895
|
+
}
|
|
896
|
+
function scanTruncationGraphemes(text, state, targetWidth, maxWidth) {
|
|
897
|
+
for (const { segment } of graphemeSegmenter.segment(text)) {
|
|
898
|
+
if (!appendWidthTruncationFragment(state, segment, graphemeWidth(segment), targetWidth, maxWidth))
|
|
899
|
+
return false;
|
|
900
|
+
}
|
|
901
|
+
return true;
|
|
902
|
+
}
|
|
903
|
+
function scanPlainTextForTruncation(text, targetWidth, maxWidth) {
|
|
904
|
+
const state = createWidthTruncationScan();
|
|
905
|
+
state.exhaustedInput = scanTruncationGraphemes(text, state, targetWidth, maxWidth);
|
|
906
|
+
return state;
|
|
907
|
+
}
|
|
908
|
+
function scanStyledTextForTruncation(text, targetWidth, maxWidth) {
|
|
909
|
+
const state = createWidthTruncationScan();
|
|
910
|
+
let index = 0;
|
|
911
|
+
while (index < text.length) {
|
|
912
|
+
const ansi = extractAnsiCode(text, index);
|
|
913
|
+
if (ansi) {
|
|
914
|
+
state.pendingAnsi += ansi.code;
|
|
915
|
+
index += ansi.length;
|
|
916
|
+
continue;
|
|
917
|
+
}
|
|
918
|
+
if (text[index] === "\t") {
|
|
919
|
+
if (!appendWidthTruncationFragment(state, "\t", 3, targetWidth, maxWidth))
|
|
920
|
+
break;
|
|
921
|
+
index++;
|
|
922
|
+
continue;
|
|
923
|
+
}
|
|
924
|
+
let end = index;
|
|
925
|
+
while (end < text.length && text[end] !== "\t" && !extractAnsiCode(text, end))
|
|
926
|
+
end++;
|
|
927
|
+
if (!scanTruncationGraphemes(text.slice(index, end), state, targetWidth, maxWidth))
|
|
928
|
+
break;
|
|
929
|
+
index = end;
|
|
930
|
+
}
|
|
931
|
+
state.exhaustedInput = index >= text.length;
|
|
932
|
+
return state;
|
|
933
|
+
}
|
|
934
|
+
function truncateWithOversizedEllipsis(text, maxWidth, ellipsis, ellipsisWidth, pad) {
|
|
935
|
+
if (ellipsisWidth < maxWidth)
|
|
936
|
+
return undefined;
|
|
937
|
+
const textWidth = visibleWidth(text);
|
|
938
|
+
if (textWidth <= maxWidth)
|
|
939
|
+
return pad ? text + " ".repeat(maxWidth - textWidth) : text;
|
|
940
|
+
const clippedEllipsis = truncateFragmentToWidth(ellipsis, maxWidth);
|
|
941
|
+
if (clippedEllipsis.width === 0)
|
|
942
|
+
return pad ? " ".repeat(maxWidth) : "";
|
|
943
|
+
return finalizeTruncatedResult("", 0, clippedEllipsis.text, clippedEllipsis.width, maxWidth, pad);
|
|
944
|
+
}
|
|
945
|
+
function truncatePrintableAscii(text, maxWidth, ellipsis, ellipsisWidth, pad) {
|
|
946
|
+
if (!isPrintableAscii(text))
|
|
947
|
+
return undefined;
|
|
948
|
+
if (text.length <= maxWidth)
|
|
949
|
+
return pad ? text + " ".repeat(maxWidth - text.length) : text;
|
|
950
|
+
const targetWidth = maxWidth - ellipsisWidth;
|
|
951
|
+
return finalizeTruncatedResult(text.slice(0, targetWidth), targetWidth, ellipsis, ellipsisWidth, maxWidth, pad);
|
|
952
|
+
}
|
|
953
|
+
function truncateMeasuredText(text, maxWidth, ellipsis, ellipsisWidth, pad) {
|
|
954
|
+
const targetWidth = maxWidth - ellipsisWidth;
|
|
955
|
+
const scan = text.includes("\x1b") || text.includes("\t")
|
|
956
|
+
? scanStyledTextForTruncation(text, targetWidth, maxWidth)
|
|
957
|
+
: scanPlainTextForTruncation(text, targetWidth, maxWidth);
|
|
958
|
+
if (!scan.overflowed && scan.exhaustedInput) {
|
|
959
|
+
return pad ? text + " ".repeat(Math.max(0, maxWidth - scan.visibleWidth)) : text;
|
|
960
|
+
}
|
|
961
|
+
return finalizeTruncatedResult(scan.prefix, scan.prefixWidth, ellipsis, ellipsisWidth, maxWidth, pad);
|
|
962
|
+
}
|
|
963
|
+
/**
|
|
964
|
+
* Truncate text to fit within a maximum visible width, adding ellipsis if needed.
|
|
965
|
+
* Optionally pad with spaces to reach exactly maxWidth.
|
|
966
|
+
* Properly handles ANSI escape codes (they don't count toward width).
|
|
967
|
+
*
|
|
968
|
+
* @param text - Text to truncate (may contain ANSI codes)
|
|
969
|
+
* @param maxWidth - Maximum visible width
|
|
970
|
+
* @param ellipsis - Ellipsis string to append when truncating (default: "...")
|
|
971
|
+
* @param pad - If true, pad result with spaces to exactly maxWidth (default: false)
|
|
972
|
+
* @returns Truncated text, optionally padded to exactly maxWidth
|
|
973
|
+
*/
|
|
974
|
+
export function truncateToWidth(text, maxWidth, ellipsis = "...", pad = false) {
|
|
975
|
+
if (maxWidth <= 0)
|
|
976
|
+
return "";
|
|
977
|
+
if (text.length === 0)
|
|
978
|
+
return pad ? " ".repeat(maxWidth) : "";
|
|
979
|
+
const ellipsisWidth = visibleWidth(ellipsis);
|
|
980
|
+
const oversizedEllipsis = truncateWithOversizedEllipsis(text, maxWidth, ellipsis, ellipsisWidth, pad);
|
|
981
|
+
if (oversizedEllipsis !== undefined)
|
|
982
|
+
return oversizedEllipsis;
|
|
983
|
+
const printableAscii = truncatePrintableAscii(text, maxWidth, ellipsis, ellipsisWidth, pad);
|
|
984
|
+
if (printableAscii !== undefined)
|
|
985
|
+
return printableAscii;
|
|
986
|
+
return truncateMeasuredText(text, maxWidth, ellipsis, ellipsisWidth, pad);
|
|
987
|
+
}
|
|
988
|
+
/**
|
|
989
|
+
* Extract a range of visible columns from a line. Handles ANSI codes and wide chars.
|
|
990
|
+
* @param strict - If true, exclude wide chars at boundary that would extend past the range
|
|
991
|
+
*/
|
|
992
|
+
export function sliceByColumn(line, startCol, length, strict = false) {
|
|
993
|
+
return sliceWithWidth(line, startCol, length, strict).text;
|
|
994
|
+
}
|
|
995
|
+
function appendAnsiToColumnSlice(state, ansi, startColumn, endColumn) {
|
|
996
|
+
if (state.currentColumn >= startColumn && state.currentColumn < endColumn)
|
|
997
|
+
state.text += ansi;
|
|
998
|
+
else if (state.currentColumn < startColumn)
|
|
999
|
+
state.pendingAnsi += ansi;
|
|
1000
|
+
}
|
|
1001
|
+
function appendGraphemeToColumnSlice(state, grapheme, graphemeWidth, startColumn, endColumn, strict) {
|
|
1002
|
+
if (state.currentColumn < startColumn || state.currentColumn >= endColumn)
|
|
1003
|
+
return;
|
|
1004
|
+
if (strict && state.currentColumn + graphemeWidth > endColumn)
|
|
1005
|
+
return;
|
|
1006
|
+
if (state.pendingAnsi) {
|
|
1007
|
+
state.text += state.pendingAnsi;
|
|
1008
|
+
state.pendingAnsi = "";
|
|
1009
|
+
}
|
|
1010
|
+
state.text += grapheme;
|
|
1011
|
+
state.width += graphemeWidth;
|
|
1012
|
+
}
|
|
1013
|
+
/** Like sliceByColumn but also returns the actual visible width of the result. */
|
|
1014
|
+
export function sliceWithWidth(line, startCol, length, strict = false) {
|
|
1015
|
+
if (length <= 0)
|
|
1016
|
+
return { text: "", width: 0 };
|
|
1017
|
+
const endColumn = startCol + length;
|
|
1018
|
+
const state = { text: "", width: 0, currentColumn: 0, pendingAnsi: "" };
|
|
1019
|
+
for (const segment of iterateAnsiTextSegments(line)) {
|
|
1020
|
+
if (segment.type === "ansi") {
|
|
1021
|
+
appendAnsiToColumnSlice(state, segment.value, startCol, endColumn);
|
|
1022
|
+
continue;
|
|
1023
|
+
}
|
|
1024
|
+
const width = graphemeWidth(segment.value);
|
|
1025
|
+
appendGraphemeToColumnSlice(state, segment.value, width, startCol, endColumn, strict);
|
|
1026
|
+
state.currentColumn += width;
|
|
1027
|
+
if (state.currentColumn >= endColumn)
|
|
1028
|
+
break;
|
|
1029
|
+
}
|
|
1030
|
+
return { text: state.text, width: state.width };
|
|
1031
|
+
}
|
|
1032
|
+
// Pooled tracker instance for extractSegments (avoids allocation per call)
|
|
1033
|
+
const pooledStyleTracker = new AnsiCodeTracker();
|
|
1034
|
+
function appendAnsiToExtractedSegments(state, ansiCode, context) {
|
|
1035
|
+
pooledStyleTracker.process(ansiCode);
|
|
1036
|
+
if (state.currentColumn < context.beforeEnd) {
|
|
1037
|
+
state.pendingAnsiBefore += ansiCode;
|
|
1038
|
+
return;
|
|
1039
|
+
}
|
|
1040
|
+
if (state.currentColumn >= context.afterStart && state.currentColumn < context.afterEnd && state.afterStarted) {
|
|
1041
|
+
state.after += ansiCode;
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
function appendGraphemeToExtractedSegments(state, grapheme, graphemeWidth, context) {
|
|
1045
|
+
if (state.currentColumn < context.beforeEnd && state.currentColumn + graphemeWidth <= context.beforeEnd) {
|
|
1046
|
+
state.before += state.pendingAnsiBefore + grapheme;
|
|
1047
|
+
state.pendingAnsiBefore = "";
|
|
1048
|
+
state.beforeWidth += graphemeWidth;
|
|
1049
|
+
return;
|
|
1050
|
+
}
|
|
1051
|
+
if (state.currentColumn < context.afterStart || state.currentColumn >= context.afterEnd)
|
|
1052
|
+
return;
|
|
1053
|
+
if (context.strictAfter && state.currentColumn + graphemeWidth > context.afterEnd)
|
|
1054
|
+
return;
|
|
1055
|
+
if (!state.afterStarted) {
|
|
1056
|
+
state.after += pooledStyleTracker.getActiveCodes();
|
|
1057
|
+
state.afterStarted = true;
|
|
1058
|
+
}
|
|
1059
|
+
state.after += grapheme;
|
|
1060
|
+
state.afterWidth += graphemeWidth;
|
|
1061
|
+
}
|
|
1062
|
+
function isLineSegmentExtractionComplete(state, context) {
|
|
1063
|
+
return context.afterLength <= 0 ? state.currentColumn >= context.beforeEnd : state.currentColumn >= context.afterEnd;
|
|
1064
|
+
}
|
|
1065
|
+
/**
|
|
1066
|
+
* Extract "before" and "after" segments from a line in a single pass.
|
|
1067
|
+
* Used for overlay compositing where we need content before and after the overlay region.
|
|
1068
|
+
* Preserves styling from before the overlay that should affect content after it.
|
|
1069
|
+
*/
|
|
1070
|
+
export function extractSegments(line, beforeEnd, afterStart, afterLen, strictAfter = false) {
|
|
1071
|
+
const context = {
|
|
1072
|
+
beforeEnd,
|
|
1073
|
+
afterStart,
|
|
1074
|
+
afterEnd: afterStart + afterLen,
|
|
1075
|
+
afterLength: afterLen,
|
|
1076
|
+
strictAfter,
|
|
1077
|
+
};
|
|
1078
|
+
const state = {
|
|
1079
|
+
before: "",
|
|
1080
|
+
beforeWidth: 0,
|
|
1081
|
+
after: "",
|
|
1082
|
+
afterWidth: 0,
|
|
1083
|
+
currentColumn: 0,
|
|
1084
|
+
pendingAnsiBefore: "",
|
|
1085
|
+
afterStarted: false,
|
|
1086
|
+
};
|
|
1087
|
+
pooledStyleTracker.clear();
|
|
1088
|
+
for (const segment of iterateAnsiTextSegments(line)) {
|
|
1089
|
+
if (segment.type === "ansi") {
|
|
1090
|
+
appendAnsiToExtractedSegments(state, segment.value, context);
|
|
1091
|
+
continue;
|
|
1092
|
+
}
|
|
1093
|
+
const width = graphemeWidth(segment.value);
|
|
1094
|
+
appendGraphemeToExtractedSegments(state, segment.value, width, context);
|
|
1095
|
+
state.currentColumn += width;
|
|
1096
|
+
if (isLineSegmentExtractionComplete(state, context))
|
|
1097
|
+
break;
|
|
1098
|
+
}
|
|
1099
|
+
return {
|
|
1100
|
+
before: state.before,
|
|
1101
|
+
beforeWidth: state.beforeWidth,
|
|
1102
|
+
after: state.after,
|
|
1103
|
+
afterWidth: state.afterWidth,
|
|
1104
|
+
};
|
|
1105
|
+
}
|
|
1106
|
+
//# sourceMappingURL=utils.js.map
|