@oh-my-pi/pi-tui 1.337.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 +654 -0
- package/package.json +45 -0
- package/src/autocomplete.ts +575 -0
- package/src/components/box.ts +134 -0
- package/src/components/cancellable-loader.ts +39 -0
- package/src/components/editor.ts +1342 -0
- package/src/components/image.ts +87 -0
- package/src/components/input.ts +344 -0
- package/src/components/loader.ts +55 -0
- package/src/components/markdown.ts +646 -0
- package/src/components/select-list.ts +184 -0
- package/src/components/settings-list.ts +188 -0
- package/src/components/spacer.ts +28 -0
- package/src/components/tab-bar.ts +140 -0
- package/src/components/text.ts +106 -0
- package/src/components/truncated-text.ts +65 -0
- package/src/index.ts +91 -0
- package/src/keys.ts +560 -0
- package/src/terminal-image.ts +340 -0
- package/src/terminal.ts +163 -0
- package/src/tui.ts +353 -0
- package/src/utils.ts +712 -0
package/src/utils.ts
ADDED
|
@@ -0,0 +1,712 @@
|
|
|
1
|
+
import { eastAsianWidth } from "get-east-asian-width";
|
|
2
|
+
|
|
3
|
+
// Grapheme segmenter (shared instance)
|
|
4
|
+
const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Get the shared grapheme segmenter instance.
|
|
8
|
+
*/
|
|
9
|
+
export function getSegmenter(): Intl.Segmenter {
|
|
10
|
+
return segmenter;
|
|
11
|
+
}
|
|
12
|
+
|
|
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
|
+
// Cache for non-ASCII strings
|
|
37
|
+
const WIDTH_CACHE_SIZE = 512;
|
|
38
|
+
const widthCache = new Map<string, number>();
|
|
39
|
+
|
|
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
|
+
/**
|
|
79
|
+
* Calculate the visible width of a string in terminal columns.
|
|
80
|
+
*/
|
|
81
|
+
export function visibleWidth(str: string): number {
|
|
82
|
+
if (str.length === 0) {
|
|
83
|
+
return 0;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Fast path: pure ASCII printable
|
|
87
|
+
let isPureAscii = true;
|
|
88
|
+
for (let i = 0; i < str.length; i++) {
|
|
89
|
+
const code = str.charCodeAt(i);
|
|
90
|
+
if (code < 0x20 || code > 0x7e) {
|
|
91
|
+
isPureAscii = false;
|
|
92
|
+
break;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if (isPureAscii) {
|
|
96
|
+
return str.length;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Check cache
|
|
100
|
+
const cached = widthCache.get(str);
|
|
101
|
+
if (cached !== undefined) {
|
|
102
|
+
return cached;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Normalize: tabs to 3 spaces, strip ANSI escape codes
|
|
106
|
+
let clean = str;
|
|
107
|
+
if (str.includes("\t")) {
|
|
108
|
+
clean = clean.replace(/\t/g, " ");
|
|
109
|
+
}
|
|
110
|
+
if (clean.includes("\x1b")) {
|
|
111
|
+
// Strip SGR codes (\x1b[...m) and cursor codes (\x1b[...G/K/H/J)
|
|
112
|
+
clean = clean.replace(/\x1b\[[0-9;]*[mGKHJ]/g, "");
|
|
113
|
+
// Strip OSC 8 hyperlinks: \x1b]8;;URL\x07 and \x1b]8;;\x07
|
|
114
|
+
clean = clean.replace(/\x1b\]8;;[^\x07]*\x07/g, "");
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Calculate width
|
|
118
|
+
let width = 0;
|
|
119
|
+
for (const { segment } of segmenter.segment(clean)) {
|
|
120
|
+
width += graphemeWidth(segment);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Cache result
|
|
124
|
+
if (widthCache.size >= WIDTH_CACHE_SIZE) {
|
|
125
|
+
const firstKey = widthCache.keys().next().value;
|
|
126
|
+
if (firstKey !== undefined) {
|
|
127
|
+
widthCache.delete(firstKey);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
widthCache.set(str, width);
|
|
131
|
+
|
|
132
|
+
return width;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Extract ANSI escape sequences from a string at the given position.
|
|
137
|
+
*/
|
|
138
|
+
function extractAnsiCode(str: string, pos: number): { code: string; length: number } | null {
|
|
139
|
+
if (pos >= str.length || str[pos] !== "\x1b" || str[pos + 1] !== "[") {
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
let j = pos + 2;
|
|
144
|
+
while (j < str.length && str[j] && !/[mGKHJ]/.test(str[j]!)) {
|
|
145
|
+
j++;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (j < str.length) {
|
|
149
|
+
return {
|
|
150
|
+
code: str.substring(pos, j + 1),
|
|
151
|
+
length: j + 1 - pos,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Track active ANSI SGR codes to preserve styling across line breaks.
|
|
160
|
+
*/
|
|
161
|
+
class AnsiCodeTracker {
|
|
162
|
+
// Track individual attributes separately so we can reset them specifically
|
|
163
|
+
private bold = false;
|
|
164
|
+
private dim = false;
|
|
165
|
+
private italic = false;
|
|
166
|
+
private underline = false;
|
|
167
|
+
private blink = false;
|
|
168
|
+
private inverse = false;
|
|
169
|
+
private hidden = false;
|
|
170
|
+
private strikethrough = false;
|
|
171
|
+
private fgColor: string | null = null; // Stores the full code like "31" or "38;5;240"
|
|
172
|
+
private bgColor: string | null = null; // Stores the full code like "41" or "48;5;240"
|
|
173
|
+
|
|
174
|
+
process(ansiCode: string): void {
|
|
175
|
+
if (!ansiCode.endsWith("m")) {
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Extract the parameters between \x1b[ and m
|
|
180
|
+
const match = ansiCode.match(/\x1b\[([\d;]*)m/);
|
|
181
|
+
if (!match) return;
|
|
182
|
+
|
|
183
|
+
const params = match[1];
|
|
184
|
+
if (params === "" || params === "0") {
|
|
185
|
+
// Full reset
|
|
186
|
+
this.reset();
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Parse parameters (can be semicolon-separated)
|
|
191
|
+
const parts = params.split(";");
|
|
192
|
+
let i = 0;
|
|
193
|
+
while (i < parts.length) {
|
|
194
|
+
const code = Number.parseInt(parts[i], 10);
|
|
195
|
+
|
|
196
|
+
// Handle 256-color and RGB codes which consume multiple parameters
|
|
197
|
+
if (code === 38 || code === 48) {
|
|
198
|
+
// 38;5;N (256 color fg) or 38;2;R;G;B (RGB fg)
|
|
199
|
+
// 48;5;N (256 color bg) or 48;2;R;G;B (RGB bg)
|
|
200
|
+
if (parts[i + 1] === "5" && parts[i + 2] !== undefined) {
|
|
201
|
+
// 256 color: 38;5;N or 48;5;N
|
|
202
|
+
const colorCode = `${parts[i]};${parts[i + 1]};${parts[i + 2]}`;
|
|
203
|
+
if (code === 38) {
|
|
204
|
+
this.fgColor = colorCode;
|
|
205
|
+
} else {
|
|
206
|
+
this.bgColor = colorCode;
|
|
207
|
+
}
|
|
208
|
+
i += 3;
|
|
209
|
+
continue;
|
|
210
|
+
} else if (parts[i + 1] === "2" && parts[i + 4] !== undefined) {
|
|
211
|
+
// RGB color: 38;2;R;G;B or 48;2;R;G;B
|
|
212
|
+
const colorCode = `${parts[i]};${parts[i + 1]};${parts[i + 2]};${parts[i + 3]};${parts[i + 4]}`;
|
|
213
|
+
if (code === 38) {
|
|
214
|
+
this.fgColor = colorCode;
|
|
215
|
+
} else {
|
|
216
|
+
this.bgColor = colorCode;
|
|
217
|
+
}
|
|
218
|
+
i += 5;
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Standard SGR codes
|
|
224
|
+
switch (code) {
|
|
225
|
+
case 0:
|
|
226
|
+
this.reset();
|
|
227
|
+
break;
|
|
228
|
+
case 1:
|
|
229
|
+
this.bold = true;
|
|
230
|
+
break;
|
|
231
|
+
case 2:
|
|
232
|
+
this.dim = true;
|
|
233
|
+
break;
|
|
234
|
+
case 3:
|
|
235
|
+
this.italic = true;
|
|
236
|
+
break;
|
|
237
|
+
case 4:
|
|
238
|
+
this.underline = true;
|
|
239
|
+
break;
|
|
240
|
+
case 5:
|
|
241
|
+
this.blink = true;
|
|
242
|
+
break;
|
|
243
|
+
case 7:
|
|
244
|
+
this.inverse = true;
|
|
245
|
+
break;
|
|
246
|
+
case 8:
|
|
247
|
+
this.hidden = true;
|
|
248
|
+
break;
|
|
249
|
+
case 9:
|
|
250
|
+
this.strikethrough = true;
|
|
251
|
+
break;
|
|
252
|
+
case 21:
|
|
253
|
+
this.bold = false;
|
|
254
|
+
break; // Some terminals
|
|
255
|
+
case 22:
|
|
256
|
+
this.bold = false;
|
|
257
|
+
this.dim = false;
|
|
258
|
+
break;
|
|
259
|
+
case 23:
|
|
260
|
+
this.italic = false;
|
|
261
|
+
break;
|
|
262
|
+
case 24:
|
|
263
|
+
this.underline = false;
|
|
264
|
+
break;
|
|
265
|
+
case 25:
|
|
266
|
+
this.blink = false;
|
|
267
|
+
break;
|
|
268
|
+
case 27:
|
|
269
|
+
this.inverse = false;
|
|
270
|
+
break;
|
|
271
|
+
case 28:
|
|
272
|
+
this.hidden = false;
|
|
273
|
+
break;
|
|
274
|
+
case 29:
|
|
275
|
+
this.strikethrough = false;
|
|
276
|
+
break;
|
|
277
|
+
case 39:
|
|
278
|
+
this.fgColor = null;
|
|
279
|
+
break; // Default fg
|
|
280
|
+
case 49:
|
|
281
|
+
this.bgColor = null;
|
|
282
|
+
break; // Default bg
|
|
283
|
+
default:
|
|
284
|
+
// Standard foreground colors 30-37, 90-97
|
|
285
|
+
if ((code >= 30 && code <= 37) || (code >= 90 && code <= 97)) {
|
|
286
|
+
this.fgColor = String(code);
|
|
287
|
+
}
|
|
288
|
+
// Standard background colors 40-47, 100-107
|
|
289
|
+
else if ((code >= 40 && code <= 47) || (code >= 100 && code <= 107)) {
|
|
290
|
+
this.bgColor = String(code);
|
|
291
|
+
}
|
|
292
|
+
break;
|
|
293
|
+
}
|
|
294
|
+
i++;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
private reset(): void {
|
|
299
|
+
this.bold = false;
|
|
300
|
+
this.dim = false;
|
|
301
|
+
this.italic = false;
|
|
302
|
+
this.underline = false;
|
|
303
|
+
this.blink = false;
|
|
304
|
+
this.inverse = false;
|
|
305
|
+
this.hidden = false;
|
|
306
|
+
this.strikethrough = false;
|
|
307
|
+
this.fgColor = null;
|
|
308
|
+
this.bgColor = null;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
getActiveCodes(): string {
|
|
312
|
+
const codes: string[] = [];
|
|
313
|
+
if (this.bold) codes.push("1");
|
|
314
|
+
if (this.dim) codes.push("2");
|
|
315
|
+
if (this.italic) codes.push("3");
|
|
316
|
+
if (this.underline) codes.push("4");
|
|
317
|
+
if (this.blink) codes.push("5");
|
|
318
|
+
if (this.inverse) codes.push("7");
|
|
319
|
+
if (this.hidden) codes.push("8");
|
|
320
|
+
if (this.strikethrough) codes.push("9");
|
|
321
|
+
if (this.fgColor) codes.push(this.fgColor);
|
|
322
|
+
if (this.bgColor) codes.push(this.bgColor);
|
|
323
|
+
|
|
324
|
+
if (codes.length === 0) return "";
|
|
325
|
+
return `\x1b[${codes.join(";")}m`;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
hasActiveCodes(): boolean {
|
|
329
|
+
return (
|
|
330
|
+
this.bold ||
|
|
331
|
+
this.dim ||
|
|
332
|
+
this.italic ||
|
|
333
|
+
this.underline ||
|
|
334
|
+
this.blink ||
|
|
335
|
+
this.inverse ||
|
|
336
|
+
this.hidden ||
|
|
337
|
+
this.strikethrough ||
|
|
338
|
+
this.fgColor !== null ||
|
|
339
|
+
this.bgColor !== null
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Get reset codes for attributes that need to be turned off at line end,
|
|
345
|
+
* specifically underline which bleeds into padding.
|
|
346
|
+
* Returns empty string if no problematic attributes are active.
|
|
347
|
+
*/
|
|
348
|
+
getLineEndReset(): string {
|
|
349
|
+
// Only underline causes visual bleeding into padding
|
|
350
|
+
// Other attributes like colors don't visually bleed to padding
|
|
351
|
+
if (this.underline) {
|
|
352
|
+
return "\x1b[24m"; // Underline off only
|
|
353
|
+
}
|
|
354
|
+
return "";
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function updateTrackerFromText(text: string, tracker: AnsiCodeTracker): void {
|
|
359
|
+
let i = 0;
|
|
360
|
+
while (i < text.length) {
|
|
361
|
+
const ansiResult = extractAnsiCode(text, i);
|
|
362
|
+
if (ansiResult) {
|
|
363
|
+
tracker.process(ansiResult.code);
|
|
364
|
+
i += ansiResult.length;
|
|
365
|
+
} else {
|
|
366
|
+
i++;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Split text into words while keeping ANSI codes attached.
|
|
373
|
+
*/
|
|
374
|
+
function splitIntoTokensWithAnsi(text: string): string[] {
|
|
375
|
+
const tokens: string[] = [];
|
|
376
|
+
let current = "";
|
|
377
|
+
let pendingAnsi = ""; // ANSI codes waiting to be attached to next visible content
|
|
378
|
+
let inWhitespace = false;
|
|
379
|
+
let i = 0;
|
|
380
|
+
|
|
381
|
+
while (i < text.length) {
|
|
382
|
+
const ansiResult = extractAnsiCode(text, i);
|
|
383
|
+
if (ansiResult) {
|
|
384
|
+
// Hold ANSI codes separately - they'll be attached to the next visible char
|
|
385
|
+
pendingAnsi += ansiResult.code;
|
|
386
|
+
i += ansiResult.length;
|
|
387
|
+
continue;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const char = text[i];
|
|
391
|
+
const charIsSpace = char === " ";
|
|
392
|
+
|
|
393
|
+
if (charIsSpace !== inWhitespace && current) {
|
|
394
|
+
// Switching between whitespace and non-whitespace, push current token
|
|
395
|
+
tokens.push(current);
|
|
396
|
+
current = "";
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// Attach any pending ANSI codes to this visible character
|
|
400
|
+
if (pendingAnsi) {
|
|
401
|
+
current += pendingAnsi;
|
|
402
|
+
pendingAnsi = "";
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
inWhitespace = charIsSpace;
|
|
406
|
+
current += char;
|
|
407
|
+
i++;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// Handle any remaining pending ANSI codes (attach to last token)
|
|
411
|
+
if (pendingAnsi) {
|
|
412
|
+
current += pendingAnsi;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
if (current) {
|
|
416
|
+
tokens.push(current);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
return tokens;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Wrap text with ANSI codes preserved.
|
|
424
|
+
*
|
|
425
|
+
* ONLY does word wrapping - NO padding, NO background colors.
|
|
426
|
+
* Returns lines where each line is <= width visible chars.
|
|
427
|
+
* Active ANSI codes are preserved across line breaks.
|
|
428
|
+
*
|
|
429
|
+
* @param text - Text to wrap (may contain ANSI codes and newlines)
|
|
430
|
+
* @param width - Maximum visible width per line
|
|
431
|
+
* @returns Array of wrapped lines (NOT padded to width)
|
|
432
|
+
*/
|
|
433
|
+
export function wrapTextWithAnsi(text: string, width: number): string[] {
|
|
434
|
+
if (!text) {
|
|
435
|
+
return [""];
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// Handle newlines by processing each line separately
|
|
439
|
+
// Track ANSI state across lines so styles carry over after literal newlines
|
|
440
|
+
const inputLines = text.split("\n");
|
|
441
|
+
const result: string[] = [];
|
|
442
|
+
const tracker = new AnsiCodeTracker();
|
|
443
|
+
|
|
444
|
+
for (const inputLine of inputLines) {
|
|
445
|
+
// Prepend active ANSI codes from previous lines (except for first line)
|
|
446
|
+
const prefix = result.length > 0 ? tracker.getActiveCodes() : "";
|
|
447
|
+
result.push(...wrapSingleLine(prefix + inputLine, width));
|
|
448
|
+
// Update tracker with codes from this line for next iteration
|
|
449
|
+
updateTrackerFromText(inputLine, tracker);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
return result.length > 0 ? result : [""];
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function wrapSingleLine(line: string, width: number): string[] {
|
|
456
|
+
if (!line) {
|
|
457
|
+
return [""];
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
const visibleLength = visibleWidth(line);
|
|
461
|
+
if (visibleLength <= width) {
|
|
462
|
+
return [line];
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
const wrapped: string[] = [];
|
|
466
|
+
const tracker = new AnsiCodeTracker();
|
|
467
|
+
const tokens = splitIntoTokensWithAnsi(line);
|
|
468
|
+
|
|
469
|
+
let currentLine = "";
|
|
470
|
+
let currentVisibleLength = 0;
|
|
471
|
+
|
|
472
|
+
for (const token of tokens) {
|
|
473
|
+
const tokenVisibleLength = visibleWidth(token);
|
|
474
|
+
const isWhitespace = token.trim() === "";
|
|
475
|
+
|
|
476
|
+
// Token itself is too long - break it character by character
|
|
477
|
+
if (tokenVisibleLength > width && !isWhitespace) {
|
|
478
|
+
if (currentLine) {
|
|
479
|
+
// Add specific reset for underline only (preserves background)
|
|
480
|
+
const lineEndReset = tracker.getLineEndReset();
|
|
481
|
+
if (lineEndReset) {
|
|
482
|
+
currentLine += lineEndReset;
|
|
483
|
+
}
|
|
484
|
+
wrapped.push(currentLine);
|
|
485
|
+
currentLine = "";
|
|
486
|
+
currentVisibleLength = 0;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// Break long token - breakLongWord handles its own resets
|
|
490
|
+
const broken = breakLongWord(token, width, tracker);
|
|
491
|
+
wrapped.push(...broken.slice(0, -1));
|
|
492
|
+
currentLine = broken[broken.length - 1];
|
|
493
|
+
currentVisibleLength = visibleWidth(currentLine);
|
|
494
|
+
continue;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// Check if adding this token would exceed width
|
|
498
|
+
const totalNeeded = currentVisibleLength + tokenVisibleLength;
|
|
499
|
+
|
|
500
|
+
if (totalNeeded > width && currentVisibleLength > 0) {
|
|
501
|
+
// Add specific reset for underline only (preserves background)
|
|
502
|
+
let lineToWrap = currentLine.trimEnd();
|
|
503
|
+
const lineEndReset = tracker.getLineEndReset();
|
|
504
|
+
if (lineEndReset) {
|
|
505
|
+
lineToWrap += lineEndReset;
|
|
506
|
+
}
|
|
507
|
+
wrapped.push(lineToWrap);
|
|
508
|
+
if (isWhitespace) {
|
|
509
|
+
// Don't start new line with whitespace
|
|
510
|
+
currentLine = tracker.getActiveCodes();
|
|
511
|
+
currentVisibleLength = 0;
|
|
512
|
+
} else {
|
|
513
|
+
currentLine = tracker.getActiveCodes() + token;
|
|
514
|
+
currentVisibleLength = tokenVisibleLength;
|
|
515
|
+
}
|
|
516
|
+
} else {
|
|
517
|
+
// Add to current line
|
|
518
|
+
currentLine += token;
|
|
519
|
+
currentVisibleLength += tokenVisibleLength;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
updateTrackerFromText(token, tracker);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
if (currentLine) {
|
|
526
|
+
// No reset at end of final line - let caller handle it
|
|
527
|
+
wrapped.push(currentLine);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
return wrapped.length > 0 ? wrapped : [""];
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
const PUNCTUATION_REGEX = /[(){}[\]<>.,;:'"!?+\-=*/\\|&%^$#@~`]/;
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* Check if a character is whitespace.
|
|
537
|
+
*/
|
|
538
|
+
export function isWhitespaceChar(char: string): boolean {
|
|
539
|
+
return /\s/.test(char);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* Check if a character is punctuation.
|
|
544
|
+
*/
|
|
545
|
+
export function isPunctuationChar(char: string): boolean {
|
|
546
|
+
return PUNCTUATION_REGEX.test(char);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
function breakLongWord(word: string, width: number, tracker: AnsiCodeTracker): string[] {
|
|
550
|
+
const lines: string[] = [];
|
|
551
|
+
let currentLine = tracker.getActiveCodes();
|
|
552
|
+
let currentWidth = 0;
|
|
553
|
+
|
|
554
|
+
// First, separate ANSI codes from visible content
|
|
555
|
+
// We need to handle ANSI codes specially since they're not graphemes
|
|
556
|
+
let i = 0;
|
|
557
|
+
const segments: Array<{ type: "ansi" | "grapheme"; value: string }> = [];
|
|
558
|
+
|
|
559
|
+
while (i < word.length) {
|
|
560
|
+
const ansiResult = extractAnsiCode(word, i);
|
|
561
|
+
if (ansiResult) {
|
|
562
|
+
segments.push({ type: "ansi", value: ansiResult.code });
|
|
563
|
+
i += ansiResult.length;
|
|
564
|
+
} else {
|
|
565
|
+
// Find the next ANSI code or end of string
|
|
566
|
+
let end = i;
|
|
567
|
+
while (end < word.length) {
|
|
568
|
+
const nextAnsi = extractAnsiCode(word, end);
|
|
569
|
+
if (nextAnsi) break;
|
|
570
|
+
end++;
|
|
571
|
+
}
|
|
572
|
+
// Segment this non-ANSI portion into graphemes
|
|
573
|
+
const textPortion = word.slice(i, end);
|
|
574
|
+
for (const seg of segmenter.segment(textPortion)) {
|
|
575
|
+
segments.push({ type: "grapheme", value: seg.segment });
|
|
576
|
+
}
|
|
577
|
+
i = end;
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// Now process segments
|
|
582
|
+
for (const seg of segments) {
|
|
583
|
+
if (seg.type === "ansi") {
|
|
584
|
+
currentLine += seg.value;
|
|
585
|
+
tracker.process(seg.value);
|
|
586
|
+
continue;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
const grapheme = seg.value;
|
|
590
|
+
// Skip empty graphemes to avoid issues with string-width calculation
|
|
591
|
+
if (!grapheme) continue;
|
|
592
|
+
|
|
593
|
+
const graphemeWidth = visibleWidth(grapheme);
|
|
594
|
+
|
|
595
|
+
if (currentWidth + graphemeWidth > width) {
|
|
596
|
+
// Add specific reset for underline only (preserves background)
|
|
597
|
+
const lineEndReset = tracker.getLineEndReset();
|
|
598
|
+
if (lineEndReset) {
|
|
599
|
+
currentLine += lineEndReset;
|
|
600
|
+
}
|
|
601
|
+
lines.push(currentLine);
|
|
602
|
+
currentLine = tracker.getActiveCodes();
|
|
603
|
+
currentWidth = 0;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
currentLine += grapheme;
|
|
607
|
+
currentWidth += graphemeWidth;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
if (currentLine) {
|
|
611
|
+
// No reset at end of final segment - caller handles continuation
|
|
612
|
+
lines.push(currentLine);
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
return lines.length > 0 ? lines : [""];
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/**
|
|
619
|
+
* Apply background color to a line, padding to full width.
|
|
620
|
+
*
|
|
621
|
+
* @param line - Line of text (may contain ANSI codes)
|
|
622
|
+
* @param width - Total width to pad to
|
|
623
|
+
* @param bgFn - Background color function
|
|
624
|
+
* @returns Line with background applied and padded to width
|
|
625
|
+
*/
|
|
626
|
+
export function applyBackgroundToLine(line: string, width: number, bgFn: (text: string) => string): string {
|
|
627
|
+
// Calculate padding needed
|
|
628
|
+
const visibleLen = visibleWidth(line);
|
|
629
|
+
const paddingNeeded = Math.max(0, width - visibleLen);
|
|
630
|
+
const padding = " ".repeat(paddingNeeded);
|
|
631
|
+
|
|
632
|
+
// Apply background to content + padding
|
|
633
|
+
const withPadding = line + padding;
|
|
634
|
+
return bgFn(withPadding);
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/**
|
|
638
|
+
* Truncate text to fit within a maximum visible width, adding ellipsis if needed.
|
|
639
|
+
* Properly handles ANSI escape codes (they don't count toward width).
|
|
640
|
+
*
|
|
641
|
+
* @param text - Text to truncate (may contain ANSI codes)
|
|
642
|
+
* @param maxWidth - Maximum visible width
|
|
643
|
+
* @param ellipsis - Ellipsis string to append when truncating (default: "...")
|
|
644
|
+
* @returns Truncated text with ellipsis if it exceeded maxWidth
|
|
645
|
+
*/
|
|
646
|
+
export function truncateToWidth(text: string, maxWidth: number, ellipsis: string = "..."): string {
|
|
647
|
+
const textVisibleWidth = visibleWidth(text);
|
|
648
|
+
|
|
649
|
+
if (textVisibleWidth <= maxWidth) {
|
|
650
|
+
return text;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
const ellipsisWidth = visibleWidth(ellipsis);
|
|
654
|
+
const targetWidth = maxWidth - ellipsisWidth;
|
|
655
|
+
|
|
656
|
+
if (targetWidth <= 0) {
|
|
657
|
+
return ellipsis.substring(0, maxWidth);
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
// Separate ANSI codes from visible content using grapheme segmentation
|
|
661
|
+
let i = 0;
|
|
662
|
+
const segments: Array<{ type: "ansi" | "grapheme"; value: string }> = [];
|
|
663
|
+
|
|
664
|
+
while (i < text.length) {
|
|
665
|
+
const ansiResult = extractAnsiCode(text, i);
|
|
666
|
+
if (ansiResult) {
|
|
667
|
+
segments.push({ type: "ansi", value: ansiResult.code });
|
|
668
|
+
i += ansiResult.length;
|
|
669
|
+
} else {
|
|
670
|
+
// Find the next ANSI code or end of string
|
|
671
|
+
let end = i;
|
|
672
|
+
while (end < text.length) {
|
|
673
|
+
const nextAnsi = extractAnsiCode(text, end);
|
|
674
|
+
if (nextAnsi) break;
|
|
675
|
+
end++;
|
|
676
|
+
}
|
|
677
|
+
// Segment this non-ANSI portion into graphemes
|
|
678
|
+
const textPortion = text.slice(i, end);
|
|
679
|
+
for (const seg of segmenter.segment(textPortion)) {
|
|
680
|
+
segments.push({ type: "grapheme", value: seg.segment });
|
|
681
|
+
}
|
|
682
|
+
i = end;
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
// Build truncated string from segments
|
|
687
|
+
let result = "";
|
|
688
|
+
let currentWidth = 0;
|
|
689
|
+
|
|
690
|
+
for (const seg of segments) {
|
|
691
|
+
if (seg.type === "ansi") {
|
|
692
|
+
result += seg.value;
|
|
693
|
+
continue;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
const grapheme = seg.value;
|
|
697
|
+
// Skip empty graphemes to avoid issues with string-width calculation
|
|
698
|
+
if (!grapheme) continue;
|
|
699
|
+
|
|
700
|
+
const graphemeWidth = visibleWidth(grapheme);
|
|
701
|
+
|
|
702
|
+
if (currentWidth + graphemeWidth > targetWidth) {
|
|
703
|
+
break;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
result += grapheme;
|
|
707
|
+
currentWidth += graphemeWidth;
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
// Add reset code before ellipsis to prevent styling leaking into it
|
|
711
|
+
return `${result}\x1b[0m${ellipsis}`;
|
|
712
|
+
}
|