wtagent 0.2.1 → 0.3.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) {
@@ -198,9 +348,19 @@ export class ShellChatInput {
198
348
  const outputStream = this.outputStream;
199
349
  const previousRaw = inputStream.isRaw;
200
350
  const decoder = new StringDecoder("utf8");
201
- const columns = () => Math.max(10, Number(inputStream.columns) || 80);
202
-
203
- let buffer = []; // flat code-point buffer; "\n" chars are real line breaks
351
+ // Terminal width lives on the TTY *output* stream; process.stdin.columns is
352
+ // undefined, so reading it always fell back to 80 — wrapping CJK input after
353
+ // ~40 chars and, worse, making the editor's multi-line row model disagree
354
+ // with the terminal's real wrapping (arrow-key redraws then duplicated the
355
+ // input up the screen). Prefer the output stream's live column count.
356
+ const columns = () => Math.max(
357
+ 10,
358
+ Number(outputStream.columns)
359
+ || Number(inputStream.columns)
360
+ || 80,
361
+ );
362
+
363
+ let buffer = []; // grapheme clusters; "\n" entries are real line breaks
204
364
  let cursor = 0;
205
365
  let historyIndex = this.history.length;
206
366
  let draft = "";
@@ -215,6 +375,7 @@ export class ShellChatInput {
215
375
  inputStream.removeListener("end", onEnd);
216
376
  inputStream.removeListener("close", onEnd);
217
377
  inputStream.setRawMode?.(previousRaw);
378
+ outputStream.write(ENABLE_AUTOWRAP);
218
379
  outputStream.write(DISABLE_MODIFY_OTHER_KEYS);
219
380
  outputStream.write(DISABLE_BRACKETED_PASTE);
220
381
  outputStream.write(DISABLE_KITTY_KEYBOARD);
@@ -234,7 +395,10 @@ export class ShellChatInput {
234
395
  };
235
396
 
236
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.
237
400
  const finishLine = () => {
401
+ outputStream.write("\r");
238
402
  if (cursorRow < linesDrawn - 1) {
239
403
  outputStream.write(`\x1b[${linesDrawn - 1 - cursorRow}B`);
240
404
  }
@@ -256,19 +420,39 @@ export class ShellChatInput {
256
420
  };
257
421
 
258
422
  const setBufferText = (text) => {
259
- buffer = [...String(text)];
423
+ buffer = splitGraphemes(text);
260
424
  cursor = buffer.length;
261
425
  render();
262
426
  };
263
427
 
264
428
  const insertText = (text) => {
265
- const chars = [...String(text)];
429
+ const chars = splitGraphemes(text).filter((ch) => ch !== "\u001b");
430
+ if (chars.length === 0) {
431
+ return;
432
+ }
266
433
  buffer.splice(cursor, 0, ...chars);
267
434
  cursor += chars.length;
268
435
  historyIndex = this.history.length;
269
436
  render();
270
437
  };
271
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
+
272
456
  // historyIndex is the DISPLAYED entry's index, or history.length while the
273
457
  // user edits the fresh draft. ↑ walks into the list newest-first; ↓ walks
274
458
  // back out and restores the draft.
@@ -298,6 +482,74 @@ export class ShellChatInput {
298
482
  }
299
483
  };
300
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
+
301
553
  const handlePlainText = (text) => {
302
554
  for (const ch of String(text)) {
303
555
  if (ch === "\r") {
@@ -308,46 +560,39 @@ export class ShellChatInput {
308
560
  insertText("\n");
309
561
  continue;
310
562
  }
311
- if (ch === "") {
563
+ if (ch === "\u0003") {
312
564
  cancel();
313
565
  return;
314
566
  }
315
- if (ch === "") {
567
+ if (ch === "\u0004") {
316
568
  if (buffer.length === 0) {
317
569
  done(null);
318
- } else if (cursor < buffer.length) {
319
- buffer.splice(cursor, 1);
320
- historyIndex = this.history.length;
321
- render();
570
+ } else {
571
+ applyDelete();
322
572
  }
323
573
  continue;
324
574
  }
325
- if (ch === "" || ch === "") {
326
- if (cursor > 0) {
327
- buffer.splice(cursor - 1, 1);
328
- cursor -= 1;
329
- historyIndex = this.history.length;
330
- render();
331
- }
575
+ if (ch === "\u007f" || ch === "\u0008") {
576
+ applyBackspace();
332
577
  continue;
333
578
  }
334
- if (ch === "") {
579
+ if (ch === "\u0001") {
335
580
  cursor = 0;
336
581
  render();
337
582
  continue;
338
583
  }
339
- if (ch === "") {
584
+ if (ch === "\u0005") {
340
585
  cursor = buffer.length;
341
586
  render();
342
587
  continue;
343
588
  }
344
- if (ch === " ") {
589
+ if (ch === "\u000b") {
345
590
  buffer = buffer.slice(0, cursor);
346
591
  historyIndex = this.history.length;
347
592
  render();
348
593
  continue;
349
594
  }
350
- if (ch === "") {
595
+ if (ch === "\u0015") {
351
596
  buffer = [];
352
597
  cursor = 0;
353
598
  historyIndex = this.history.length;
@@ -358,55 +603,6 @@ export class ShellChatInput {
358
603
  }
359
604
  };
360
605
 
361
- const handleSequence = (sequence) => {
362
- switch (sequence) {
363
- case BRACKETED_PASTE_START:
364
- inPaste = true;
365
- return;
366
- case BRACKETED_PASTE_END:
367
- inPaste = false;
368
- return;
369
- case KEY_UP:
370
- historyPrev();
371
- return;
372
- case KEY_DOWN:
373
- historyNext();
374
- return;
375
- case KEY_RIGHT:
376
- cursor = Math.min(cursor + 1, buffer.length);
377
- render();
378
- return;
379
- case KEY_LEFT:
380
- cursor = Math.max(cursor - 1, 0);
381
- render();
382
- return;
383
- case KEY_HOME:
384
- case KEY_HOME_TILDE:
385
- case KEY_HOME_APP:
386
- cursor = 0;
387
- render();
388
- return;
389
- case KEY_END:
390
- case KEY_END_TILDE:
391
- case KEY_END_APP:
392
- cursor = buffer.length;
393
- render();
394
- return;
395
- case KEY_DELETE:
396
- if (cursor < buffer.length) {
397
- buffer.splice(cursor, 1);
398
- historyIndex = this.history.length;
399
- render();
400
- }
401
- return;
402
- default:
403
- break;
404
- }
405
- if (SHIFT_ENTER_SEQUENCES.includes(sequence)) {
406
- insertText("\n");
407
- }
408
- };
409
-
410
606
  const consume = (text) => {
411
607
  pending += text;
412
608
  for (;;) {
@@ -443,26 +639,26 @@ export class ShellChatInput {
443
639
  continue;
444
640
  }
445
641
 
446
- // pending starts with ESC.
447
- const sequence = KNOWN_ESCAPE_SEQUENCES.find((candidate) => (
448
- pending.startsWith(candidate)
449
- ));
450
- if (sequence) {
451
- handleSequence(sequence);
452
- pending = pending.slice(sequence.length);
453
- continue;
642
+ const matched = matchEscape(pending);
643
+ if (!matched) {
644
+ handlePlainText(pending);
645
+ pending = "";
646
+ return;
454
647
  }
455
- if (
456
- pending.length === 1
457
- || KNOWN_ESCAPE_SEQUENCES.some((candidate) => (
458
- candidate.startsWith(pending)
459
- ))
460
- ) {
461
- // The rest of the sequence may arrive in the next chunk.
648
+ if (matched.incomplete) {
462
649
  return;
463
650
  }
464
- // Unknown escape: drop the ESC byte and re-scan what follows.
465
- 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);
466
662
  }
467
663
  };
468
664
 
@@ -471,7 +667,6 @@ export class ShellChatInput {
471
667
  const render = () => {
472
668
  const width = columns();
473
669
  const promptWidth = displayWidth(prompt);
474
- const text = buffer.join("");
475
670
 
476
671
  const rendered = [];
477
672
  const ranges = []; // { start, end } flat char indices per rendered line
@@ -486,15 +681,27 @@ export class ShellChatInput {
486
681
  currentWidth = 0;
487
682
  rangeStart = charIndex;
488
683
  };
489
- for (const ch of text) {
684
+ for (let index = 0; index < buffer.length; index += 1) {
685
+ const ch = buffer[index];
490
686
  if (ch === "\n") {
491
687
  flush();
492
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;
493
696
  continue;
494
697
  }
495
- const w = charWidth(ch.codePointAt(0));
698
+ const w = graphemeWidth(ch);
496
699
  const base = rendered.length === 0 ? promptWidth : 0;
497
- 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) {
498
705
  flush();
499
706
  }
500
707
  current += ch;
@@ -507,10 +714,18 @@ export class ShellChatInput {
507
714
  ranges.push({ start: 0, end: 0 });
508
715
  }
509
716
 
510
- if (linesDrawn > 1) {
511
- outputStream.write(`\x1b[${linesDrawn - 1}A`);
512
- }
717
+ // The previous render left the physical cursor on row `cursorRow`, which
718
+ // is NOT always the last drawn row: arrowing left/up into an earlier
719
+ // wrapped row leaves it higher. Move up by that actual row to reach the
720
+ // top — using `linesDrawn - 1` here overshoots whenever the cursor sits
721
+ // above the bottom row, drifting the whole block up the screen on every
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.
513
725
  outputStream.write("\r");
726
+ if (cursorRow > 0) {
727
+ outputStream.write(`\x1b[${cursorRow}A`);
728
+ }
514
729
  for (let index = 0; index < rendered.length; index += 1) {
515
730
  outputStream.write("\x1b[2K");
516
731
  if (index === 0) {
@@ -521,14 +736,11 @@ export class ShellChatInput {
521
736
  outputStream.write("\r\n");
522
737
  }
523
738
  }
524
- if (rendered.length < linesDrawn) {
525
- for (let index = rendered.length; index < linesDrawn; index += 1) {
526
- outputStream.write("\x1b[2K");
527
- if (index < linesDrawn - 1) {
528
- outputStream.write("\r\n");
529
- }
530
- }
531
- }
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");
532
744
  linesDrawn = rendered.length;
533
745
 
534
746
  // Position the cursor at the flat cursor index.
@@ -539,20 +751,21 @@ export class ShellChatInput {
539
751
  if (cursor < range.end || r === rendered.length - 1) {
540
752
  row = r;
541
753
  const base = r === 0 ? promptWidth : 0;
542
- const prefixLength = Math.max(0, cursor - range.start);
543
754
  col = base + displayWidth(
544
- [...rendered[r]].slice(0, prefixLength).join(""),
755
+ buffer.slice(range.start, cursor).join(""),
545
756
  );
546
757
  break;
547
758
  }
548
759
  }
549
760
  cursorRow = row;
550
- // 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.
551
764
  const moveUp = rendered.length - 1 - row;
765
+ outputStream.write("\r");
552
766
  if (moveUp > 0) {
553
767
  outputStream.write(`\x1b[${moveUp}A`);
554
768
  }
555
- outputStream.write("\r");
556
769
  if (col > 0) {
557
770
  outputStream.write(`\x1b[${col}C`);
558
771
  }
@@ -570,6 +783,7 @@ export class ShellChatInput {
570
783
  };
571
784
 
572
785
  inputStream.setRawMode?.(true);
786
+ outputStream.write(DISABLE_AUTOWRAP);
573
787
  outputStream.write(ENABLE_BRACKETED_PASTE);
574
788
  // Ask the terminal to disambiguate modified Enter. Kitty CSI-u covers
575
789
  // kitty/iTerm; xterm modifyOtherKeys covers many others, including some