fix(terminal): stop injecting Ctrl-U after session exit

This commit is contained in:
Austin Spraggins
2026-09-12 14:04:00 -07:00
parent 66c42e48b7
commit 1dd0a0a083
2 changed files with 139 additions and 24 deletions
+67 -14
View File
@@ -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"
);
}
}
+72 -10
View File
@@ -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"
);
}