Compare commits

...
Author SHA1 Message Date
Cursor Agentand陈大猫 84ba68cef3 fix(terminal): skip paste EL after a same-batch alt-screen exit
Remember the pre-write alternate buffer so a paste echo and ESC[?1049l
in one chunk cannot inject ESC[K] into the restored normal screen.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 04:23:09 +00:00
Cursor Agentand陈大猫 2be06f2b54 fix(terminal): skip paste residual EL on the alternate screen
Vim often finishes a paste chunk with the cursor mid-line. Injecting
ESC[K there erases the rest of the row and is not redrawn. Clear the
pending flag so the cleanup cannot run after leaving the alt screen.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 03:54:51 +00:00
2 changed files with 82 additions and 0 deletions

No files matched your search

@@ -448,6 +448,68 @@ test("long multi-line paste clears cursor-right residue after terminal echo", ()
assert.deepEqual(writes, ["\x1b[K"]);
});
test("long multi-line paste does not erase to end of line on the alternate screen", () => {
const writes: string[] = [];
let bufferType: "alternate" | "normal" = "alternate";
const term = {
cols: 20,
rows: 4,
buffer: {
active: {
get type() {
return bufferType;
},
},
},
paste: () => {},
scrollToBottom: () => {},
write: (data: string) => writes.push(data),
};
const longPaste = Array.from({ length: 20 }, (_, index) => `line ${index} with enough content`).join("\n");
pasteTextIntoTerminal(term, longPaste, {
scrollOnPaste: false,
});
prepareTerminalDataForUserPasteDisplay(term, "\x1b[7mline 3 with enough content\x1b[27m");
assert.equal(clearPasteResidualAfterTerminalWrite(term), null);
assert.deepEqual(writes, []);
bufferType = "normal";
assert.equal(clearPasteResidualAfterTerminalWrite(term), null);
assert.deepEqual(writes, []);
});
test("long multi-line paste does not erase the restored normal buffer when the same batch leaves the alternate screen", () => {
const writes: string[] = [];
let bufferType: "alternate" | "normal" = "alternate";
const term = {
cols: 20,
rows: 4,
buffer: {
active: {
get type() {
return bufferType;
},
},
},
paste: () => {},
scrollToBottom: () => {},
write: (data: string) => writes.push(data),
};
const longPaste = Array.from({ length: 20 }, (_, index) => `line ${index} with enough content`).join("\n");
pasteTextIntoTerminal(term, longPaste, {
scrollOnPaste: false,
});
prepareTerminalDataForUserPasteDisplay(term, "\x1b[7mline 3 with enough content\x1b[27m\x1b[?1049l");
// xterm applies ESC[?1049l before the write callback observes buffer.active.
bufferType = "normal";
assert.equal(clearPasteResidualAfterTerminalWrite(term), null);
assert.deepEqual(writes, []);
});
test("sensitive paste override matches sanitized data containing zero-width characters", () => {
const term = {
paste: () => {},
@@ -32,6 +32,8 @@ type PasteDisplayState = {
clearPending: number;
pasteEchoFragments: string[];
inPasteEchoActiveRegion: boolean;
// Buffer type observed while preparing this chunk, before xterm applies it.
preparedOnAlternateScreen: boolean;
};
type PasteInputScrollState = {
@@ -328,6 +330,7 @@ export function pasteTextIntoTerminal(
clearPending: 0,
pasteEchoFragments: getPasteEchoFragments(text),
inPasteEchoActiveRegion: false,
preparedOnAlternateScreen: false,
});
}
@@ -521,10 +524,17 @@ function consumePasteInputState(
return true;
}
const isAlternateScreenBuffer = (term: object): boolean =>
(term as { buffer?: { active?: { type?: string } } }).buffer?.active?.type === "alternate";
export function prepareTerminalDataForUserPasteDisplay(term: object, data: string): string {
const state = pasteDisplayStates.get(term);
if (!isStateActive(state)) return data;
// Captured before term.write. The residual check runs in the write callback,
// after a same-batch ESC[?1049l has already restored the normal buffer.
state.preparedOnAlternateScreen = isAlternateScreenBuffer(term);
const strippedActiveRegion = stripMatchedReadlineActiveRegion(data, state);
if (strippedActiveRegion.matched) {
state.clearPending = Math.max(state.clearPending, 3);
@@ -541,6 +551,16 @@ export function prepareTerminalDataForUserPasteDisplay(term: object, data: strin
export function clearPasteResidualAfterTerminalWrite(term: object): string | null {
const state = pasteDisplayStates.get(term);
if (!isStateActive(state)) return null;
// Vim and other alternate-screen apps often end a paste chunk with the cursor
// mid-line. EL would erase the rest of that row, and the app may not redraw it (#3166).
// Drop the pending clear so it cannot fire after returning to the normal screen.
// preparedOnAlternateScreen covers a same-batch exit: buffer.active is already
// normal when this write callback runs.
if (state.preparedOnAlternateScreen || isAlternateScreenBuffer(term)) {
state.preparedOnAlternateScreen = false;
state.clearPending = 0;
return null;
}
if (state.clearPending <= 0) return null;
if (typeof (term as Partial<Pick<XTerm, "write">>).write !== "function") return null;