dsh-ssh-tui 0.7.4 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/README.en.md +37 -0
  2. package/README.md +436 -572
  3. package/docs/display-mode.md +122 -0
  4. package/docs/remote-ops.md +104 -0
  5. package/docs/terminals.md +53 -0
  6. package/lib/attach.js +4 -4
  7. package/lib/attach.js.map +1 -1
  8. package/lib/auth-failure.js +128 -0
  9. package/lib/auth-failure.js.map +1 -0
  10. package/lib/commands.js +3 -0
  11. package/lib/commands.js.map +1 -1
  12. package/lib/dialogs.js +43 -0
  13. package/lib/dialogs.js.map +1 -1
  14. package/lib/display-mode.js +147 -0
  15. package/lib/display-mode.js.map +1 -0
  16. package/lib/display-sock.js +361 -21
  17. package/lib/display-sock.js.map +1 -1
  18. package/lib/footer.js +6 -9
  19. package/lib/footer.js.map +1 -1
  20. package/lib/glyph-measure.js +92 -0
  21. package/lib/glyph-measure.js.map +1 -0
  22. package/lib/i18n/en.js +27 -2
  23. package/lib/i18n/en.js.map +1 -1
  24. package/lib/i18n/zh.js +27 -2
  25. package/lib/i18n/zh.js.map +1 -1
  26. package/lib/index.js +74 -5
  27. package/lib/index.js.map +1 -1
  28. package/lib/paint.js +22 -9
  29. package/lib/paint.js.map +1 -1
  30. package/lib/picker.js +14 -13
  31. package/lib/picker.js.map +1 -1
  32. package/lib/plan.js +11 -11
  33. package/lib/plan.js.map +1 -1
  34. package/lib/platform.js +96 -0
  35. package/lib/platform.js.map +1 -1
  36. package/lib/session-blank.js +81 -0
  37. package/lib/session-blank.js.map +1 -0
  38. package/lib/session-list.js +102 -81
  39. package/lib/session-list.js.map +1 -1
  40. package/lib/startup.js +7 -0
  41. package/lib/startup.js.map +1 -1
  42. package/lib/subagent-model.js +8 -7
  43. package/lib/subagent-model.js.map +1 -1
  44. package/lib/term-text.js +296 -28
  45. package/lib/term-text.js.map +1 -1
  46. package/lib/terminal-input.js +132 -10
  47. package/lib/terminal-input.js.map +1 -1
  48. package/lib/theme.js +318 -0
  49. package/lib/theme.js.map +1 -0
  50. package/lib/tool-present.js +11 -9
  51. package/lib/tool-present.js.map +1 -1
  52. package/lib/tui.js +535 -37
  53. package/lib/tui.js.map +1 -1
  54. package/lib/types/attach.d.ts +6 -2
  55. package/lib/types/auth-failure.d.ts +78 -0
  56. package/lib/types/commands.d.ts +9 -0
  57. package/lib/types/dialogs.d.ts +36 -0
  58. package/lib/types/display-mode.d.ts +99 -0
  59. package/lib/types/display-sock.d.ts +75 -0
  60. package/lib/types/footer.d.ts +1 -1
  61. package/lib/types/glyph-measure.d.ts +41 -0
  62. package/lib/types/index.d.ts +20 -0
  63. package/lib/types/plan.d.ts +5 -2
  64. package/lib/types/platform.d.ts +83 -0
  65. package/lib/types/session-blank.d.ts +51 -0
  66. package/lib/types/session-list.d.ts +34 -0
  67. package/lib/types/startup.d.ts +6 -0
  68. package/lib/types/subagent-model.d.ts +7 -6
  69. package/lib/types/term-text.d.ts +55 -23
  70. package/lib/types/terminal-input.d.ts +37 -0
  71. package/lib/types/theme.d.ts +109 -0
  72. package/lib/types/tool-present.d.ts +2 -2
  73. package/lib/types/tui.d.ts +121 -1
  74. package/package.json +92 -93
package/lib/term-text.js CHANGED
@@ -7,6 +7,7 @@
7
7
  import { t } from './i18n/index.js';
8
8
  import { downgradeSgr } from './color-depth.js';
9
9
  import { asciiFallbackEnabled } from './platform.js';
10
+ import { activeTheme, themeExtraToken } from './theme.js';
10
11
  /**
11
12
  * Codex-style compact elapsed: `0s`, `1m 05s`, `1h 01m 01s`.
12
13
  * Used by the workspace wait card while the model has not streamed yet.
@@ -296,9 +297,246 @@ export function mapAsciiChrome(text) {
296
297
  * Overflow into the input box is handled by clipping/padding painted rows to
297
298
  * the measured column count, not by inflating glyph width.
298
299
  */
300
+ /**
301
+ * East-Asian Ambiguous families that a CJK-configured terminal draws two cells
302
+ * wide, and that this TUI therefore budgets two cells for.
303
+ *
304
+ * The reported bug was `①`-`⑩`: the model writes them, the terminal draws them
305
+ * from a CJK font in the space of two cells, and every row holding one came out
306
+ * a cell short — the same class of failure as the emoji glyphs above, with one
307
+ * important difference. Emoji can be *pinned* ({@link pinEmojiCells} asks for the
308
+ * text presentation and reserves the second cell) because they have variation
309
+ * selectors; these characters have none, so the only lever is to budget what the
310
+ * terminal will actually spend.
311
+ *
312
+ * What is deliberately **not** in this table matters as much. Box drawing (`─`),
313
+ * geometric ornaments (`●`, `▸`) and the chrome glyphs `❯` / `✓` are ambiguous
314
+ * too, but they were measured against a real terminal in the earlier emoji pass
315
+ * and they come from the monospace font at one cell; counting them as two is
316
+ * what once painted half-width rules and parked the cursor past the text. They
317
+ * stay one cell until a measurement says otherwise.
318
+ */
319
+ const AMBIGUOUS_WIDE_RANGES = [
320
+ [0x2460, 0x24ff], // Enclosed Alphanumerics: ① ⑩ ⑳ ⑴ ⒈ ⓐ ⓿
321
+ [0x2776, 0x2793], // Dingbat circled digits: ❶ ➀ ➊ ➓
322
+ [0x2160, 0x217f], // Roman numerals: Ⅰ Ⅱ ⅰ ⅻ
323
+ [0x1f100, 0x1f1ff], // Enclosed Alphanumeric Supplement, incl. regional indicators
324
+ [0x1f200, 0x1f2ff], // Enclosed Ideographic Supplement
325
+ // Punctuation that a CJK font sets **full width**, and that a Chinese
326
+ // transcript is full of: an em dash, an ellipsis and curly quotes are 全角 in
327
+ // GB2312/GBK. A real session carries `—` tens of thousands of times, so
328
+ // budgeting one cell for it is not a corner case — it is every line.
329
+ [0x00b7, 0x00b7], // · middle dot
330
+ [0x2013, 0x2014], // – — dashes
331
+ [0x2018, 0x201d], // ‘ ’ “ ” quotes
332
+ [0x2022, 0x2022], // • bullet
333
+ [0x2025, 0x2026], // ‥ … ellipsis
334
+ [0x2039, 0x203a], // ‹ › guillemets
335
+ ];
336
+ /** Cached decision for {@link ambiguousWidthIsTwo}, keyed on the inputs. */
337
+ let ambiguousCache;
338
+ /**
339
+ * What a measurement of the real terminal said, when there was one.
340
+ *
341
+ * The locale is only a guess about which font the terminal is configured with,
342
+ * and a guess is what produced three rounds of "① is misaligned" reports: the
343
+ * terminal either advances two cells for it or it does not, and that is
344
+ * measurable (`CSI 6n` after the glyph). The launcher measures once at boot, in
345
+ * the process that owns the terminal, and sets this. `undefined` means nobody
346
+ * measured — a pipe, a dumb terminal, or a launch that never got that far — and
347
+ * the locale decides as before.
348
+ */
349
+ let ambiguousMeasured;
350
+ /**
351
+ * Whether the second cell must be *reserved* with a space.
352
+ *
353
+ * An ambiguous glyph can be drawn wider than the single cell it advances — the
354
+ * same failure the emoji symbols have, and the one behind "`①` collides with the
355
+ * character after it". Emoji can be pinned by asking for the text presentation
356
+ * with VS15; `①` has no variation sequence, so the only half of that trick
357
+ * available is the reserving space: print the glyph, then a space, and the next
358
+ * character starts on a clean cell while the layout still spends two.
359
+ *
360
+ * Set from the measurement, because the two cases need opposite treatment:
361
+ * a terminal that advances two cells spends them itself (`wide`), while one that
362
+ * advances one cell needs the space (`reserve`).
363
+ */
364
+ let ambiguousReserve;
365
+ /**
366
+ * The resolved policy, cached.
367
+ *
368
+ * This used to be worked out *inside* `displayWidth`'s per-character loop, which
369
+ * read `process.env` and ran regexes for every character of every painted line —
370
+ * a profile of a 2000-row frame put ~75% of the samples there, and it is why
371
+ * rendering in a long session felt slow. The policy cannot change during a frame,
372
+ * so it is resolved once and read as plain booleans afterwards; the setters below
373
+ * and `ambiguousPolicy()`'s own cheap key check are what invalidate it.
374
+ */
375
+ let ambiguousPolicyCache;
376
+ /** The three inputs the policy depends on, as one cheap-to-compare string. */
377
+ function ambiguousPolicyKey(env, onTerminal) {
378
+ return `${env.DSH_TUI_AMBIGUOUS_WIDTH ?? ''}|${env.DSH_TUI_AMBIGUOUS_RESERVE ?? ''}|`
379
+ + `${env.LC_ALL ?? ''}|${env.LC_CTYPE ?? ''}|${env.LANG ?? ''}|`
380
+ + `${onTerminal ? 'tty' : 'pipe'}|${String(ambiguousMeasured)}|${String(ambiguousReserve)}`;
381
+ }
382
+ /**
383
+ * Resolve the ambiguous-glyph policy for this process.
384
+ *
385
+ * Cheap enough to call once per line (it compares one string) and far too
386
+ * expensive to call per character, which is the distinction that matters.
387
+ * @param env - the environment to read.
388
+ * @param onTerminal - whether the output is a terminal at all.
389
+ * @returns whether the glyphs are two cells wide, and whether the second cell is
390
+ * ours to reserve.
391
+ */
392
+ export function ambiguousPolicy(env = process.env, onTerminal = process.stdout?.isTTY === true) {
393
+ const key = ambiguousPolicyKey(env, onTerminal);
394
+ const cached = ambiguousPolicyCache;
395
+ if (cached !== undefined && cached.key === key)
396
+ return cached;
397
+ const override = String(env.DSH_TUI_AMBIGUOUS_WIDTH ?? '').trim();
398
+ const reserveOverride = String(env.DSH_TUI_AMBIGUOUS_RESERVE ?? '').trim();
399
+ const locale = `${env.LC_ALL ?? ''} ${env.LC_CTYPE ?? ''} ${env.LANG ?? ''}`.toLowerCase();
400
+ const localeIsCjk = /(?:^|[^a-z])(?:zh|ja|ko)[_@.-]/u.test(locale) || /(?:^|\s)(?:zh|ja|ko)(?:\s|$)/u.test(locale);
401
+ let wide;
402
+ if (override === '1')
403
+ wide = false;
404
+ else if (override === '2')
405
+ wide = true;
406
+ else if (ambiguousMeasured !== undefined)
407
+ wide = ambiguousMeasured;
408
+ else
409
+ wide = onTerminal && localeIsCjk;
410
+ // The reservation is only worth its space when the terminal advances one cell
411
+ // for a glyph it draws wider than a cell; a measurement is the evidence for it,
412
+ // and the environment is the manual override.
413
+ let reserve = ambiguousReserve ?? (reserveOverride === '1' || reserveOverride === 'true');
414
+ if (override === '1' || override === '2')
415
+ reserve = ambiguousReserve ?? false;
416
+ ambiguousPolicyCache = { key, wide, reserve };
417
+ return ambiguousPolicyCache;
418
+ }
419
+ /**
420
+ * The reserve setting from the environment, when a parent passed one.
421
+ *
422
+ * The Host cannot measure, so its launcher hands the verdict over in the
423
+ * environment alongside the advance: `DSH_TUI_AMBIGUOUS_WIDTH` says how many
424
+ * cells the terminal spends, `DSH_TUI_AMBIGUOUS_RESERVE` says whether the second
425
+ * one has to be spent by us. Reading it lazily keeps this module free of a
426
+ * startup order: the value is a property of the deployment, not of the call.
427
+ */
428
+ function reserveFromEnv() {
429
+ const raw = String(process.env.DSH_TUI_AMBIGUOUS_RESERVE ?? '').trim();
430
+ return raw === '1' || raw === 'true';
431
+ }
432
+ /**
433
+ * Record what the terminal's own answer said about these glyphs.
434
+ *
435
+ * Called by the launcher after measuring; also the escape hatch for a caller
436
+ * that knows better than the locale (a relay that has already measured for its
437
+ * own accounting, or a test).
438
+ * @param wide - true when the terminal advances two cells, false for one, or
439
+ * undefined to fall back to the locale.
440
+ */
441
+ export function setAmbiguousWidthMeasured(wide) {
442
+ ambiguousMeasured = wide;
443
+ ambiguousPolicyCache = undefined;
444
+ // A terminal that advances one cell for a glyph drawn wider than a cell is
445
+ // exactly the collision case; reserve the second cell so the next character is
446
+ // not painted on top of it.
447
+ ambiguousReserve = wide === false;
448
+ ambiguousCache = undefined;
449
+ }
450
+ /**
451
+ * Force the reserve behaviour, for a caller that measured the same terminal for
452
+ * its own accounting (a relay) or a test.
453
+ * @param reserve - whether to spend a space after each ambiguous glyph.
454
+ */
455
+ export function setAmbiguousWidthReserve(reserve) {
456
+ ambiguousReserve = reserve;
457
+ ambiguousPolicyCache = undefined;
458
+ ambiguousCache = undefined;
459
+ }
460
+ /** Whether the second cell is currently reserved with a space. */
461
+ export function ambiguousWidthReserved() {
462
+ return ambiguousPolicy().reserve;
463
+ }
464
+ /**
465
+ * Whether one code point is in a family the ambiguous policy governs.
466
+ *
467
+ * A scan over a handful of pairs, cheap enough for the per-character path — which
468
+ * is why the policy booleans are resolved once per line and passed in, rather
469
+ * than looked up per character.
470
+ * @param cp - the code point.
471
+ * @returns true when the ambiguous table applies to it.
472
+ */
473
+ function inAmbiguousRange(cp) {
474
+ for (const [start, end] of AMBIGUOUS_WIDE_RANGES) {
475
+ if (cp >= start && cp <= end)
476
+ return true;
477
+ }
478
+ return false;
479
+ }
480
+ /**
481
+ * Cells one character costs in this TUI's layout.
482
+ *
483
+ * The reserve case still costs two: the glyph advances one cell and the reserving
484
+ * space takes the next, so the layout must budget both or every row holding one
485
+ * comes up short. Non-ambiguous characters are unchanged.
486
+ * @param cp - the code point.
487
+ * @returns 0, 1 or 2 cells.
488
+ */
489
+ export function ambiguousCellCost(cp) {
490
+ if (!inAmbiguousRange(cp))
491
+ return 0;
492
+ const policy = ambiguousPolicy();
493
+ return policy.reserve || policy.wide ? 2 : 1;
494
+ }
495
+ /** What the last measurement decided, for diagnostics and tests. */
496
+ export function ambiguousWidthMeasured() {
497
+ return ambiguousMeasured;
498
+ }
499
+ /**
500
+ * Whether ambiguous glyphs in {@link AMBIGUOUS_WIDE_RANGES} are drawn two cells
501
+ * wide here.
502
+ *
503
+ * `DSH_TUI_AMBIGUOUS_WIDTH=1|2` answers outright. Otherwise the locale decides,
504
+ * and only when there really is a terminal: a zh/ja/ko locale means the terminal
505
+ * is very likely using a CJK font, where these glyphs are full width. The UI
506
+ * language is deliberately *not* consulted — a Chinese reader on a Western
507
+ * terminal has narrow glyphs, and typing in Chinese does not change the font
508
+ * metrics.
509
+ * @param env - the environment to read (tests pass their own).
510
+ * @param onTerminal - whether the output is a terminal at all; a pipe or a test
511
+ * harness has no font metrics, so there the narrow default applies.
512
+ * @returns true when those glyphs should be budgeted two cells.
513
+ */
514
+ export function ambiguousWidthIsTwo(env = process.env, onTerminal = process.stdout?.isTTY === true) {
515
+ return ambiguousPolicy(env, onTerminal).wide;
516
+ }
299
517
  export function displayWidth(text) {
300
518
  let width = 0;
301
- for (const char of mapAsciiChrome(text)) {
519
+ const measured = mapAsciiChrome(text);
520
+ // Resolved once per call rather than per character: this runs for every
521
+ // character of every painted line, and the old lookup read `process.env` and
522
+ // ran a regex each time (a profile of a 2000-row frame put ~75% of its samples
523
+ // there). Here it costs one string comparison for the whole line.
524
+ const policy = ambiguousPolicy();
525
+ for (let index = 0; index < measured.length;) {
526
+ const char = measured[index] ?? '';
527
+ if (char === '')
528
+ break;
529
+ // A reserved glyph plus our own space is ONE two-cell unit: the pin adds the
530
+ // space so the terminal spends the second cell, and counting it again here
531
+ // would make every padded row one cell short.
532
+ if (char !== ' ' && policy.reserve && inAmbiguousRange(char.codePointAt(0) ?? 0)) {
533
+ width += 2;
534
+ index += char.length;
535
+ if (measured[index] === ' ')
536
+ index += 1;
537
+ continue;
538
+ }
539
+ index += char.length;
302
540
  if (char === '\t') {
303
541
  // Tabs are expanded to spaces before rendering; keep the width
304
542
  // calculation consistent with `sanitizeTerminalText()`.
@@ -317,7 +555,10 @@ export function displayWidth(text) {
317
555
  if (cp <= 0x1f || (cp >= 0x7f && cp <= 0x9f)) {
318
556
  continue;
319
557
  }
320
- const wide = (cp >= 0x1100 && cp <= 0x115f) ||
558
+ // NOTE: `index` has already advanced past this character above; the branches
559
+ // below only decide how many cells it cost.
560
+ const wide = ((policy.wide || policy.reserve) && inAmbiguousRange(cp)) ||
561
+ (cp >= 0x1100 && cp <= 0x115f) ||
321
562
  cp === 0x2329 || cp === 0x232a ||
322
563
  (cp >= 0x2e80 && cp <= 0xa4cf) ||
323
564
  (cp >= 0xac00 && cp <= 0xd7a3) ||
@@ -362,6 +603,7 @@ export function pinEmojiCells(text) {
362
603
  const mapped = mapAsciiChrome(text);
363
604
  if (mapped !== text)
364
605
  return mapped;
606
+ const reserveWide = ambiguousPolicy().reserve;
365
607
  let out = '';
366
608
  let index = 0;
367
609
  while (index < text.length) {
@@ -371,6 +613,15 @@ export function pinEmojiCells(text) {
371
613
  const char = String.fromCodePoint(cp);
372
614
  index += char.length;
373
615
  out += char;
616
+ // An ambiguous glyph on a terminal that advances it one cell while drawing it
617
+ // wider: reserve the second cell so the next character is not painted on top
618
+ // of it. Skipped when the glyph is already followed by a space (the run may
619
+ // have been pinned once and be repainted), so pinning stays idempotent.
620
+ if (reserveWide && inAmbiguousRange(cp)) {
621
+ if (!/^[ ]/u.test(text.slice(index)))
622
+ out += ' ';
623
+ continue;
624
+ }
374
625
  if (!EMOJI_SYMBOLS.has(cp) || EMOJI_PRESENTATION_SYMBOLS.has(cp))
375
626
  continue;
376
627
  const selector = text.codePointAt(index);
@@ -410,7 +661,10 @@ export function padAnsiToWidth(text, width) {
410
661
  }
411
662
  /** Visible width of an ANSI-styled line, ignoring CSI / OSC sequences. */
412
663
  export function visibleWidth(text) {
413
- let used = 0;
664
+ // Strip the escapes, then ask the one width function: summing per character
665
+ // here would count a reserved glyph's space twice (see `displayWidth`), and
666
+ // two width notions in one renderer is how rows drift apart.
667
+ let plain = '';
414
668
  let index = 0;
415
669
  while (index < text.length) {
416
670
  if (text.charCodeAt(index) === 0x1b) {
@@ -421,10 +675,10 @@ export function visibleWidth(text) {
421
675
  if (cp === undefined)
422
676
  break;
423
677
  const char = String.fromCodePoint(cp);
424
- used += displayWidth(char);
678
+ plain += char;
425
679
  index += char.length;
426
680
  }
427
- return used;
681
+ return displayWidth(plain);
428
682
  }
429
683
  /** Advance past one ESC sequence starting at `index`. */
430
684
  export function skipAnsiSequence(text, index) {
@@ -848,27 +1102,30 @@ function wrapMarkdownSegments(segments, width, prefixSegments = []) {
848
1102
  return lines.map(line => line.length === 0 ? [{ kind: 'text', text: '' }] : line);
849
1103
  }
850
1104
  function markdownSegmentCode(kind) {
1105
+ const theme = activeTheme();
851
1106
  switch (kind) {
852
1107
  // Bright + bold so **span** still pops when the font has no heavy CJK weight.
853
- case 'bold': return '1;97';
854
- case 'italic': return '3;37';
855
- case 'code': return '36';
856
- case 'link': return '4;36';
857
- case 'muted': return '2;37';
1108
+ case 'bold': return themeExtraToken(theme, 'md-bold');
1109
+ case 'italic': return themeExtraToken(theme, 'md-italic');
1110
+ case 'code': return themeExtraToken(theme, 'md-code');
1111
+ case 'link': return themeExtraToken(theme, 'md-link');
1112
+ case 'muted': return themeExtraToken(theme, 'md-muted');
858
1113
  default: return '';
859
1114
  }
860
1115
  }
861
1116
  function markdownBaseCode(kind) {
1117
+ const theme = activeTheme();
862
1118
  switch (kind) {
863
- case 'heading1': return '1;4;97';
864
- case 'heading2': return '1;4;36';
865
- case 'heading3': return '1;36';
866
- case 'code': return '36';
867
- case 'quote': return '3;37';
868
- case 'rule': return '90';
869
- // Body is normal white so inline bold/italic/code are not painted on
870
- // already-bold text (CJK fonts often have only one weight).
871
- default: return '37';
1119
+ case 'heading1': return themeExtraToken(theme, 'md-h1');
1120
+ case 'heading2': return themeExtraToken(theme, 'md-h2');
1121
+ case 'heading3': return themeExtraToken(theme, 'md-h3');
1122
+ case 'code': return themeExtraToken(theme, 'md-code');
1123
+ case 'quote': return themeExtraToken(theme, 'md-quote');
1124
+ case 'rule': return themeExtraToken(theme, 'md-rule');
1125
+ // Body keeps the terminal's own foreground: freezing it to white was
1126
+ // unreadable on a light background, and bold/italic runs carry their own
1127
+ // emphasis (CJK fonts often have only one weight).
1128
+ default: return '';
872
1129
  }
873
1130
  }
874
1131
  function wrapHyperlink(label, href, hyperlinks) {
@@ -887,14 +1144,19 @@ function renderMarkdownBlockLine(block, color, hyperlinks) {
887
1144
  return segments.map(segment => wrapHyperlink(segment.text, segment.href, hyperlinks)).join('');
888
1145
  }
889
1146
  const base = markdownBaseCode(block.base);
890
- let out = `\x1b[${base}m`;
1147
+ // An empty base means "the terminal's own foreground", and opening with
1148
+ // `\x1b[m` would emit a reset the row does not need — on a slow link that is
1149
+ // one wasted escape per line, and it makes the transcript harder to read in a
1150
+ // log. So the base is only emitted when the palette actually asks for it.
1151
+ const open = base === '' ? '' : `\x1b[${base}m`;
1152
+ let out = open;
891
1153
  for (const segment of segments) {
892
1154
  const code = markdownSegmentCode(segment.kind);
893
1155
  const body = code === ''
894
1156
  ? segment.text
895
1157
  // SGR 0 first: restoring only the base codes does not clear italic,
896
1158
  // underline, or bold, so those attributes would leak into later spans.
897
- : `\x1b[${code}m${segment.text}\x1b[0m\x1b[${base}m`;
1159
+ : `\x1b[${code}m${segment.text}\x1b[0m${open}`;
898
1160
  out += wrapHyperlink(body, segment.href, hyperlinks);
899
1161
  }
900
1162
  return `${out}\x1b[0m`;
@@ -1150,6 +1412,8 @@ function backwardSliceByWidth(text, end, maxWidth) {
1150
1412
  * caret in the middle of later text. Fold the *current line* (between the
1151
1413
  * surrounding newlines) and keep `\n` out of the visible slice.
1152
1414
  */
1415
+ /** The character that marks a folded edge of the input row. */
1416
+ const FOLD_MARKER = '…';
1153
1417
  export function foldInputView(input, cursor, maxWidth) {
1154
1418
  const width = Math.max(1, maxWidth);
1155
1419
  const safeCursor = Math.max(0, Math.min(cursor, input.length));
@@ -1168,16 +1432,16 @@ export function foldInputView(input, cursor, maxWidth) {
1168
1432
  // One-row fold: keep a blank cell for the caret when the line fills
1169
1433
  // the row, otherwise CSI lands on the last glyph.
1170
1434
  if (cursorOffset >= width && width > 1) {
1171
- let budget = width - 1;
1435
+ let budget = Math.max(1, width - 1);
1172
1436
  const probe = backwardSliceByWidth(line, lineCursor, budget);
1173
1437
  const left = probe.start > 0;
1174
1438
  if (left)
1175
- budget = Math.max(1, width - 2);
1439
+ budget = Math.max(1, width - displayWidth(FOLD_MARKER) - 1);
1176
1440
  const clipped = backwardSliceByWidth(line, lineCursor, budget);
1177
1441
  const beforeText = line.slice(clipped.start, lineCursor);
1178
1442
  return {
1179
- text: `${left ? '…' : ''}${beforeText}`,
1180
- cursorOffset: (left ? 1 : 0) + displayWidth(beforeText),
1443
+ text: `${left ? FOLD_MARKER : ''}${beforeText}`,
1444
+ cursorOffset: (left ? displayWidth(FOLD_MARKER) : 0) + displayWidth(beforeText),
1181
1445
  folded: true,
1182
1446
  };
1183
1447
  }
@@ -1187,7 +1451,11 @@ export function foldInputView(input, cursor, maxWidth) {
1187
1451
  const after = totalWidth - cursorOffset;
1188
1452
  const leftFolded = before > 0;
1189
1453
  const rightFolded = after > 0;
1190
- const markers = (leftFolded ? 1 : 0) + (rightFolded ? 1 : 0);
1454
+ // The marker's own width, not a hardcoded cell: `…` is full width on a CJK
1455
+ // terminal, and counting it as one made the folded row overrun its budget and
1456
+ // put the caret a column off.
1457
+ const markerWidth = displayWidth(FOLD_MARKER);
1458
+ const markers = (leftFolded ? markerWidth : 0) + (rightFolded ? markerWidth : 0);
1191
1459
  // Leave one cell for the caret so it never sits on the last glyph
1192
1460
  // (DEC auto-margin would otherwise punch the caret through that cell).
1193
1461
  const available = Math.max(1, width - markers - 1);
@@ -1200,8 +1468,8 @@ export function foldInputView(input, cursor, maxWidth) {
1200
1468
  const afterSlice = forwardSliceByWidth(line.slice(lineCursor), afterBudget);
1201
1469
  const beforeText = line.slice(beforeSlice.start, lineCursor);
1202
1470
  return {
1203
- text: `${leftFolded ? '…' : ''}${beforeText}${afterSlice.text}${rightFolded ? '…' : ''}`,
1204
- cursorOffset: (leftFolded ? 1 : 0) + displayWidth(beforeText),
1471
+ text: `${leftFolded ? FOLD_MARKER : ''}${beforeText}${afterSlice.text}${rightFolded ? FOLD_MARKER : ''}`,
1472
+ cursorOffset: (leftFolded ? markerWidth : 0) + displayWidth(beforeText),
1205
1473
  folded: true,
1206
1474
  };
1207
1475
  }