diff --git a/src/terminal/typeahead.rs b/src/terminal/typeahead.rs index 21f097c2..52b9887f 100644 --- a/src/terminal/typeahead.rs +++ b/src/terminal/typeahead.rs @@ -90,17 +90,15 @@ impl Typeahead { } fn record_enter(&mut self) { - if self.text.len() + 1 > RECORD_CAP { - self.tainted = true; - return; - } - self.text.push('\r'); + // Enter has already gone to the foreground reader. That line is no + // longer pending shell input: keeping even an empty seed would send + // Ctrl-U into the next prompt after `exit` returns from SSH or a TUI. + // Only text typed after this boundary can belong to the next prompt. + self.discard(); } fn record_backspace(&mut self) { - if !self.text.ends_with('\r') { - self.text.pop(); - } + self.text.pop(); } fn taint(&mut self) { @@ -117,8 +115,7 @@ impl Typeahead { if self.tainted { return Some(String::new()); } - let seed = self.text.rsplit('\r').next().unwrap_or(""); - Some(seed.to_string()) + Some(self.text) } } @@ -322,11 +319,11 @@ mod tests { } #[test] - fn fully_submitted_input_wipes_but_seeds_nothing() { + fn fully_submitted_input_owes_no_wipe() { let mut p = Typeahead::new(); p.record_text("ls"); p.record_enter(); - assert_eq!(p.drain(), Some(String::new())); + assert_eq!(p.drain(), None); } #[test] @@ -335,7 +332,7 @@ mod tests { p.record_text("ls"); p.record_enter(); p.record_backspace(); - assert_eq!(p.drain(), Some(String::new())); + assert_eq!(p.drain(), None); } #[test] @@ -381,7 +378,63 @@ mod tests { let mut p = Typeahead::new(); p.taint(); p.record_text("ls"); - p.record_enter(); assert_eq!(p.drain(), Some(String::new())); } + + #[test] + fn submitting_a_recalled_exit_discards_taint_and_paste_provenance() { + let mut t = Typeahead::new(); + t.observe(RawInput::Pasted("old command"), false); + t.observe( + RawInput::Key { + key: "up", + plain: true, + }, + false, + ); + t.observe( + RawInput::Key { + key: "enter", + plain: true, + }, + false, + ); + assert!(!t.pasted(), "the submitted line's paste mark is spent"); + assert_eq!( + t.adopt(), + None, + "returning to the prompt must not owe Ctrl-U" + ); + assert_eq!(t.drain(), None); + t.observe(RawInput::Text("git status"), false); + assert_eq!(t.drain(), Some("git status".to_string())); + } + + #[test] + fn a_submitted_exit_does_not_taint_the_next_prompts_typeahead() { + let mut t = Typeahead::new(); + t.observe(RawInput::Text("x".repeat(RECORD_CAP).as_str()), false); + t.observe( + RawInput::Key { + key: "enter", + plain: true, + }, + false, + ); + t.observe(RawInput::Text("exit"), false); + t.observe( + RawInput::Key { + key: "enter", + plain: true, + }, + false, + ); + t.observe(RawInput::Text("git status"), false); + assert_eq!(t.adopt(), Some("git status".to_string())); + assert_eq!( + t.drain(), + Some(String::new()), + "only the unsubmitted text needs a wipe" + ); + } } diff --git a/src/terminal/view.rs b/src/terminal/view.rs index 56555d5d..753c1d81 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -2291,9 +2291,9 @@ impl TerminalView { if !held { self.release_hold(); if !shell_owns_prompt && interrupt { - // Ctrl-C cancels the foreground input transaction. Clear - // the gap before delivering it so a prompt transition - // cannot flush this interrupt as a later Ctrl-U. + // Ctrl-C interrupts and Ctrl-D can close the foreground reader. + // Discard the gap before sending either so a prompt transition + // cannot turn the pending record into a later Ctrl-U. self.observe_typeahead(RawInput::Interrupt); } self.terminal.write(bytes); @@ -6729,7 +6729,7 @@ impl TerminalView { } fn is_typeahead_interrupt(key: &str, modifiers: &Modifiers) -> bool { - modifiers.control && !modifiers.alt && !modifiers.platform && key == "c" + modifiers.control && !modifiers.alt && !modifiers.platform && matches!(key, "c" | "d") } fn sync_typeahead_owner_state( @@ -8045,13 +8045,14 @@ mod tests { } #[test] - fn only_plain_ctrl_c_is_a_typeahead_interrupt() { + fn ctrl_c_and_ctrl_d_discard_foreground_typeahead() { let ctrl = Modifiers { control: true, ..Default::default() }; assert!(is_typeahead_interrupt("c", &ctrl)); - assert!(!is_typeahead_interrupt("d", &ctrl)); + assert!(is_typeahead_interrupt("d", &ctrl)); + assert!(!is_typeahead_interrupt("u", &ctrl)); let ctrl_alt = Modifiers { control: true, @@ -8059,6 +8060,7 @@ mod tests { ..Default::default() }; assert!(!is_typeahead_interrupt("c", &ctrl_alt)); + assert!(!is_typeahead_interrupt("d", &ctrl_alt)); } fn ws(target: RemoteTarget, with_spec: bool) -> PaneWorkspace { @@ -11012,6 +11014,19 @@ mod gpui_tests { #[gpui::test] fn passthrough_ctrl_c_discards_typeahead_before_the_shell_can_resume(cx: &mut TestAppContext) { + assert_foreground_interrupt_does_not_wipe_prompt(cx, "ctrl-c", 0x03); + } + + #[gpui::test] + fn passthrough_ctrl_d_discards_typeahead_before_the_shell_can_resume(cx: &mut TestAppContext) { + assert_foreground_interrupt_does_not_wipe_prompt(cx, "ctrl-d", 0x04); + } + + fn assert_foreground_interrupt_does_not_wipe_prompt( + cx: &mut TestAppContext, + chord: &str, + byte: u8, + ) { let (window, mut daemon) = harness(cx); window .update(cx, |view, window, cx| { @@ -11027,23 +11042,70 @@ mod gpui_tests { view.on_key_down( &KeyDownEvent { - keystroke: key("ctrl-c"), + keystroke: key(chord), is_held: false, prefer_character_input: false, }, window, cx, ); - assert_eq!(view.typeahead.drain(), None); + // Exercise the consumers without draining their input first. + view.adopt_typeahead(); view.flush_typeahead(); + assert!(view.cmd.text().is_empty()); }) .unwrap(); - assert_eq!(next_input_until_timeout(&mut daemon), Some(vec![0x03])); + assert_eq!(next_input_until_timeout(&mut daemon), Some(vec![byte])); assert_eq!( next_input_until_timeout(&mut daemon), None, - "resuming the shell must not synthesize Ctrl-U after Ctrl-C" + "resuming the shell must not synthesize Ctrl-U after {chord}" + ); + } + + #[gpui::test] + fn submitted_exit_typeahead_does_not_wipe_the_returned_prompt(cx: &mut TestAppContext) { + let (window, mut daemon) = harness(cx); + window + .update(cx, |view, window, cx| { + // Ordinary SSH need not take the alternate screen or identify + // as an agent. Its input reaches the passthrough recorder. + assert!(!view.input_active()); + for ch in ["e", "x", "i", "t"] { + type_char(view, ch, window, cx); + } + view.on_key_down( + &KeyDownEvent { + keystroke: key("enter"), + is_held: false, + prefer_character_input: false, + }, + window, + cx, + ); + }) + .unwrap(); + for bytes in [b"e", b"x", b"i", b"t", b"\r"] { + assert_eq!(next_input_until_timeout(&mut daemon), Some(bytes.to_vec())); + } + + prompt_ready(&window, cx, &mut daemon); + window + .update(cx, |view, _, _| { + assert!(view.input_active()); + view.adopt_typeahead(); + view.flush_typeahead(); + assert!( + view.cmd.text().is_empty(), + "exit belongs to the finished session" + ); + }) + .unwrap(); + assert_eq!( + next_input_until_timeout(&mut daemon), + None, + "returning from exit must not inject Ctrl-U into the local prompt" ); }