Skip to content

Pasted CRLF is not normalized to CR in bracketed-paste mode #64513

Description

@azukihoney

Summary

On Windows, pasting multi-line text into Zed's integrated terminal sends CRLF
for every line break when the running application has enabled bracketed paste
(CSI ?2004h). TUI applications see CR (Enter) followed by LF (Ctrl+J),
so a single line break arrives as two distinct key events. In practice the first
line is submitted immediately and the rest of the paste lands in the next prompt.

paste() already normalizes line endings, but only on the non-bracketed branch,
so the normalization never runs for the case where it matters most.

Environment

  • Zed 1.20.2 (stable), Windows 11 26200
  • Reproduced with OpenAI Codex CLI 0.154.0, but it is not specific to that app:
    any TUI that enables bracketed paste will see the same bytes.

Root cause

crates/terminal/src/terminal.rs:

pub fn paste(&mut self, text: &str) {
    let paste_text = if self.last_content.mode.contains(Modes::BRACKETED_PASTE) {
        format!("{}{}{}", "\x1b[200~", text.replace('\x1b', ""), "\x1b[201~")
    } else {
        text.replace("\r\n", "\r").replace('\n', "\r")
    };

    self.input(paste_text.into_bytes());
}

The \r\n -> \r normalization is inside the else branch. When bracketed
paste is active the clipboard text is forwarded unchanged (only ESC is
stripped), so on Windows — where the clipboard holds CRLF — the terminal
emits CRLF per line.

For comparison, xterm.js (used by VS Code) normalizes before deciding how to
send, in prepareTextForTerminal:

return text.replace(/\r?\n/g, '\r');

so both paths send CR only.

Evidence

Same 23-line clipboard payload, pasted into Zed's terminal and into VS Code's
terminal, with the raw console input captured
(ENABLE_VIRTUAL_TERMINAL_INPUT on stdin, CSI ?2004h requested):

Zed 1.20.2 VS Code
ESC[200~ / ESC[201~ 1 / 1 1 / 1
read chunks 2 2
total bytes 1031 1008
CRLF 23 0
CR alone 0 23
LF alone 0 0

Everything else — the bracketed-paste markers and the way the payload is
delivered — is identical. The only difference is the line terminator, and the
23-byte size difference is exactly the 23 extra CR bytes.

Steps to reproduce

  1. Save the snippet below as probe.py.
  2. Run it in Zed's integrated terminal: py -3.12 probe.py
  3. Paste any multi-line text. Wait 2 seconds.
  4. Repeat in another terminal (VS Code, Windows Terminal) for comparison.
import ctypes, sys, time
from ctypes import wintypes
k32 = ctypes.WinDLL("kernel32", use_last_error=True)
hin, hout = k32.GetStdHandle(-10), k32.GetStdHandle(-11)
def mode(h):
    m = wintypes.DWORD(); k32.GetConsoleMode(h, ctypes.byref(m)); return m.value
in0, out0 = mode(hin), mode(hout)
k32.SetConsoleMode(hout, out0 | 0x0004)          # VT processing (output)
k32.SetConsoleMode(hin, (in0 | 0x0200) & ~0x0007)  # VT input, raw
sys.stdout.write("\x1b[?2004h"); sys.stdout.flush()
buf, read, data, last = ctypes.create_string_buffer(4096), wintypes.DWORD(), b"", None
started = time.time()
try:
    while True:
        n = wintypes.DWORD(); k32.GetNumberOfConsoleInputEvents(hin, ctypes.byref(n))
        if n.value and k32.ReadFile(hin, buf, 4096, ctypes.byref(read), None):
            data += buf.raw[:read.value]; last = time.time()
        elif (last and time.time() - last > 2) or time.time() - started > 60:
            break          # Ctrl+C is disabled here, so always leave a way out
        else:
            time.sleep(0.002)
finally:
    sys.stdout.write("\x1b[?2004l"); sys.stdout.flush()
    k32.SetConsoleMode(hin, in0); k32.SetConsoleMode(hout, out0)
crlf = data.count(b"\r\n")
print(f"\nbytes={len(data)} start={data.count(b'[200~')} end={data.count(b'[201~')} "
      f"CRLF={crlf} CR={data.count(chr(13).encode())-crlf} LF={data.count(chr(10).encode())-crlf}")

Expected: CRLF=0, CR=<number of lines>.
Actual in Zed: CRLF=<number of lines>, CR=0.

Suggested fix

Normalize once, before the branch, so both paths behave the same:

pub fn paste(&mut self, text: &str) {
    let text = text.replace("\r\n", "\r").replace('\n', "\r");
    let paste_text = if self.last_content.mode.contains(Modes::BRACKETED_PASTE) {
        format!("{}{}{}", "\x1b[200~", text.replace('\x1b', ""), "\x1b[201~")
    } else {
        text
    };

    self.input(paste_text.into_bytes());
}

Workaround

Normalize the clipboard to CR before pasting.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    platform:windowshappens only on Windows, not other OSreach:some usersBugs that happen for less than one-third of users, special configurations, rare circumstances, etcseverity:S2Average run-of-the-mill bugs; a feature is broken but only partially

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions