Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions packages/tui/src/tui/engine/LOCAL_CHANGES.json
Original file line number Diff line number Diff line change
Expand Up @@ -126,10 +126,10 @@
},
{
"path": "stdin-buffer.ts",
"currentSha256": "95e18c6c702fee39289c2221ddb54dfe7269812fa2f80223025d6a6df05d7e10",
"changeIds": ["L005"],
"reason": "Make the length-guarded final byte access explicit under the shared MCode strict TypeScript contract.",
"behaviorImpact": "none_intended"
"currentSha256": "1aca0d7fb7dacaf16a28ede79f52fcf7bea651d8a263777ee327fdaeb2dcaa28",
"changeIds": ["L005", "L036"],
"reason": "Keep strict TypeScript guards and preserve unframed multiline text chunks before key splitting.",
"behaviorImpact": "Plain-text chunks with internal line endings use the existing paste path; standalone Enter and control sequences retain key semantics."
},
{
"path": "terminal-colors.ts",
Expand Down
8 changes: 8 additions & 0 deletions packages/tui/src/tui/engine/LOCAL_CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,3 +123,11 @@ Remove `L024` when the selected Pi baseline natively matches legacy-terminal `Ct
- Tradeoff: structural reconstruction clears native scrollback, including shell history from before TUI startup. Initial short chat documents retain natural document placement.
- 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.
- Removal condition: the selected Pi baseline provides equivalent complete viewport and unique-history behavior.

## L036: Unframed multiline paste chunks

- 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.
- 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.
- 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.
- 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.
- Removal condition: the selected Pi baseline provides equivalent unframed multiline input handling.
15 changes: 15 additions & 0 deletions packages/tui/src/tui/engine/stdin-buffer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,21 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
return;
}

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

this.buffer += str;

if (this.pasteMode) {
Expand Down
147 changes: 147 additions & 0 deletions packages/tui/test/unit/tui-terminal-text-paste.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import { PassThrough } from 'node:stream';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { ProcessTerminal, StdinBuffer } from '../../src/tui/engine/public.js';
import { Editor } from '../../src/tui/widgets/editor/editor.js';

const startPaste = '\x1b[200~';
const endPaste = '\x1b[201~';
const identity = (text: string) => text;

function createHarness() {
const stdin = Object.assign(new PassThrough(), {
isRaw: false,
setRawMode: vi.fn(),
});
vi.spyOn(process, 'stdin', 'get').mockReturnValue(stdin as typeof process.stdin);
vi.spyOn(process, 'kill').mockReturnValue(true);
const output = vi.spyOn(process.stdout, 'write').mockReturnValue(true);
vi.stubEnv('TERM_PROGRAM', '');
const editor = new Editor(
{ terminal: { rows: 24 }, requestRender: vi.fn() },
{
borderColor: identity,
selectList: {
selectedPrefix: identity, selectedText: identity, description: identity,
scrollInfo: identity, noMatch: identity,
},
},
);
const submitted = vi.fn();
editor.onSubmit = submitted;
const terminal = new ProcessTerminal();
const input = vi.fn((data: string) => editor.handleInput(data));
const start = () => terminal.start(input, () => {});
start();
return { editor, submitted, terminal, input, output, stdin, start,
send: (data: string) => stdin.emit('data', data) };
}

afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllEnvs();
});

describe('terminal text paste', () => {
it.each(['\r', '\n', '\r\n'])('preserves unframed multiline chunks with %j line endings', (eol) => {
const h = createHarness();
try {
h.send(`first${eol}第二行 😀${eol}last${eol}`);
expect(h.submitted).not.toHaveBeenCalled();
expect(h.editor.getExpandedText()).toBe('first\n第二行 😀\nlast\n');
h.send('\r');
expect(h.submitted.mock.calls.map(([text]) => text)).toEqual(['first\n第二行 😀\nlast']);
} finally { h.terminal.stop(); }
});

it('retains immediate Enter and shortcut dispatch for ordinary key input', () => {
const h = createHarness();
try {
for (const character of 'typed quickly') h.send(character);
h.send('\r');
h.send('batched text\r');
expect(h.submitted.mock.calls.map(([text]) => text)).toEqual(['typed quickly', 'batched text']);
h.send('\x1b[A\x03\x1b[13;2u');
expect(h.input.mock.calls.slice(-3)).toEqual([['\x1b[A'], ['\x03'], ['\x1b[13;2u']]);
} finally { h.terminal.stop(); }
});

it('uses the paste path for a large payload with tabs and Unicode', () => {
const h = createHarness();
const payload = Array.from({ length: 20 }, (_, i) => `line ${i}\t中文 😀`).join('\r\n');
try {
h.send(payload);
expect(h.submitted).not.toHaveBeenCalled();
expect(h.editor.getText()).toMatch(/^\[paste #1 /);
expect(h.editor.getExpandedText()).toBe(payload.replace(/\r\n/g, '\n').replace(/\t/g, ' '));
} finally { h.terminal.stop(); }
});

it('reassembles bracketed paste at every chunk boundary before accepting Enter', () => {
const h = createHarness();
const payload = 'first\r\n第二行\rlast\n';
const wire = `${startPaste}${payload}${endPaste}`;
try {
for (let split = 1; split < wire.length; split++) {
h.editor.setText('');
h.submitted.mockClear();
h.send(wire.slice(0, split));
h.send(wire.slice(split));
expect(h.submitted).not.toHaveBeenCalled();
expect(h.editor.getExpandedText()).toBe('first\n第二行\nlast\n');
h.send('\r');
expect(h.submitted.mock.calls.map(([text]) => text)).toEqual(['first\n第二行\nlast']);
}
} finally { h.terminal.stop(); }
});

it('enables paste mode after restart and discards an unfinished previous paste', () => {
const h = createHarness();
try {
expect(h.output).toHaveBeenCalledWith('\x1b[?2004h');
h.send(`${startPaste}unfinished\r`);
h.terminal.stop();
expect(h.output).toHaveBeenCalledWith('\x1b[?2004l');
expect(h.stdin.listenerCount('data')).toBe(0);
h.output.mockClear();
h.start();
expect(h.output).toHaveBeenCalledWith('\x1b[?2004h');
h.send(`${startPaste}new\r\ntext${endPaste}\r`);
expect(h.submitted.mock.calls.map(([text]) => text)).toEqual(['new\ntext']);
} finally { h.terminal.stop(); }
});

it('keeps control sequences and incomplete escape sequences out of the unframed fallback', () => {
const buffer = new StdinBuffer();
const paste = vi.fn();
const data = vi.fn();
buffer.on('paste', paste);
buffer.on('data', data);
try {
buffer.process('first\r\x03last');
expect(paste).not.toHaveBeenCalled();
expect(data.mock.calls).toContainEqual(['\x03']);
buffer.process('\x1b[');
buffer.process('first\rlast');
expect(paste).not.toHaveBeenCalled();
} finally { buffer.destroy(); }
});

it('does not time out a bracketed payload or merge neighboring keyboard events into it', () => {
vi.useFakeTimers();
const buffer = new StdinBuffer();
const events: string[][] = [];
buffer.on('paste', (text) => events.push(['paste', text]));
buffer.on('data', (text) => events.push(['data', text]));
try {
buffer.process(`a${startPaste}first\r`);
vi.advanceTimersByTime(1_000);
buffer.process(`\nsecond${endPaste}${startPaste}${endPaste}\r`);
expect(events).toEqual([
['data', 'a'], ['paste', 'first\r\nsecond'], ['paste', ''], ['data', '\r'],
]);
} finally {
buffer.destroy();
vi.useRealTimers();
}
});
});
1 change: 1 addition & 0 deletions release/public-source.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions test/vitest-suites.json
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@
"packages/tui/test/unit/tui-shell-block.test.ts",
"packages/tui/test/unit/tui-status-line-items.test.ts",
"packages/tui/test/unit/tui-terminal-image-paste.test.ts",
"packages/tui/test/unit/tui-terminal-text-paste.test.ts",
"packages/tui/test/unit/tui-thinking-preview.test.ts",
"packages/tui/test/unit/tui-transcript-presentation.test.ts",
"packages/tui/test/unit/tui-transcript-view.test.ts",
Expand Down
Loading