dsh-ssh-tui 0.5.2 → 0.5.4
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.en.md +36 -6
- package/README.md +24 -13
- package/lib/approval-reviewer.js +102 -17
- package/lib/approval-reviewer.js.map +1 -1
- package/lib/auto-approval.js +312 -29
- package/lib/auto-approval.js.map +1 -1
- package/lib/footer.js +337 -0
- package/lib/footer.js.map +1 -0
- package/lib/i18n/en.js +83 -8
- package/lib/i18n/en.js.map +1 -1
- package/lib/i18n/index.js +1 -0
- package/lib/i18n/index.js.map +1 -1
- package/lib/i18n/zh.js +83 -8
- package/lib/i18n/zh.js.map +1 -1
- package/lib/json-args.js +30 -0
- package/lib/json-args.js.map +1 -0
- package/lib/paint.js +262 -0
- package/lib/paint.js.map +1 -0
- package/lib/picker.js +407 -56
- package/lib/picker.js.map +1 -1
- package/lib/plan.js +369 -0
- package/lib/plan.js.map +1 -0
- package/lib/quota.js +408 -0
- package/lib/quota.js.map +1 -0
- package/lib/session-list.js +18 -21
- package/lib/session-list.js.map +1 -1
- package/lib/term-text.js +827 -0
- package/lib/term-text.js.map +1 -0
- package/lib/tool-present.js +744 -0
- package/lib/tool-present.js.map +1 -0
- package/lib/transcript-types.js +6 -0
- package/lib/transcript-types.js.map +1 -0
- package/lib/tui.js +747 -3077
- package/lib/tui.js.map +1 -1
- package/lib/types/approval-reviewer.d.ts +18 -4
- package/lib/types/auto-approval.d.ts +53 -2
- package/lib/types/footer.d.ts +155 -0
- package/lib/types/i18n/index.d.ts +2 -0
- package/lib/types/json-args.d.ts +7 -0
- package/lib/types/paint.d.ts +78 -0
- package/lib/types/picker.d.ts +99 -7
- package/lib/types/plan.d.ts +80 -0
- package/lib/types/quota.d.ts +94 -0
- package/lib/types/session-list.d.ts +13 -0
- package/lib/types/term-text.d.ts +130 -0
- package/lib/types/tool-present.d.ts +165 -0
- package/lib/types/transcript-types.d.ts +152 -0
- package/lib/types/tui.d.ts +39 -665
- package/package.json +1 -1
package/lib/term-text.js
ADDED
|
@@ -0,0 +1,827 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal cell metrics, wrapping, markdown, and input folding.
|
|
3
|
+
*
|
|
4
|
+
* Isolated so the launch session picker can clip labels without loading
|
|
5
|
+
* the interactive TUI class.
|
|
6
|
+
*/
|
|
7
|
+
import { t } from './i18n/index.js';
|
|
8
|
+
/**
|
|
9
|
+
* Codex-style compact elapsed: `0s`, `1m 05s`, `1h 01m 01s`.
|
|
10
|
+
* Used by the workspace wait card while the model has not streamed yet.
|
|
11
|
+
*/
|
|
12
|
+
export function fmtElapsedCompact(elapsedSecs) {
|
|
13
|
+
const secs = Math.max(0, Math.floor(elapsedSecs));
|
|
14
|
+
if (secs < 60)
|
|
15
|
+
return `${secs}s`;
|
|
16
|
+
if (secs < 3600) {
|
|
17
|
+
const minutes = Math.floor(secs / 60);
|
|
18
|
+
const seconds = secs % 60;
|
|
19
|
+
return `${minutes}m ${String(seconds).padStart(2, '0')}s`;
|
|
20
|
+
}
|
|
21
|
+
const hours = Math.floor(secs / 3600);
|
|
22
|
+
const minutes = Math.floor((secs % 3600) / 60);
|
|
23
|
+
const seconds = secs % 60;
|
|
24
|
+
return `${hours}h ${String(minutes).padStart(2, '0')}m ${String(seconds).padStart(2, '0')}s`;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Sweep highlight across `text` (Codex `shimmer.rs`). Truecolor blends a
|
|
28
|
+
* highlight band; otherwise DIM / default / BOLD. Process-start based so
|
|
29
|
+
* every paint of the same frame stays in phase.
|
|
30
|
+
*/
|
|
31
|
+
export function shimmerText(text, nowMs, color) {
|
|
32
|
+
const chars = Array.from(text);
|
|
33
|
+
if (chars.length === 0)
|
|
34
|
+
return '';
|
|
35
|
+
if (!color)
|
|
36
|
+
return text;
|
|
37
|
+
const padding = 10;
|
|
38
|
+
const period = chars.length + padding * 2;
|
|
39
|
+
const sweepMs = 2000;
|
|
40
|
+
const pos = Math.floor(((nowMs % sweepMs) / sweepMs) * period);
|
|
41
|
+
const bandHalf = 5;
|
|
42
|
+
let out = '';
|
|
43
|
+
for (let index = 0; index < chars.length; index += 1) {
|
|
44
|
+
const dist = Math.abs(index + padding - pos);
|
|
45
|
+
const t = dist <= bandHalf
|
|
46
|
+
? 0.5 * (1 + Math.cos(Math.PI * (dist / bandHalf)))
|
|
47
|
+
: 0;
|
|
48
|
+
const style = t < 0.2 ? '2' : t < 0.6 ? '0' : '1';
|
|
49
|
+
out += `\x1b[${style}m${chars[index]}\x1b[0m`;
|
|
50
|
+
}
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Codex `extract_first_bold`: the first **closed** `**bold**` in the thinking
|
|
55
|
+
* stream, else the first markdown heading. An unclosed `**` means the title
|
|
56
|
+
* has not arrived yet, so return undefined and keep the default header —
|
|
57
|
+
* never fall back to hard-truncated reasoning, reply, or prompt text.
|
|
58
|
+
*/
|
|
59
|
+
export function waitSummaryFromReasoning(text) {
|
|
60
|
+
const raw = text.replace(/\r\n?/gu, '\n');
|
|
61
|
+
const chars = Array.from(raw);
|
|
62
|
+
for (let i = 0; i + 1 < chars.length; i += 1) {
|
|
63
|
+
if (chars[i] !== '*' || chars[i + 1] !== '*')
|
|
64
|
+
continue;
|
|
65
|
+
let j = i + 2;
|
|
66
|
+
while (j + 1 < chars.length && !(chars[j] === '*' && chars[j + 1] === '*'))
|
|
67
|
+
j += 1;
|
|
68
|
+
if (j + 1 >= chars.length)
|
|
69
|
+
return undefined;
|
|
70
|
+
const inner = chars.slice(i + 2, j).join('').replace(/\s+/gu, ' ').trim();
|
|
71
|
+
return inner === '' ? undefined : inner;
|
|
72
|
+
}
|
|
73
|
+
const heading = /^#{1,6}\s+(.+)$/mu.exec(raw)?.[1];
|
|
74
|
+
const source = heading?.replace(/\s+/gu, ' ').trim() ?? '';
|
|
75
|
+
return source === '' ? undefined : source;
|
|
76
|
+
}
|
|
77
|
+
/** Wait-card header + optional detail. Header tracks model work when known. */
|
|
78
|
+
export function waitCardCopy(input) {
|
|
79
|
+
const toolTitle = input.toolTitle?.trim() ?? '';
|
|
80
|
+
const toolSummary = input.toolSummary?.trim() ?? '';
|
|
81
|
+
const header = waitSummaryFromReasoning(input.reasoning ?? '') ?? t('wait.working');
|
|
82
|
+
if (toolTitle !== '') {
|
|
83
|
+
return { header, detail: toolSummary === '' ? toolTitle : `${toolTitle} ${toolSummary}` };
|
|
84
|
+
}
|
|
85
|
+
return { header };
|
|
86
|
+
}
|
|
87
|
+
const WAIT_DETAIL_PREFIX = ' └ ';
|
|
88
|
+
const WAIT_DETAIL_MAX_LINES = 3;
|
|
89
|
+
/**
|
|
90
|
+
* Codex `wrapped_details_lines`: word-wrap the wait-card detail under the
|
|
91
|
+
* ` └ ` prefix, continue wrapped rows at the prefix width, cap at 3 rows and
|
|
92
|
+
* end the last one with an ellipsis when the text does not fit.
|
|
93
|
+
*/
|
|
94
|
+
export function wrapWaitDetails(detail, width, maxLines = WAIT_DETAIL_MAX_LINES) {
|
|
95
|
+
const prefixWidth = displayWidth(WAIT_DETAIL_PREFIX);
|
|
96
|
+
const contentWidth = Math.max(1, width - prefixWidth);
|
|
97
|
+
const rows = [];
|
|
98
|
+
let current = '';
|
|
99
|
+
const flush = () => {
|
|
100
|
+
if (current !== '')
|
|
101
|
+
rows.push(current);
|
|
102
|
+
current = '';
|
|
103
|
+
};
|
|
104
|
+
for (const word of detail.split(/\s+/u)) {
|
|
105
|
+
if (word === '')
|
|
106
|
+
continue;
|
|
107
|
+
let rest = word;
|
|
108
|
+
while (displayWidth(rest) > contentWidth) {
|
|
109
|
+
flush();
|
|
110
|
+
let cut = 0;
|
|
111
|
+
let used = 0;
|
|
112
|
+
for (const char of rest) {
|
|
113
|
+
const charWidth = displayWidth(char);
|
|
114
|
+
if (used + charWidth > contentWidth)
|
|
115
|
+
break;
|
|
116
|
+
used += charWidth;
|
|
117
|
+
cut += char.length;
|
|
118
|
+
}
|
|
119
|
+
if (cut === 0)
|
|
120
|
+
cut = firstCodePointLength(rest);
|
|
121
|
+
rows.push(rest.slice(0, cut));
|
|
122
|
+
rest = rest.slice(cut);
|
|
123
|
+
}
|
|
124
|
+
if (rest === '')
|
|
125
|
+
continue;
|
|
126
|
+
if (current === '')
|
|
127
|
+
current = rest;
|
|
128
|
+
else if (displayWidth(current) + 1 + displayWidth(rest) <= contentWidth)
|
|
129
|
+
current += ` ${rest}`;
|
|
130
|
+
else {
|
|
131
|
+
flush();
|
|
132
|
+
current = rest;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
flush();
|
|
136
|
+
if (rows.length === 0)
|
|
137
|
+
return [];
|
|
138
|
+
const overflow = rows.length > maxLines;
|
|
139
|
+
const kept = overflow ? rows.slice(0, maxLines) : rows;
|
|
140
|
+
if (overflow) {
|
|
141
|
+
// Codex rewrites the last kept row with an explicit ellipsis so it reads
|
|
142
|
+
// as "more below", even when the row itself still has spare room.
|
|
143
|
+
const last = kept[maxLines - 1] ?? '';
|
|
144
|
+
const limit = Math.max(1, contentWidth - 1);
|
|
145
|
+
let cut = 0;
|
|
146
|
+
let used = 0;
|
|
147
|
+
for (const char of last) {
|
|
148
|
+
const charWidth = displayWidth(char);
|
|
149
|
+
if (used + charWidth > limit)
|
|
150
|
+
break;
|
|
151
|
+
used += charWidth;
|
|
152
|
+
cut += char.length;
|
|
153
|
+
}
|
|
154
|
+
kept[maxLines - 1] = `${last.slice(0, cut)}…`;
|
|
155
|
+
}
|
|
156
|
+
return kept.map((line, index) => index === 0 ? `${WAIT_DETAIL_PREFIX}${line}` : `${' '.repeat(prefixWidth)}${line}`);
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Terminal cell width for one string.
|
|
160
|
+
*
|
|
161
|
+
* Match glibc wcwidth / typical UTF-8 SSH terminals: CJK ideographs and
|
|
162
|
+
* fullwidth forms occupy two cells; East-Asian Ambiguous box-drawing and
|
|
163
|
+
* ornaments (`─`, `●`, `·`, `▸`, `❯`, Braille spinners) occupy one. Counting
|
|
164
|
+
* those ambiguous glyphs as two made `repeatToWidth('─', cols)` paint a
|
|
165
|
+
* half-width rule and parked the input cursor half a cell past the text.
|
|
166
|
+
*
|
|
167
|
+
* Overflow into the input box is handled by clipping/padding painted rows to
|
|
168
|
+
* the measured column count, not by inflating glyph width.
|
|
169
|
+
*/
|
|
170
|
+
export function displayWidth(text) {
|
|
171
|
+
let width = 0;
|
|
172
|
+
for (const char of text) {
|
|
173
|
+
if (char === '\t') {
|
|
174
|
+
// Tabs are expanded to spaces before rendering; keep the width
|
|
175
|
+
// calculation consistent with `sanitizeTerminalText()`.
|
|
176
|
+
width += 4;
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
const cp = char.codePointAt(0) ?? 0;
|
|
180
|
+
if (cp === 0x00ad || (cp >= 0x200b && cp <= 0x200f) || (cp >= 0x2060 && cp <= 0x2064) || cp === 0xfeff) {
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
if (cp <= 0x1f || (cp >= 0x7f && cp <= 0x9f)) {
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
const wide = (cp >= 0x1100 && cp <= 0x115f) ||
|
|
187
|
+
cp === 0x2329 || cp === 0x232a ||
|
|
188
|
+
(cp >= 0x2e80 && cp <= 0xa4cf) ||
|
|
189
|
+
(cp >= 0xac00 && cp <= 0xd7a3) ||
|
|
190
|
+
(cp >= 0xf900 && cp <= 0xfaff) ||
|
|
191
|
+
(cp >= 0xfe10 && cp <= 0xfe19) ||
|
|
192
|
+
(cp >= 0xfe30 && cp <= 0xfe6f) ||
|
|
193
|
+
(cp >= 0xff00 && cp <= 0xff60) ||
|
|
194
|
+
(cp >= 0xffe0 && cp <= 0xffe6) ||
|
|
195
|
+
(cp >= 0x1f300 && cp <= 0x1faff) ||
|
|
196
|
+
(cp >= 0x20000 && cp <= 0x3fffd);
|
|
197
|
+
width += wide ? 2 : 1;
|
|
198
|
+
}
|
|
199
|
+
return width;
|
|
200
|
+
}
|
|
201
|
+
/** Pad or clip one already-sanitized line so it occupies exactly `width` cells. */
|
|
202
|
+
export function padToWidth(text, width) {
|
|
203
|
+
const safe = sanitizeTerminalText(text);
|
|
204
|
+
if (width <= 0)
|
|
205
|
+
return '';
|
|
206
|
+
const clipped = truncateToWidth(safe, width);
|
|
207
|
+
const used = displayWidth(clipped);
|
|
208
|
+
return used >= width ? clipped : `${clipped}${' '.repeat(width - used)}`;
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Pad an already-styled ANSI line to `width` cells without resetting SGR.
|
|
212
|
+
* Diff add/del rows keep their background across the whole terminal row
|
|
213
|
+
* instead of only the glyphs.
|
|
214
|
+
*/
|
|
215
|
+
export function padAnsiToWidth(text, width) {
|
|
216
|
+
if (width <= 0)
|
|
217
|
+
return '';
|
|
218
|
+
const clipped = clipAnsiToWidth(text, width);
|
|
219
|
+
const used = visibleWidth(clipped);
|
|
220
|
+
if (used >= width)
|
|
221
|
+
return clipped;
|
|
222
|
+
const pad = ' '.repeat(width - used);
|
|
223
|
+
// Insert spaces before a trailing SGR reset so backgrounds (diff rows)
|
|
224
|
+
// and the cell budget both fill the whole terminal row.
|
|
225
|
+
if (clipped.endsWith('\x1b[0m'))
|
|
226
|
+
return `${clipped.slice(0, -4)}${pad}\x1b[0m`;
|
|
227
|
+
return `${clipped}${pad}`;
|
|
228
|
+
}
|
|
229
|
+
/** Visible width of an ANSI-styled line, ignoring CSI / OSC sequences. */
|
|
230
|
+
export function visibleWidth(text) {
|
|
231
|
+
let used = 0;
|
|
232
|
+
let index = 0;
|
|
233
|
+
while (index < text.length) {
|
|
234
|
+
if (text.charCodeAt(index) === 0x1b) {
|
|
235
|
+
index = skipAnsiSequence(text, index);
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
const cp = text.codePointAt(index);
|
|
239
|
+
if (cp === undefined)
|
|
240
|
+
break;
|
|
241
|
+
const char = String.fromCodePoint(cp);
|
|
242
|
+
used += displayWidth(char);
|
|
243
|
+
index += char.length;
|
|
244
|
+
}
|
|
245
|
+
return used;
|
|
246
|
+
}
|
|
247
|
+
/** Advance past one ESC sequence starting at `index`. */
|
|
248
|
+
function skipAnsiSequence(text, index) {
|
|
249
|
+
let seqEnd = index + 1;
|
|
250
|
+
if (seqEnd >= text.length)
|
|
251
|
+
return text.length;
|
|
252
|
+
const intro = text.charCodeAt(seqEnd);
|
|
253
|
+
if (intro === 0x5b) {
|
|
254
|
+
seqEnd += 1;
|
|
255
|
+
while (seqEnd < text.length) {
|
|
256
|
+
const code = text.charCodeAt(seqEnd);
|
|
257
|
+
seqEnd += 1;
|
|
258
|
+
if (code >= 0x40 && code <= 0x7e)
|
|
259
|
+
break;
|
|
260
|
+
}
|
|
261
|
+
return seqEnd;
|
|
262
|
+
}
|
|
263
|
+
if (intro === 0x5d) {
|
|
264
|
+
seqEnd += 1;
|
|
265
|
+
while (seqEnd < text.length) {
|
|
266
|
+
const code = text.charCodeAt(seqEnd);
|
|
267
|
+
seqEnd += 1;
|
|
268
|
+
if (code === 0x07)
|
|
269
|
+
break;
|
|
270
|
+
if (code === 0x1b && text.charCodeAt(seqEnd) === 0x5c) {
|
|
271
|
+
seqEnd += 1;
|
|
272
|
+
break;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
return seqEnd;
|
|
276
|
+
}
|
|
277
|
+
while (seqEnd < text.length) {
|
|
278
|
+
const code = text.charCodeAt(seqEnd);
|
|
279
|
+
seqEnd += 1;
|
|
280
|
+
if (code >= 0x40 && code <= 0x7e)
|
|
281
|
+
break;
|
|
282
|
+
}
|
|
283
|
+
return seqEnd;
|
|
284
|
+
}
|
|
285
|
+
/** Repeat a glyph until it occupies exactly `width` cells. */
|
|
286
|
+
export function repeatToWidth(glyph, width) {
|
|
287
|
+
if (width <= 0)
|
|
288
|
+
return '';
|
|
289
|
+
const unit = displayWidth(glyph);
|
|
290
|
+
if (unit <= 0)
|
|
291
|
+
return ' '.repeat(width);
|
|
292
|
+
const count = Math.max(1, Math.floor(width / unit));
|
|
293
|
+
return padToWidth(glyph.repeat(count), width);
|
|
294
|
+
}
|
|
295
|
+
/** Strip terminal control sequences and expand tabs for display output. */
|
|
296
|
+
export function sanitizeTerminalText(text) {
|
|
297
|
+
return text
|
|
298
|
+
.replace(/[\x1b\u009b]/gu, '')
|
|
299
|
+
.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/gu, '')
|
|
300
|
+
.replaceAll('\t', ' ');
|
|
301
|
+
}
|
|
302
|
+
/** UTF-16 length of the first code point, so fallback cuts never split a surrogate pair. */
|
|
303
|
+
export function firstCodePointLength(text) {
|
|
304
|
+
return Array.from(text)[0]?.length ?? 1;
|
|
305
|
+
}
|
|
306
|
+
export function wrap(text, width) {
|
|
307
|
+
const limit = Math.max(1, width);
|
|
308
|
+
const lines = [];
|
|
309
|
+
for (const sourceLine of text.split('\n')) {
|
|
310
|
+
if (sourceLine === '') {
|
|
311
|
+
lines.push('');
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
let rest = sanitizeTerminalText(sourceLine);
|
|
315
|
+
while (displayWidth(rest) > limit) {
|
|
316
|
+
let cut = 0;
|
|
317
|
+
let used = 0;
|
|
318
|
+
for (const char of rest) {
|
|
319
|
+
const charWidth = displayWidth(char);
|
|
320
|
+
if (charWidth > 0 && used + charWidth > limit)
|
|
321
|
+
break;
|
|
322
|
+
used += charWidth;
|
|
323
|
+
cut += char.length;
|
|
324
|
+
}
|
|
325
|
+
if (cut === 0) {
|
|
326
|
+
// A single double-width glyph on a 1-cell row still has to occupy a
|
|
327
|
+
// line; the next wrap continues after it so we never stall.
|
|
328
|
+
cut = firstCodePointLength(rest);
|
|
329
|
+
}
|
|
330
|
+
lines.push(rest.slice(0, cut));
|
|
331
|
+
rest = rest.slice(cut);
|
|
332
|
+
}
|
|
333
|
+
lines.push(rest);
|
|
334
|
+
}
|
|
335
|
+
return lines;
|
|
336
|
+
}
|
|
337
|
+
/** Wrap plain text and report each output line's char range in the source. */
|
|
338
|
+
export function wrapTracked(text, width) {
|
|
339
|
+
const limit = Math.max(1, width);
|
|
340
|
+
const out = [];
|
|
341
|
+
let base = 0;
|
|
342
|
+
for (const sourceLine of text.split('\n')) {
|
|
343
|
+
if (sourceLine === '') {
|
|
344
|
+
out.push({ line: '', start: base, end: base });
|
|
345
|
+
base += 1;
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
348
|
+
let rest = sourceLine;
|
|
349
|
+
let cursor = base;
|
|
350
|
+
while (displayWidth(rest) > limit) {
|
|
351
|
+
let cut = 0;
|
|
352
|
+
let used = 0;
|
|
353
|
+
for (const char of rest) {
|
|
354
|
+
const charWidth = displayWidth(char);
|
|
355
|
+
if (charWidth > 0 && used + charWidth > limit)
|
|
356
|
+
break;
|
|
357
|
+
used += charWidth;
|
|
358
|
+
cut += char.length;
|
|
359
|
+
}
|
|
360
|
+
if (cut === 0)
|
|
361
|
+
cut = firstCodePointLength(rest);
|
|
362
|
+
out.push({ line: rest.slice(0, cut), start: cursor, end: cursor + cut });
|
|
363
|
+
rest = rest.slice(cut);
|
|
364
|
+
cursor += cut;
|
|
365
|
+
}
|
|
366
|
+
out.push({ line: rest, start: cursor, end: cursor + rest.length });
|
|
367
|
+
base += sourceLine.length + 1;
|
|
368
|
+
}
|
|
369
|
+
return out;
|
|
370
|
+
}
|
|
371
|
+
/** Paint one already-wrapped output line by the segments overlapping its range. */
|
|
372
|
+
export function paintSegmentedLine(line, start, end, segments) {
|
|
373
|
+
if (segments.length === 0)
|
|
374
|
+
return line;
|
|
375
|
+
let out = '';
|
|
376
|
+
let cursor = start;
|
|
377
|
+
for (const seg of segments) {
|
|
378
|
+
if (seg.end <= start)
|
|
379
|
+
continue;
|
|
380
|
+
if (seg.start >= end)
|
|
381
|
+
break;
|
|
382
|
+
const from = Math.max(seg.start, start);
|
|
383
|
+
const to = Math.min(seg.end, end);
|
|
384
|
+
if (to <= from)
|
|
385
|
+
continue;
|
|
386
|
+
// Gaps (the tool title) stay default foreground — do not drop them.
|
|
387
|
+
if (from > cursor)
|
|
388
|
+
out += line.slice(cursor - start, from - start);
|
|
389
|
+
out += `\x1b[${seg.sgr}m${line.slice(from - start, to - start)}\x1b[0m`;
|
|
390
|
+
cursor = to;
|
|
391
|
+
}
|
|
392
|
+
if (cursor < end)
|
|
393
|
+
out += line.slice(cursor - start, end - start);
|
|
394
|
+
return out === '' ? line : out;
|
|
395
|
+
}
|
|
396
|
+
/** Wrap `text` and color each output line by overlapping `segments`. */
|
|
397
|
+
export function wrapSegmented(text, width, segments) {
|
|
398
|
+
return wrapTracked(text, width).map(({ line, start, end }) => paintSegmentedLine(line, start, end, segments));
|
|
399
|
+
}
|
|
400
|
+
export function truncate(text, maxLines) {
|
|
401
|
+
const lines = text.split('\n');
|
|
402
|
+
if (maxLines <= 0)
|
|
403
|
+
return '';
|
|
404
|
+
if (lines.length <= maxLines)
|
|
405
|
+
return text;
|
|
406
|
+
if (maxLines === 1)
|
|
407
|
+
return `… ${lines.length - 1} more line(s) …`;
|
|
408
|
+
const head = lines.slice(0, Math.max(0, maxLines - 2));
|
|
409
|
+
const tail = lines.slice(-1);
|
|
410
|
+
return [...head, `… ${lines.length - head.length - 1} more line(s) …`, ...tail].join('\n');
|
|
411
|
+
}
|
|
412
|
+
const INLINE_MARKDOWN_PATTERN = /(\*\*[^*\n]+\*\*)|(`[^`\n]+`)|(\[[^\]\n]+\]\([^)\n]+\))|(\*[^*\n]+\*)|(_[^_\n]+_)/gu;
|
|
413
|
+
/** Parse one line's bold / italic / inline-code / link spans. */
|
|
414
|
+
function parseInlineMarkdown(line) {
|
|
415
|
+
const segments = [];
|
|
416
|
+
let last = 0;
|
|
417
|
+
for (const match of line.matchAll(INLINE_MARKDOWN_PATTERN)) {
|
|
418
|
+
const index = match.index;
|
|
419
|
+
if (index > last)
|
|
420
|
+
segments.push({ kind: 'text', text: line.slice(last, index) });
|
|
421
|
+
const token = match[0];
|
|
422
|
+
if (match[1] !== undefined) {
|
|
423
|
+
segments.push({ kind: 'bold', text: token.slice(2, -2) });
|
|
424
|
+
}
|
|
425
|
+
else if (match[2] !== undefined) {
|
|
426
|
+
segments.push({ kind: 'code', text: token.slice(1, -1) });
|
|
427
|
+
}
|
|
428
|
+
else if (match[3] !== undefined) {
|
|
429
|
+
const labelEnd = token.indexOf('](');
|
|
430
|
+
const label = token.slice(1, labelEnd);
|
|
431
|
+
const url = token.slice(labelEnd + 2, -1);
|
|
432
|
+
segments.push({ kind: 'link', text: label });
|
|
433
|
+
if (url !== '')
|
|
434
|
+
segments.push({ kind: 'muted', text: ` (${url})` });
|
|
435
|
+
}
|
|
436
|
+
else if (match[4] !== undefined) {
|
|
437
|
+
segments.push({ kind: 'italic', text: token.slice(1, -1) });
|
|
438
|
+
}
|
|
439
|
+
else if (match[5] !== undefined) {
|
|
440
|
+
segments.push({ kind: 'italic', text: token.slice(1, -1) });
|
|
441
|
+
}
|
|
442
|
+
last = index + token.length;
|
|
443
|
+
}
|
|
444
|
+
if (last < line.length)
|
|
445
|
+
segments.push({ kind: 'text', text: line.slice(last) });
|
|
446
|
+
if (segments.length === 0)
|
|
447
|
+
segments.push({ kind: 'text', text: line });
|
|
448
|
+
return segments;
|
|
449
|
+
}
|
|
450
|
+
function markdownSegmentWidth(segments) {
|
|
451
|
+
return segments.reduce((total, segment) => total + displayWidth(segment.text), 0);
|
|
452
|
+
}
|
|
453
|
+
/** Wrap styled inline segments into visual rows, carrying a prefix only on row one. */
|
|
454
|
+
function wrapMarkdownSegments(segments, width, prefixSegments = []) {
|
|
455
|
+
const limit = Math.max(1, width);
|
|
456
|
+
const lines = [];
|
|
457
|
+
let current = [...prefixSegments];
|
|
458
|
+
let used = markdownSegmentWidth(current);
|
|
459
|
+
for (const segment of segments) {
|
|
460
|
+
let rest = segment.text;
|
|
461
|
+
while (rest !== '') {
|
|
462
|
+
const available = limit - used;
|
|
463
|
+
if (available <= 0) {
|
|
464
|
+
lines.push(current);
|
|
465
|
+
current = [];
|
|
466
|
+
used = 0;
|
|
467
|
+
continue;
|
|
468
|
+
}
|
|
469
|
+
const slice = forwardSliceByWidth(rest, available);
|
|
470
|
+
let chunk = slice.text;
|
|
471
|
+
if (chunk === '') {
|
|
472
|
+
// A wide character does not fit the remaining cell: wrap to the next
|
|
473
|
+
// row instead of overflowing that cell into the input area.
|
|
474
|
+
if (used > 0) {
|
|
475
|
+
lines.push(current);
|
|
476
|
+
current = [];
|
|
477
|
+
used = 0;
|
|
478
|
+
continue;
|
|
479
|
+
}
|
|
480
|
+
chunk = Array.from(rest)[0] ?? rest.slice(0, 1);
|
|
481
|
+
}
|
|
482
|
+
current.push({ kind: segment.kind, text: chunk });
|
|
483
|
+
used += displayWidth(chunk);
|
|
484
|
+
rest = rest.slice(chunk.length);
|
|
485
|
+
if (rest !== '') {
|
|
486
|
+
lines.push(current);
|
|
487
|
+
current = [];
|
|
488
|
+
used = 0;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
if (current.length > 0 || lines.length === 0)
|
|
493
|
+
lines.push(current);
|
|
494
|
+
return lines.map(line => line.length === 0 ? [{ kind: 'text', text: '' }] : line);
|
|
495
|
+
}
|
|
496
|
+
function markdownSegmentCode(kind) {
|
|
497
|
+
switch (kind) {
|
|
498
|
+
case 'bold': return '1;97';
|
|
499
|
+
case 'italic': return '3;37';
|
|
500
|
+
case 'code': return '36';
|
|
501
|
+
case 'link': return '4;36';
|
|
502
|
+
case 'muted': return '2;37';
|
|
503
|
+
default: return '';
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
function markdownBaseCode(kind) {
|
|
507
|
+
switch (kind) {
|
|
508
|
+
case 'heading1': return '1;4;97';
|
|
509
|
+
case 'heading2': return '1;4;36';
|
|
510
|
+
case 'heading3': return '1;36';
|
|
511
|
+
case 'code': return '36';
|
|
512
|
+
case 'quote': return '3;37';
|
|
513
|
+
case 'rule': return '90';
|
|
514
|
+
default: return '1;37';
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
/** Render one pre-wrapped markdown line as ANSI (or plain text without color). */
|
|
518
|
+
function renderMarkdownBlockLine(block, color) {
|
|
519
|
+
const segments = block.segments.map(segment => ({ ...segment, text: sanitizeTerminalText(segment.text) }));
|
|
520
|
+
if (!color)
|
|
521
|
+
return segments.map(segment => segment.text).join('');
|
|
522
|
+
const base = markdownBaseCode(block.base);
|
|
523
|
+
let out = `\x1b[${base}m`;
|
|
524
|
+
for (const segment of segments) {
|
|
525
|
+
const code = markdownSegmentCode(segment.kind);
|
|
526
|
+
if (code === '') {
|
|
527
|
+
out += segment.text;
|
|
528
|
+
}
|
|
529
|
+
else {
|
|
530
|
+
out += `\x1b[${code}m${segment.text}\x1b[${base}m`;
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
return `${out}\x1b[0m`;
|
|
534
|
+
}
|
|
535
|
+
/** Enlarge H1 text visually: fullwidth ASCII and spaced CJK glyphs. */
|
|
536
|
+
function expandHeadingText(text) {
|
|
537
|
+
let out = '';
|
|
538
|
+
for (const char of text) {
|
|
539
|
+
const cp = char.codePointAt(0) ?? 0;
|
|
540
|
+
if (cp >= 0x21 && cp <= 0x7e) {
|
|
541
|
+
out += String.fromCodePoint(0xff01 + cp - 0x21);
|
|
542
|
+
}
|
|
543
|
+
else if (char.trim() === '') {
|
|
544
|
+
out += ' ';
|
|
545
|
+
}
|
|
546
|
+
else {
|
|
547
|
+
out += `${char} `;
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
return out;
|
|
551
|
+
}
|
|
552
|
+
function headingSegments(text, level) {
|
|
553
|
+
const segments = parseInlineMarkdown(text);
|
|
554
|
+
if (level !== 1)
|
|
555
|
+
return segments;
|
|
556
|
+
return segments.map(segment => segment.kind === 'code' || segment.kind === 'link' || segment.kind === 'muted'
|
|
557
|
+
? segment
|
|
558
|
+
: { kind: segment.kind, text: expandHeadingText(segment.text) });
|
|
559
|
+
}
|
|
560
|
+
/**
|
|
561
|
+
* Render workspace markdown into width-bounded terminal rows. Assistant
|
|
562
|
+
* replies get a bold-white base; code blocks, headings, quotes, lists, rules,
|
|
563
|
+
* links and inline spans keep their own ANSI treatment.
|
|
564
|
+
*/
|
|
565
|
+
export function renderMarkdownLines(text, width, color) {
|
|
566
|
+
const lines = [];
|
|
567
|
+
let inFence = false;
|
|
568
|
+
for (const sourceLine of text.split('\n')) {
|
|
569
|
+
const raw = sanitizeTerminalText(sourceLine);
|
|
570
|
+
const fence = /^```([^\n]*)$/u.exec(raw.trim());
|
|
571
|
+
if (fence !== null) {
|
|
572
|
+
inFence = !inFence;
|
|
573
|
+
lines.push(renderMarkdownBlockLine({
|
|
574
|
+
base: 'code',
|
|
575
|
+
segments: [{ kind: 'text', text: `\`\`\`${fence[1] ?? ''}` }],
|
|
576
|
+
}, color));
|
|
577
|
+
continue;
|
|
578
|
+
}
|
|
579
|
+
if (inFence) {
|
|
580
|
+
if (raw === '') {
|
|
581
|
+
lines.push('');
|
|
582
|
+
continue;
|
|
583
|
+
}
|
|
584
|
+
for (const line of wrap(raw, width)) {
|
|
585
|
+
lines.push(renderMarkdownBlockLine({
|
|
586
|
+
base: 'code',
|
|
587
|
+
segments: [{ kind: 'text', text: line }],
|
|
588
|
+
}, color));
|
|
589
|
+
}
|
|
590
|
+
continue;
|
|
591
|
+
}
|
|
592
|
+
const heading = /^(#{1,6})\s+(.*)$/u.exec(raw);
|
|
593
|
+
if (heading !== null) {
|
|
594
|
+
// The hashes are markdown syntax, not content: replace them with
|
|
595
|
+
// heading style. Levels differ visually: H1 is enlarged and
|
|
596
|
+
// underlined, H2 underlined, H3 colored, H4+ bold white.
|
|
597
|
+
const level = Math.min(6, (heading[1] ?? '#').length);
|
|
598
|
+
const base = level === 1
|
|
599
|
+
? 'heading1'
|
|
600
|
+
: level === 2
|
|
601
|
+
? 'heading2'
|
|
602
|
+
: level === 3
|
|
603
|
+
? 'heading3'
|
|
604
|
+
: 'assistant';
|
|
605
|
+
if (level === 1 && lines.at(-1) !== '')
|
|
606
|
+
lines.push('');
|
|
607
|
+
for (const segments of wrapMarkdownSegments(headingSegments(heading[2] ?? '', level), width)) {
|
|
608
|
+
lines.push(renderMarkdownBlockLine({ base, segments }, color));
|
|
609
|
+
}
|
|
610
|
+
if (level === 1)
|
|
611
|
+
lines.push('');
|
|
612
|
+
continue;
|
|
613
|
+
}
|
|
614
|
+
if (/^\s*(?:-{3,}|\*{3,}|_{3,})\s*$/u.test(raw) && raw.trim() !== '') {
|
|
615
|
+
lines.push(renderMarkdownBlockLine({
|
|
616
|
+
base: 'rule',
|
|
617
|
+
segments: [{ kind: 'text', text: repeatToWidth('─', Math.max(1, width)) }],
|
|
618
|
+
}, color));
|
|
619
|
+
continue;
|
|
620
|
+
}
|
|
621
|
+
const quote = /^(\s*)>\s?(.*)$/u.exec(raw);
|
|
622
|
+
if (quote !== null) {
|
|
623
|
+
const indent = quote[1] ?? '';
|
|
624
|
+
const prefix = `${indent}│ `;
|
|
625
|
+
for (const segments of wrapMarkdownSegments(parseInlineMarkdown(quote[2] ?? ''), width, [{ kind: 'text', text: prefix }])) {
|
|
626
|
+
lines.push(renderMarkdownBlockLine({ base: 'quote', segments }, color));
|
|
627
|
+
}
|
|
628
|
+
continue;
|
|
629
|
+
}
|
|
630
|
+
const list = /^(\s*)([-*+]|\d+[.)])\s+(.*)$/u.exec(raw);
|
|
631
|
+
if (list !== null) {
|
|
632
|
+
const indent = list[1] ?? '';
|
|
633
|
+
const marker = list[2] ?? '-';
|
|
634
|
+
const prefix = `${indent}${marker} `;
|
|
635
|
+
for (const segments of wrapMarkdownSegments(parseInlineMarkdown(list[3] ?? ''), width, [{ kind: 'text', text: prefix }])) {
|
|
636
|
+
lines.push(renderMarkdownBlockLine({ base: 'assistant', segments }, color));
|
|
637
|
+
}
|
|
638
|
+
continue;
|
|
639
|
+
}
|
|
640
|
+
if (raw === '') {
|
|
641
|
+
lines.push('');
|
|
642
|
+
continue;
|
|
643
|
+
}
|
|
644
|
+
for (const segments of wrapMarkdownSegments(parseInlineMarkdown(raw), width)) {
|
|
645
|
+
lines.push(renderMarkdownBlockLine({ base: 'assistant', segments }, color));
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
return lines;
|
|
649
|
+
}
|
|
650
|
+
/** Cut one line to fit a width, appending an ellipsis when truncated. */
|
|
651
|
+
export function truncateToWidth(text, width) {
|
|
652
|
+
const safe = sanitizeTerminalText(text);
|
|
653
|
+
if (width <= 0)
|
|
654
|
+
return '';
|
|
655
|
+
if (displayWidth(safe) <= width)
|
|
656
|
+
return safe;
|
|
657
|
+
if (width === 1)
|
|
658
|
+
return '…';
|
|
659
|
+
const limit = width - 1;
|
|
660
|
+
let cut = 0;
|
|
661
|
+
let used = 0;
|
|
662
|
+
for (const char of safe) {
|
|
663
|
+
const charWidth = displayWidth(char);
|
|
664
|
+
if (used + charWidth > limit)
|
|
665
|
+
break;
|
|
666
|
+
used += charWidth;
|
|
667
|
+
cut += char.length;
|
|
668
|
+
}
|
|
669
|
+
if (cut === 0)
|
|
670
|
+
cut = firstCodePointLength(safe);
|
|
671
|
+
return `${safe.slice(0, cut)}…`;
|
|
672
|
+
}
|
|
673
|
+
/**
|
|
674
|
+
* Clip an already-styled ANSI line to `width` terminal cells without dropping
|
|
675
|
+
* the reset/SGR sequences. Used by the incremental painter so a leftover wide
|
|
676
|
+
* glyph cannot wrap into the next row.
|
|
677
|
+
*/
|
|
678
|
+
export function clipAnsiToWidth(text, width) {
|
|
679
|
+
if (width <= 0)
|
|
680
|
+
return '';
|
|
681
|
+
let used = 0;
|
|
682
|
+
let out = '';
|
|
683
|
+
let index = 0;
|
|
684
|
+
while (index < text.length) {
|
|
685
|
+
if (text.charCodeAt(index) === 0x1b) {
|
|
686
|
+
const seqEnd = skipAnsiSequence(text, index);
|
|
687
|
+
out += text.slice(index, seqEnd);
|
|
688
|
+
index = seqEnd;
|
|
689
|
+
continue;
|
|
690
|
+
}
|
|
691
|
+
const cp = text.codePointAt(index);
|
|
692
|
+
if (cp === undefined)
|
|
693
|
+
break;
|
|
694
|
+
const char = String.fromCodePoint(cp);
|
|
695
|
+
const charWidth = displayWidth(char);
|
|
696
|
+
if (used + charWidth > width)
|
|
697
|
+
break;
|
|
698
|
+
out += char;
|
|
699
|
+
used += charWidth;
|
|
700
|
+
index += char.length;
|
|
701
|
+
}
|
|
702
|
+
return out;
|
|
703
|
+
}
|
|
704
|
+
/** Slice up to `maxWidth` display columns from the beginning of `text`. */
|
|
705
|
+
function forwardSliceByWidth(text, maxWidth) {
|
|
706
|
+
let cut = 0;
|
|
707
|
+
let used = 0;
|
|
708
|
+
for (const char of text) {
|
|
709
|
+
const charWidth = displayWidth(char);
|
|
710
|
+
if (used + charWidth > maxWidth)
|
|
711
|
+
break;
|
|
712
|
+
used += charWidth;
|
|
713
|
+
cut += char.length;
|
|
714
|
+
}
|
|
715
|
+
return { text: text.slice(0, cut), width: used };
|
|
716
|
+
}
|
|
717
|
+
/** Slice up to `maxWidth` display columns ending at `end` in `text`. */
|
|
718
|
+
function backwardSliceByWidth(text, end, maxWidth) {
|
|
719
|
+
if (end <= 0 || maxWidth <= 0)
|
|
720
|
+
return { start: end, width: 0 };
|
|
721
|
+
const chars = Array.from(text.slice(0, end));
|
|
722
|
+
let used = 0;
|
|
723
|
+
let firstIncluded = chars.length;
|
|
724
|
+
for (let index = chars.length - 1; index >= 0; index--) {
|
|
725
|
+
const charWidth = displayWidth(chars[index] ?? '');
|
|
726
|
+
if (used + charWidth > maxWidth)
|
|
727
|
+
break;
|
|
728
|
+
used += charWidth;
|
|
729
|
+
firstIncluded = index;
|
|
730
|
+
}
|
|
731
|
+
return {
|
|
732
|
+
start: chars.slice(0, firstIncluded).join('').length,
|
|
733
|
+
width: used,
|
|
734
|
+
};
|
|
735
|
+
}
|
|
736
|
+
/**
|
|
737
|
+
* Fold a long input into one terminal row around the cursor.
|
|
738
|
+
*
|
|
739
|
+
* Newlines from a paste are display-only: they do not occupy cells, so a
|
|
740
|
+
* naive `displayWidth(input)` under-counts a multi-line paste and parks the
|
|
741
|
+
* caret in the middle of later text. Fold the *current line* (between the
|
|
742
|
+
* surrounding newlines) and keep `\n` out of the visible slice.
|
|
743
|
+
*/
|
|
744
|
+
export function foldInputView(input, cursor, maxWidth) {
|
|
745
|
+
const width = Math.max(1, maxWidth);
|
|
746
|
+
const safeCursor = Math.max(0, Math.min(cursor, input.length));
|
|
747
|
+
const lineStart = input.lastIndexOf('\n', Math.max(0, safeCursor - 1)) + 1;
|
|
748
|
+
const lineEndRaw = input.indexOf('\n', safeCursor);
|
|
749
|
+
const lineEnd = lineEndRaw === -1 ? input.length : lineEndRaw;
|
|
750
|
+
const line = input.slice(lineStart, lineEnd);
|
|
751
|
+
const lineCursor = safeCursor - lineStart;
|
|
752
|
+
const totalWidth = displayWidth(line);
|
|
753
|
+
const cursorOffset = displayWidth(line.slice(0, lineCursor));
|
|
754
|
+
const hasMoreLines = lineStart > 0 || lineEnd < input.length;
|
|
755
|
+
if (totalWidth <= width && !hasMoreLines) {
|
|
756
|
+
return { text: line, cursorOffset, folded: false };
|
|
757
|
+
}
|
|
758
|
+
if (totalWidth <= width) {
|
|
759
|
+
return { text: line, cursorOffset, folded: true };
|
|
760
|
+
}
|
|
761
|
+
const before = cursorOffset;
|
|
762
|
+
const after = totalWidth - cursorOffset;
|
|
763
|
+
const leftFolded = before > 0;
|
|
764
|
+
const rightFolded = after > 0;
|
|
765
|
+
const markers = (leftFolded ? 1 : 0) + (rightFolded ? 1 : 0);
|
|
766
|
+
const available = Math.max(1, width - markers);
|
|
767
|
+
let beforeBudget = Math.min(before, Math.ceil(available / 2));
|
|
768
|
+
let afterBudget = Math.min(after, available - beforeBudget);
|
|
769
|
+
// If the tail is shorter than its budget, spend the spare columns on the
|
|
770
|
+
// side before the cursor so the cursor stays visible near its true offset.
|
|
771
|
+
beforeBudget = Math.min(before, beforeBudget + (available - beforeBudget - afterBudget));
|
|
772
|
+
const beforeSlice = backwardSliceByWidth(line, lineCursor, beforeBudget);
|
|
773
|
+
const afterSlice = forwardSliceByWidth(line.slice(lineCursor), afterBudget);
|
|
774
|
+
const beforeText = line.slice(beforeSlice.start, lineCursor);
|
|
775
|
+
return {
|
|
776
|
+
text: `${leftFolded ? '…' : ''}${beforeText}${afterSlice.text}${rightFolded ? '…' : ''}`,
|
|
777
|
+
cursorOffset: (leftFolded ? 1 : 0) + displayWidth(beforeText),
|
|
778
|
+
folded: true,
|
|
779
|
+
};
|
|
780
|
+
}
|
|
781
|
+
/**
|
|
782
|
+
* Map a character index in the input text to its visual (row, col) after the
|
|
783
|
+
* same width wrapping `wrap()` applies to the rendered input. `row` is the
|
|
784
|
+
* 0-based input display line, `col` the 0-based column within that line
|
|
785
|
+
* (before any prompt prefix). This keeps the cursor on the correct line/column
|
|
786
|
+
* when the input contains literal newlines from multi-line pastes.
|
|
787
|
+
*/
|
|
788
|
+
export function cursorVisualPosition(text, cursor, width) {
|
|
789
|
+
let row = 0;
|
|
790
|
+
let col = 0;
|
|
791
|
+
let used = 0;
|
|
792
|
+
let offset = 0;
|
|
793
|
+
for (const char of text) {
|
|
794
|
+
if (offset >= cursor)
|
|
795
|
+
break;
|
|
796
|
+
if (char === '\n') {
|
|
797
|
+
row += 1;
|
|
798
|
+
col = 0;
|
|
799
|
+
used = 0;
|
|
800
|
+
}
|
|
801
|
+
else {
|
|
802
|
+
const charWidth = displayWidth(char);
|
|
803
|
+
if (used + charWidth > width) {
|
|
804
|
+
row += 1;
|
|
805
|
+
col = 0;
|
|
806
|
+
used = 0;
|
|
807
|
+
}
|
|
808
|
+
used += charWidth;
|
|
809
|
+
col += charWidth;
|
|
810
|
+
}
|
|
811
|
+
offset += char.length;
|
|
812
|
+
}
|
|
813
|
+
return { row, col };
|
|
814
|
+
}
|
|
815
|
+
/** Take the first `max` code points of a string without splitting surrogates. */
|
|
816
|
+
export function sliceCodePoints(text, max) {
|
|
817
|
+
if (max <= 0)
|
|
818
|
+
return '';
|
|
819
|
+
return Array.from(text).slice(0, max).join('');
|
|
820
|
+
}
|
|
821
|
+
/** Take the last `max` code points of a string without splitting surrogates. */
|
|
822
|
+
export function lastCodePoints(text, max) {
|
|
823
|
+
if (max <= 0)
|
|
824
|
+
return '';
|
|
825
|
+
return Array.from(text).slice(-max).join('');
|
|
826
|
+
}
|
|
827
|
+
//# sourceMappingURL=term-text.js.map
|