From 0ac9f8aebfbc0f5c7d80fe23aa6000753d941849 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sat, 25 Jul 2026 11:43:36 +0800 Subject: [PATCH] fix(input): normalize CR before the bracketed submit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The unbracketed fallback stripped a CRLF clipboard's stray \r, but the bracketed branch passed it inside the markers, leaving the line break to whatever the far side does with a CR in a paste — a blank line under zsh, a literal ^M under a shell that doesn't translate it. Normalize every line break to one \n in the same pass that strips ESC, so both branches start from the same shape. --- src/terminal/view.rs | 37 +++++++++++++++++++++++++++++++++---- 1 file changed, 33 insertions(+), 4 deletions(-) diff --git a/src/terminal/view.rs b/src/terminal/view.rs index 460bbde4..f3add00b 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -605,7 +605,8 @@ fn paste_bytes(text: &str, bracketed: bool) -> Vec { /// ESC is stripped first (in both branches, unlike the paste path): clipboard /// text carrying its own `ESC[201~` could otherwise close the paste early and /// have the rest run as typed input, and a raw ESC reaching zle unbracketed is -/// an editor command, not text. +/// an editor command, not text. CR is normalized away in the same pass, so a +/// CRLF clipboard is one line break either way rather than a stray blank Enter. /// /// An empty buffer skips the markers: zsh's `bracketed-paste-magic` (which /// oh-my-zsh turns on) errors on a paste with nothing between them. @@ -614,9 +615,19 @@ fn paste_bytes(text: &str, bracketed: bool) -> Vec { /// [`crate::core::agent_prompt::submit_bytes`], which is this shape minus the /// unbracketed fallback (an agent TUI always enables the mode). fn submit_bytes(line: &str, bracketed: bool) -> Vec { - let clean: String = line.chars().filter(|&c| c != '\x1b').collect(); - // The non-bracketed fallback rides `paste_bytes`' newline normalization, so - // a CRLF clipboard yields one CR per line rather than a stray extra Enter. + // A CRLF clipboard pastes into the editor verbatim, so the `\r` has to go + // before either branch sees it. Unbracketed it would be a second Enter + // (`\r\n` → `\r\r`, a blank line submitted mid-command); bracketed it would + // ride inside the markers and land on whatever the far side happens to do + // with a CR in a paste — zsh turns it into a newline, so the block gains a + // blank line, and a shell that doesn't leaves a literal `^M` in the command. + // One `\n` per line is the shape both branches are written for. + let clean: String = line + .replace("\r\n", "\n") + .chars() + .filter(|&c| c != '\x1b') + .map(|c| if c == '\r' { '\n' } else { c }) + .collect(); let mut bytes = paste_bytes(&clean, bracketed && !clean.is_empty()); bytes.push(b'\r'); bytes @@ -6272,6 +6283,24 @@ mod tests { assert_eq!(submit_bytes("a\r\nb", false), b"a\rb\r".to_vec()); } + #[test] + fn submit_bytes_normalizes_line_breaks_inside_the_paste() { + // The CR of a CRLF clipboard must not ride inside the markers either: + // zsh turns a pasted CR into a newline (so the block would gain a blank + // line) and a shell that doesn't leaves a literal `^M` in the command. + assert_eq!( + submit_bytes("a\r\nb", true), + b"\x1b[200~a\nb\x1b[201~\r".to_vec() + ); + // A lone CR is a line break too — dropping it would glue the lines + // together into one command. + assert_eq!( + submit_bytes("a\rb", true), + b"\x1b[200~a\nb\x1b[201~\r".to_vec() + ); + assert_eq!(submit_bytes("a\rb", false), b"a\rb\r".to_vec()); + } + #[test] fn submit_bytes_strips_esc_and_skips_markers_on_an_empty_line() { // Clipboard text carrying its own `ESC[201~` would otherwise close the