From 433a5454446cbdb8ff1e44001490b6262fb7dea3 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sat, 25 Jul 2026 10:48:47 +0800 Subject: [PATCH 1/2] perf(input): submit a multi-line command as one bracketed paste Replaying each embedded newline as its own CR made the shell's line editor run a full prompt cycle per line -- preexec, the user's precmd chain (git-status prompts, conda), a syntax-highlight pass over the whole buffer, plus our own OSC 133 D follow-up work. A pasted block crawled down the screen line by line, as if the command were being retyped: instant to paste (the local editor never touches the PTY), then visibly slow to run. When the shell has bracketed paste enabled, send the buffer as one paste followed by a single CR. zle keeps the embedded newlines in its buffer, so backslash / open-quote continuation and heredocs still parse as one unit -- the PS2 assembly the per-line replay existed for now happens inside the buffer instead of on the wire -- and the block costs one prompt cycle whatever its line count. Shells without the mode keep the per-line fallback. This is the shape core::agent_prompt::submit_bytes has always used for agent prompts; the shell path was the odd one out. Two fixes ride along, both from the same helper: - ESC is stripped on the submit path (it already was on the clipboard paste path), so clipboard text can't smuggle its own ESC[201~ end marker or hand zle an editor command. - A CRLF clipboard no longer turns \r\n into \r\r -- a stray blank Enter submitted mid-command. 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. --- src/terminal/view.rs | 107 +++++++++++++++++++++++++++++++++++++++---- 1 file changed, 99 insertions(+), 8 deletions(-) diff --git a/src/terminal/view.rs b/src/terminal/view.rs index 519e5f3d..460bbde4 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -583,6 +583,45 @@ fn paste_bytes(text: &str, bracketed: bool) -> Vec { } } +/// Build the byte sequence that submits the local editor's buffer to the shell. +/// +/// The line count is what matters here. Replaying every embedded newline as its +/// own CR makes the shell's line editor run a *full* prompt cycle per line — +/// preexec, the user's precmd chain (git-status prompts, conda…), a +/// syntax-highlight pass over the whole buffer, plus our own OSC 133 `D` +/// follow-up work. A 30-line paste costs 30 of them and visibly crawls down the +/// screen, as if the command were being retyped. Under bracketed paste the +/// whole buffer goes in as a single paste and one CR accepts it: one prompt +/// cycle whatever the line count. +/// +/// Continuation still works — better, in fact. zle keeps the embedded newlines +/// in its buffer, so a backslash / open-quote / heredoc command parses as one +/// unit; the PS2 assembly the per-line replay existed for now happens inside +/// the buffer instead of on the wire. The visible difference is that the block +/// executes as one unit and lands in the shell's history as one entry — which +/// is what pasting multi-line text into any other terminal already does, and +/// what our own history has always recorded. +/// +/// 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 empty buffer skips the markers: zsh's `bracketed-paste-magic` (which +/// oh-my-zsh turns on) errors on a paste with nothing between them. +/// +/// The agent-prompt path already delivers multi-line text this way — see +/// [`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. + let mut bytes = paste_bytes(&clean, bracketed && !clean.is_empty()); + bytes.push(b'\r'); + bytes +} + /// Strip trailing spaces/tabs from every line, preserving the line structure /// (and any final newline). Used by copy when `clipboard_trim_trailing_spaces` /// is on so selections don't carry cell-padding whitespace. @@ -3266,12 +3305,16 @@ impl TerminalView { // "ls" strays + "pwd\r" runs `lspwd`. Wipe first: FIFO puts the ^U // ahead of the line bytes. self.wipe_pending_typeahead(); - // Replay each embedded newline as an Enter so the shell's own line editor - // assembles the multi-line command — backslash / open-quote continuation - // with its PS2 prompts — exactly as if it had been typed line by line. - let mut bytes = line.replace('\n', "\r").into_bytes(); - bytes.push(b'\r'); - self.terminal.write(bytes); + // One paste + one CR when the shell takes bracketed paste, so a + // multi-line command costs one prompt cycle instead of one per line + // (see `submit_bytes`); per-line CRs otherwise. + let bracketed = self + .terminal + .term + .lock() + .mode() + .contains(TermMode::BRACKETED_PASTE); + self.terminal.write(submit_bytes(&line, bracketed)); self.cmd.clear(); self.cursor_visible = true; let mut term = self.terminal.term.lock(); @@ -5763,8 +5806,8 @@ mod tests { SelectEndCopy, WheelRoute, clipboard_paste_text, display_width, drag_scroll_step, encode_mouse, expand_file_command_template, fallback_chain, fig_icon_emoji, fig_icon_glyph, focus_report_bytes, input_overflow_shift, input_overlay_rows, menu_layout, paste_bytes, - select_end_copy, shell_escape_path, smooth_scroll_step, trim_trailing_spaces, wheel_route, - wrapped_click_index, + select_end_copy, shell_escape_path, smooth_scroll_step, submit_bytes, trim_trailing_spaces, + wheel_route, wrapped_click_index, }; use alacritty_terminal::term::TermMode; use gpui::{ClipboardEntry, ClipboardItem, ExternalPaths, Modifiers}; @@ -6198,6 +6241,54 @@ mod tests { ); } + #[test] + fn submit_bytes_sends_a_multi_line_command_as_one_bracketed_paste() { + // The regression this exists for: replaying each newline as its own CR + // made zle run a full prompt cycle per line (preexec + the user's + // precmd chain + a highlight pass), so a pasted block crawled down the + // screen. One paste, one CR — one cycle, whatever the line count. + assert_eq!( + submit_bytes("echo a\necho b\necho c", true), + b"\x1b[200~echo a\necho b\necho c\x1b[201~\r".to_vec() + ); + // Exactly one CR reaches the shell: the accept, not one per line. + let out = submit_bytes("a\nb\nc\nd", true); + assert_eq!(out.iter().filter(|&&b| b == b'\r').count(), 1); + // Single-line commands take the same shape — no special case. + assert_eq!( + submit_bytes("ls -la", true), + b"\x1b[200~ls -la\x1b[201~\r".to_vec() + ); + } + + #[test] + fn submit_bytes_falls_back_to_per_line_cr_without_bracketed_paste() { + // A shell that never enabled bracketed paste can only assemble a + // multi-line command the old way: one Enter per line, letting its + // editor do the PS2 continuation. + assert_eq!(submit_bytes("a\nb", false), b"a\rb\r".to_vec()); + // A CRLF clipboard yields one CR per line, not a stray extra Enter + // (`\r\n` used to become `\r\r` — a blank line submitted mid-command). + assert_eq!(submit_bytes("a\r\nb", 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 + // paste early and have the rest run as typed input. + let out = submit_bytes("foo\x1b[201~\nrm -rf ~", true); + let end = b"\x1b[201~"; + assert_eq!(out.windows(end.len()).filter(|w| *w == end).count(), 1); + assert_eq!(out, b"\x1b[200~foo[201~\nrm -rf ~\x1b[201~\r".to_vec()); + // ESC is stripped on the unbracketed path too — raw ESC reaching zle is + // an editor command, not text. + assert_eq!(submit_bytes("a\x1bb", false), b"ab\r".to_vec()); + + // An empty line is a bare Enter: zsh's `bracketed-paste-magic` errors + // on a paste with nothing between the markers. + assert_eq!(submit_bytes("", true), b"\r".to_vec()); + } + #[test] fn shell_escape_path_escapes_spaces_and_metachars() { // A plain path is untouched. 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 2/2] 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