Skip to content

Commit 3c5d18f

Browse files
authored
fix: preserve unframed multiline terminal paste chunks (#268)
1 parent bcdd606 commit 3c5d18f

6 files changed

Lines changed: 176 additions & 4 deletions

File tree

‎packages/tui/src/tui/engine/LOCAL_CHANGES.json‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -126,10 +126,10 @@
126126
},
127127
{
128128
"path": "stdin-buffer.ts",
129-
"currentSha256": "95e18c6c702fee39289c2221ddb54dfe7269812fa2f80223025d6a6df05d7e10",
130-
"changeIds": ["L005"],
131-
"reason": "Make the length-guarded final byte access explicit under the shared MCode strict TypeScript contract.",
132-
"behaviorImpact": "none_intended"
129+
"currentSha256": "1aca0d7fb7dacaf16a28ede79f52fcf7bea651d8a263777ee327fdaeb2dcaa28",
130+
"changeIds": ["L005", "L036"],
131+
"reason": "Keep strict TypeScript guards and preserve unframed multiline text chunks before key splitting.",
132+
"behaviorImpact": "Plain-text chunks with internal line endings use the existing paste path; standalone Enter and control sequences retain key semantics."
133133
},
134134
{
135135
"path": "terminal-colors.ts",

‎packages/tui/src/tui/engine/LOCAL_CHANGES.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,3 +123,11 @@ Remove `L024` when the selected Pi baseline natively matches legacy-terminal `Ct
123123
- Tradeoff: structural reconstruction clears native scrollback, including shell history from before TUI startup. Initial short chat documents retain natural document placement.
124124
- Evidence: local-delta tests assert every visible row and the complete history, while real Tasks and feature lifecycle tests cover short/long content, background growth, paging, resize, nested panels and return to chat. Virtual terminals do not establish native iTerm2 touchpad acceptance.
125125
- Removal condition: the selected Pi baseline provides equivalent complete viewport and unique-history behavior.
126+
127+
## L036: Unframed multiline paste chunks
128+
129+
- Product contract: a plain-text stdin chunk containing an internal CR/LF is inserted as a single paste, so its CR bytes cannot submit each line separately.
130+
- Minimal difference: `stdin-buffer.ts` emits the existing paste event before key splitting when there is no pending escape or bracketed paste and the chunk contains only text, tabs and line endings. The existing editor paste path normalizes CR/LF and tabs and folds large payloads.
131+
- Boundary: this is a conservative fallback, not a replacement for bracketed paste. A standalone Enter and text followed only by a final Enter retain key semantics. Unframed pastes split into line-sized or character-sized chunks cannot be distinguished from typing and are not inferred using timing. Conversely, multiple typed lines delivered in a single chunk are indistinguishable from an unframed paste and use this fallback. Control sequences retain their existing parser.
132+
- Evidence: `test/unit/tui-terminal-text-paste.test.ts` replays ProcessTerminal input into the product Editor, including CR, LF, CRLF, Unicode, large pastes, every bracketed chunk split, Enter/shortcuts, and stop/start mode lifecycle. The CR and CRLF cases submitted three separate messages before the fix. These are synthetic input replays, not real WSL terminal acceptance.
133+
- Removal condition: the selected Pi baseline provides equivalent unframed multiline input handling.

‎packages/tui/src/tui/engine/stdin-buffer.ts‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -319,6 +319,21 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
319319
return;
320320
}
321321

322+
// Some terminal paste actions send plain text even with mode 2004 enabled.
323+
// Preserve a multiline text chunk before splitting it into keypresses: CR
324+
// would otherwise submit each line. Require text after a newline so ordinary
325+
// text + Enter still submits. Never guess across chunks or control sequences;
326+
// bracketed paste remains the only unambiguous framing for those inputs.
327+
if (
328+
!this.pasteMode && this.buffer.length === 0 &&
329+
!/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(str) &&
330+
/[\r\n][^\r\n]/.test(str)
331+
) {
332+
this.pendingKittyPrintableCodepoint = undefined;
333+
this.emit("paste", str);
334+
return;
335+
}
336+
322337
this.buffer += str;
323338

324339
if (this.pasteMode) {
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import { PassThrough } from 'node:stream';
2+
import { afterEach, describe, expect, it, vi } from 'vitest';
3+
import { ProcessTerminal, StdinBuffer } from '../../src/tui/engine/public.js';
4+
import { Editor } from '../../src/tui/widgets/editor/editor.js';
5+
6+
const startPaste = '\x1b[200~';
7+
const endPaste = '\x1b[201~';
8+
const identity = (text: string) => text;
9+
10+
function createHarness() {
11+
const stdin = Object.assign(new PassThrough(), {
12+
isRaw: false,
13+
setRawMode: vi.fn(),
14+
});
15+
vi.spyOn(process, 'stdin', 'get').mockReturnValue(stdin as typeof process.stdin);
16+
vi.spyOn(process, 'kill').mockReturnValue(true);
17+
const output = vi.spyOn(process.stdout, 'write').mockReturnValue(true);
18+
vi.stubEnv('TERM_PROGRAM', '');
19+
const editor = new Editor(
20+
{ terminal: { rows: 24 }, requestRender: vi.fn() },
21+
{
22+
borderColor: identity,
23+
selectList: {
24+
selectedPrefix: identity, selectedText: identity, description: identity,
25+
scrollInfo: identity, noMatch: identity,
26+
},
27+
},
28+
);
29+
const submitted = vi.fn();
30+
editor.onSubmit = submitted;
31+
const terminal = new ProcessTerminal();
32+
const input = vi.fn((data: string) => editor.handleInput(data));
33+
const start = () => terminal.start(input, () => {});
34+
start();
35+
return { editor, submitted, terminal, input, output, stdin, start,
36+
send: (data: string) => stdin.emit('data', data) };
37+
}
38+
39+
afterEach(() => {
40+
vi.restoreAllMocks();
41+
vi.unstubAllEnvs();
42+
});
43+
44+
describe('terminal text paste', () => {
45+
it.each(['\r', '\n', '\r\n'])('preserves unframed multiline chunks with %j line endings', (eol) => {
46+
const h = createHarness();
47+
try {
48+
h.send(`first${eol}第二行 😀${eol}last${eol}`);
49+
expect(h.submitted).not.toHaveBeenCalled();
50+
expect(h.editor.getExpandedText()).toBe('first\n第二行 😀\nlast\n');
51+
h.send('\r');
52+
expect(h.submitted.mock.calls.map(([text]) => text)).toEqual(['first\n第二行 😀\nlast']);
53+
} finally { h.terminal.stop(); }
54+
});
55+
56+
it('retains immediate Enter and shortcut dispatch for ordinary key input', () => {
57+
const h = createHarness();
58+
try {
59+
for (const character of 'typed quickly') h.send(character);
60+
h.send('\r');
61+
h.send('batched text\r');
62+
expect(h.submitted.mock.calls.map(([text]) => text)).toEqual(['typed quickly', 'batched text']);
63+
h.send('\x1b[A\x03\x1b[13;2u');
64+
expect(h.input.mock.calls.slice(-3)).toEqual([['\x1b[A'], ['\x03'], ['\x1b[13;2u']]);
65+
} finally { h.terminal.stop(); }
66+
});
67+
68+
it('uses the paste path for a large payload with tabs and Unicode', () => {
69+
const h = createHarness();
70+
const payload = Array.from({ length: 20 }, (_, i) => `line ${i}\t中文 😀`).join('\r\n');
71+
try {
72+
h.send(payload);
73+
expect(h.submitted).not.toHaveBeenCalled();
74+
expect(h.editor.getText()).toMatch(/^\[paste #1 /);
75+
expect(h.editor.getExpandedText()).toBe(payload.replace(/\r\n/g, '\n').replace(/\t/g, ' '));
76+
} finally { h.terminal.stop(); }
77+
});
78+
79+
it('reassembles bracketed paste at every chunk boundary before accepting Enter', () => {
80+
const h = createHarness();
81+
const payload = 'first\r\n第二行\rlast\n';
82+
const wire = `${startPaste}${payload}${endPaste}`;
83+
try {
84+
for (let split = 1; split < wire.length; split++) {
85+
h.editor.setText('');
86+
h.submitted.mockClear();
87+
h.send(wire.slice(0, split));
88+
h.send(wire.slice(split));
89+
expect(h.submitted).not.toHaveBeenCalled();
90+
expect(h.editor.getExpandedText()).toBe('first\n第二行\nlast\n');
91+
h.send('\r');
92+
expect(h.submitted.mock.calls.map(([text]) => text)).toEqual(['first\n第二行\nlast']);
93+
}
94+
} finally { h.terminal.stop(); }
95+
});
96+
97+
it('enables paste mode after restart and discards an unfinished previous paste', () => {
98+
const h = createHarness();
99+
try {
100+
expect(h.output).toHaveBeenCalledWith('\x1b[?2004h');
101+
h.send(`${startPaste}unfinished\r`);
102+
h.terminal.stop();
103+
expect(h.output).toHaveBeenCalledWith('\x1b[?2004l');
104+
expect(h.stdin.listenerCount('data')).toBe(0);
105+
h.output.mockClear();
106+
h.start();
107+
expect(h.output).toHaveBeenCalledWith('\x1b[?2004h');
108+
h.send(`${startPaste}new\r\ntext${endPaste}\r`);
109+
expect(h.submitted.mock.calls.map(([text]) => text)).toEqual(['new\ntext']);
110+
} finally { h.terminal.stop(); }
111+
});
112+
113+
it('keeps control sequences and incomplete escape sequences out of the unframed fallback', () => {
114+
const buffer = new StdinBuffer();
115+
const paste = vi.fn();
116+
const data = vi.fn();
117+
buffer.on('paste', paste);
118+
buffer.on('data', data);
119+
try {
120+
buffer.process('first\r\x03last');
121+
expect(paste).not.toHaveBeenCalled();
122+
expect(data.mock.calls).toContainEqual(['\x03']);
123+
buffer.process('\x1b[');
124+
buffer.process('first\rlast');
125+
expect(paste).not.toHaveBeenCalled();
126+
} finally { buffer.destroy(); }
127+
});
128+
129+
it('does not time out a bracketed payload or merge neighboring keyboard events into it', () => {
130+
vi.useFakeTimers();
131+
const buffer = new StdinBuffer();
132+
const events: string[][] = [];
133+
buffer.on('paste', (text) => events.push(['paste', text]));
134+
buffer.on('data', (text) => events.push(['data', text]));
135+
try {
136+
buffer.process(`a${startPaste}first\r`);
137+
vi.advanceTimersByTime(1_000);
138+
buffer.process(`\nsecond${endPaste}${startPaste}${endPaste}\r`);
139+
expect(events).toEqual([
140+
['data', 'a'], ['paste', 'first\r\nsecond'], ['paste', ''], ['data', '\r'],
141+
]);
142+
} finally {
143+
buffer.destroy();
144+
vi.useRealTimers();
145+
}
146+
});
147+
});

‎release/public-source.json‎

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎test/vitest-suites.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@
8484
"packages/tui/test/unit/tui-shell-block.test.ts",
8585
"packages/tui/test/unit/tui-status-line-items.test.ts",
8686
"packages/tui/test/unit/tui-terminal-image-paste.test.ts",
87+
"packages/tui/test/unit/tui-terminal-text-paste.test.ts",
8788
"packages/tui/test/unit/tui-thinking-preview.test.ts",
8889
"packages/tui/test/unit/tui-transcript-presentation.test.ts",
8990
"packages/tui/test/unit/tui-transcript-view.test.ts",

0 commit comments

Comments
 (0)