From 48c13684a88dbc73b8e2d90a2607883e2049b8a8 Mon Sep 17 00:00:00 2001 From: l0ng-ai Date: Sun, 2 Aug 2026 12:12:14 +0800 Subject: [PATCH] fix(input): restore editor after interrupting tab handoff (#290) Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> --- src/terminal/view.rs | 83 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 82 insertions(+), 1 deletion(-) diff --git a/src/terminal/view.rs b/src/terminal/view.rs index 23f90806..18ae87ab 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -159,6 +159,7 @@ pub struct TerminalView { remote_completion_inflight: bool, completion_generation: u64, editor_handoff: Option, + editor_handoff_interrupt_seq: Option, reverse_search: Option, integration_notice: Option, integration_notice_shown: bool, @@ -760,6 +761,7 @@ impl TerminalView { completion: None, completion_generation: 0, editor_handoff: None, + editor_handoff_interrupt_seq: None, remote_completion_inflight: false, reverse_search: None, integration_notice: None, @@ -1129,6 +1131,15 @@ impl TerminalView { self.note_integration_gap(cx); } + if m.control && !m.platform && !m.alt && ks.key == "c" && self.handoff_active() { + // A Tab/unknown-chord handoff leaves the daemon at_prompt: the shell + // never saw Enter, so there is no C mark. Ctrl-C makes readline draw + // a fresh prompt whose A/B report is consequently true -> true and + // does not advance prompt_cycle. Remember this report boundary so + // that fresh prompt can still return ownership to the local editor. + self.editor_handoff_interrupt_seq = Some(self.terminal.prompt_seq()); + } + let kitty = self.kitty_flags(); if let Some(bytes) = super::input::keystroke_to_bytes(ks, kitty) { let plain = !m.control && !m.alt && !m.platform; @@ -2485,7 +2496,7 @@ impl TerminalView { if self.shell_vi_prompt() { return Some("the shell prompt is in vi mode"); } - if self.editor_handoff == Some(self.terminal.prompt_cycle()) { + if self.handoff_active() { return Some("this prompt's line was already handed to the shell"); } if !self.at_shell_prompt() { @@ -2504,6 +2515,9 @@ impl TerminalView { fn handoff_active(&self) -> bool { self.editor_handoff == Some(self.terminal.prompt_cycle()) + && self + .editor_handoff_interrupt_seq + .is_none_or(|seq| self.terminal.prompt_seq() <= seq) && self.terminal.at_prompt() && !self.on_alt_screen() } @@ -2903,6 +2917,7 @@ impl TerminalView { } self.cmd.clear(); self.editor_handoff = Some(self.terminal.prompt_cycle()); + self.editor_handoff_interrupt_seq = None; self.send_to_pty(chord, cx); } @@ -5980,6 +5995,72 @@ mod gpui_tests { wait_for_input_active(&window, cx); } + #[gpui::test] + fn ctrl_c_after_tab_handoff_returns_the_fresh_prompt_to_the_editor(cx: &mut TestAppContext) { + crate::core::config::pin_test_config_dir(); + let (window, mut daemon) = harness(cx); + DaemonMsg::Prompt { + active: true, + at_prompt: true, + last_exit: None, + } + .encode(&mut daemon) + .unwrap(); + wait_for_input_active(&window, cx); + + window + .update(cx, |view, window, cx| { + for ch in ["z", "z", "q", "q", "x"] { + type_char(view, ch, window, cx); + } + view.complete_tab(true, cx); + assert!(!view.input_active(), "Tab handed this line to the shell"); + view.on_key_down( + &KeyDownEvent { + keystroke: key("ctrl-c"), + is_held: false, + prefer_character_input: false, + }, + window, + cx, + ); + }) + .unwrap(); + assert_eq!( + next_input_until_timeout(&mut daemon), + Some(b"zzqqx".to_vec()) + ); + assert_eq!(next_input_until_timeout(&mut daemon), Some(b"\t".to_vec())); + assert_eq!(next_input_until_timeout(&mut daemon), Some(vec![0x03])); + + // Tab handoff never emitted C, so the shell integration reports the + // interrupted prompt as another A/B while the daemon is still at_prompt. + DaemonMsg::Prompt { + active: true, + at_prompt: true, + last_exit: Some(130), + } + .encode(&mut daemon) + .unwrap(); + wait_for_input_active(&window, cx); + + window + .update(cx, |view, window, cx| { + type_char(view, "n", window, cx); + assert_eq!( + view.cmd.text(), + "n", + "the first character on the fresh line belongs to tty7's editor" + ); + }) + .unwrap(); + assert_eq!( + next_input_until_timeout(&mut daemon), + None, + "the fresh line must not keep going raw to the shell" + ); + } + #[gpui::test] fn a_late_remote_listing_leaves_a_line_the_editor_no_longer_owns_alone( cx: &mut TestAppContext,