@logtape/pretty 1.0.0-dev.231

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/wcwidth.ts ADDED
@@ -0,0 +1,383 @@
1
+ /**
2
+ * @fileoverview
3
+ * wcwidth implementation for JavaScript/TypeScript
4
+ *
5
+ * This module provides functions to calculate the display width of Unicode
6
+ * characters and strings in terminal/monospace contexts, compatible with
7
+ * the Python wcwidth library and POSIX wcwidth() standard.
8
+ *
9
+ * Based on Unicode 15.1.0 character width tables.
10
+ */
11
+
12
+ /**
13
+ * Remove all ANSI escape sequences from a string.
14
+ *
15
+ * @param text The string to clean
16
+ * @returns String with ANSI escape sequences removed
17
+ */
18
+ export function stripAnsi(text: string): string {
19
+ return text.replace(/\[[0-9;]*m/g, "");
20
+ }
21
+
22
+ /**
23
+ * Calculate the display width of a string, ignoring ANSI escape codes
24
+ * and accounting for Unicode character widths using wcwidth-compatible logic.
25
+ *
26
+ * @param text The string to measure
27
+ * @returns The display width in terminal columns
28
+ */
29
+ export function getDisplayWidth(text: string): number {
30
+ // Remove all ANSI escape sequences first
31
+ const cleanText = stripAnsi(text);
32
+
33
+ if (cleanText.length === 0) return 0;
34
+
35
+ let width = 0;
36
+ let i = 0;
37
+
38
+ // Process character by character, handling surrogate pairs and combining characters
39
+ while (i < cleanText.length) {
40
+ const code = cleanText.codePointAt(i);
41
+ if (code === undefined) {
42
+ i++;
43
+ continue;
44
+ }
45
+
46
+ const charWidth = wcwidth(code);
47
+ if (charWidth >= 0) {
48
+ width += charWidth;
49
+ }
50
+
51
+ // Move to next code point (handles surrogate pairs)
52
+ i += (code > 0xFFFF) ? 2 : 1;
53
+ }
54
+
55
+ return width;
56
+ }
57
+
58
+ /**
59
+ * Get the display width of a single Unicode code point.
60
+ * Based on wcwidth implementation - returns:
61
+ * -1: Non-printable/control character
62
+ * 0: Zero-width character (combining marks, etc.)
63
+ * 1: Normal width character
64
+ * 2: Wide character (East Asian, emoji, etc.)
65
+ *
66
+ * @param code Unicode code point
67
+ * @returns Display width (-1, 0, 1, or 2)
68
+ */
69
+ export function wcwidth(code: number): number {
70
+ // C0 and C1 control characters
71
+ if (code < 32 || (code >= 0x7F && code < 0xA0)) {
72
+ return -1;
73
+ }
74
+
75
+ // Zero-width characters (based on wcwidth table_zero.py)
76
+ if (isZeroWidth(code)) {
77
+ return 0;
78
+ }
79
+
80
+ // Wide characters (based on wcwidth table_wide.py)
81
+ if (isWideCharacter(code)) {
82
+ return 2;
83
+ }
84
+
85
+ return 1;
86
+ }
87
+
88
+ /**
89
+ * Check if a character is zero-width (combining marks, etc.)
90
+ * Based on wcwidth's zero-width table.
91
+ *
92
+ * @param code Unicode code point
93
+ * @returns True if the character has zero display width
94
+ */
95
+ function isZeroWidth(code: number): boolean {
96
+ return (
97
+ // Combining Diacritical Marks
98
+ (code >= 0x0300 && code <= 0x036F) ||
99
+ // Hebrew combining marks
100
+ (code >= 0x0483 && code <= 0x0489) ||
101
+ // Arabic combining marks
102
+ (code >= 0x0591 && code <= 0x05BD) ||
103
+ code === 0x05BF ||
104
+ (code >= 0x05C1 && code <= 0x05C2) ||
105
+ (code >= 0x05C4 && code <= 0x05C5) ||
106
+ code === 0x05C7 ||
107
+ // More Arabic combining marks
108
+ (code >= 0x0610 && code <= 0x061A) ||
109
+ (code >= 0x064B && code <= 0x065F) ||
110
+ code === 0x0670 ||
111
+ (code >= 0x06D6 && code <= 0x06DC) ||
112
+ (code >= 0x06DF && code <= 0x06E4) ||
113
+ (code >= 0x06E7 && code <= 0x06E8) ||
114
+ (code >= 0x06EA && code <= 0x06ED) ||
115
+ code === 0x0711 ||
116
+ (code >= 0x0730 && code <= 0x074A) ||
117
+ (code >= 0x07A6 && code <= 0x07B0) ||
118
+ (code >= 0x07EB && code <= 0x07F3) ||
119
+ code === 0x07FD ||
120
+ // Various other combining marks
121
+ (code >= 0x0816 && code <= 0x0819) ||
122
+ (code >= 0x081B && code <= 0x0823) ||
123
+ (code >= 0x0825 && code <= 0x0827) ||
124
+ (code >= 0x0829 && code <= 0x082D) ||
125
+ (code >= 0x0859 && code <= 0x085B) ||
126
+ (code >= 0x08D3 && code <= 0x08E1) ||
127
+ (code >= 0x08E3 && code <= 0x0902) ||
128
+ code === 0x093A ||
129
+ code === 0x093C ||
130
+ (code >= 0x0941 && code <= 0x0948) ||
131
+ code === 0x094D ||
132
+ (code >= 0x0951 && code <= 0x0957) ||
133
+ (code >= 0x0962 && code <= 0x0963) ||
134
+ code === 0x0981 ||
135
+ code === 0x09BC ||
136
+ (code >= 0x09C1 && code <= 0x09C4) ||
137
+ code === 0x09CD ||
138
+ (code >= 0x09E2 && code <= 0x09E3) ||
139
+ (code >= 0x09FE && code <= 0x09FE) ||
140
+ (code >= 0x0A01 && code <= 0x0A02) ||
141
+ code === 0x0A3C ||
142
+ (code >= 0x0A41 && code <= 0x0A42) ||
143
+ (code >= 0x0A47 && code <= 0x0A48) ||
144
+ (code >= 0x0A4B && code <= 0x0A4D) ||
145
+ code === 0x0A51 ||
146
+ (code >= 0x0A70 && code <= 0x0A71) ||
147
+ code === 0x0A75 ||
148
+ (code >= 0x0A81 && code <= 0x0A82) ||
149
+ code === 0x0ABC ||
150
+ (code >= 0x0AC1 && code <= 0x0AC5) ||
151
+ (code >= 0x0AC7 && code <= 0x0AC8) ||
152
+ code === 0x0ACD ||
153
+ (code >= 0x0AE2 && code <= 0x0AE3) ||
154
+ (code >= 0x0AFA && code <= 0x0AFF) ||
155
+ code === 0x0B01 ||
156
+ code === 0x0B3C ||
157
+ code === 0x0B3F ||
158
+ (code >= 0x0B41 && code <= 0x0B44) ||
159
+ code === 0x0B4D ||
160
+ (code >= 0x0B55 && code <= 0x0B56) ||
161
+ (code >= 0x0B62 && code <= 0x0B63) ||
162
+ code === 0x0B82 ||
163
+ code === 0x0BC0 ||
164
+ code === 0x0BCD ||
165
+ code === 0x0C00 ||
166
+ code === 0x0C04 ||
167
+ (code >= 0x0C3E && code <= 0x0C40) ||
168
+ (code >= 0x0C46 && code <= 0x0C48) ||
169
+ (code >= 0x0C4A && code <= 0x0C4D) ||
170
+ (code >= 0x0C55 && code <= 0x0C56) ||
171
+ (code >= 0x0C62 && code <= 0x0C63) ||
172
+ code === 0x0C81 ||
173
+ code === 0x0CBC ||
174
+ code === 0x0CBF ||
175
+ code === 0x0CC6 ||
176
+ (code >= 0x0CCC && code <= 0x0CCD) ||
177
+ (code >= 0x0CE2 && code <= 0x0CE3) ||
178
+ (code >= 0x0D00 && code <= 0x0D01) ||
179
+ (code >= 0x0D3B && code <= 0x0D3C) ||
180
+ code === 0x0D41 ||
181
+ (code >= 0x0D44 && code <= 0x0D44) ||
182
+ code === 0x0D4D ||
183
+ (code >= 0x0D62 && code <= 0x0D63) ||
184
+ code === 0x0D81 ||
185
+ code === 0x0DCA ||
186
+ (code >= 0x0DD2 && code <= 0x0DD4) ||
187
+ code === 0x0DD6 ||
188
+ code === 0x0E31 ||
189
+ (code >= 0x0E34 && code <= 0x0E3A) ||
190
+ (code >= 0x0E47 && code <= 0x0E4E) ||
191
+ code === 0x0EB1 ||
192
+ (code >= 0x0EB4 && code <= 0x0EBC) ||
193
+ (code >= 0x0EC8 && code <= 0x0ECD) ||
194
+ (code >= 0x0F18 && code <= 0x0F19) ||
195
+ code === 0x0F35 ||
196
+ code === 0x0F37 ||
197
+ code === 0x0F39 ||
198
+ (code >= 0x0F71 && code <= 0x0F7E) ||
199
+ (code >= 0x0F80 && code <= 0x0F84) ||
200
+ (code >= 0x0F86 && code <= 0x0F87) ||
201
+ (code >= 0x0F8D && code <= 0x0F97) ||
202
+ (code >= 0x0F99 && code <= 0x0FBC) ||
203
+ code === 0x0FC6 ||
204
+ (code >= 0x102D && code <= 0x1030) ||
205
+ (code >= 0x1032 && code <= 0x1037) ||
206
+ (code >= 0x1039 && code <= 0x103A) ||
207
+ (code >= 0x103D && code <= 0x103E) ||
208
+ (code >= 0x1058 && code <= 0x1059) ||
209
+ (code >= 0x105E && code <= 0x1060) ||
210
+ (code >= 0x1071 && code <= 0x1074) ||
211
+ code === 0x1082 ||
212
+ (code >= 0x1085 && code <= 0x1086) ||
213
+ code === 0x108D ||
214
+ code === 0x109D ||
215
+ (code >= 0x135D && code <= 0x135F) ||
216
+ (code >= 0x1712 && code <= 0x1714) ||
217
+ (code >= 0x1732 && code <= 0x1734) ||
218
+ (code >= 0x1752 && code <= 0x1753) ||
219
+ (code >= 0x1772 && code <= 0x1773) ||
220
+ (code >= 0x17B4 && code <= 0x17B5) ||
221
+ (code >= 0x17B7 && code <= 0x17BD) ||
222
+ code === 0x17C6 ||
223
+ (code >= 0x17C9 && code <= 0x17D3) ||
224
+ code === 0x17DD ||
225
+ (code >= 0x180B && code <= 0x180D) ||
226
+ (code >= 0x1885 && code <= 0x1886) ||
227
+ code === 0x18A9 ||
228
+ (code >= 0x1920 && code <= 0x1922) ||
229
+ (code >= 0x1927 && code <= 0x1928) ||
230
+ code === 0x1932 ||
231
+ (code >= 0x1939 && code <= 0x193B) ||
232
+ (code >= 0x1A17 && code <= 0x1A18) ||
233
+ code === 0x1A1B ||
234
+ code === 0x1A56 ||
235
+ (code >= 0x1A58 && code <= 0x1A5E) ||
236
+ code === 0x1A60 ||
237
+ code === 0x1A62 ||
238
+ (code >= 0x1A65 && code <= 0x1A6C) ||
239
+ (code >= 0x1A73 && code <= 0x1A7C) ||
240
+ code === 0x1A7F ||
241
+ (code >= 0x1AB0 && code <= 0x1ABE) ||
242
+ (code >= 0x1B00 && code <= 0x1B03) ||
243
+ code === 0x1B34 ||
244
+ (code >= 0x1B36 && code <= 0x1B3A) ||
245
+ code === 0x1B3C ||
246
+ code === 0x1B42 ||
247
+ (code >= 0x1B6B && code <= 0x1B73) ||
248
+ (code >= 0x1B80 && code <= 0x1B81) ||
249
+ (code >= 0x1BA2 && code <= 0x1BA5) ||
250
+ (code >= 0x1BA8 && code <= 0x1BA9) ||
251
+ (code >= 0x1BAB && code <= 0x1BAD) ||
252
+ code === 0x1BE6 ||
253
+ (code >= 0x1BE8 && code <= 0x1BE9) ||
254
+ code === 0x1BED ||
255
+ (code >= 0x1BEF && code <= 0x1BF1) ||
256
+ (code >= 0x1C2C && code <= 0x1C33) ||
257
+ (code >= 0x1C36 && code <= 0x1C37) ||
258
+ (code >= 0x1CD0 && code <= 0x1CD2) ||
259
+ (code >= 0x1CD4 && code <= 0x1CE0) ||
260
+ (code >= 0x1CE2 && code <= 0x1CE8) ||
261
+ code === 0x1CED ||
262
+ code === 0x1CF4 ||
263
+ (code >= 0x1CF8 && code <= 0x1CF9) ||
264
+ (code >= 0x1DC0 && code <= 0x1DF9) ||
265
+ (code >= 0x1DFB && code <= 0x1DFF) ||
266
+ (code >= 0x200B && code <= 0x200F) || // Zero-width spaces
267
+ (code >= 0x202A && code <= 0x202E) || // Bidirectional format characters
268
+ (code >= 0x2060 && code <= 0x2064) || // Word joiner, etc.
269
+ (code >= 0x2066 && code <= 0x206F) || // More bidirectional
270
+ code === 0xFEFF || // Zero-width no-break space
271
+ (code >= 0xFE00 && code <= 0xFE0F) || // Variation selectors
272
+ (code >= 0xFE20 && code <= 0xFE2F) // Combining half marks
273
+ );
274
+ }
275
+
276
+ /**
277
+ * Check if a character code point represents a wide character.
278
+ * Based on wcwidth's wide character table (selected ranges from Unicode 15.1.0).
279
+ *
280
+ * @param code Unicode code point
281
+ * @returns True if the character has width 2
282
+ */
283
+ function isWideCharacter(code: number): boolean {
284
+ return (
285
+ // Based on wcwidth table_wide.py for Unicode 15.1.0
286
+ (code >= 0x1100 && code <= 0x115F) || // Hangul Jamo
287
+ (code >= 0x231A && code <= 0x231B) || // Watch, Hourglass
288
+ (code >= 0x2329 && code <= 0x232A) || // Angle brackets
289
+ (code >= 0x23E9 && code <= 0x23EC) || // Media controls
290
+ code === 0x23F0 || code === 0x23F3 || // Alarm clock, hourglass
291
+ (code >= 0x25FD && code <= 0x25FE) || // Small squares
292
+ (code >= 0x2614 && code <= 0x2615) || // Umbrella, coffee
293
+ (code >= 0x2648 && code <= 0x2653) || // Zodiac signs
294
+ code === 0x267F || code === 0x2693 || // Wheelchair, anchor
295
+ code === 0x26A0 || code === 0x26A1 || code === 0x26AA || code === 0x26AB || // Warning, lightning, circles
296
+ (code >= 0x26BD && code <= 0x26BE) || // Sports balls
297
+ (code >= 0x26C4 && code <= 0x26C5) || // Weather
298
+ code === 0x26CE || code === 0x26D4 || // Ophiuchus, no entry
299
+ (code >= 0x26EA && code <= 0x26EA) || // Church
300
+ (code >= 0x26F2 && code <= 0x26F3) || // Fountain, golf
301
+ code === 0x26F5 || code === 0x26FA || // Sailboat, tent
302
+ code === 0x26FD || // Gas pump
303
+ (code >= 0x2705 && code <= 0x2705) || // Check mark
304
+ (code >= 0x270A && code <= 0x270B) || // Raised fists
305
+ code === 0x2728 || // Sparkles (✨)
306
+ code === 0x274C || // Cross mark (❌)
307
+ code === 0x274E || // Cross mark button
308
+ (code >= 0x2753 && code <= 0x2755) || // Question marks
309
+ code === 0x2757 || // Exclamation
310
+ (code >= 0x2795 && code <= 0x2797) || // Plus signs
311
+ code === 0x27B0 || code === 0x27BF || // Curly loop, double curly loop
312
+ (code >= 0x2B1B && code <= 0x2B1C) || // Large squares
313
+ code === 0x2B50 || code === 0x2B55 || // Star, circle
314
+ (code >= 0x2E80 && code <= 0x2E99) || // CJK Radicals Supplement
315
+ (code >= 0x2E9B && code <= 0x2EF3) ||
316
+ (code >= 0x2F00 && code <= 0x2FD5) || // Kangxi Radicals
317
+ (code >= 0x2FF0 && code <= 0x2FFB) || // Ideographic Description Characters
318
+ (code >= 0x3000 && code <= 0x303E) || // CJK Symbols and Punctuation
319
+ (code >= 0x3041 && code <= 0x3096) || // Hiragana
320
+ (code >= 0x3099 && code <= 0x30FF) || // Katakana
321
+ (code >= 0x3105 && code <= 0x312F) || // Bopomofo
322
+ (code >= 0x3131 && code <= 0x318E) || // Hangul Compatibility Jamo
323
+ (code >= 0x3190 && code <= 0x31E3) || // Various CJK
324
+ (code >= 0x31F0 && code <= 0x321E) || // Katakana Phonetic Extensions
325
+ (code >= 0x3220 && code <= 0x3247) || // Enclosed CJK Letters and Months
326
+ (code >= 0x3250 && code <= 0x4DBF) || // Various CJK
327
+ (code >= 0x4E00 && code <= 0x9FFF) || // CJK Unified Ideographs
328
+ (code >= 0xA960 && code <= 0xA97F) || // Hangul Jamo Extended-A
329
+ (code >= 0xAC00 && code <= 0xD7A3) || // Hangul Syllables
330
+ (code >= 0xD7B0 && code <= 0xD7C6) || // Hangul Jamo Extended-B
331
+ (code >= 0xF900 && code <= 0xFAFF) || // CJK Compatibility Ideographs
332
+ (code >= 0xFE10 && code <= 0xFE19) || // Vertical Forms
333
+ (code >= 0xFE30 && code <= 0xFE6F) || // CJK Compatibility Forms
334
+ (code >= 0xFF00 && code <= 0xFF60) || // Fullwidth Forms
335
+ (code >= 0xFFE0 && code <= 0xFFE6) || // Fullwidth Forms
336
+ (code >= 0x16FE0 && code <= 0x16FE4) || // Tangut
337
+ (code >= 0x16FF0 && code <= 0x16FF1) ||
338
+ (code >= 0x17000 && code <= 0x187F7) || // Tangut
339
+ (code >= 0x18800 && code <= 0x18CD5) || // Tangut Components
340
+ (code >= 0x18D00 && code <= 0x18D08) || // Tangut Supplement
341
+ (code >= 0x1AFF0 && code <= 0x1AFF3) ||
342
+ (code >= 0x1AFF5 && code <= 0x1AFFB) ||
343
+ (code >= 0x1AFFD && code <= 0x1AFFE) ||
344
+ (code >= 0x1B000 && code <= 0x1B122) || // Kana Extended-A/Supplement
345
+ (code >= 0x1B150 && code <= 0x1B152) ||
346
+ (code >= 0x1B164 && code <= 0x1B167) ||
347
+ (code >= 0x1B170 && code <= 0x1B2FB) ||
348
+ code === 0x1F004 || // Mahjong Red Dragon
349
+ code === 0x1F0CF || // Playing Card Black Joker
350
+ (code >= 0x1F18E && code <= 0x1F18E) || // AB Button
351
+ (code >= 0x1F191 && code <= 0x1F19A) || // Various squared symbols
352
+ (code >= 0x1F1E6 && code <= 0x1F1FF) || // Regional Indicator Symbols (flags)
353
+ (code >= 0x1F200 && code <= 0x1F202) || // Squared symbols
354
+ (code >= 0x1F210 && code <= 0x1F23B) || // Squared CJK
355
+ (code >= 0x1F240 && code <= 0x1F248) || // Tortoise shell bracketed
356
+ (code >= 0x1F250 && code <= 0x1F251) || // Circled ideographs
357
+ (code >= 0x1F260 && code <= 0x1F265) ||
358
+ (code >= 0x1F300 && code <= 0x1F6D7) || // Large emoji block
359
+ (code >= 0x1F6E0 && code <= 0x1F6EC) ||
360
+ (code >= 0x1F6F0 && code <= 0x1F6FC) ||
361
+ (code >= 0x1F700 && code <= 0x1F773) ||
362
+ (code >= 0x1F780 && code <= 0x1F7D8) ||
363
+ (code >= 0x1F7E0 && code <= 0x1F7EB) ||
364
+ (code >= 0x1F7F0 && code <= 0x1F7F0) ||
365
+ (code >= 0x1F800 && code <= 0x1F80B) ||
366
+ (code >= 0x1F810 && code <= 0x1F847) ||
367
+ (code >= 0x1F850 && code <= 0x1F859) ||
368
+ (code >= 0x1F860 && code <= 0x1F887) ||
369
+ (code >= 0x1F890 && code <= 0x1F8AD) ||
370
+ (code >= 0x1F8B0 && code <= 0x1F8B1) ||
371
+ (code >= 0x1F900 && code <= 0x1FA53) || // Supplemental symbols and pictographs
372
+ (code >= 0x1FA60 && code <= 0x1FA6D) ||
373
+ (code >= 0x1FA70 && code <= 0x1FA7C) ||
374
+ (code >= 0x1FA80 && code <= 0x1FA88) ||
375
+ (code >= 0x1FA90 && code <= 0x1FABD) ||
376
+ (code >= 0x1FABF && code <= 0x1FAC5) ||
377
+ (code >= 0x1FACE && code <= 0x1FADB) ||
378
+ (code >= 0x1FAE0 && code <= 0x1FAE8) ||
379
+ (code >= 0x1FAF0 && code <= 0x1FAF8) ||
380
+ (code >= 0x20000 && code <= 0x2FFFD) || // CJK Extension B
381
+ (code >= 0x30000 && code <= 0x3FFFD) // CJK Extension C
382
+ );
383
+ }
@@ -0,0 +1,110 @@
1
+ import { suite } from "@alinea/suite";
2
+ import { assertEquals } from "@std/assert/equals";
3
+ import { assert } from "@std/assert/assert";
4
+ import { wrapText } from "./wordwrap.ts";
5
+
6
+ const test = suite(import.meta);
7
+
8
+ test("wrapText() should not wrap short text", () => {
9
+ const result = wrapText("short text", 80, "short text");
10
+ assertEquals(result, "short text");
11
+ });
12
+
13
+ test("wrapText() should wrap long text", () => {
14
+ const text =
15
+ "This is a very long line that should be wrapped at 40 characters maximum width for testing purposes.";
16
+ const result = wrapText(text, 40, "This is a very long line");
17
+
18
+ const lines = result.split("\n");
19
+ assert(lines.length > 1, "Should have multiple lines");
20
+
21
+ // Each line should be within the limit (with some tolerance for word boundaries)
22
+ for (const line of lines) {
23
+ assert(line.length <= 45, `Line too long: ${line.length} chars`);
24
+ }
25
+ });
26
+
27
+ test("wrapText() should preserve ANSI codes", () => {
28
+ const text =
29
+ "\x1b[31mThis is a very long red line that should be wrapped while preserving the color codes\x1b[0m";
30
+ const result = wrapText(text, 40, "This is a very long red line");
31
+
32
+ // Should contain ANSI codes
33
+ assert(result.includes("\x1b[31m"), "Should preserve opening ANSI code");
34
+ assert(result.includes("\x1b[0m"), "Should preserve closing ANSI code");
35
+ });
36
+
37
+ test("wrapText() should handle emojis correctly", () => {
38
+ const text =
39
+ "✨ info test This is a very long message that should wrap properly with emoji alignment";
40
+ const result = wrapText(text, 40, "This is a very long message");
41
+
42
+ const lines = result.split("\n");
43
+ assert(lines.length > 1, "Should have multiple lines");
44
+
45
+ // Check that continuation lines are indented properly
46
+ // The emoji ✨ should be accounted for in width calculation
47
+ const firstLine = lines[0];
48
+ const continuationLine = lines[1];
49
+
50
+ assert(firstLine.includes("✨"), "First line should contain emoji");
51
+ assert(
52
+ continuationLine.startsWith(" "),
53
+ "Continuation line should be indented",
54
+ );
55
+ });
56
+
57
+ test("wrapText() should handle newlines in interpolated content", () => {
58
+ const textWithNewlines =
59
+ "Error occurred: Error: Something went wrong\n at line 1\n at line 2";
60
+ const result = wrapText(textWithNewlines, 40, "Error occurred");
61
+
62
+ const lines = result.split("\n");
63
+ assert(
64
+ lines.length >= 3,
65
+ "Should preserve original newlines and add more if needed",
66
+ );
67
+ });
68
+
69
+ test("wrapText() should calculate indentation based on display width", () => {
70
+ // Test with different emojis that have different string lengths but same display width
71
+ const sparklesText = "✨ info test Message content here";
72
+ const crossText = "❌ error test Message content here";
73
+
74
+ const sparklesResult = wrapText(sparklesText, 25, "Message content here");
75
+ const crossResult = wrapText(crossText, 25, "Message content here");
76
+
77
+ const sparklesLines = sparklesResult.split("\n");
78
+ const crossLines = crossResult.split("\n");
79
+
80
+ if (sparklesLines.length > 1 && crossLines.length > 1) {
81
+ // Both should have similar indentation for continuation lines
82
+ // (accounting for the fact that both emojis are width 2)
83
+ const sparklesIndent = sparklesLines[1].search(/\S/);
84
+ const crossIndent = crossLines[1].search(/\S/);
85
+
86
+ // The indentation should be very close (within 1-2 characters)
87
+ // since both emojis have width 2
88
+ assert(
89
+ Math.abs(sparklesIndent - crossIndent) <= 2,
90
+ `Indentation should be similar: sparkles=${sparklesIndent}, cross=${crossIndent}`,
91
+ );
92
+ }
93
+ });
94
+
95
+ test("wrapText() should handle zero width", () => {
96
+ const result = wrapText("any text", 0, "any text");
97
+ assertEquals(result, "any text");
98
+ });
99
+
100
+ test("wrapText() should break at word boundaries", () => {
101
+ const text = "word1 word2 word3 word4 word5";
102
+ const result = wrapText(text, 15, "word1 word2 word3");
103
+
104
+ const lines = result.split("\n");
105
+ // Should break at spaces, not in the middle of words
106
+ for (const line of lines) {
107
+ const words = line.trim().split(" ");
108
+ assert(words.every((word) => word.length > 0), "Should not break words");
109
+ }
110
+ });
package/wordwrap.ts ADDED
@@ -0,0 +1,161 @@
1
+ /**
2
+ * @fileoverview
3
+ * Word wrapping utilities for terminal output
4
+ *
5
+ * This module provides functions for wrapping text at specified widths
6
+ * while preserving proper indentation and handling Unicode characters
7
+ * correctly.
8
+ */
9
+
10
+ import { getDisplayWidth, stripAnsi } from "./wcwidth.ts";
11
+
12
+ /**
13
+ * Wrap text at specified width with proper indentation for continuation lines.
14
+ * Automatically detects the message start position from the first line.
15
+ *
16
+ * @param text The text to wrap (may contain ANSI escape codes)
17
+ * @param maxWidth Maximum width in terminal columns
18
+ * @param messageContent The plain message content (used to find message start)
19
+ * @returns Wrapped text with proper indentation
20
+ */
21
+ export function wrapText(
22
+ text: string,
23
+ maxWidth: number,
24
+ messageContent: string,
25
+ ): string {
26
+ if (maxWidth <= 0) return text;
27
+
28
+ const displayWidth = getDisplayWidth(text);
29
+ // If text has newlines (multiline interpolated values), always process it
30
+ // even if it fits within the width
31
+ if (displayWidth <= maxWidth && !text.includes("\n")) return text;
32
+
33
+ // Find where the message content starts in the first line
34
+ const firstLineWords = messageContent.split(" ");
35
+ const firstWord = firstLineWords[0];
36
+ const plainText = stripAnsi(text);
37
+ const messageStartIndex = plainText.indexOf(firstWord);
38
+
39
+ // Calculate the display width of the text up to the message start
40
+ // This is crucial for proper alignment when emojis are present
41
+ let indentWidth = 0;
42
+ if (messageStartIndex >= 0) {
43
+ const prefixText = plainText.slice(0, messageStartIndex);
44
+ indentWidth = getDisplayWidth(prefixText);
45
+ }
46
+ const indent = " ".repeat(Math.max(0, indentWidth));
47
+
48
+ // Check if text contains newlines (from interpolated values like Error objects)
49
+ if (text.includes("\n")) {
50
+ // Split by existing newlines and process each line
51
+ const lines = text.split("\n");
52
+ const wrappedLines: string[] = [];
53
+
54
+ for (let i = 0; i < lines.length; i++) {
55
+ const line = lines[i];
56
+ const lineDisplayWidth = getDisplayWidth(line);
57
+
58
+ if (lineDisplayWidth <= maxWidth) {
59
+ // Line doesn't need wrapping, but add indentation if it's not the first line
60
+ if (i === 0) {
61
+ wrappedLines.push(line);
62
+ } else {
63
+ wrappedLines.push(indent + line);
64
+ }
65
+ } else {
66
+ // Line needs wrapping
67
+ const wrappedLine = wrapSingleLine(line, maxWidth, indent);
68
+ if (i === 0) {
69
+ wrappedLines.push(wrappedLine);
70
+ } else {
71
+ // For continuation lines from interpolated values, add proper indentation
72
+ const subLines = wrappedLine.split("\n");
73
+ for (let j = 0; j < subLines.length; j++) {
74
+ if (j === 0) {
75
+ wrappedLines.push(indent + subLines[j]);
76
+ } else {
77
+ wrappedLines.push(subLines[j]);
78
+ }
79
+ }
80
+ }
81
+ }
82
+ }
83
+
84
+ return wrappedLines.join("\n");
85
+ }
86
+
87
+ // Process as a single line since log records should not have newlines in the formatted output
88
+ return wrapSingleLine(text, maxWidth, indent);
89
+ }
90
+
91
+ /**
92
+ * Wrap a single line of text (without existing newlines) at word boundaries.
93
+ * Preserves ANSI escape codes and handles Unicode character widths correctly.
94
+ *
95
+ * @param text The text to wrap (single line, may contain ANSI codes)
96
+ * @param maxWidth Maximum width in terminal columns
97
+ * @param indent Indentation string for continuation lines
98
+ * @returns Wrapped text with newlines and proper indentation
99
+ */
100
+ export function wrapSingleLine(
101
+ text: string,
102
+ maxWidth: number,
103
+ indent: string,
104
+ ): string {
105
+ // Split text into chunks while preserving ANSI codes
106
+ const lines: string[] = [];
107
+ let currentLine = "";
108
+ let currentDisplayWidth = 0;
109
+ let i = 0;
110
+
111
+ while (i < text.length) {
112
+ // Check for ANSI escape sequence
113
+ if (text[i] === "\x1b" && text[i + 1] === "[") {
114
+ // Find the end of the ANSI sequence
115
+ let j = i + 2;
116
+ while (j < text.length && text[j] !== "m") {
117
+ j++;
118
+ }
119
+ if (j < text.length) {
120
+ j++; // Include the 'm'
121
+ currentLine += text.slice(i, j);
122
+ i = j;
123
+ continue;
124
+ }
125
+ }
126
+
127
+ const char = text[i];
128
+
129
+ // Check if adding this character would exceed the width
130
+ if (currentDisplayWidth >= maxWidth && char !== " ") {
131
+ // Try to find a good break point (space) before the current position
132
+ const breakPoint = currentLine.lastIndexOf(" ");
133
+ if (breakPoint > 0) {
134
+ // Break at the space
135
+ lines.push(currentLine.slice(0, breakPoint));
136
+ currentLine = indent + currentLine.slice(breakPoint + 1) + char;
137
+ currentDisplayWidth = getDisplayWidth(currentLine);
138
+ } else {
139
+ // No space found, hard break
140
+ lines.push(currentLine);
141
+ currentLine = indent + char;
142
+ currentDisplayWidth = getDisplayWidth(currentLine);
143
+ }
144
+ } else {
145
+ currentLine += char;
146
+ // Recalculate display width properly for Unicode characters
147
+ currentDisplayWidth = getDisplayWidth(currentLine);
148
+ }
149
+
150
+ i++;
151
+ }
152
+
153
+ if (currentLine.trim()) {
154
+ lines.push(currentLine);
155
+ }
156
+
157
+ // Filter out empty lines (lines with only indentation/spaces)
158
+ const filteredLines = lines.filter((line) => line.trim().length > 0);
159
+
160
+ return filteredLines.join("\n");
161
+ }