fix(input): hand Ctrl+V to a full-screen program on the alternate screen (#677) (#682)

In vim or neovim on Windows and Linux, Ctrl+V pasted the clipboard where
the editor expected blockwise Visual mode. Windows Terminal (with its
ctrl+v binding removed), WezTerm and Alacritty all send the key; macOS
was never affected, since Cmd+V is the paste chord there.

Ctrl+V was not a keybinding at all. `on_key_down` hands plain Ctrl+C, V
and X to `handle_cmd_shortcut` off macOS, and of the three the "v" arm
was the only unconditional one: Ctrl+C copies with a selection and
otherwise falls through to SIGINT, Ctrl+X falls through outside the
editor, but Ctrl+V always consumed, so SYN never reached the PTY --
`input.rs` had the byte, unreachably -- and an empty clipboard turned the
key into nothing at all. #270 set the rule that off macOS ctrl-<letter>
belongs to the terminal and anything sitting on one must fall through;
Ctrl+V was the exception that had escaped it.

The arm is now contextual like its neighbours. On the alternate screen
it falls through, and `keystroke_to_bytes` sends 0x16, or the CSI u form
when the program has the kitty protocol on; off it Ctrl+V pastes exactly
as before, and Cmd+V on macOS is untouched. The alternate screen is the
gate rather than `input_active` because the editor is inactive whenever
shell integration is missing or the prompt editor is off, and gating on
that would take paste away from every such user; a program that has
switched screens is precisely the case reported. Inside such a program
paste is Ctrl+Shift+V, Shift+Insert or the right-click menu, all of
which still stage a clipboard image for an agent.

The same block did not exclude Shift, so Ctrl+Shift+C/V/X reached the
hardcoded path whenever the keymap had nothing on them -- exactly the
state rebinding Paste leaves behind, which #271 promised would retire
Ctrl+Shift+V, but it went on pasting behind the user's back. Only
unshifted chords enter the block now; the shifted ones are the keymap's
alone.

The right-click menu advertised Ctrl+C, Ctrl+X and Ctrl+V off macOS as
though they were the bindings, next to a Select All row that already
showed its hint on macOS only. The three rows take the same treatment,
which is also what the command palette does.

Three view tests pin the split -- Ctrl+V falls through on the alternate
screen while Cmd+V still pastes there, Ctrl+V pastes off it, and a key
down on the alternate screen arrives at the PTY as SYN and nothing else
-- and the keymap's paste test now asserts that no default claims ctrl-v
in the Terminal context. The shortcuts reference notes where plain
Ctrl+V pastes and where it is the program's.

Fixes #677.
This commit is contained in:
webdev
2026-08-18 23:28:01 +08:00
committed by GitHub
parent ef333bf055
commit 3c95995e82
3 changed files with 119 additions and 5 deletions
+5
View File
@@ -53,6 +53,11 @@ it shows what *your* copy is bound to. This is the shipped default.
| Accept ghost suggestion | <kbd>→</kbd> | same |
| Completion menu | <kbd>⇥</kbd> | same |
On Windows and Linux plain <kbd>Ctrl V</kbd> also pastes at a shell prompt.
Inside a full-screen application — vim, less, tmux — it is passed through as
the key, so it means what the application says it means (blockwise Visual mode
in vim); paste there with <kbd>Ctrl ⇧ V</kbd> or <kbd>⇧ Insert</kbd>.
## Git and SSH
| Action | macOS | Windows / Linux |
+108 -5
View File
@@ -1844,10 +1844,13 @@ impl TerminalView {
}
}
// Ctrl+Shift+C/V/X are the keymap's alone: rebinding Paste has to
// retire Ctrl+Shift+V, which it cannot if this path answers it too.
if cfg!(not(target_os = "macos"))
&& m.control
&& !m.platform
&& !m.alt
&& !m.shift
&& matches!(ks.key.as_str(), "c" | "v" | "x")
{
match self.handle_cmd_shortcut(ks, window, cx) {
@@ -1983,8 +1986,16 @@ impl TerminalView {
}
}
"v" => {
self.paste_from_clipboard(cx);
CmdKey::Consumed
// Off macOS Ctrl+V is a control code first: on the alternate
// screen the key is the program's (vim's blockwise select), the
// way Ctrl+C is SIGINT when there is nothing to copy. Cmd+V
// pastes anywhere.
if m.control && !m.platform && self.on_alt_screen() {
CmdKey::FallThrough
} else {
self.paste_from_clipboard(cx);
CmdKey::Consumed
}
}
"a" => {
self.select_all_contextual(cx);
@@ -6139,16 +6150,16 @@ impl Render for TerminalView {
.menu_element_with_disabled(
Box::new(CopyText),
!has_selection,
menu_row_with_hint(t(L10nKey::AppMenuCopy), Some("secondary-c")),
menu_row_with_hint(t(L10nKey::AppMenuCopy), mac_only("secondary-c")),
)
.menu_element_with_disabled(
Box::new(CutText),
!has_selection,
menu_row_with_hint(t(L10nKey::AppMenuCut), Some("secondary-x")),
menu_row_with_hint(t(L10nKey::AppMenuCut), mac_only("secondary-x")),
)
.menu_element(
Box::new(PasteText),
menu_row_with_hint(t(L10nKey::AppMenuPaste), Some("secondary-v")),
menu_row_with_hint(t(L10nKey::AppMenuPaste), mac_only("secondary-v")),
)
.menu_element(
Box::new(SelectAll),
@@ -8355,6 +8366,27 @@ mod gpui_tests {
panic!("the prompt report never reached the view");
}
fn alt_screen_ready(
window: &gpui::WindowHandle<TerminalView>,
cx: &mut TestAppContext,
daemon: &mut UnixStream,
) {
DaemonMsg::Output(b"\x1b[?1049h".to_vec())
.encode(daemon)
.unwrap();
for _ in 0..400 {
cx.run_until_parked();
if window
.update(cx, |view, _, _| view.on_alt_screen())
.unwrap()
{
return;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
panic!("the alternate-screen switch never reached the grid");
}
#[gpui::test]
fn an_agent_that_has_finished_its_turn_is_not_busy(cx: &mut TestAppContext) {
use crate::core::cli_agent::{AgentSessionState, AgentStatus, CLIAgent};
@@ -9113,6 +9145,34 @@ mod gpui_tests {
);
}
#[gpui::test]
fn ctrl_v_on_the_alternate_screen_reaches_the_pty_as_syn(cx: &mut TestAppContext) {
let (window, mut daemon) = harness(cx);
cx.update(|cx| cx.write_to_clipboard(ClipboardItem::new_string("echo hi".into())));
alt_screen_ready(&window, cx, &mut daemon);
window
.update(cx, |view, window, cx| {
assert!(!view.input_active(), "the full-screen program owns input");
view.on_key_down(
&KeyDownEvent {
keystroke: key("ctrl-v"),
is_held: false,
prefer_character_input: false,
},
window,
cx,
);
})
.unwrap();
assert_eq!(next_input_until_timeout(&mut daemon), Some(vec![0x16]));
assert_eq!(
next_input_until_timeout(&mut daemon),
None,
"the clipboard must stay where it is: vim's Ctrl+V is blockwise select, not paste"
);
}
#[gpui::test]
fn shell_vi_mode_prompt_bypasses_the_local_editor(cx: &mut TestAppContext) {
let (window, mut daemon) = harness(cx);
@@ -12068,6 +12128,49 @@ mod gpui_tests {
assert_eq!(text.as_deref(), Some("hello"));
}
#[gpui::test]
fn ctrl_v_reaches_a_tui_on_the_alternate_screen(cx: &mut TestAppContext) {
let (window, mut daemon) = harness(cx);
cx.update(|cx| cx.write_to_clipboard(ClipboardItem::new_string("echo hi".into())));
alt_screen_ready(&window, cx, &mut daemon);
window
.update(cx, |view, window, cx| {
let fell_through = view.handle_cmd_shortcut(&key("ctrl-v"), window, cx);
assert!(
matches!(fell_through, CmdKey::FallThrough),
"a full-screen program owns Ctrl+V"
);
let pasted = view.handle_cmd_shortcut(&key("cmd-v"), window, cx);
assert!(
matches!(pasted, CmdKey::Consumed),
"Cmd+V is a paste chord on every screen"
);
})
.unwrap();
assert_eq!(
next_input(&mut daemon),
b"echo hi".to_vec(),
"only the Cmd+V paste may reach the PTY"
);
assert_eq!(next_input_until_timeout(&mut daemon), None);
}
#[gpui::test]
fn ctrl_v_pastes_off_the_alternate_screen(cx: &mut TestAppContext) {
let (window, mut daemon) = harness(cx);
cx.update(|cx| cx.write_to_clipboard(ClipboardItem::new_string("echo hi".into())));
window
.update(cx, |view, window, cx| {
assert!(!view.on_alt_screen());
let consumed = view.handle_cmd_shortcut(&key("ctrl-v"), window, cx);
assert!(matches!(consumed, CmdKey::Consumed));
})
.unwrap();
assert_eq!(next_input(&mut daemon), b"echo hi".to_vec());
}
#[cfg(target_os = "macos")]
#[gpui::test]
fn cmd_backspace_reaches_a_foreground_tui_as_ctrl_u(cx: &mut TestAppContext) {
+6
View File
@@ -1328,6 +1328,12 @@ mod tests {
"{key} is a terminal chord and must not paste outside one"
);
}
// Plain Ctrl+V is the terminal's, not the keymap's: the pane pastes on
// it at a prompt and hands it to a full-screen program otherwise.
assert!(
dispatched(&effective, "ctrl-v", "Terminal").is_empty(),
"ctrl-v is a control code and no default may claim it"
);
let rebound = vec![("PasteText".to_string(), "ctrl-alt-v".to_string())];
assert!(
!extra_keystrokes(&rebound)