wtagent 0.2.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -11,51 +11,25 @@ const DISABLE_BRACKETED_PASTE = "\u001b[?2004l";
11
11
  // ESC + CR some iTerm / VS Code / tmux setups
12
12
  // Terminals that still send a bare CR for Shift+Enter cannot be disambiguated;
13
13
  // Ctrl+J (bare LF in raw mode) is the reliable fallback and is also mapped.
14
- const SHIFT_ENTER_KITTY = "\u001b[13;2u";
15
- const SHIFT_ENTER_MODIFY_OTHER = "\u001b[27;2;13~";
16
14
  const SHIFT_ENTER_ESC_CR = "\u001b\r";
17
- const SHIFT_ENTER_SEQUENCES = Object.freeze([
18
- SHIFT_ENTER_KITTY,
19
- SHIFT_ENTER_MODIFY_OTHER,
20
- SHIFT_ENTER_ESC_CR,
21
- ]);
22
15
  const ENABLE_KITTY_KEYBOARD = "\u001b[>1u";
23
16
  const DISABLE_KITTY_KEYBOARD = "\u001b[<1u";
24
17
  const ENABLE_MODIFY_OTHER_KEYS = "\u001b[>4;2m";
25
18
  const DISABLE_MODIFY_OTHER_KEYS = "\u001b[>4;0m";
26
-
27
- const KEY_UP = "\u001b[A";
28
- const KEY_DOWN = "\u001b[B";
29
- const KEY_RIGHT = "\u001b[C";
30
- const KEY_LEFT = "\u001b[D";
31
- const KEY_HOME = "\u001b[H";
32
- const KEY_END = "\u001b[F";
33
- const KEY_HOME_TILDE = "\u001b[1~";
34
- const KEY_END_TILDE = "\u001b[4~";
35
- const KEY_HOME_APP = "\u001bOH";
36
- const KEY_END_APP = "\u001bOF";
37
- const KEY_DELETE = "\u001b[3~";
38
-
39
- // Every escape sequence the editor understands, used both for dispatch and to
40
- // hold a split-across-chunks prefix back instead of inserting it as text.
41
- const KNOWN_ESCAPE_SEQUENCES = Object.freeze([
42
- BRACKETED_PASTE_START,
43
- BRACKETED_PASTE_END,
44
- KEY_UP,
45
- KEY_DOWN,
46
- KEY_RIGHT,
47
- KEY_LEFT,
48
- KEY_HOME,
49
- KEY_END,
50
- KEY_HOME_TILDE,
51
- KEY_END_TILDE,
52
- KEY_HOME_APP,
53
- KEY_END_APP,
54
- KEY_DELETE,
55
- SHIFT_ENTER_KITTY,
56
- SHIFT_ENTER_MODIFY_OTHER,
57
- SHIFT_ENTER_ESC_CR,
58
- ]);
19
+ // DECAWM: if a redraw fills the last column, the terminal otherwise wraps onto a
20
+ // phantom next row (XENL). CJK is width 2 so it hits that edge constantly, and
21
+ // the next backspace then leaves the "deleted" glyph sitting on that extra row.
22
+ const DISABLE_AUTOWRAP = "\u001b[?7l";
23
+ const ENABLE_AUTOWRAP = "\u001b[?7h";
24
+
25
+ const SS3_ACTIONS = Object.freeze({
26
+ A: "up",
27
+ B: "down",
28
+ C: "right",
29
+ D: "left",
30
+ H: "home",
31
+ F: "end",
32
+ });
59
33
 
60
34
  const CLEAN_EXIT_ERRORS = new Set([
61
35
  "AbortPromptError",
@@ -69,22 +43,30 @@ const EXIT_COMMANDS = new Set([
69
43
  "/quit",
70
44
  ]);
71
45
 
72
- // Rough terminal cell width for one code point. Good enough for wrap and
73
- // cursor math on typical chat input: CJK/fullwidth/emoji count 2, combining
74
- // marks count 0, everything else 1.
75
- function charWidth(codePoint) {
46
+ // Rough terminal cell width for one code point. CJK/fullwidth/emoji count 2,
47
+ // combining marks count 0, everything else 1 — including East-Asian Ambiguous
48
+ // (quotes, dashes, ›, box drawing), which wcwidth and every default terminal
49
+ // config render as 1 cell.
50
+ //
51
+ // The › in the prompt makes this a hard rule, not a taste call: the 7-cell
52
+ // prompt is ODD and every CJK glyph is 2 cells, so the cursor column after any
53
+ // amount of CJK is always odd = the LEADING cell of a glyph. macOS Terminal.app
54
+ // does not draw the cursor on a glyph's trailing cell, so counting › as 2
55
+ // (prompt 8, even) makes the cursor land on trailing cells and visibly blink
56
+ // in and out of existence while typing Chinese.
57
+ export function charWidth(codePoint) {
76
58
  if (
77
59
  codePoint >= 0x1100 && (
78
60
  codePoint <= 0x115F
79
61
  || (codePoint >= 0x2E80 && codePoint <= 0xA4CF)
80
- || (codePoint >= 0xAC00 && codePoint <= 0xD7A3)
62
+ || (codePoint >= 0xA960 && codePoint <= 0xA97F)
63
+ || (codePoint >= 0xAC00 && codePoint <= 0xD7FB)
81
64
  || (codePoint >= 0xF900 && codePoint <= 0xFAFF)
82
65
  || (codePoint >= 0xFE10 && codePoint <= 0xFE19)
83
66
  || (codePoint >= 0xFE30 && codePoint <= 0xFE6F)
84
67
  || (codePoint >= 0xFF00 && codePoint <= 0xFF60)
85
68
  || (codePoint >= 0xFFE0 && codePoint <= 0xFFE6)
86
- || (codePoint >= 0x1F300 && codePoint <= 0x1F64F)
87
- || (codePoint >= 0x1F900 && codePoint <= 0x1F9FF)
69
+ || (codePoint >= 0x1F300 && codePoint <= 0x1FAFF)
88
70
  || (codePoint >= 0x20000 && codePoint <= 0x3FFFD)
89
71
  )
90
72
  ) {
@@ -103,7 +85,7 @@ function charWidth(codePoint) {
103
85
  return 1;
104
86
  }
105
87
 
106
- function displayWidth(text) {
88
+ export function displayWidth(text) {
107
89
  let width = 0;
108
90
  for (const ch of String(text ?? "")) {
109
91
  width += charWidth(ch.codePointAt(0));
@@ -111,6 +93,174 @@ function displayWidth(text) {
111
93
  return width;
112
94
  }
113
95
 
96
+ function splitGraphemes(text) {
97
+ const value = String(text ?? "");
98
+ if (typeof Intl !== "undefined" && Intl.Segmenter) {
99
+ return [...new Intl.Segmenter(undefined, { granularity: "grapheme" })
100
+ .segment(value)]
101
+ .map((part) => part.segment);
102
+ }
103
+ return [...value];
104
+ }
105
+
106
+ function graphemeWidth(grapheme) {
107
+ let width = 0;
108
+ for (const ch of String(grapheme ?? "")) {
109
+ width += charWidth(ch.codePointAt(0));
110
+ }
111
+ return width;
112
+ }
113
+
114
+ // Parse one escape at the start of `text`. Incomplete sequences (split across
115
+ // TTY chunks) are held back; complete unknown CSI is consumed, never inserted.
116
+ function matchEscape(text) {
117
+ if (!text.startsWith("\u001b")) {
118
+ return null;
119
+ }
120
+ if (text.length === 1) {
121
+ return { incomplete: true };
122
+ }
123
+ if (text[1] === "\r") {
124
+ return { sequence: SHIFT_ENTER_ESC_CR, kind: "shift-enter" };
125
+ }
126
+ if (text[1] === "O") {
127
+ if (text.length < 3) {
128
+ return { incomplete: true };
129
+ }
130
+ return { sequence: text.slice(0, 3), kind: "ss3", final: text[2] };
131
+ }
132
+ if (text[1] === "[") {
133
+ let index = 2;
134
+ while (
135
+ index < text.length
136
+ && text.charCodeAt(index) >= 0x30
137
+ && text.charCodeAt(index) <= 0x3F
138
+ ) {
139
+ index += 1;
140
+ }
141
+ while (
142
+ index < text.length
143
+ && text.charCodeAt(index) >= 0x20
144
+ && text.charCodeAt(index) <= 0x2F
145
+ ) {
146
+ index += 1;
147
+ }
148
+ if (index >= text.length) {
149
+ return { incomplete: true };
150
+ }
151
+ const finalCode = text.charCodeAt(index);
152
+ if (finalCode < 0x40 || finalCode > 0x7E) {
153
+ return { sequence: "\u001b", kind: "unknown" };
154
+ }
155
+ return {
156
+ sequence: text.slice(0, index + 1),
157
+ kind: "csi",
158
+ final: text[index],
159
+ params: text.slice(2, index),
160
+ };
161
+ }
162
+ return { sequence: text.slice(0, 2), kind: "unknown" };
163
+ }
164
+
165
+ function parseCsiParams(params) {
166
+ const body = String(params ?? "").replace(/^[?<>]/, "");
167
+ if (!body) {
168
+ return [];
169
+ }
170
+ return body.split(";").map((part) => {
171
+ const base = part.split(":")[0];
172
+ const value = Number(base);
173
+ return Number.isFinite(value) && base.length > 0 ? value : 0;
174
+ });
175
+ }
176
+
177
+ function classifyUnicodeKey(key, mods) {
178
+ const bits = Math.max(0, (mods || 1) - 1);
179
+ const shift = (bits & 1) !== 0;
180
+ const ctrl = (bits & 4) !== 0;
181
+ if (key === 13) {
182
+ return shift ? { type: "newline" } : { type: "submit" };
183
+ }
184
+ if (key === 10) {
185
+ return { type: "newline" };
186
+ }
187
+ if (key === 127 || key === 8) {
188
+ return { type: "backspace" };
189
+ }
190
+ if (key === 27 || key === 9) {
191
+ return { type: "ignore" };
192
+ }
193
+ if (ctrl) {
194
+ if (key === 99 || key === 67) {
195
+ return { type: "cancel" };
196
+ }
197
+ if (key === 100 || key === 68) {
198
+ return { type: "eof-or-delete" };
199
+ }
200
+ if (key === 97 || key === 65) {
201
+ return { type: "home" };
202
+ }
203
+ if (key === 101 || key === 69) {
204
+ return { type: "end" };
205
+ }
206
+ if (key === 107 || key === 75) {
207
+ return { type: "kill-line" };
208
+ }
209
+ if (key === 117 || key === 85) {
210
+ return { type: "kill-all" };
211
+ }
212
+ }
213
+ return { type: "ignore" };
214
+ }
215
+
216
+ function classifyCsi(final, params) {
217
+ const nums = parseCsiParams(params);
218
+ if (final === "u") {
219
+ return classifyUnicodeKey(nums[0] || 0, nums[1] || 1);
220
+ }
221
+ if (final === "~") {
222
+ const first = nums[0] || 0;
223
+ if (first === 200) {
224
+ return { type: "paste-start" };
225
+ }
226
+ if (first === 201) {
227
+ return { type: "paste-end" };
228
+ }
229
+ if (first === 3) {
230
+ return { type: "delete" };
231
+ }
232
+ if (first === 1) {
233
+ return { type: "home" };
234
+ }
235
+ if (first === 4) {
236
+ return { type: "end" };
237
+ }
238
+ if (first === 27) {
239
+ return classifyUnicodeKey(nums[2] || 0, nums[1] || 1);
240
+ }
241
+ return { type: "ignore" };
242
+ }
243
+ if (final === "A") {
244
+ return { type: "up" };
245
+ }
246
+ if (final === "B") {
247
+ return { type: "down" };
248
+ }
249
+ if (final === "C") {
250
+ return { type: "right" };
251
+ }
252
+ if (final === "D") {
253
+ return { type: "left" };
254
+ }
255
+ if (final === "H") {
256
+ return { type: "home" };
257
+ }
258
+ if (final === "F") {
259
+ return { type: "end" };
260
+ }
261
+ return { type: "ignore" };
262
+ }
263
+
114
264
  // Longest tail of `text` that is a strict prefix of `sequence` — the part that
115
265
  // must be held back because the sequence may continue in the next chunk.
116
266
  function longestPrefixAtEnd(text, sequence) {
@@ -210,7 +360,7 @@ export class ShellChatInput {
210
360
  || 80,
211
361
  );
212
362
 
213
- let buffer = []; // flat code-point buffer; "\n" chars are real line breaks
363
+ let buffer = []; // grapheme clusters; "\n" entries are real line breaks
214
364
  let cursor = 0;
215
365
  let historyIndex = this.history.length;
216
366
  let draft = "";
@@ -225,6 +375,7 @@ export class ShellChatInput {
225
375
  inputStream.removeListener("end", onEnd);
226
376
  inputStream.removeListener("close", onEnd);
227
377
  inputStream.setRawMode?.(previousRaw);
378
+ outputStream.write(ENABLE_AUTOWRAP);
228
379
  outputStream.write(DISABLE_MODIFY_OTHER_KEYS);
229
380
  outputStream.write(DISABLE_BRACKETED_PASTE);
230
381
  outputStream.write(DISABLE_KITTY_KEYBOARD);
@@ -244,7 +395,10 @@ export class ShellChatInput {
244
395
  };
245
396
 
246
397
  // Moves to the end of the drawn input and starts a fresh line.
398
+ // CR first: CUU/CUD from the trailing cell of a CJK glyph splits it in
399
+ // half on macOS Terminal.app. Column 0 is always a safe cell boundary.
247
400
  const finishLine = () => {
401
+ outputStream.write("\r");
248
402
  if (cursorRow < linesDrawn - 1) {
249
403
  outputStream.write(`\x1b[${linesDrawn - 1 - cursorRow}B`);
250
404
  }
@@ -266,19 +420,39 @@ export class ShellChatInput {
266
420
  };
267
421
 
268
422
  const setBufferText = (text) => {
269
- buffer = [...String(text)];
423
+ buffer = splitGraphemes(text);
270
424
  cursor = buffer.length;
271
425
  render();
272
426
  };
273
427
 
274
428
  const insertText = (text) => {
275
- const chars = [...String(text)];
429
+ const chars = splitGraphemes(text).filter((ch) => ch !== "\u001b");
430
+ if (chars.length === 0) {
431
+ return;
432
+ }
276
433
  buffer.splice(cursor, 0, ...chars);
277
434
  cursor += chars.length;
278
435
  historyIndex = this.history.length;
279
436
  render();
280
437
  };
281
438
 
439
+ const applyBackspace = () => {
440
+ if (cursor > 0) {
441
+ buffer.splice(cursor - 1, 1);
442
+ cursor -= 1;
443
+ historyIndex = this.history.length;
444
+ render();
445
+ }
446
+ };
447
+
448
+ const applyDelete = () => {
449
+ if (cursor < buffer.length) {
450
+ buffer.splice(cursor, 1);
451
+ historyIndex = this.history.length;
452
+ render();
453
+ }
454
+ };
455
+
282
456
  // historyIndex is the DISPLAYED entry's index, or history.length while the
283
457
  // user edits the fresh draft. ↑ walks into the list newest-first; ↓ walks
284
458
  // back out and restores the draft.
@@ -308,6 +482,74 @@ export class ShellChatInput {
308
482
  }
309
483
  };
310
484
 
485
+ const handleAction = (action) => {
486
+ switch (action?.type) {
487
+ case "paste-start":
488
+ inPaste = true;
489
+ return;
490
+ case "paste-end":
491
+ inPaste = false;
492
+ return;
493
+ case "up":
494
+ historyPrev();
495
+ return;
496
+ case "down":
497
+ historyNext();
498
+ return;
499
+ case "right":
500
+ cursor = Math.min(cursor + 1, buffer.length);
501
+ render();
502
+ return;
503
+ case "left":
504
+ cursor = Math.max(cursor - 1, 0);
505
+ render();
506
+ return;
507
+ case "home":
508
+ cursor = 0;
509
+ render();
510
+ return;
511
+ case "end":
512
+ cursor = buffer.length;
513
+ render();
514
+ return;
515
+ case "backspace":
516
+ applyBackspace();
517
+ return;
518
+ case "delete":
519
+ applyDelete();
520
+ return;
521
+ case "submit":
522
+ submit();
523
+ return;
524
+ case "newline":
525
+ insertText("\n");
526
+ return;
527
+ case "cancel":
528
+ cancel();
529
+ return;
530
+ case "eof-or-delete":
531
+ if (buffer.length === 0) {
532
+ done(null);
533
+ } else {
534
+ applyDelete();
535
+ }
536
+ return;
537
+ case "kill-line":
538
+ buffer = buffer.slice(0, cursor);
539
+ historyIndex = this.history.length;
540
+ render();
541
+ return;
542
+ case "kill-all":
543
+ buffer = [];
544
+ cursor = 0;
545
+ historyIndex = this.history.length;
546
+ render();
547
+ return;
548
+ default:
549
+ return;
550
+ }
551
+ };
552
+
311
553
  const handlePlainText = (text) => {
312
554
  for (const ch of String(text)) {
313
555
  if (ch === "\r") {
@@ -318,46 +560,39 @@ export class ShellChatInput {
318
560
  insertText("\n");
319
561
  continue;
320
562
  }
321
- if (ch === "") {
563
+ if (ch === "\u0003") {
322
564
  cancel();
323
565
  return;
324
566
  }
325
- if (ch === "") {
567
+ if (ch === "\u0004") {
326
568
  if (buffer.length === 0) {
327
569
  done(null);
328
- } else if (cursor < buffer.length) {
329
- buffer.splice(cursor, 1);
330
- historyIndex = this.history.length;
331
- render();
570
+ } else {
571
+ applyDelete();
332
572
  }
333
573
  continue;
334
574
  }
335
- if (ch === "" || ch === "") {
336
- if (cursor > 0) {
337
- buffer.splice(cursor - 1, 1);
338
- cursor -= 1;
339
- historyIndex = this.history.length;
340
- render();
341
- }
575
+ if (ch === "\u007f" || ch === "\u0008") {
576
+ applyBackspace();
342
577
  continue;
343
578
  }
344
- if (ch === "") {
579
+ if (ch === "\u0001") {
345
580
  cursor = 0;
346
581
  render();
347
582
  continue;
348
583
  }
349
- if (ch === "") {
584
+ if (ch === "\u0005") {
350
585
  cursor = buffer.length;
351
586
  render();
352
587
  continue;
353
588
  }
354
- if (ch === " ") {
589
+ if (ch === "\u000b") {
355
590
  buffer = buffer.slice(0, cursor);
356
591
  historyIndex = this.history.length;
357
592
  render();
358
593
  continue;
359
594
  }
360
- if (ch === "") {
595
+ if (ch === "\u0015") {
361
596
  buffer = [];
362
597
  cursor = 0;
363
598
  historyIndex = this.history.length;
@@ -368,55 +603,6 @@ export class ShellChatInput {
368
603
  }
369
604
  };
370
605
 
371
- const handleSequence = (sequence) => {
372
- switch (sequence) {
373
- case BRACKETED_PASTE_START:
374
- inPaste = true;
375
- return;
376
- case BRACKETED_PASTE_END:
377
- inPaste = false;
378
- return;
379
- case KEY_UP:
380
- historyPrev();
381
- return;
382
- case KEY_DOWN:
383
- historyNext();
384
- return;
385
- case KEY_RIGHT:
386
- cursor = Math.min(cursor + 1, buffer.length);
387
- render();
388
- return;
389
- case KEY_LEFT:
390
- cursor = Math.max(cursor - 1, 0);
391
- render();
392
- return;
393
- case KEY_HOME:
394
- case KEY_HOME_TILDE:
395
- case KEY_HOME_APP:
396
- cursor = 0;
397
- render();
398
- return;
399
- case KEY_END:
400
- case KEY_END_TILDE:
401
- case KEY_END_APP:
402
- cursor = buffer.length;
403
- render();
404
- return;
405
- case KEY_DELETE:
406
- if (cursor < buffer.length) {
407
- buffer.splice(cursor, 1);
408
- historyIndex = this.history.length;
409
- render();
410
- }
411
- return;
412
- default:
413
- break;
414
- }
415
- if (SHIFT_ENTER_SEQUENCES.includes(sequence)) {
416
- insertText("\n");
417
- }
418
- };
419
-
420
606
  const consume = (text) => {
421
607
  pending += text;
422
608
  for (;;) {
@@ -453,26 +639,26 @@ export class ShellChatInput {
453
639
  continue;
454
640
  }
455
641
 
456
- // pending starts with ESC.
457
- const sequence = KNOWN_ESCAPE_SEQUENCES.find((candidate) => (
458
- pending.startsWith(candidate)
459
- ));
460
- if (sequence) {
461
- handleSequence(sequence);
462
- pending = pending.slice(sequence.length);
463
- continue;
642
+ const matched = matchEscape(pending);
643
+ if (!matched) {
644
+ handlePlainText(pending);
645
+ pending = "";
646
+ return;
464
647
  }
465
- if (
466
- pending.length === 1
467
- || KNOWN_ESCAPE_SEQUENCES.some((candidate) => (
468
- candidate.startsWith(pending)
469
- ))
470
- ) {
471
- // The rest of the sequence may arrive in the next chunk.
648
+ if (matched.incomplete) {
472
649
  return;
473
650
  }
474
- // Unknown escape: drop the ESC byte and re-scan what follows.
475
- pending = pending.slice(1);
651
+ if (matched.kind === "shift-enter") {
652
+ handleAction({ type: "newline" });
653
+ } else if (matched.kind === "ss3") {
654
+ const type = SS3_ACTIONS[matched.final];
655
+ if (type) {
656
+ handleAction({ type });
657
+ }
658
+ } else if (matched.kind === "csi") {
659
+ handleAction(classifyCsi(matched.final, matched.params));
660
+ }
661
+ pending = pending.slice(matched.sequence.length);
476
662
  }
477
663
  };
478
664
 
@@ -481,7 +667,6 @@ export class ShellChatInput {
481
667
  const render = () => {
482
668
  const width = columns();
483
669
  const promptWidth = displayWidth(prompt);
484
- const text = buffer.join("");
485
670
 
486
671
  const rendered = [];
487
672
  const ranges = []; // { start, end } flat char indices per rendered line
@@ -496,15 +681,27 @@ export class ShellChatInput {
496
681
  currentWidth = 0;
497
682
  rangeStart = charIndex;
498
683
  };
499
- for (const ch of text) {
684
+ for (let index = 0; index < buffer.length; index += 1) {
685
+ const ch = buffer[index];
500
686
  if (ch === "\n") {
501
687
  flush();
502
688
  charIndex += 1;
689
+ // The "\n" itself belongs to no rendered line. flush() left
690
+ // rangeStart on the "\n"'s index, so the next line's range would
691
+ // include it — and the cursor column (displayWidth of the row
692
+ // prefix) came out 1 too far right on every row after a newline,
693
+ // parking the cursor on a CJK glyph's trailing cell where macOS
694
+ // Terminal.app hides it (the "cursor blinks out" bug).
695
+ rangeStart = charIndex;
503
696
  continue;
504
697
  }
505
- const w = charWidth(ch.codePointAt(0));
698
+ const w = graphemeWidth(ch);
506
699
  const base = rendered.length === 0 ? promptWidth : 0;
507
- if (base + currentWidth + w > width && currentWidth > 0) {
700
+ // `>= width` (not `>`): filling the last column trips terminal autowrap
701
+ // even with DECAWM off on some emulators, and CJK (width 2) lands on
702
+ // that boundary far more often than ASCII. Keep one spare cell so the
703
+ // editor's row model and the physical cursor stay aligned.
704
+ if (base + currentWidth + w >= width && currentWidth > 0) {
508
705
  flush();
509
706
  }
510
707
  current += ch;
@@ -523,10 +720,12 @@ export class ShellChatInput {
523
720
  // top — using `linesDrawn - 1` here overshoots whenever the cursor sits
524
721
  // above the bottom row, drifting the whole block up the screen on every
525
722
  // keystroke (duplicating wrapped lines). Mirror of finishLine()'s move.
723
+ // CR first: CUU from the trailing cell of a CJK glyph splits it in half
724
+ // on macOS Terminal.app. Column 0 is always a safe cell boundary.
725
+ outputStream.write("\r");
526
726
  if (cursorRow > 0) {
527
727
  outputStream.write(`\x1b[${cursorRow}A`);
528
728
  }
529
- outputStream.write("\r");
530
729
  for (let index = 0; index < rendered.length; index += 1) {
531
730
  outputStream.write("\x1b[2K");
532
731
  if (index === 0) {
@@ -537,14 +736,11 @@ export class ShellChatInput {
537
736
  outputStream.write("\r\n");
538
737
  }
539
738
  }
540
- if (rendered.length < linesDrawn) {
541
- for (let index = rendered.length; index < linesDrawn; index += 1) {
542
- outputStream.write("\x1b[2K");
543
- if (index < linesDrawn - 1) {
544
- outputStream.write("\r\n");
545
- }
546
- }
547
- }
739
+ // Erase leftover rows from a previous taller wrap (CJK backspace/unwrap).
740
+ // ED 0 from the end of the last content line. The old shrink path CSI-2K'd
741
+ // the current line instead — wiping the row we just drew and never moving
742
+ // onto the stale row below, so the "deleted" glyph stayed visible.
743
+ outputStream.write("\x1b[J");
548
744
  linesDrawn = rendered.length;
549
745
 
550
746
  // Position the cursor at the flat cursor index.
@@ -555,20 +751,21 @@ export class ShellChatInput {
555
751
  if (cursor < range.end || r === rendered.length - 1) {
556
752
  row = r;
557
753
  const base = r === 0 ? promptWidth : 0;
558
- const prefixLength = Math.max(0, cursor - range.start);
559
754
  col = base + displayWidth(
560
- [...rendered[r]].slice(0, prefixLength).join(""),
755
+ buffer.slice(range.start, cursor).join(""),
561
756
  );
562
757
  break;
563
758
  }
564
759
  }
565
760
  cursorRow = row;
566
- // Drawing ends at the last rendered line; move UP to the cursor's row.
761
+ // Drawing ends at the last rendered line; CR first, then move UP. After
762
+ // the CR the cursor is at column 0, so CUU keeps column 0 — no second CR
763
+ // is needed before the CUF.
567
764
  const moveUp = rendered.length - 1 - row;
765
+ outputStream.write("\r");
568
766
  if (moveUp > 0) {
569
767
  outputStream.write(`\x1b[${moveUp}A`);
570
768
  }
571
- outputStream.write("\r");
572
769
  if (col > 0) {
573
770
  outputStream.write(`\x1b[${col}C`);
574
771
  }
@@ -586,6 +783,7 @@ export class ShellChatInput {
586
783
  };
587
784
 
588
785
  inputStream.setRawMode?.(true);
786
+ outputStream.write(DISABLE_AUTOWRAP);
589
787
  outputStream.write(ENABLE_BRACKETED_PASTE);
590
788
  // Ask the terminal to disambiguate modified Enter. Kitty CSI-u covers
591
789
  // kitty/iTerm; xterm modifyOtherKeys covers many others, including some