From 1dd0a0a083d584a5dbcd5805f09690f0bfb81775 Mon Sep 17 00:00:00 2001 From: Austin Spraggins Date: Sat, 12 Sep 2026 14:04:00 -0700 Subject: [PATCH 1/2] fix(terminal): stop injecting Ctrl-U after session exit --- src/terminal/typeahead.rs | 81 +++++++++++++++++++++++++++++++------- src/terminal/view.rs | 82 ++++++++++++++++++++++++++++++++++----- 2 files changed, 139 insertions(+), 24 deletions(-) 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" ); } From 609e1b04a6476d7dcd2527917546e5db138d9943 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Mon, 14 Sep 2026 18:33:22 +0800 Subject: [PATCH 2/2] fix(terminal): let Ctrl-D close the typeahead record only on an empty line Readline, PSReadLine, a cooked tty and tty7's own editor all treat Ctrl-D as end of input only when the line is empty; with text on it Ctrl-D is an edit. Discarding a record that still holds unsubmitted text dropped the owed wipe, so text typed during a gap followed by Ctrl-D stayed on the shell's line and was glued to the front of the next command. Ctrl-D now discards only a record with no unsubmitted text, and taints otherwise. --- src/terminal/typeahead.rs | 37 +++++++++++++++++++++++++++++++ src/terminal/view.rs | 46 ++++++++++++++++++++++++++------------- 2 files changed, 68 insertions(+), 15 deletions(-) diff --git a/src/terminal/typeahead.rs b/src/terminal/typeahead.rs index 52b9887f..3bd99d59 100644 --- a/src/terminal/typeahead.rs +++ b/src/terminal/typeahead.rs @@ -19,6 +19,11 @@ pub enum RawInput<'a> { plain: bool, }, Interrupt, + /// Ctrl-D. Readers take it as end of input only on an empty line — on a + /// line with text it deletes a character, and a shell that reads the gap + /// later is still left holding that text — so it closes the record only + /// when nothing unsubmitted was typed. + EndOfInput, } impl Typeahead { @@ -29,7 +34,9 @@ impl Typeahead { pub fn observe(&mut self, input: RawInput, externally_owned: bool) { match input { RawInput::Interrupt => self.discard(), + RawInput::EndOfInput if self.text.is_empty() => self.discard(), _ if externally_owned => {} + RawInput::EndOfInput => self.taint(), RawInput::Text(s) => self.record_text(s), RawInput::Pasted(s) => { self.record_text(s); @@ -381,6 +388,36 @@ mod tests { assert_eq!(p.drain(), Some(String::new())); } + #[test] + fn end_of_input_on_an_empty_line_closes_the_record() { + let mut t = Typeahead::new(); + t.observe(RawInput::Text("exit"), false); + t.observe( + RawInput::Key { + key: "enter", + plain: true, + }, + false, + ); + t.observe( + RawInput::Key { + key: "up", + plain: true, + }, + false, + ); + t.observe(RawInput::EndOfInput, false); + assert_eq!(t.drain(), None); + } + + #[test] + fn end_of_input_after_unsubmitted_text_still_owes_the_wipe() { + let mut t = Typeahead::new(); + t.observe(RawInput::Text("ab"), false); + t.observe(RawInput::EndOfInput, false); + assert_eq!(t.drain(), Some(String::new())); + } + #[test] fn submitting_a_recalled_exit_discards_taint_and_paste_provenance() { let mut t = Typeahead::new(); diff --git a/src/terminal/view.rs b/src/terminal/view.rs index 753c1d81..435cc7ec 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -2273,7 +2273,8 @@ impl TerminalView { let kitty = self.key_flags(); if let Some(bytes) = super::input::keystroke_to_bytes(ks, kitty) { let plain = !m.control && !m.alt && !m.platform; - let interrupt = is_typeahead_interrupt(ks.key.as_str(), m); + let boundary = typeahead_boundary(ks.key.as_str(), m); + let interrupt = boundary.is_some(); let shell_owns_prompt = self.shell_owns_prompt(); let held = plain && ks.key == "backspace" @@ -2290,11 +2291,11 @@ impl TerminalView { }; if !held { self.release_hold(); - if !shell_owns_prompt && interrupt { + if let Some(boundary) = boundary.filter(|_| !shell_owns_prompt) { // 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.observe_typeahead(boundary); } self.terminal.write(bytes); if !shell_owns_prompt && !interrupt { @@ -6728,8 +6729,15 @@ impl TerminalView { } } -fn is_typeahead_interrupt(key: &str, modifiers: &Modifiers) -> bool { - modifiers.control && !modifiers.alt && !modifiers.platform && matches!(key, "c" | "d") +fn typeahead_boundary(key: &str, modifiers: &Modifiers) -> Option> { + if !modifiers.control || modifiers.alt || modifiers.platform { + return None; + } + match key { + "c" => Some(RawInput::Interrupt), + "d" => Some(RawInput::EndOfInput), + _ => None, + } } fn sync_typeahead_owner_state( @@ -7871,8 +7879,8 @@ mod tests { use super::{ COMPLETION_MENU_MAX_W, LoopbackPlan, PortRoute, RawInput, SelectEndCopy, Typeahead, WheelRoute, clipboard_paste_text, compose_notification_title, cwd_is_on_host, - display_width, is_typeahead_interrupt, link_path_style, loopback_plan, - observe_typeahead_for_owner, + display_width, link_path_style, loopback_plan, observe_typeahead_for_owner, + typeahead_boundary, }; use super::{SCROLL_ANIM_FRAME, scroll_anim_step}; use super::{ @@ -8050,17 +8058,23 @@ mod tests { control: true, ..Default::default() }; - assert!(is_typeahead_interrupt("c", &ctrl)); - assert!(is_typeahead_interrupt("d", &ctrl)); - assert!(!is_typeahead_interrupt("u", &ctrl)); + assert!(matches!( + typeahead_boundary("c", &ctrl), + Some(RawInput::Interrupt) + )); + assert!(matches!( + typeahead_boundary("d", &ctrl), + Some(RawInput::EndOfInput) + )); + assert!(typeahead_boundary("u", &ctrl).is_none()); let ctrl_alt = Modifiers { control: true, alt: true, ..Default::default() }; - assert!(!is_typeahead_interrupt("c", &ctrl_alt)); - assert!(!is_typeahead_interrupt("d", &ctrl_alt)); + assert!(typeahead_boundary("c", &ctrl_alt).is_none()); + assert!(typeahead_boundary("d", &ctrl_alt).is_none()); } fn ws(target: RemoteTarget, with_spec: bool) -> PaneWorkspace { @@ -11014,16 +11028,18 @@ 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); + assert_foreground_interrupt_does_not_wipe_prompt(cx, "agent input", "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); + // Ctrl-D only ends input on an empty line; with text it is an edit. + assert_foreground_interrupt_does_not_wipe_prompt(cx, "", "ctrl-d", 0x04); } fn assert_foreground_interrupt_does_not_wipe_prompt( cx: &mut TestAppContext, + pending: &str, chord: &str, byte: u8, ) { @@ -11031,7 +11047,7 @@ mod gpui_tests { window .update(cx, |view, window, cx| { assert!(!view.input_active(), "the foreground process owns input"); - view.typeahead.observe(RawInput::Text("agent input"), false); + view.typeahead.observe(RawInput::Text(pending), false); view.typeahead.observe( RawInput::Key { key: "up",