dsh-ssh-tui 0.5.3 → 0.5.5

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.
Files changed (57) hide show
  1. package/README.en.md +36 -10
  2. package/README.md +21 -12
  3. package/lib/approval-reviewer.js +56 -15
  4. package/lib/approval-reviewer.js.map +1 -1
  5. package/lib/auto-approval.js +251 -35
  6. package/lib/auto-approval.js.map +1 -1
  7. package/lib/copy-text.js +62 -0
  8. package/lib/copy-text.js.map +1 -0
  9. package/lib/dsh-compat.js +142 -1
  10. package/lib/dsh-compat.js.map +1 -1
  11. package/lib/footer.js +337 -0
  12. package/lib/footer.js.map +1 -0
  13. package/lib/i18n/en.js +62 -3
  14. package/lib/i18n/en.js.map +1 -1
  15. package/lib/i18n/zh.js +62 -3
  16. package/lib/i18n/zh.js.map +1 -1
  17. package/lib/index.js +7 -5
  18. package/lib/index.js.map +1 -1
  19. package/lib/json-args.js +30 -0
  20. package/lib/json-args.js.map +1 -0
  21. package/lib/paint.js +288 -0
  22. package/lib/paint.js.map +1 -0
  23. package/lib/picker.js +453 -66
  24. package/lib/picker.js.map +1 -1
  25. package/lib/plan.js +369 -0
  26. package/lib/plan.js.map +1 -0
  27. package/lib/quota.js +408 -0
  28. package/lib/quota.js.map +1 -0
  29. package/lib/session-index.js +80 -0
  30. package/lib/session-index.js.map +1 -0
  31. package/lib/session-list.js +210 -88
  32. package/lib/session-list.js.map +1 -1
  33. package/lib/term-text.js +953 -0
  34. package/lib/term-text.js.map +1 -0
  35. package/lib/tool-present.js +744 -0
  36. package/lib/tool-present.js.map +1 -0
  37. package/lib/transcript-types.js +6 -0
  38. package/lib/transcript-types.js.map +1 -0
  39. package/lib/tui.js +643 -3299
  40. package/lib/tui.js.map +1 -1
  41. package/lib/types/approval-reviewer.d.ts +8 -2
  42. package/lib/types/auto-approval.d.ts +28 -0
  43. package/lib/types/copy-text.d.ts +9 -0
  44. package/lib/types/dsh-compat.d.ts +78 -6
  45. package/lib/types/footer.d.ts +155 -0
  46. package/lib/types/json-args.d.ts +7 -0
  47. package/lib/types/paint.d.ts +80 -0
  48. package/lib/types/picker.d.ts +101 -7
  49. package/lib/types/plan.d.ts +80 -0
  50. package/lib/types/quota.d.ts +94 -0
  51. package/lib/types/session-index.d.ts +32 -0
  52. package/lib/types/session-list.d.ts +35 -2
  53. package/lib/types/term-text.d.ts +149 -0
  54. package/lib/types/tool-present.d.ts +165 -0
  55. package/lib/types/transcript-types.d.ts +152 -0
  56. package/lib/types/tui.d.ts +53 -723
  57. package/package.json +20 -18
@@ -0,0 +1,953 @@
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
+ export function osc8Open(href) {
413
+ return `\x1b]8;;${href.replace(/[\x00-\x1f\x7f]/gu, '')}\x1b\\`;
414
+ }
415
+ export function osc8Close() {
416
+ return `\x1b]8;;\x1b\\`;
417
+ }
418
+ /** True when OSC 8 hyperlinks should be painted. Off when DSH_TUI_OSC8=0/false. */
419
+ export function osc8Enabled(env = process.env) {
420
+ const raw = (env.DSH_TUI_OSC8 ?? '').trim().toLowerCase();
421
+ if (raw === '0' || raw === 'false' || raw === 'off' || raw === 'no')
422
+ return false;
423
+ if (raw === '1' || raw === 'true' || raw === 'on' || raw === 'yes')
424
+ return true;
425
+ const term = (env.TERM ?? '').toLowerCase();
426
+ if (term === '' || term === 'dumb' || term === 'linux' || term === 'vt100' || term === 'vt220')
427
+ return false;
428
+ return true;
429
+ }
430
+ /** OSC 52 clipboard write. Empty payload clears. ST is ESC \\ so a following CSI cannot be eaten as OSC payload. */
431
+ export function osc52Clipboard(text) {
432
+ const payload = Buffer.from(text, 'utf8').toString('base64');
433
+ return `\x1b]52;c;${payload}\x1b\\`;
434
+ }
435
+ export function stripAnsi(text) {
436
+ let out = '';
437
+ let index = 0;
438
+ while (index < text.length) {
439
+ if (text.charCodeAt(index) === 0x1b) {
440
+ index = skipAnsiSequence(text, index);
441
+ continue;
442
+ }
443
+ const cp = text.codePointAt(index);
444
+ if (cp === undefined)
445
+ break;
446
+ const char = String.fromCodePoint(cp);
447
+ out += char;
448
+ index += char.length;
449
+ }
450
+ return out;
451
+ }
452
+ /**
453
+ * Locate OSC 8 hyperlinks in a painted ANSI line. Columns are 0-based
454
+ * display cells of the visible glyphs (not counting the sequences).
455
+ */
456
+ export function paintedLinkHits(line) {
457
+ const hits = [];
458
+ let index = 0;
459
+ let col = 0;
460
+ let openHref;
461
+ let openCol = 0;
462
+ const close = (endCol) => {
463
+ if (openHref === undefined)
464
+ return;
465
+ if (endCol > openCol)
466
+ hits.push({ href: openHref, startCol: openCol, endCol });
467
+ openHref = undefined;
468
+ };
469
+ while (index < line.length) {
470
+ if (line.charCodeAt(index) !== 0x1b) {
471
+ const cp = line.codePointAt(index);
472
+ if (cp === undefined)
473
+ break;
474
+ const char = String.fromCodePoint(cp);
475
+ col += displayWidth(char);
476
+ index += char.length;
477
+ continue;
478
+ }
479
+ const seqEnd = skipAnsiSequence(line, index);
480
+ const seq = line.slice(index, seqEnd);
481
+ const osc = parseOsc8(seq);
482
+ if (osc !== undefined) {
483
+ if (osc === '')
484
+ close(col);
485
+ else {
486
+ close(col);
487
+ openHref = osc;
488
+ openCol = col;
489
+ }
490
+ }
491
+ index = seqEnd;
492
+ }
493
+ close(col);
494
+ return hits;
495
+ }
496
+ function parseOsc8(seq) {
497
+ if (!seq.startsWith('\x1b]8;'))
498
+ return undefined;
499
+ const body = seq.slice(4).replace(/\x07$/u, '').replace(/\x1b\\$/u, '');
500
+ const second = body.indexOf(';');
501
+ if (second === -1)
502
+ return '';
503
+ return body.slice(second + 1);
504
+ }
505
+ export function hrefAtColumn(hits, col) {
506
+ for (const hit of hits) {
507
+ if (col >= hit.startCol && col < hit.endCol)
508
+ return hit.href;
509
+ }
510
+ return undefined;
511
+ }
512
+ const INLINE_MARKDOWN_PATTERN = /(\*\*[^*\n]+\*\*)|(`[^`\n]+`)|(\[[^\]\n]+\]\([^)\n]+\))|(https?:\/\/[^\s<>\[\]()'"`]+)|(\*[^*\n]+\*)|(_[^_\n]+_)/giu;
513
+ /** Parse one line's bold / italic / inline-code / link spans. */
514
+ function parseInlineMarkdown(line) {
515
+ const segments = [];
516
+ let last = 0;
517
+ for (const match of line.matchAll(INLINE_MARKDOWN_PATTERN)) {
518
+ const index = match.index;
519
+ if (index > last)
520
+ segments.push({ kind: 'text', text: line.slice(last, index) });
521
+ const token = match[0];
522
+ if (match[1] !== undefined) {
523
+ segments.push({ kind: 'bold', text: token.slice(2, -2) });
524
+ }
525
+ else if (match[2] !== undefined) {
526
+ segments.push({ kind: 'code', text: token.slice(1, -1) });
527
+ }
528
+ else if (match[3] !== undefined) {
529
+ const labelEnd = token.indexOf('](');
530
+ const label = token.slice(1, labelEnd);
531
+ const url = token.slice(labelEnd + 2, -1);
532
+ segments.push({ kind: 'link', text: label === '' ? url : label, href: url });
533
+ }
534
+ else if (match[4] !== undefined) {
535
+ const href = token.replace(/[),.;:!?]+$/u, '');
536
+ segments.push({ kind: 'link', text: href, href });
537
+ }
538
+ else if (match[5] !== undefined) {
539
+ segments.push({ kind: 'italic', text: token.slice(1, -1) });
540
+ }
541
+ else if (match[6] !== undefined) {
542
+ segments.push({ kind: 'italic', text: token.slice(1, -1) });
543
+ }
544
+ last = index + token.length;
545
+ }
546
+ if (last < line.length)
547
+ segments.push({ kind: 'text', text: line.slice(last) });
548
+ if (segments.length === 0)
549
+ segments.push({ kind: 'text', text: line });
550
+ return segments;
551
+ }
552
+ function markdownSegmentWidth(segments) {
553
+ return segments.reduce((total, segment) => total + displayWidth(segment.text), 0);
554
+ }
555
+ /** Wrap styled inline segments into visual rows, carrying a prefix only on row one. */
556
+ function wrapMarkdownSegments(segments, width, prefixSegments = []) {
557
+ const limit = Math.max(1, width);
558
+ const lines = [];
559
+ let current = [...prefixSegments];
560
+ let used = markdownSegmentWidth(current);
561
+ for (const segment of segments) {
562
+ let rest = segment.text;
563
+ while (rest !== '') {
564
+ const available = limit - used;
565
+ if (available <= 0) {
566
+ lines.push(current);
567
+ current = [];
568
+ used = 0;
569
+ continue;
570
+ }
571
+ const slice = forwardSliceByWidth(rest, available);
572
+ let chunk = slice.text;
573
+ if (chunk === '') {
574
+ // A wide character does not fit the remaining cell: wrap to the next
575
+ // row instead of overflowing that cell into the input area.
576
+ if (used > 0) {
577
+ lines.push(current);
578
+ current = [];
579
+ used = 0;
580
+ continue;
581
+ }
582
+ chunk = Array.from(rest)[0] ?? rest.slice(0, 1);
583
+ }
584
+ current.push({
585
+ kind: segment.kind,
586
+ text: chunk,
587
+ ...(segment.href === undefined ? {} : { href: segment.href }),
588
+ });
589
+ used += displayWidth(chunk);
590
+ rest = rest.slice(chunk.length);
591
+ if (rest !== '') {
592
+ lines.push(current);
593
+ current = [];
594
+ used = 0;
595
+ }
596
+ }
597
+ }
598
+ if (current.length > 0 || lines.length === 0)
599
+ lines.push(current);
600
+ return lines.map(line => line.length === 0 ? [{ kind: 'text', text: '' }] : line);
601
+ }
602
+ function markdownSegmentCode(kind) {
603
+ switch (kind) {
604
+ // Bright + bold so **span** still pops when the font has no heavy CJK weight.
605
+ case 'bold': return '1;97';
606
+ case 'italic': return '3;37';
607
+ case 'code': return '36';
608
+ case 'link': return '4;36';
609
+ case 'muted': return '2;37';
610
+ default: return '';
611
+ }
612
+ }
613
+ function markdownBaseCode(kind) {
614
+ switch (kind) {
615
+ case 'heading1': return '1;4;97';
616
+ case 'heading2': return '1;4;36';
617
+ case 'heading3': return '1;36';
618
+ case 'code': return '36';
619
+ case 'quote': return '3;37';
620
+ case 'rule': return '90';
621
+ // Body is normal white so inline bold/italic/code are not painted on
622
+ // already-bold text (CJK fonts often have only one weight).
623
+ default: return '37';
624
+ }
625
+ }
626
+ function wrapHyperlink(label, href, hyperlinks) {
627
+ if (!hyperlinks || href === undefined || href.trim() === '')
628
+ return label;
629
+ return `${osc8Open(href)}${label}${osc8Close()}`;
630
+ }
631
+ /** Render one pre-wrapped markdown line as ANSI (or plain text without color). */
632
+ function renderMarkdownBlockLine(block, color, hyperlinks) {
633
+ const segments = block.segments.map(segment => ({
634
+ ...segment,
635
+ text: sanitizeTerminalText(segment.text),
636
+ href: segment.href === undefined ? undefined : sanitizeTerminalText(segment.href),
637
+ }));
638
+ if (!color) {
639
+ return segments.map(segment => wrapHyperlink(segment.text, segment.href, hyperlinks)).join('');
640
+ }
641
+ const base = markdownBaseCode(block.base);
642
+ let out = `\x1b[${base}m`;
643
+ for (const segment of segments) {
644
+ const code = markdownSegmentCode(segment.kind);
645
+ const body = code === ''
646
+ ? segment.text
647
+ // SGR 0 first: restoring only the base codes does not clear italic,
648
+ // underline, or bold, so those attributes would leak into later spans.
649
+ : `\x1b[${code}m${segment.text}\x1b[0m\x1b[${base}m`;
650
+ out += wrapHyperlink(body, segment.href, hyperlinks);
651
+ }
652
+ return `${out}\x1b[0m`;
653
+ }
654
+ /** Enlarge H1 text visually: fullwidth ASCII and spaced CJK glyphs. */
655
+ function expandHeadingText(text) {
656
+ let out = '';
657
+ for (const char of text) {
658
+ const cp = char.codePointAt(0) ?? 0;
659
+ if (cp >= 0x21 && cp <= 0x7e) {
660
+ out += String.fromCodePoint(0xff01 + cp - 0x21);
661
+ }
662
+ else if (char.trim() === '') {
663
+ out += ' ';
664
+ }
665
+ else {
666
+ out += `${char} `;
667
+ }
668
+ }
669
+ return out;
670
+ }
671
+ function headingSegments(text, level) {
672
+ const segments = parseInlineMarkdown(text);
673
+ if (level !== 1)
674
+ return segments;
675
+ return segments.map(segment => segment.kind === 'code' || segment.kind === 'link' || segment.kind === 'muted'
676
+ ? segment
677
+ : { kind: segment.kind, text: expandHeadingText(segment.text) });
678
+ }
679
+ /**
680
+ * Render workspace markdown into width-bounded terminal rows. Assistant
681
+ * replies use a normal-white base so inline bold can contrast; code blocks,
682
+ * headings, quotes, lists, rules, links and inline spans keep their own ANSI.
683
+ */
684
+ export function renderMarkdownLines(text, width, color, hyperlinks = osc8Enabled()) {
685
+ const lines = [];
686
+ let inFence = false;
687
+ for (const sourceLine of text.split('\n')) {
688
+ const raw = sanitizeTerminalText(sourceLine);
689
+ const fence = /^```([^\n]*)$/u.exec(raw.trim());
690
+ if (fence !== null) {
691
+ inFence = !inFence;
692
+ lines.push(renderMarkdownBlockLine({
693
+ base: 'code',
694
+ segments: [{ kind: 'text', text: `\`\`\`${fence[1] ?? ''}` }],
695
+ }, color, hyperlinks));
696
+ continue;
697
+ }
698
+ if (inFence) {
699
+ if (raw === '') {
700
+ lines.push('');
701
+ continue;
702
+ }
703
+ for (const line of wrap(raw, width)) {
704
+ lines.push(renderMarkdownBlockLine({
705
+ base: 'code',
706
+ segments: [{ kind: 'text', text: line }],
707
+ }, color, hyperlinks));
708
+ }
709
+ continue;
710
+ }
711
+ const heading = /^(#{1,6})\s+(.*)$/u.exec(raw);
712
+ if (heading !== null) {
713
+ // The hashes are markdown syntax, not content: replace them with
714
+ // heading style. Levels differ visually: H1 is enlarged and
715
+ // underlined, H2 underlined, H3 colored, H4+ body white.
716
+ const level = Math.min(6, (heading[1] ?? '#').length);
717
+ const base = level === 1
718
+ ? 'heading1'
719
+ : level === 2
720
+ ? 'heading2'
721
+ : level === 3
722
+ ? 'heading3'
723
+ : 'assistant';
724
+ if (level === 1 && lines.at(-1) !== '')
725
+ lines.push('');
726
+ for (const segments of wrapMarkdownSegments(headingSegments(heading[2] ?? '', level), width)) {
727
+ lines.push(renderMarkdownBlockLine({ base, segments }, color, hyperlinks));
728
+ }
729
+ if (level === 1)
730
+ lines.push('');
731
+ continue;
732
+ }
733
+ if (/^\s*(?:-{3,}|\*{3,}|_{3,})\s*$/u.test(raw) && raw.trim() !== '') {
734
+ lines.push(renderMarkdownBlockLine({
735
+ base: 'rule',
736
+ segments: [{ kind: 'text', text: repeatToWidth('─', Math.max(1, width)) }],
737
+ }, color, hyperlinks));
738
+ continue;
739
+ }
740
+ const quote = /^(\s*)>\s?(.*)$/u.exec(raw);
741
+ if (quote !== null) {
742
+ const indent = quote[1] ?? '';
743
+ const prefix = `${indent}│ `;
744
+ for (const segments of wrapMarkdownSegments(parseInlineMarkdown(quote[2] ?? ''), width, [{ kind: 'text', text: prefix }])) {
745
+ lines.push(renderMarkdownBlockLine({ base: 'quote', segments }, color, hyperlinks));
746
+ }
747
+ continue;
748
+ }
749
+ const list = /^(\s*)([-*+]|\d+[.)])\s+(.*)$/u.exec(raw);
750
+ if (list !== null) {
751
+ const indent = list[1] ?? '';
752
+ const marker = list[2] ?? '-';
753
+ const prefix = `${indent}${marker} `;
754
+ for (const segments of wrapMarkdownSegments(parseInlineMarkdown(list[3] ?? ''), width, [{ kind: 'text', text: prefix }])) {
755
+ lines.push(renderMarkdownBlockLine({ base: 'assistant', segments }, color, hyperlinks));
756
+ }
757
+ continue;
758
+ }
759
+ if (raw === '') {
760
+ lines.push('');
761
+ continue;
762
+ }
763
+ for (const segments of wrapMarkdownSegments(parseInlineMarkdown(raw), width)) {
764
+ lines.push(renderMarkdownBlockLine({ base: 'assistant', segments }, color, hyperlinks));
765
+ }
766
+ }
767
+ return lines;
768
+ }
769
+ /** Cut one line to fit a width, appending an ellipsis when truncated. */
770
+ export function truncateToWidth(text, width) {
771
+ const safe = sanitizeTerminalText(text);
772
+ if (width <= 0)
773
+ return '';
774
+ if (displayWidth(safe) <= width)
775
+ return safe;
776
+ if (width === 1)
777
+ return '…';
778
+ const limit = width - 1;
779
+ let cut = 0;
780
+ let used = 0;
781
+ for (const char of safe) {
782
+ const charWidth = displayWidth(char);
783
+ if (used + charWidth > limit)
784
+ break;
785
+ used += charWidth;
786
+ cut += char.length;
787
+ }
788
+ if (cut === 0)
789
+ cut = firstCodePointLength(safe);
790
+ return `${safe.slice(0, cut)}…`;
791
+ }
792
+ /**
793
+ * Clip an already-styled ANSI line to `width` terminal cells without dropping
794
+ * the reset/SGR sequences. Used by the incremental painter so a leftover wide
795
+ * glyph cannot wrap into the next row.
796
+ */
797
+ export function clipAnsiToWidth(text, width) {
798
+ if (width <= 0)
799
+ return '';
800
+ let used = 0;
801
+ let out = '';
802
+ let index = 0;
803
+ while (index < text.length) {
804
+ if (text.charCodeAt(index) === 0x1b) {
805
+ const seqEnd = skipAnsiSequence(text, index);
806
+ out += text.slice(index, seqEnd);
807
+ index = seqEnd;
808
+ continue;
809
+ }
810
+ const cp = text.codePointAt(index);
811
+ if (cp === undefined)
812
+ break;
813
+ const char = String.fromCodePoint(cp);
814
+ const charWidth = displayWidth(char);
815
+ if (used + charWidth > width)
816
+ break;
817
+ out += char;
818
+ used += charWidth;
819
+ index += char.length;
820
+ }
821
+ return out;
822
+ }
823
+ /** Slice up to `maxWidth` display columns from the beginning of `text`. */
824
+ function forwardSliceByWidth(text, maxWidth) {
825
+ let cut = 0;
826
+ let used = 0;
827
+ for (const char of text) {
828
+ const charWidth = displayWidth(char);
829
+ if (used + charWidth > maxWidth)
830
+ break;
831
+ used += charWidth;
832
+ cut += char.length;
833
+ }
834
+ return { text: text.slice(0, cut), width: used };
835
+ }
836
+ /** Slice up to `maxWidth` display columns ending at `end` in `text`. */
837
+ function backwardSliceByWidth(text, end, maxWidth) {
838
+ if (end <= 0 || maxWidth <= 0)
839
+ return { start: end, width: 0 };
840
+ const chars = Array.from(text.slice(0, end));
841
+ let used = 0;
842
+ let firstIncluded = chars.length;
843
+ for (let index = chars.length - 1; index >= 0; index--) {
844
+ const charWidth = displayWidth(chars[index] ?? '');
845
+ if (used + charWidth > maxWidth)
846
+ break;
847
+ used += charWidth;
848
+ firstIncluded = index;
849
+ }
850
+ return {
851
+ start: chars.slice(0, firstIncluded).join('').length,
852
+ width: used,
853
+ };
854
+ }
855
+ /**
856
+ * Fold a long input into one terminal row around the cursor.
857
+ *
858
+ * Newlines from a paste are display-only: they do not occupy cells, so a
859
+ * naive `displayWidth(input)` under-counts a multi-line paste and parks the
860
+ * caret in the middle of later text. Fold the *current line* (between the
861
+ * surrounding newlines) and keep `\n` out of the visible slice.
862
+ */
863
+ export function foldInputView(input, cursor, maxWidth) {
864
+ const width = Math.max(1, maxWidth);
865
+ const safeCursor = Math.max(0, Math.min(cursor, input.length));
866
+ const lineStart = input.lastIndexOf('\n', Math.max(0, safeCursor - 1)) + 1;
867
+ const lineEndRaw = input.indexOf('\n', safeCursor);
868
+ const lineEnd = lineEndRaw === -1 ? input.length : lineEndRaw;
869
+ const line = input.slice(lineStart, lineEnd);
870
+ const lineCursor = safeCursor - lineStart;
871
+ const totalWidth = displayWidth(line);
872
+ const cursorOffset = displayWidth(line.slice(0, lineCursor));
873
+ const hasMoreLines = lineStart > 0 || lineEnd < input.length;
874
+ if (totalWidth <= width && !hasMoreLines) {
875
+ return { text: line, cursorOffset, folded: false };
876
+ }
877
+ if (totalWidth <= width) {
878
+ // One-row fold: keep a blank cell for the caret when the line fills
879
+ // the row, otherwise CSI lands on the last glyph.
880
+ if (cursorOffset >= width && width > 1) {
881
+ let budget = width - 1;
882
+ const probe = backwardSliceByWidth(line, lineCursor, budget);
883
+ const left = probe.start > 0;
884
+ if (left)
885
+ budget = Math.max(1, width - 2);
886
+ const clipped = backwardSliceByWidth(line, lineCursor, budget);
887
+ const beforeText = line.slice(clipped.start, lineCursor);
888
+ return {
889
+ text: `${left ? '…' : ''}${beforeText}`,
890
+ cursorOffset: (left ? 1 : 0) + displayWidth(beforeText),
891
+ folded: true,
892
+ };
893
+ }
894
+ return { text: line, cursorOffset, folded: true };
895
+ }
896
+ const before = cursorOffset;
897
+ const after = totalWidth - cursorOffset;
898
+ const leftFolded = before > 0;
899
+ const rightFolded = after > 0;
900
+ const markers = (leftFolded ? 1 : 0) + (rightFolded ? 1 : 0);
901
+ // Leave one cell for the caret so it never sits on the last glyph
902
+ // (DEC auto-margin would otherwise punch the caret through that cell).
903
+ const available = Math.max(1, width - markers - 1);
904
+ let beforeBudget = Math.min(before, Math.ceil(available / 2));
905
+ let afterBudget = Math.min(after, available - beforeBudget);
906
+ // If the tail is shorter than its budget, spend the spare columns on the
907
+ // side before the cursor so the cursor stays visible near its true offset.
908
+ beforeBudget = Math.min(before, beforeBudget + (available - beforeBudget - afterBudget));
909
+ const beforeSlice = backwardSliceByWidth(line, lineCursor, beforeBudget);
910
+ const afterSlice = forwardSliceByWidth(line.slice(lineCursor), afterBudget);
911
+ const beforeText = line.slice(beforeSlice.start, lineCursor);
912
+ return {
913
+ text: `${leftFolded ? '…' : ''}${beforeText}${afterSlice.text}${rightFolded ? '…' : ''}`,
914
+ cursorOffset: (leftFolded ? 1 : 0) + displayWidth(beforeText),
915
+ folded: true,
916
+ };
917
+ }
918
+ /**
919
+ * Map a character index in the input text to its visual (row, col) after the
920
+ * same width wrapping `wrap()` applies to the rendered input. `row` is the
921
+ * 0-based input display line, `col` the 0-based column within that line
922
+ * (before any prompt prefix). This keeps the cursor on the correct line/column
923
+ * when the input contains literal newlines from multi-line pastes.
924
+ */
925
+ export function cursorVisualPosition(text, cursor, width) {
926
+ const limit = Math.max(1, width);
927
+ const safeCursor = Math.max(0, Math.min(cursor, text.length));
928
+ const lines = wrap(text.slice(0, safeCursor), limit);
929
+ const last = lines.at(-1) ?? '';
930
+ let row = Math.max(0, lines.length - 1);
931
+ let col = displayWidth(last);
932
+ // wrap() keeps a full-width last line on this row. The caret *after*
933
+ // that last glyph belongs at col 0 of the next row — sitting at
934
+ // `limit` would overlay the glyph (DEC auto-margin punch-through).
935
+ if (col >= limit) {
936
+ row += 1;
937
+ col = 0;
938
+ }
939
+ return { row, col };
940
+ }
941
+ /** Take the first `max` code points of a string without splitting surrogates. */
942
+ export function sliceCodePoints(text, max) {
943
+ if (max <= 0)
944
+ return '';
945
+ return Array.from(text).slice(0, max).join('');
946
+ }
947
+ /** Take the last `max` code points of a string without splitting surrogates. */
948
+ export function lastCodePoints(text, max) {
949
+ if (max <= 0)
950
+ return '';
951
+ return Array.from(text).slice(-max).join('');
952
+ }
953
+ //# sourceMappingURL=term-text.js.map