diff --git a/docs/reference/keyboard-shortcuts.mdx b/docs/reference/keyboard-shortcuts.mdx index 495e2445..be91bb37 100644 --- a/docs/reference/keyboard-shortcuts.mdx +++ b/docs/reference/keyboard-shortcuts.mdx @@ -53,6 +53,11 @@ it shows what *your* copy is bound to. This is the shipped default. | Accept ghost suggestion | | same | | Completion menu | | same | +On Windows and Linux plain Ctrl V 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 Ctrl ⇧ V or ⇧ Insert. + ## Git and SSH | Action | macOS | Windows / Linux | diff --git a/src/terminal/view.rs b/src/terminal/view.rs index f2665f00..53fd74b6 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -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, + 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) { diff --git a/src/ui/keymap.rs b/src/ui/keymap.rs index a514fe95..ccc07265 100644 --- a/src/ui/keymap.rs +++ b/src/ui/keymap.rs @@ -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)