From a7c7e63f42a149a264b653382cb0df67c751a189 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Mon, 7 Sep 2026 22:13:51 +0800 Subject: [PATCH] fix(terminal): keep the paste mark on text held across the prompt gap (#660) `TerminalView::paste` marks the line pasted only on the branch that reaches the editor. The other branch hands the clipboard text to `write_gap_text`, and while the prompt is still on its way that text is *held* rather than written: `GapHold` keeps it out of the PTY and the editor prepends the whole net when it takes over (`Render`, and the two `engage` sites on submit and handoff). Provenance was lost on that route, so a paste made in the gap -- paste, wait for the prompt, Enter, which is how typing ahead works -- came out the far side looking typed and was submitted raw. A fish user with `abbr -a l 'ls -la'` pasting `l /tmp` there got `ls -la /tmp`: exactly the paste contract `insert_pasted` was added to hold. The hold now carries the mark with the text it is holding, and hands it to the editor with the net. `release` and `engage` clear it along with the net, so it is scoped to one gap the way `CmdEditor::clear` scopes the editor's to one line, and a passthrough marks nothing -- those bytes went straight to the shell. The three `engage` + `prepend_str` sites become one `engage_hold_into_editor` so the mark cannot be dropped at one of them later. --- src/terminal/cmd_editor.rs | 22 +++++++++++++ src/terminal/hold.rs | 63 ++++++++++++++++++++++++++++++++++++++ src/terminal/view.rs | 47 ++++++++++++++++++++-------- 3 files changed, 119 insertions(+), 13 deletions(-) diff --git a/src/terminal/cmd_editor.rs b/src/terminal/cmd_editor.rs index 5168c371..352472ad 100644 --- a/src/terminal/cmd_editor.rs +++ b/src/terminal/cmd_editor.rs @@ -106,6 +106,16 @@ impl CmdEditor { self.pasted } + /// Prepend text the gap hold collected, and remember that some of it came + /// off the clipboard. The counterpart to [`insert_pasted`](Self::insert_pasted) + /// for the one route into this buffer that does not go through the editor: + /// a paste made before the prompt arrived is held outside it and prepended + /// when the editor takes over. + pub fn prepend_pasted(&mut self, s: &str) { + self.pasted = true; + self.prepend_str(s); + } + pub fn prepend_str(&mut self, s: &str) { if s.is_empty() { return; @@ -889,5 +899,17 @@ mod tests { assert!(!e.pasted()); e.insert_str("j build"); assert!(!e.pasted()); + + // Text pasted before the prompt arrived is held outside this buffer + // and prepended when the editor takes over; it has to bring the mark + // with it, or the gap would be a way around `insert_pasted`. + e.clear(); + e.insert_str(" /tmp"); + e.prepend_pasted("l"); + assert_eq!(e.text(), "l /tmp"); + assert!(e.pasted()); + e.clear(); + e.prepend_str("ls"); + assert!(!e.pasted(), "typed gap text stays typed"); } } diff --git a/src/terminal/hold.rs b/src/terminal/hold.rs index 39203e1a..29fe7a2e 100644 --- a/src/terminal/hold.rs +++ b/src/terminal/hold.rs @@ -17,6 +17,7 @@ pub struct GapHold { net: String, bytes: Vec, epoch: u64, + pasted: bool, } impl GapHold { @@ -28,6 +29,28 @@ impl GapHold { self.hold(bytes, |net| net.push_str(s)) } + /// [`hold_text`](Self::hold_text) for text that came off the clipboard + /// rather than the keyboard. + /// + /// The provenance has to ride along with the held text: a paste made while + /// the prompt was still on its way lands here, not in the editor, and the + /// editor takes the whole net over when the gap ends. Without the mark + /// that text would arrive looking typed and be submitted as typed (#660), + /// which is exactly what `CmdEditor::insert_pasted` exists to prevent. + pub fn hold_pasted_text(&mut self, s: &str, bytes: &[u8]) -> Verdict { + let verdict = self.hold_text(s, bytes); + if matches!(verdict, Verdict::Held(_)) { + self.pasted = true; + } + verdict + } + + /// Whether any of the text held for the editor came off the clipboard. + /// Read it before [`engage`](Self::engage), which clears it with the net. + pub fn pasted(&self) -> bool { + self.pasted + } + pub fn hold_backspace(&mut self, bytes: &[u8]) -> Verdict { self.hold(bytes, |net| { net.pop(); @@ -53,6 +76,7 @@ impl GapHold { pub fn release(&mut self) -> Option<(String, Vec)> { let held = matches!(self.state, State::Holding); self.state = State::Passthrough; + self.pasted = false; held.then(|| { ( std::mem::take(&mut self.net), @@ -72,6 +96,7 @@ impl GapHold { pub fn engage(&mut self) -> Option { self.state = State::Idle; self.bytes.clear(); + self.pasted = false; let net = std::mem::take(&mut self.net); (!net.is_empty()).then_some(net) } @@ -90,6 +115,44 @@ mod tests { assert_eq!(h.engage(), None); } + #[test] + fn a_paste_held_in_the_gap_reaches_the_editor_marked() { + // Pasting while the previous command is still finishing puts the + // clipboard text here rather than in the editor. It must not arrive + // looking typed: the submit path would then hand it to the shell's + // binding table (#660). + let mut h = GapHold::new(); + assert!(!h.pasted(), "a fresh hold carries nothing pasted"); + assert!(matches!( + h.hold_text("cat ", b"cat "), + Verdict::Held(Some(_)) + )); + assert!(!h.pasted()); + assert!(matches!( + h.hold_pasted_text("/tmp/x", b"/tmp/x"), + Verdict::Held(None) + )); + assert!(h.pasted(), "the whole net is pasted once any of it is"); + assert_eq!(h.engage(), Some("cat /tmp/x".to_string())); + assert!(!h.pasted(), "engage hands the mark over with the net"); + + // A dump to the PTY takes the mark with it too: what the editor never + // receives cannot be submitted from it. + let mut h = GapHold::new(); + h.hold_pasted_text("ls", b"ls"); + assert!(h.pasted()); + assert_eq!(h.release(), Some(("ls".to_string(), b"ls".to_vec()))); + assert!(!h.pasted()); + + // Past the window the hold is a passthrough, so there is nothing to + // mark -- the bytes went straight to the shell. + assert!(matches!( + h.hold_pasted_text("x", b"x"), + Verdict::Passthrough + )); + assert!(!h.pasted()); + } + #[test] fn timeout_dumps_typed_bytes_once_and_goes_passthrough() { let mut h = GapHold::new(); diff --git a/src/terminal/view.rs b/src/terminal/view.rs index b4c6d94d..499f588b 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -2835,7 +2835,7 @@ impl TerminalView { .lock() .mode() .contains(TermMode::BRACKETED_PASTE); - self.write_gap_text(&text, paste_bytes(&text, bracketed), cx); + self.write_gap_text(&text, paste_bytes(&text, bracketed), true, cx); cx.notify(); } @@ -4017,14 +4017,21 @@ impl TerminalView { self.terminal.shell_active() && !self.on_alt_screen() && !self.shell_owns_prompt() } - fn write_gap_text(&mut self, text: &str, bytes: Vec, cx: &mut Context) { + /// `pasted` says the text came off the clipboard rather than the keyboard, + /// so that a paste the hold keeps for the editor still reaches it marked. + fn write_gap_text(&mut self, text: &str, bytes: Vec, pasted: bool, cx: &mut Context) { if self.shell_owns_prompt() { self.release_hold(); self.terminal.write(bytes); return; } if self.gap_holdable() && !text.chars().any(char::is_control) { - match self.hold.hold_text(text, &bytes) { + let held = if pasted { + self.hold.hold_pasted_text(text, &bytes) + } else { + self.hold.hold_text(text, &bytes) + }; + match held { Verdict::Held(arm) => { if let Some(epoch) = arm { self.arm_hold_timer(epoch, cx); @@ -4040,6 +4047,26 @@ impl TerminalView { self.observe_typeahead(RawInput::Text(text)); } + /// Move whatever the gap hold collected into the editor's buffer, keeping + /// the paste mark with it. + /// + /// The hold is the one route into that buffer that does not run through + /// the editor: text arriving before the prompt does is kept out here and + /// prepended when the editor takes over. A paste that lost its provenance + /// on the way would be submitted as typed (#660) — see + /// [`CmdEditor::prepend_pasted`]. + fn engage_hold_into_editor(&mut self) { + let pasted = self.hold.pasted(); + let Some(net) = self.hold.engage() else { + return; + }; + if pasted { + self.cmd.prepend_pasted(&net); + } else { + self.cmd.prepend_str(&net); + } + } + fn release_hold(&mut self) { if let Some((net, bytes)) = self.hold.release() { self.terminal.write(bytes); @@ -4112,9 +4139,7 @@ impl TerminalView { if self.terminal.exited || !self.accepts_input(cx) { return; } - if let Some(net) = self.hold.engage() { - self.cmd.prepend_str(&net); - } + self.engage_hold_into_editor(); let line = self.cmd.text(); if !line.trim().is_empty() { let cwd = self.cwd(); @@ -4385,9 +4410,7 @@ impl TerminalView { if !self.accepts_input(cx) { return; } - if let Some(net) = self.hold.engage() { - self.cmd.prepend_str(&net); - } + self.engage_hold_into_editor(); let line = self.cmd.text(); if line.contains('\n') { cx.notify(); @@ -4878,7 +4901,7 @@ impl TerminalView { cx.notify(); return; } - self.write_gap_text(text, text.as_bytes().to_vec(), cx); + self.write_gap_text(text, text.as_bytes().to_vec(), false, cx); self.cursor_visible = true; cx.notify(); } @@ -6388,9 +6411,7 @@ impl Render for TerminalView { } self.typeahead.drain(); } else if self.input_active() { - if let Some(net) = self.hold.engage() { - self.cmd.prepend_str(&net); - } + self.engage_hold_into_editor(); if self.terminal.zle_reading() { self.flush_typeahead(); }