From bfabe0537547a0f785c4e9e27c165e187fd9f590 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Tue, 8 Sep 2026 00:22:24 +0800 Subject: [PATCH] fix(terminal): submit the line the shell is still holding, not an empty one (#800) Refs #433. Adopts the held seed at the editor's own doors rather than at submit time, so a recalled history entry, a ctrl-U, a ghost suggestion or a completion is no longer glued to the front of the gap text. Also closes a paste-provenance hole left by #790: release_hold()/dump_hold() pushed the hold's contents into the typeahead record as plain text, dropping the paste mark, so a paste made during a gap that outlived the hold window came back looking typed and was submitted raw through the shell's binding table. --- src/terminal/typeahead.rs | 91 +++++++- src/terminal/view.rs | 423 +++++++++++++++++++++++++++++++++++++- 2 files changed, 505 insertions(+), 9 deletions(-) diff --git a/src/terminal/typeahead.rs b/src/terminal/typeahead.rs index 4e593b99..21f097c2 100644 --- a/src/terminal/typeahead.rs +++ b/src/terminal/typeahead.rs @@ -4,11 +4,20 @@ const RECORD_CAP: usize = 4096; pub struct Typeahead { text: String, tainted: bool, + pasted: bool, } pub enum RawInput<'a> { Text(&'a str), - Key { key: &'a str, plain: bool }, + /// [`Text`](Self::Text) for text that came off the clipboard rather than + /// the keyboard. The record replays into the editor's buffer, so the + /// provenance has to survive the round trip or a paste comes back looking + /// typed and is submitted as typed (#660). + Pasted(&'a str), + Key { + key: &'a str, + plain: bool, + }, Interrupt, } @@ -22,6 +31,10 @@ impl Typeahead { RawInput::Interrupt => self.discard(), _ if externally_owned => {} RawInput::Text(s) => self.record_text(s), + RawInput::Pasted(s) => { + self.record_text(s); + self.pasted = true; + } RawInput::Key { key: "enter", plain: true, @@ -40,10 +53,30 @@ impl Typeahead { *self = Self::default(); } + /// Whether any of the recorded text came off the clipboard. Read it before + /// [`drain`](Self::drain) or [`adopt`](Self::adopt), which clear it with + /// the seed — the same contract `GapHold::pasted` carries. + pub fn pasted(&self) -> bool { + self.pasted + } + pub fn drain(&mut self) -> Option { std::mem::take(self).flush() } + /// Hand the seed to the local editor while leaving the wipe owed. + /// + /// The shell is still sitting on this text, so the `^U` that erases it has + /// to go out eventually — but not necessarily now. Taking the seed out and + /// keeping the record in its tainted (wipe, seed nothing) shape lets the + /// editor own the whole line straight away, and the next `drain` still + /// produces the wipe. + pub fn adopt(&mut self) -> Option { + let seed = self.drain()?; + self.tainted = true; + Some(seed) + } + fn record_text(&mut self, s: &str) { if s.chars().any(char::is_control) { self.tainted = true; @@ -201,6 +234,62 @@ mod tests { assert_eq!(Typeahead::new().drain(), None); } + #[test] + fn adopting_moves_the_seed_out_and_leaves_the_wipe_owed() { + let mut t = Typeahead::new(); + t.observe(RawInput::Text("echo"), false); + assert_eq!(t.adopt(), Some("echo".to_string())); + // The seed is the editor's now, but the shell is still holding it. + assert_eq!(t.drain(), Some(String::new())); + assert_eq!(t.drain(), None); + } + + #[test] + fn adopting_an_empty_record_owes_nothing() { + let mut t = Typeahead::new(); + assert_eq!(t.adopt(), None); + assert_eq!(t.drain(), None); + } + + #[test] + fn adopting_twice_seeds_once() { + let mut t = Typeahead::new(); + t.observe(RawInput::Text("echo"), false); + assert_eq!(t.adopt(), Some("echo".to_string())); + assert_eq!(t.adopt(), Some(String::new())); + assert_eq!(t.drain(), Some(String::new())); + } + + #[test] + fn a_pasted_gap_replays_as_a_paste() { + let mut t = Typeahead::new(); + assert!(!t.pasted(), "a fresh record carries nothing pasted"); + t.observe(RawInput::Text("cat "), false); + assert!(!t.pasted()); + t.observe(RawInput::Pasted("/tmp/x"), false); + assert!(t.pasted(), "the whole seed is pasted once any of it is"); + assert_eq!(t.drain(), Some("cat /tmp/x".to_string())); + assert!(!t.pasted(), "the drain hands the mark over with the seed"); + } + + #[test] + fn adopting_hands_the_paste_mark_over_with_the_seed() { + let mut t = Typeahead::new(); + t.observe(RawInput::Pasted("ls"), false); + assert_eq!(t.adopt(), Some("ls".to_string())); + assert!(!t.pasted(), "what is left is the owed wipe, not a paste"); + assert_eq!(t.drain(), Some(String::new())); + } + + #[test] + fn discarding_an_adopted_record_drops_the_owed_wipe() { + let mut t = Typeahead::new(); + t.observe(RawInput::Text("echo"), false); + assert_eq!(t.adopt(), Some("echo".to_string())); + t.discard(); + assert_eq!(t.drain(), None); + } + #[test] fn typed_text_is_wiped_and_seeded() { let mut p = Typeahead::new(); diff --git a/src/terminal/view.rs b/src/terminal/view.rs index bda9a686..fe246279 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -2370,6 +2370,7 @@ impl TerminalView { let key = ks.key.as_str(); self.cursor_visible = true; self.jump_to_prompt(); + self.adopt_typeahead(); let aliased; let ks = if m.control && !m.platform && !m.alt && matches!(key, "p" | "n") { @@ -4050,12 +4051,36 @@ impl TerminalView { } fn flush_typeahead(&mut self) { + let pasted = self.typeahead.pasted(); let Some(seed) = self.typeahead.drain() else { return; }; self.terminal.write(vec![0x15]); if !seed.is_empty() { - self.cmd.prepend_str(&seed); + self.prepend_into_editor(&seed, pasted); + } + } + + /// Take the record into the editor without paying the wipe yet. Every door + /// into the editor opens with this. + /// + /// `at_prompt` comes back on the `D` mark, a whole prompt draw ahead of the + /// `B` that arms `zle_reading`, and this editor is live for that whole + /// window. Everything it offers rewrites the line — history recall and the + /// ghost suggestion replace it wholesale, ⌃U empties it, completion filters + /// on it — so the line has to be whole *before* those run, not stitched + /// back together at submit time in front of whatever replaced it. Folding + /// it in that late made `↑` then Enter run the recalled entry with the gap + /// text glued to its front, and ⌃U then Enter bring back the text ⌃U had + /// just cleared. + /// + /// The `^U` stays owed until `flush_typeahead`, which keeps it where it has + /// always been on the wire: immediately before the line. Sending it here + /// instead would put it out before the shell's own editor is reading. + fn adopt_typeahead(&mut self) { + let pasted = self.typeahead.pasted(); + if let Some(seed) = self.typeahead.adopt() { + self.prepend_into_editor(&seed, pasted); } } @@ -4096,7 +4121,7 @@ impl TerminalView { self.release_hold(); } self.terminal.write(bytes); - self.observe_typeahead(RawInput::Text(text)); + self.observe_gap_text(text, pasted); } /// Move whatever the gap hold collected into the editor's buffer, keeping @@ -4112,17 +4137,36 @@ impl TerminalView { let Some(net) = self.hold.engage() else { return; }; + self.prepend_into_editor(&net, pasted); + } + + /// Put text in front of the editor's line that the editor did not receive + /// through its own keys, keeping the provenance that decides how the line + /// is submitted (#660). Both routes that do this — the gap hold and the + /// typeahead record — carry a `pasted()` to read before they hand over. + fn prepend_into_editor(&mut self, text: &str, pasted: bool) { if pasted { - self.cmd.prepend_pasted(&net); + self.cmd.prepend_pasted(text); } else { - self.cmd.prepend_str(&net); + self.cmd.prepend_str(text); } } + /// A gap's text on its way to the record, which replays it into the editor + /// later — so a paste has to be recorded as one. + fn observe_gap_text(&mut self, text: &str, pasted: bool) { + self.observe_typeahead(if pasted { + RawInput::Pasted(text) + } else { + RawInput::Text(text) + }); + } + fn release_hold(&mut self) { + let pasted = self.hold.pasted(); if let Some((net, bytes)) = self.hold.release() { self.terminal.write(bytes); - self.observe_typeahead(RawInput::Text(&net)); + self.observe_gap_text(&net, pasted); } } @@ -4139,9 +4183,10 @@ impl TerminalView { let _ = self.hold.timeout(epoch); return; } + let pasted = self.hold.pasted(); if let Some((net, bytes)) = self.hold.timeout(epoch) { self.terminal.write(bytes); - self.observe_typeahead(RawInput::Text(&net)); + self.observe_gap_text(&net, pasted); cx.notify(); } } @@ -4192,6 +4237,16 @@ impl TerminalView { return; } self.engage_hold_into_editor(); + // The shell is still holding the recorded text on its own line, and the + // ^U that erases it has not gone out yet: `at_prompt` comes back on the + // `D` mark, before the prompt is even drawn, while the wipe waits for + // `B`. The key that got here has folded the seed into the line already, + // so this is usually just paying the wipe that left owed; where nothing + // has, the drain still puts the seed back the way every other drain + // does. Dropping it submitted only what was typed after the handover, + // and an empty command when that was nothing, which is the blank line + // #433 reports. + self.flush_typeahead(); let line = self.cmd.text(); if !line.trim().is_empty() { let cwd = self.cwd(); @@ -4227,7 +4282,6 @@ impl TerminalView { self.history_prefix.clear(); self.close_completion(); - self.wipe_pending_typeahead(); let bracketed = self .terminal .term @@ -4463,13 +4517,18 @@ impl TerminalView { return; } self.engage_hold_into_editor(); + // Same reason as `submit_command`: what the record holds is on the + // shell's own line, so it belongs in front of the line handed back. + // The wipe waits until past the multi-line bail, which hands nothing + // over and so must put nothing on the wire either. + self.adopt_typeahead(); let line = self.cmd.text(); if line.contains('\n') { cx.notify(); return; } self.close_completion(); - self.wipe_pending_typeahead(); + self.flush_typeahead(); let tail = line.chars().count().saturating_sub(self.cmd.cursor()); if !line.is_empty() { self.terminal.write(line.into_bytes()); @@ -4944,6 +5003,7 @@ impl TerminalView { return; } if self.input_active() { + self.adopt_typeahead(); self.cmd.insert_str(text); self.history_nav = None; self.editor_goal_col = None; @@ -14207,3 +14267,350 @@ mod gpui_tests { .unwrap(); } } + +/// The window between the shell reporting a prompt and its line editor +/// actually reading, which is where a fast typist's line goes missing (#433). +/// +/// These drive a real `TerminalView` over a pane link on every platform, so +/// they are not gated to unix the way `gpui_tests` is. +#[cfg(test)] +mod prompt_handover_tests { + use super::*; + use crate::daemon::protocol::{ClientMsg, DaemonMsg}; + use crate::daemon::transport::Stream; + use gpui::TestAppContext; + + fn harness(cx: &mut TestAppContext) -> (gpui::WindowHandle, Stream) { + crate::core::config::pin_test_config_dir(); + cx.executor().allow_parking(); + let (client_side, daemon_side) = test_stream_pair(); + cx.update(|cx| { + gpui_component::init(cx); + cx.set_global(Config::default()); + }); + let window = cx.add_window(|window, cx| { + let terminal = RemoteTerminal::from_stream(client_side, TermSize::new(80, 24)) + .expect("link-backed terminal"); + TerminalView::with_terminal(terminal, 1, window, cx) + }); + (window, daemon_side) + } + + /// Everything the pane has written to the PTY, in order. + fn drain(daemon: &mut Stream) -> Vec { + daemon + .set_read_timeout(Some(std::time::Duration::from_millis(150))) + .unwrap(); + let mut out = Vec::new(); + loop { + match ClientMsg::read(daemon) { + Ok(ClientMsg::Input(bytes)) => out.extend_from_slice(&bytes), + Ok(_) => continue, + Err(e) + if matches!( + e.kind(), + std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut + ) => + { + break; + } + Err(e) => panic!("pane link failed: {e}"), + } + } + out + } + + fn settle( + cx: &mut TestAppContext, + window: &gpui::WindowHandle, + what: &str, + f: impl Fn(&TerminalView) -> bool, + ) { + for _ in 0..300 { + cx.run_until_parked(); + if window.update(cx, |view, _, _| f(view)).unwrap() { + return; + } + std::thread::sleep(std::time::Duration::from_millis(2)); + } + panic!("never settled: {what}"); + } + + /// Printable text arrives the way the platform delivers it — through the + /// text-input path, which is what the gap hold and the typeahead record see. + fn type_text(window: &gpui::WindowHandle, cx: &mut TestAppContext, text: &str) { + for ch in text.chars() { + window + .update(cx, |view, _, cx| view.commit_text(&ch.to_string(), cx)) + .unwrap(); + } + } + + fn press(window: &gpui::WindowHandle, cx: &mut TestAppContext, key: &str) { + window + .update(cx, |view, window, cx| { + view.on_key_down( + &KeyDownEvent { + keystroke: gpui::Keystroke::parse(key).unwrap(), + is_held: false, + prefer_character_input: false, + }, + window, + cx, + ); + }) + .unwrap(); + } + + fn prompt(daemon: &mut Stream, at_prompt: bool) { + DaemonMsg::Prompt { + active: true, + at_prompt, + last_exit: None, + } + .encode(daemon) + .unwrap(); + } + + /// Types `text` into the gap of a running command and lets the hold window + /// expire, so the bytes go to the PTY and are recorded for replay. Then + /// puts the pane back at a prompt the way the `D` mark does — before the + /// prompt is drawn, so the shell's line editor is not reading yet. + fn typed_into_the_gap_then_handed_back( + cx: &mut TestAppContext, + window: &gpui::WindowHandle, + daemon: &mut Stream, + text: &str, + ) { + prompt(daemon, true); + DaemonMsg::Output(b"\x1b]133;B\x07".to_vec()) + .encode(daemon) + .unwrap(); + settle(cx, window, "the editor takes the first prompt", |view| { + view.input_active() && view.terminal.zle_reading() + }); + + prompt(daemon, false); + DaemonMsg::Output(b"\x1b]133;C\x07".to_vec()) + .encode(daemon) + .unwrap(); + settle(cx, window, "a command takes the pane", |view| { + !view.input_active() + }); + + type_text(window, cx, text); + cx.executor().advance_clock(HOLD_WINDOW * 2); + cx.run_until_parked(); + assert_eq!( + drain(daemon), + text.as_bytes(), + "the hold window gives up and dumps what it held" + ); + + prompt(daemon, true); + settle(cx, window, "the editor takes the prompt back", |view| { + view.input_active() + }); + assert!( + !window + .update(cx, |view, _, _| view.terminal.zle_reading()) + .unwrap(), + "this is the D-to-B window: the shell is not reading its line yet" + ); + } + + #[gpui::test] + fn a_line_typed_into_the_gap_survives_a_prompt_that_is_not_reading_yet( + cx: &mut TestAppContext, + ) { + let (window, mut daemon) = harness(cx); + typed_into_the_gap_then_handed_back(cx, &window, &mut daemon, "echo hi"); + + press(&window, cx, "enter"); + cx.run_until_parked(); + assert_eq!( + drain(&mut daemon), + b"\x15echo hi\r".to_vec(), + "the line the shell is holding must be erased and submitted whole, \ + not erased and replaced by an empty command" + ); + } + + #[gpui::test] + fn a_prompt_handover_keeps_the_held_text_in_front_of_what_follows_it(cx: &mut TestAppContext) { + let (window, mut daemon) = harness(cx); + typed_into_the_gap_then_handed_back(cx, &window, &mut daemon, "echo"); + + // Typing carries straight on into the editor that just took the prompt. + type_text(&window, cx, " hi"); + cx.run_until_parked(); + assert_eq!( + drain(&mut daemon), + Vec::::new(), + "the editor owns these keys, so none of them reach the PTY" + ); + + press(&window, cx, "enter"); + cx.run_until_parked(); + assert_eq!( + drain(&mut daemon), + b"\x15echo hi\r".to_vec(), + "what the shell was holding leads the line, not the tail alone" + ); + } + + /// The editor takes the held line over the moment it is touched, before it + /// edits anything — and takes it over without putting the wipe on the wire, + /// which is still the shell's line editor's to receive when it starts + /// reading. + #[gpui::test] + fn the_editor_takes_the_held_line_over_before_it_edits_it(cx: &mut TestAppContext) { + let (window, mut daemon) = harness(cx); + typed_into_the_gap_then_handed_back(cx, &window, &mut daemon, "echo"); + + // `home` moves the caret and nothing else: any editor key is enough. + press(&window, cx, "home"); + cx.run_until_parked(); + window + .update(cx, |view, _, _| { + assert_eq!( + view.cmd.text(), + "echo", + "the line the shell is sitting on is the editor's line now" + ); + }) + .unwrap(); + assert_eq!( + drain(&mut daemon), + Vec::::new(), + "taking the line over owes the wipe, it does not send it early" + ); + + press(&window, cx, "enter"); + cx.run_until_parked(); + assert_eq!( + drain(&mut daemon), + b"\x15echo\r".to_vec(), + "the owed wipe is paid on submit, still in front of the line" + ); + } + + /// The half of the window the seed alone does not cover: the editor is + /// live, so the user can *replace* the line before submitting it. Recalling + /// history and pressing Enter has to run the entry recalled — not that + /// entry with the text the shell was holding glued to its front. + #[gpui::test] + fn recalling_history_in_the_gap_window_replaces_the_held_line(cx: &mut TestAppContext) { + let (window, mut daemon) = harness(cx); + typed_into_the_gap_then_handed_back(cx, &window, &mut daemon, "echo"); + window + .update(cx, |view, _, _| { + view.history.push("echo from history".to_string()); + }) + .unwrap(); + + press(&window, cx, "up"); + cx.run_until_parked(); + window + .update(cx, |view, _, _| { + assert_eq!( + view.cmd.text(), + "echo from history", + "the recall searches on the whole line, held text included" + ); + }) + .unwrap(); + + press(&window, cx, "enter"); + cx.run_until_parked(); + assert_eq!( + drain(&mut daemon), + b"\x15echo from history\r".to_vec(), + "the recalled entry runs on its own, with the held text replaced \ + rather than prefixed to it" + ); + } + + /// A paste that landed in the gap is still a paste after the handover. The + /// record replays it into the editor, and a line that arrives there looking + /// typed is submitted raw through the shell's binding table (#660) — the + /// hole `GapHold::pasted` closed for the hold's own route. + #[gpui::test] + fn a_paste_held_in_the_gap_is_still_a_paste_after_the_handover(cx: &mut TestAppContext) { + let (window, mut daemon) = harness(cx); + // Bracketed paste is what a live prompt advertises; without it there is + // no framing to lose in the first place. + DaemonMsg::Output(b"\x1b[?2004h".to_vec()) + .encode(&mut daemon) + .unwrap(); + settle(cx, &window, "the shell turns bracketed paste on", |view| { + view.terminal + .term + .lock() + .mode() + .contains(TermMode::BRACKETED_PASTE) + }); + + prompt(&mut daemon, true); + DaemonMsg::Output(b"\x1b]133;B\x07".to_vec()) + .encode(&mut daemon) + .unwrap(); + settle(cx, &window, "the editor takes the first prompt", |view| { + view.input_active() && view.terminal.zle_reading() + }); + prompt(&mut daemon, false); + DaemonMsg::Output(b"\x1b]133;C\x07".to_vec()) + .encode(&mut daemon) + .unwrap(); + settle(cx, &window, "a command takes the pane", |view| { + !view.input_active() + }); + + window + .update(cx, |view, _, cx| view.paste("echo hi".to_string(), cx)) + .unwrap(); + cx.executor().advance_clock(HOLD_WINDOW * 2); + cx.run_until_parked(); + assert_eq!( + drain(&mut daemon), + b"\x1b[200~echo hi\x1b[201~".to_vec(), + "the hold window gives up and dumps the paste as a paste" + ); + + prompt(&mut daemon, true); + settle(cx, &window, "the editor takes the prompt back", |view| { + view.input_active() + }); + + press(&window, cx, "enter"); + cx.run_until_parked(); + assert_eq!( + drain(&mut daemon), + b"\x15\x1b[200~echo hi\x1b[201~\r".to_vec(), + "the replayed line keeps its framing instead of being typed at the \ + shell's binding table" + ); + } + + /// The same for an emptied line: ⌃U clears what the editor is holding, and + /// the shell's copy of it goes too instead of coming back at submit. + #[gpui::test] + fn clearing_the_line_in_the_gap_window_clears_the_held_text_too(cx: &mut TestAppContext) { + let (window, mut daemon) = harness(cx); + typed_into_the_gap_then_handed_back(cx, &window, &mut daemon, "echo"); + + press(&window, cx, "ctrl-u"); + cx.run_until_parked(); + window + .update(cx, |view, _, _| assert_eq!(view.cmd.text(), "")) + .unwrap(); + + press(&window, cx, "enter"); + cx.run_until_parked(); + assert_eq!( + drain(&mut daemon), + b"\x15\r".to_vec(), + "an emptied line submits empty: the wipe is still owed, the seed is not" + ); + } +}