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
- Save the snippet below as
probe.py.
- Run it in Zed's integrated terminal:
py -3.12 probe.py
- Paste any multi-line text. Wait 2 seconds.
- 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.
Summary
On Windows, pasting multi-line text into Zed's integrated terminal sends
CRLFfor every line break when the running application has enabled bracketed paste
(
CSI ?2004h). TUI applications seeCR(Enter) followed byLF(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
any TUI that enables bracketed paste will see the same bytes.
Root cause
crates/terminal/src/terminal.rs:The
\r\n->\rnormalization is inside theelsebranch. When bracketedpaste is active the clipboard text is forwarded unchanged (only
ESCisstripped), so on Windows — where the clipboard holds
CRLF— the terminalemits
CRLFper line.For comparison, xterm.js (used by VS Code) normalizes before deciding how to
send, in
prepareTextForTerminal:so both paths send
CRonly.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_INPUTon stdin,CSI ?2004hrequested):ESC[200~/ESC[201~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
CRbytes.Steps to reproduce
probe.py.py -3.12 probe.pyExpected:
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:
Workaround
Normalize the clipboard to
CRbefore pasting.