From 761e8e75c1f40552c28983db3d439cf166237adb Mon Sep 17 00:00:00 2001 From: Hongwei Qin <122079993+shihuaidexianyu@users.noreply.github.com> Date: Tue, 4 Aug 2026 18:57:40 +0800 Subject: [PATCH] fix(terminal): prevent Ctrl-U after agent interrupt (#312) * chore: reserve issue 305 draft * fix(terminal): ignore alt-screen-only typeahead * fix(terminal): discard typeahead at alt-screen boundaries (#305) * fix(terminal): drop alt-screen boundary input (#305) * fix(terminal): discard agent typeahead on interrupt (#305) --- src/terminal/typeahead.rs | 66 ++++++++-- src/terminal/view.rs | 263 ++++++++++++++++++++++++++++++++++---- 2 files changed, 298 insertions(+), 31 deletions(-) diff --git a/src/terminal/typeahead.rs b/src/terminal/typeahead.rs index 2cba7213..4e593b99 100644 --- a/src/terminal/typeahead.rs +++ b/src/terminal/typeahead.rs @@ -9,6 +9,7 @@ pub struct Typeahead { pub enum RawInput<'a> { Text(&'a str), Key { key: &'a str, plain: bool }, + Interrupt, } impl Typeahead { @@ -16,12 +17,10 @@ impl Typeahead { Self::default() } - pub fn observe(&mut self, input: RawInput, alt_screen: bool) { - if alt_screen { - self.taint(); - return; - } + pub fn observe(&mut self, input: RawInput, externally_owned: bool) { match input { + RawInput::Interrupt => self.discard(), + _ if externally_owned => {} RawInput::Text(s) => self.record_text(s), RawInput::Key { key: "enter", @@ -35,6 +34,12 @@ impl Typeahead { } } + /// Discard a record at an input-ownership boundary without producing a + /// shell-line wipe. + pub fn discard(&mut self) { + *self = Self::default(); + } + pub fn drain(&mut self) -> Option { std::mem::take(self).flush() } @@ -70,7 +75,10 @@ impl Typeahead { } fn flush(self) -> Option { - if self.text.is_empty() && !self.tainted { + if self.text.is_empty() { + if self.tainted { + return Some(String::new()); + } return None; } if self.tainted { @@ -140,10 +148,52 @@ mod tests { } #[test] - fn alt_screen_input_taints_instead_of_seeding() { + fn alt_screen_input_is_discarded_without_a_shell_gap() { let mut t = Typeahead::new(); t.observe(RawInput::Text("q"), true); - assert_eq!(t.drain(), Some(String::new())); + assert_eq!(t.drain(), None); + } + + #[test] + fn alt_screen_input_does_not_taint_later_shell_input() { + let mut t = Typeahead::new(); + t.observe(RawInput::Text("q"), true); + t.observe(RawInput::Text("ls"), false); + assert_eq!(t.drain(), Some("ls".to_string())); + } + + #[test] + fn interrupt_discards_a_gap_even_without_alt_screen() { + let mut t = Typeahead::new(); + t.observe(RawInput::Text("agent input"), false); + t.observe( + RawInput::Key { + key: "up", + plain: true, + }, + false, + ); + + t.observe(RawInput::Interrupt, false); + assert_eq!(t.drain(), None); + } + + #[test] + fn discarding_a_tainted_record_starts_a_fresh_gap_without_a_shell_wipe() { + let mut t = Typeahead::new(); + t.observe(RawInput::Text("ls"), false); + t.observe( + RawInput::Key { + key: "up", + plain: true, + }, + false, + ); + t.discard(); + assert_eq!(t.drain(), None); + + t.observe(RawInput::Text("git status"), false); + assert_eq!(t.drain(), Some("git status".to_string())); } #[test] diff --git a/src/terminal/view.rs b/src/terminal/view.rs index fbca11f9..dd35533a 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -130,6 +130,7 @@ pub struct TerminalView { pub bell_flash: bool, pub report_mouse: bool, last_at_prompt: bool, + last_typeahead_blocked: bool, running_since: Option, running_title: String, running_agent: Option, @@ -740,6 +741,7 @@ impl TerminalView { search_last_query: String::new(), bell_flash: false, last_at_prompt: false, + last_typeahead_blocked: false, running_since: None, running_title: String::new(), running_agent: None, @@ -975,6 +977,7 @@ impl TerminalView { fn handle_event(&mut self, ev: AlacEvent, cx: &mut Context) { self.terminal.poll_exited(); + self.sync_typeahead_owner(); if self.terminal.has_pending_auth() { cx.emit(AuthPromptReady); } @@ -1151,6 +1154,7 @@ impl TerminalView { let kitty = self.kitty_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 shell_owns_prompt = self.shell_owns_prompt(); let held = plain && ks.key == "backspace" @@ -1167,15 +1171,18 @@ 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. + self.observe_typeahead(RawInput::Interrupt); + } self.terminal.write(bytes); - if !shell_owns_prompt { - self.typeahead.observe( - RawInput::Key { - key: ks.key.as_str(), - plain, - }, - self.on_alt_screen(), - ); + if !shell_owns_prompt && !interrupt { + self.observe_typeahead(RawInput::Key { + key: ks.key.as_str(), + plain, + }); } } self.cursor_visible = true; @@ -1440,13 +1447,10 @@ impl TerminalView { "backspace" => { if self.cmd.is_empty() { self.terminal.write(vec![0x7f]); - self.typeahead.observe( - RawInput::Key { - key: "backspace", - plain: true, - }, - false, - ); + self.observe_typeahead(RawInput::Key { + key: "backspace", + plain: true, + }); return; } if m.alt && self.cmd.selection().is_none() { @@ -2561,6 +2565,32 @@ impl TerminalView { .contains(TermMode::ALT_SCREEN) } + fn typeahead_blocked(&self) -> bool { + self.on_alt_screen() + || self.terminal.foreground_agent().is_some() + || self.terminal.agent_session().is_some() + } + + fn sync_typeahead_owner(&mut self) { + let blocked = self.typeahead_blocked(); + sync_typeahead_owner_state( + &mut self.typeahead, + &mut self.last_typeahead_blocked, + blocked, + ); + } + + fn observe_typeahead(&mut self, input: RawInput<'_>) { + // The input that crosses an ownership boundary belongs to neither side. + let blocked = self.typeahead_blocked(); + observe_typeahead_for_owner( + &mut self.typeahead, + &mut self.last_typeahead_blocked, + input, + blocked, + ); + } + fn flush_typeahead(&mut self) { let Some(seed) = self.typeahead.drain() else { return; @@ -2601,15 +2631,13 @@ impl TerminalView { self.release_hold(); } self.terminal.write(bytes); - let alt = self.on_alt_screen(); - self.typeahead.observe(RawInput::Text(text), alt); + self.observe_typeahead(RawInput::Text(text)); } fn release_hold(&mut self) { if let Some((net, bytes)) = self.hold.release() { self.terminal.write(bytes); - let alt = self.on_alt_screen(); - self.typeahead.observe(RawInput::Text(&net), alt); + self.observe_typeahead(RawInput::Text(&net)); } } @@ -2628,8 +2656,7 @@ impl TerminalView { } if let Some((net, bytes)) = self.hold.timeout(epoch) { self.terminal.write(bytes); - let alt = self.on_alt_screen(); - self.typeahead.observe(RawInput::Text(&net), alt); + self.observe_typeahead(RawInput::Text(&net)); cx.notify(); } } @@ -4282,6 +4309,36 @@ impl TerminalView { } } +fn is_typeahead_interrupt(key: &str, modifiers: &Modifiers) -> bool { + modifiers.control && !modifiers.alt && !modifiers.platform && key == "c" +} + +fn sync_typeahead_owner_state( + typeahead: &mut Typeahead, + last_blocked: &mut bool, + blocked: bool, +) -> bool { + // Alternate-screen TUIs and known agents own their input. Never replay a + // record across an ownership boundary as shell input. + let changed = blocked != *last_blocked; + if changed { + typeahead.discard(); + *last_blocked = blocked; + } + changed +} + +fn observe_typeahead_for_owner( + typeahead: &mut Typeahead, + last_blocked: &mut bool, + input: RawInput<'_>, + blocked: bool, +) { + if !sync_typeahead_owner_state(typeahead, last_blocked, blocked) { + typeahead.observe(input, *last_blocked); + } +} + impl Focusable for TerminalView { fn focus_handle(&self, _: &App) -> FocusHandle { self.focus_handle.clone() @@ -4296,6 +4353,7 @@ impl Drop for TerminalView { impl Render for TerminalView { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + self.sync_typeahead_owner(); if self.shell_owns_prompt() { if let Some((_net, bytes)) = self.hold.release() { self.terminal.write(bytes); @@ -4891,8 +4949,9 @@ fn drag_scroll_step(overshoot: f32) -> i32 { #[cfg(test)] mod tests { use super::{ - LoopbackPlan, SelectEndCopy, WheelRoute, clipboard_paste_text, cwd_is_on_host, - display_width, loopback_plan, + LoopbackPlan, RawInput, SelectEndCopy, Typeahead, WheelRoute, clipboard_paste_text, + cwd_is_on_host, display_width, is_typeahead_interrupt, loopback_plan, + observe_typeahead_for_owner, }; use super::{ drag_scroll_step, encode_mouse, escape_candidate, expand_file_command_template, @@ -4909,6 +4968,127 @@ mod tests { use crate::daemon::protocol::RemoteKind; use crate::terminal::PaneWorkspace; + #[test] + fn alt_screen_exit_discards_the_boundary_input_before_recording_shell_text() { + let mut typeahead = Typeahead::new(); + let mut last_blocked = true; + typeahead.observe(RawInput::Text("stale"), false); + + observe_typeahead_for_owner( + &mut typeahead, + &mut last_blocked, + RawInput::Key { + key: "c", + plain: false, + }, + false, + ); + assert_eq!( + typeahead.drain(), + None, + "the Ctrl-C crossing TUI exit must not become a tainted shell record" + ); + + observe_typeahead_for_owner( + &mut typeahead, + &mut last_blocked, + RawInput::Text("ls"), + false, + ); + assert_eq!(typeahead.drain(), Some("ls".to_string())); + } + + #[test] + fn agent_interrupt_discards_typeahead_without_an_alt_screen_transition() { + let mut typeahead = Typeahead::new(); + let mut last_blocked = false; + typeahead.observe(RawInput::Text("agent input"), false); + typeahead.observe( + RawInput::Key { + key: "up", + plain: true, + }, + false, + ); + + observe_typeahead_for_owner( + &mut typeahead, + &mut last_blocked, + RawInput::Interrupt, + false, + ); + assert_eq!( + typeahead.drain(), + None, + "Ctrl-C must cancel a stable non-ALT_SCREEN agent gap" + ); + + observe_typeahead_for_owner( + &mut typeahead, + &mut last_blocked, + RawInput::Text("ls"), + false, + ); + assert_eq!(typeahead.drain(), Some("ls".to_string())); + } + + #[test] + fn known_agent_input_is_discarded_at_both_ownership_boundaries() { + let mut typeahead = Typeahead::new(); + let mut last_blocked = false; + typeahead.observe(RawInput::Text("stale shell gap"), false); + + observe_typeahead_for_owner( + &mut typeahead, + &mut last_blocked, + RawInput::Text("agent input"), + true, + ); + observe_typeahead_for_owner( + &mut typeahead, + &mut last_blocked, + RawInput::Key { + key: "up", + plain: true, + }, + true, + ); + assert_eq!(typeahead.drain(), None); + + observe_typeahead_for_owner( + &mut typeahead, + &mut last_blocked, + RawInput::Text("boundary input"), + false, + ); + assert_eq!(typeahead.drain(), None); + + observe_typeahead_for_owner( + &mut typeahead, + &mut last_blocked, + RawInput::Text("ls"), + false, + ); + assert_eq!(typeahead.drain(), Some("ls".to_string())); + } + + #[test] + fn only_plain_ctrl_c_is_a_typeahead_interrupt() { + let ctrl = Modifiers { + control: true, + ..Default::default() + }; + assert!(is_typeahead_interrupt("c", &ctrl)); + assert!(!is_typeahead_interrupt("d", &ctrl)); + + let ctrl_alt = Modifiers { + control: true, + alt: true, + ..Default::default() + }; + assert!(!is_typeahead_interrupt("c", &ctrl_alt)); + } + fn ws(target: RemoteTarget, with_spec: bool) -> PaneWorkspace { PaneWorkspace { workspace: WorkspaceId::new(), @@ -5905,6 +6085,43 @@ mod gpui_tests { assert_eq!(next_input_until_timeout(&mut daemon), Some(vec![0x0c])); } + #[gpui::test] + fn passthrough_ctrl_c_discards_typeahead_before_the_shell_can_resume(cx: &mut TestAppContext) { + let (window, mut daemon) = harness(cx); + 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::Key { + key: "up", + plain: true, + }, + false, + ); + + view.on_key_down( + &KeyDownEvent { + keystroke: key("ctrl-c"), + is_held: false, + prefer_character_input: false, + }, + window, + cx, + ); + assert_eq!(view.typeahead.drain(), None); + view.flush_typeahead(); + }) + .unwrap(); + + assert_eq!(next_input_until_timeout(&mut daemon), Some(vec![0x03])); + assert_eq!( + next_input_until_timeout(&mut daemon), + None, + "resuming the shell must not synthesize Ctrl-U after Ctrl-C" + ); + } + #[gpui::test] fn shell_vi_mode_prompt_bypasses_the_local_editor(cx: &mut TestAppContext) { let (window, mut daemon) = harness(cx);