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)
This commit is contained in:
Hongwei Qin
2026-08-04 18:57:40 +08:00
committed by GitHub
parent a89d9ea45b
commit 761e8e75c1
2 changed files with 298 additions and 31 deletions
+58 -8
View File
@@ -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<String> {
std::mem::take(self).flush()
}
@@ -70,7 +75,10 @@ impl Typeahead {
}
fn flush(self) -> Option<String> {
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]
+240 -23
View File
@@ -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<std::time::Instant>,
running_title: String,
running_agent: Option<crate::core::cli_agent::CLIAgent>,
@@ -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>) {
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<Self>) -> 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);