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.
This commit is contained in:
l0ng-ai
2026-09-14 18:33:22 +08:00
parent 1dd0a0a083
commit 609e1b04a6
2 changed files with 68 additions and 15 deletions
+37
View File
@@ -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();
+31 -15
View File
@@ -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<RawInput<'static>> {
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",