mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-21 16:02:20 +00:00
Merge pull request #877 from spragginsdesigns/fix/exit-typeahead-ctrl-u
fix(terminal): stop injecting Ctrl-U after session exit
This commit is contained in:
+104
-14
@@ -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);
|
||||
@@ -90,17 +97,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 +122,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 +326,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 +339,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 +385,93 @@ 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 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();
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+97
-19
@@ -2294,7 +2294,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"
|
||||
@@ -2311,11 +2312,11 @@ 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);
|
||||
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(boundary);
|
||||
}
|
||||
self.terminal.write(bytes);
|
||||
if !shell_owns_prompt && !interrupt {
|
||||
@@ -6765,8 +6766,15 @@ impl TerminalView {
|
||||
}
|
||||
}
|
||||
|
||||
fn is_typeahead_interrupt(key: &str, modifiers: &Modifiers) -> bool {
|
||||
modifiers.control && !modifiers.alt && !modifiers.platform && key == "c"
|
||||
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(
|
||||
@@ -7908,8 +7916,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::{
|
||||
@@ -8082,20 +8090,28 @@ 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!(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!(typeahead_boundary("c", &ctrl_alt).is_none());
|
||||
assert!(typeahead_boundary("d", &ctrl_alt).is_none());
|
||||
}
|
||||
|
||||
fn ws(target: RemoteTarget, with_spec: bool) -> PaneWorkspace {
|
||||
@@ -11076,11 +11092,26 @@ 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, "agent input", "ctrl-c", 0x03);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn passthrough_ctrl_d_discards_typeahead_before_the_shell_can_resume(cx: &mut TestAppContext) {
|
||||
// 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,
|
||||
) {
|
||||
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::Text(pending), false);
|
||||
view.typeahead.observe(
|
||||
RawInput::Key {
|
||||
key: "up",
|
||||
@@ -11091,23 +11122,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"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user