fix(input): give the PTY back the Ctrl chords tty7 was eating

Follow-up to #682, which handed Ctrl+V to a full-screen program but left
three neighbouring holes of the same shape: a key the terminal answers
without the keymap ever seeing it.

The C0 table was half a table. `input.rs` mapped the alphabet, `[ \ ]`
and Ctrl+2, and nothing else — so `Ctrl-^` (Ctrl+6, vim's alternate
file), `Ctrl-_` (readline's undo, typed as Ctrl+/ or Ctrl+Shift+-) and
Ctrl+3..8 produced no bytes at all. They were not mis-encoded, they were
silent: gpui filters control characters out of `key_char` on all three
backends, so the text fallback had nothing to offer either. The table is
now the VT-220 one, each digit beside the punctuation that shares its
key, because every platform hands Ctrl+Shift+6 over as `^` with the
Shift already spent. Ctrl+/ is xterm's addition rather than VT-220's and
is spelled out with the reason. The twenty-six letters fold to `& 0x1f`.

`on_key_down` swallowed plain Ctrl+1..9 off macOS with a bare `return`,
left over from when tabs lived on ctrl-digits — they have been on
Alt+1..9 for a long time, so nothing claimed those chords and the block
only deleted keys. It also sat before `keystroke_to_bytes`, so not even
the kitty protocol got through it. Gone.

Ctrl+V is now a binding. `AlternatePaste` carries `ctrl-v` off macOS in
a `Terminal && !alt_screen` context, and the pane declares `alt_screen`
whenever a full-screen program owns the grid, so the behaviour #682
settled on is unchanged — paste at a prompt, SYN inside vim — while the
keymap can finally express it, the Keybindings page lists it, and the
user gets a say: `"AlternatePaste": ""` hands Ctrl+V to the shell
everywhere, including readline's `quoted-insert`, and
`"PasteText": "ctrl-v"` pastes on every screen the way Windows Terminal
does. That cohort is real — Warp keeps Ctrl+V pasting on Windows on
purpose, as a removable binding, for exactly this reason. The hardcoded
arm in `handle_cmd_shortcut` now answers Cmd+V alone, which is macOS's
only paste chord and carries no control code to lose.

Last, the rule about control codes is one function instead of an
assertion buried in a test. `steals_a_control_code` plus a commented
`control_code_binding_allowed` back both the defaults test and a new
runtime warning, so a hand-edited config.json that takes EOF away from
every shell says so in the log. It warns rather than refuses: a chord
the user asked for by name is theirs to spend, the way the tmux preset
spends Ctrl+B. The invariant that still fails a build is that no
*default* spends one silently.

Tests: `cargo test --bin tty7-app` 1360 passed, 1 known flake
(`a_routed_auth_prompt_carries_the_machine_that_raised_it`, green on a
rerun and on a clean tree). New: the whole VT-220 table asserted byte by
byte, with Ctrl+- held out; `ctrl_6_reaches_the_pty_as_rs`,
`ctrl_v_pastes_at_a_prompt` and `ctrl_v_reaches_a_full_screen_program_as_syn`
drive the real keymap through `simulate_keystrokes` rather than calling
into the view; the keymap tests cover both escape hatches and the
context that withholds the binding. #682's two `handle_cmd_shortcut`
tests are replaced by those three, which assert the same behaviour at
the layer that now decides it; its end-to-end SYN test stands unchanged.
The gpui tests are unix-only, so CI is what runs them.
This commit is contained in:
l0ng-ai
2026-08-18 23:44:04 +08:00
parent 3c95995e82
commit e4bbf24f3e
8 changed files with 341 additions and 102 deletions
+20
View File
@@ -58,6 +58,26 @@ 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>.
That convenience is the **Paste (outside full-screen apps)** binding, and it is
yours to move. Clearing it hands <kbd>Ctrl V</kbd> to the shell everywhere,
including readline's `quoted-insert`:
```json
{ "keybindings": { "AlternatePaste": "" } }
```
Putting Paste itself on the chord goes the other way, pasting on every screen
the way Windows Terminal does out of the box:
```json
{ "keybindings": { "PasteText": "ctrl-v" } }
```
Every other <kbd>Ctrl</kbd> chord reaches the program you are running, control
codes included: <kbd>Ctrl 6</kbd> and <kbd>Ctrl ⇧ 6</kbd> are `^^` (vim's
alternate file), <kbd>Ctrl /</kbd> and <kbd>Ctrl ⇧ </kbd> are `^_` (readline's
undo), <kbd>Ctrl 3</kbd> is Escape.
## Git and SSH
| Action | macOS | Windows / Linux |
+70 -34
View File
@@ -239,45 +239,45 @@ fn associated_text(ks: &gpui::Keystroke) -> Option<Vec<u32>> {
(!cps.is_empty()).then_some(cps)
}
/// The C0 control byte a `Ctrl+<key>` chord stands for, or `None` when the
/// chord is not a control code at all.
///
/// The letters fold onto `0x01..=0x1A` — `Ctrl+A` is 1, `Ctrl+Z` is 26 — which
/// is the whole alphabet in one line instead of twenty-six. The rest is the
/// VT-220 table (chapter 3.2.5): the digits 2..8, and beside each the
/// punctuation that shares its key, because `Ctrl+^` is typed as Ctrl+Shift+6
/// and every platform hands that over as `^` with the Shift already spent.
///
/// `Ctrl+/` is not in that table. xterm and every terminal since encode it as
/// US and editors bind against it — vim's `<C-/>` — but unlike `Ctrl+[` and
/// its neighbours no keyboard layer folds it into a control byte for us, so it
/// has to be spelled out here. `Ctrl+-` is deliberately absent: off macOS that
/// is Decrease Font Size, and the chord this table owes readline's undo is
/// `Ctrl+_`, which arrives as `_`.
fn ctrl_c0(key: &str) -> Option<u8> {
if let [b] = key.as_bytes()
&& b.is_ascii_alphabetic()
{
return Some(b.to_ascii_uppercase() & 0x1f);
}
Some(match key {
"space" | "2" | "@" => 0x00,
"3" | "[" => 0x1b,
"4" | "\\" => 0x1c,
"5" | "]" => 0x1d,
"6" | "^" => 0x1e,
"7" | "_" | "/" => 0x1f,
"8" | "?" => 0x7f,
_ => return None,
})
}
fn legacy_keystroke_to_bytes(ks: &gpui::Keystroke, flags: KeyFlags) -> Option<Vec<u8>> {
let m = &ks.modifiers;
let key = ks.key.as_str();
if m.control && !m.platform {
let b = match key {
"space" | "2" => Some(0x00),
"a" => Some(0x01),
"b" => Some(0x02),
"c" => Some(0x03),
"d" => Some(0x04),
"e" => Some(0x05),
"f" => Some(0x06),
"g" => Some(0x07),
"h" => Some(0x08),
"i" => Some(0x09),
"j" => Some(0x0a),
"k" => Some(0x0b),
"l" => Some(0x0c),
"m" => Some(0x0d),
"n" => Some(0x0e),
"o" => Some(0x0f),
"p" => Some(0x10),
"q" => Some(0x11),
"r" => Some(0x12),
"s" => Some(0x13),
"t" => Some(0x14),
"u" => Some(0x15),
"v" => Some(0x16),
"w" => Some(0x17),
"x" => Some(0x18),
"y" => Some(0x19),
"z" => Some(0x1a),
"[" => Some(0x1b),
"\\" => Some(0x1c),
"]" => Some(0x1d),
_ => None,
};
if let Some(b) = b {
if let Some(b) = ctrl_c0(key) {
if m.alt {
return Some(vec![0x1b, b]);
}
@@ -610,6 +610,42 @@ mod tests {
assert_eq!(legacy(&ks(ctrl, "z", None)), Some(vec![0x1a]));
}
#[test]
fn keystroke_to_bytes_maps_the_whole_vt220_control_table() {
let ctrl = Modifiers {
control: true,
..Default::default()
};
// The VT-220 table, each digit next to the punctuation that shares its
// key: whichever of the two the platform reports, the byte is the same.
let cases: &[(&str, u8)] = &[
("2", 0x00),
("@", 0x00),
("3", 0x1b),
("4", 0x1c),
("5", 0x1d),
("6", 0x1e),
// vim's `Ctrl-^`, the whole reason the digits are here.
("^", 0x1e),
("7", 0x1f),
("_", 0x1f),
// readline's undo; xterm's addition to the table, not VT-220's.
("/", 0x1f),
("8", 0x7f),
("?", 0x7f),
];
for (key, byte) in cases {
assert_eq!(
legacy(&ks(ctrl, key, None)),
Some(vec![*byte]),
"ctrl-{key}"
);
}
// Decrease Font Size owns Ctrl+- off macOS, and readline is served by
// Ctrl+_ above, so the bare minus stays out of the table.
assert_eq!(legacy(&ks(ctrl, "-", None)), None);
}
#[test]
fn keystroke_to_bytes_ctrl_plus_cmd_is_not_a_c0_byte() {
let ctrl_cmd = Modifiers {
+96 -43
View File
@@ -88,6 +88,7 @@ actions!(
CopyText,
CutText,
PasteText,
AlternatePaste,
SelectAll,
UndoEdit,
RedoEdit,
@@ -1844,14 +1845,20 @@ 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.
// Ctrl+V is not here: off macOS it is the `AlternatePaste` binding,
// which the keymap withholds on the alternate screen so a full-screen
// program gets its SYN, and which the user can retire outright. Copy
// and cut stay, because both answer a selection this view owns and
// fall through to the PTY when there is none.
//
// Ctrl+Shift+C/X are the keymap's alone: rebinding Copy has to retire
// Ctrl+Shift+C, 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")
&& matches!(ks.key.as_str(), "c" | "x")
{
match self.handle_cmd_shortcut(ks, window, cx) {
CmdKey::Consumed => {
@@ -1862,18 +1869,6 @@ impl TerminalView {
}
}
if cfg!(not(target_os = "macos"))
&& m.control
&& !m.platform
&& !m.alt
&& matches!(
ks.key.as_str(),
"1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9"
)
{
return;
}
if !self.accepts_input(cx) {
return;
}
@@ -1985,17 +1980,12 @@ impl TerminalView {
CmdKey::FallThrough
}
}
// Cmd+V only: macOS leaves `PasteText` unbound and pastes from
// here, and Cmd carries no control code to lose. The Ctrl+V half
// of this key lives in the keymap as `AlternatePaste`.
"v" => {
// 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
}
self.paste_from_clipboard(cx);
CmdKey::Consumed
}
"a" => {
self.select_all_contextual(cx);
@@ -2466,6 +2456,21 @@ impl TerminalView {
self.has_selection() || (self.input_active() && self.cmd.selected_text().is_some())
}
/// The keymap context this pane declares each frame.
///
/// `alt_screen` is how a binding steps aside for a full-screen program:
/// `AlternatePaste` carries `Terminal && !alt_screen`, so Ctrl+V pastes at
/// a prompt, reaches vim as SYN, and can still be handed the whole screen
/// by rebinding `PasteText` onto it (#677).
pub(super) fn key_context(&self) -> gpui::KeyContext {
let mut context = gpui::KeyContext::new_with_defaults();
context.add("Terminal");
if self.on_alt_screen() {
context.add("alt_screen");
}
context
}
pub(super) fn key_flags(&self) -> super::input::KeyFlags {
super::input::KeyFlags::from_mode(self.terminal.term.lock().mode())
}
@@ -6057,7 +6062,7 @@ impl Render for TerminalView {
div()
.id("terminal-surface")
.track_focus(&self.focus_handle)
.key_context("Terminal")
.key_context(self.key_context())
.size_full()
.relative()
.overflow_hidden()
@@ -6101,6 +6106,9 @@ impl Render for TerminalView {
this.cut_contextual(cx);
}))
.on_action(cx.listener(|this, _: &PasteText, _w, cx| this.paste_from_clipboard(cx)))
.on_action(
cx.listener(|this, _: &AlternatePaste, _w, cx| this.paste_from_clipboard(cx)),
)
.on_action(cx.listener(|this, _: &SelectAll, _w, cx| this.select_all_contextual(cx)))
.on_action(cx.listener(|this, _: &UndoEdit, _w, cx| this.undo_edit(false, cx)))
.on_action(cx.listener(|this, _: &RedoEdit, _w, cx| this.undo_edit(true, cx)))
@@ -12129,46 +12137,91 @@ mod gpui_tests {
}
#[gpui::test]
fn ctrl_v_reaches_a_tui_on_the_alternate_screen(cx: &mut TestAppContext) {
fn cmd_v_pastes_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"
"Cmd+V carries no control code and pastes 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(&mut daemon), b"echo hi".to_vec());
assert_eq!(next_input_until_timeout(&mut daemon), None);
}
/// The Ctrl+V half of the same key, through the real keymap: whether it
/// pastes is the `AlternatePaste` binding's decision, not this view's, so
/// these two drive it the way a user does rather than calling in.
#[cfg(not(target_os = "macos"))]
#[gpui::test]
fn ctrl_v_pastes_off_the_alternate_screen(cx: &mut TestAppContext) {
fn ctrl_v_pastes_at_a_prompt(cx: &mut TestAppContext) {
crate::core::config::pin_test_config_dir();
let (window, mut daemon) = harness(cx);
cx.update(|cx| crate::ui::keymap::init(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));
window.activate_window();
view.focus_handle.focus(window, cx);
})
.unwrap();
assert_eq!(next_input(&mut daemon), b"echo hi".to_vec());
let mut vcx = gpui::VisualTestContext::from_window(window.into(), cx);
vcx.simulate_keystrokes("ctrl-v");
assert_eq!(
next_input_until_timeout(&mut daemon),
Some(b"echo hi".to_vec())
);
}
#[cfg(not(target_os = "macos"))]
#[gpui::test]
fn ctrl_v_reaches_a_full_screen_program_as_syn(cx: &mut TestAppContext) {
crate::core::config::pin_test_config_dir();
let (window, mut daemon) = harness(cx);
cx.update(|cx| crate::ui::keymap::init(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| {
window.activate_window();
view.focus_handle.focus(window, cx);
})
.unwrap();
let mut vcx = gpui::VisualTestContext::from_window(window.into(), cx);
vcx.simulate_keystrokes("ctrl-v");
assert_eq!(next_input_until_timeout(&mut daemon), Some(vec![0x16]));
}
/// `Ctrl-^`, which used to die in a hardcoded block that swallowed every
/// Ctrl+digit off macOS — nothing has claimed those chords since tabs
/// moved to Alt+1..9.
#[gpui::test]
fn ctrl_6_reaches_the_pty_as_rs(cx: &mut TestAppContext) {
crate::core::config::pin_test_config_dir();
let (window, mut daemon) = harness(cx);
cx.update(|cx| crate::ui::keymap::init(cx));
window
.update(cx, |view, window, cx| {
window.activate_window();
view.focus_handle.focus(window, cx);
})
.unwrap();
let mut vcx = gpui::VisualTestContext::from_window(window.into(), cx);
vcx.simulate_keystrokes("ctrl-6");
assert_eq!(next_input_until_timeout(&mut daemon), Some(vec![0x1e]));
}
#[cfg(target_os = "macos")]
+1
View File
@@ -1434,6 +1434,7 @@ pub fn translate_en(key: L10nKey) -> &'static str {
L10nKey::CmdCopy => "Copy",
L10nKey::CmdCut => "Cut",
L10nKey::CmdPaste => "Paste",
L10nKey::CmdAlternatePaste => "Paste (outside full-screen apps)",
L10nKey::CmdSelectAll => "Select All",
L10nKey::CmdSshAddConnection => "SSH: Add Connection…",
L10nKey::CmdSshManageProfiles => "SSH: Manage Profiles…",
+1
View File
@@ -1489,6 +1489,7 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> {
L10nKey::CmdCopy => "コピー",
L10nKey::CmdCut => "切り取り",
L10nKey::CmdPaste => "貼り付け",
L10nKey::CmdAlternatePaste => "貼り付け(全画面アプリを除く)",
L10nKey::CmdSelectAll => "すべて選択",
L10nKey::CmdSshAddConnection => "SSH: 接続を追加…",
L10nKey::CmdSshManageProfiles => "SSH: プロファイルを管理…",
+1
View File
@@ -1182,6 +1182,7 @@ l10n_keys! {
CmdCopy,
CmdCut,
CmdPaste,
CmdAlternatePaste,
CmdSelectAll,
CmdSshAddConnection,
CmdSshManageProfiles,
+1
View File
@@ -1352,6 +1352,7 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> {
L10nKey::CmdCopy => "复制",
L10nKey::CmdCut => "剪切",
L10nKey::CmdPaste => "粘贴",
L10nKey::CmdAlternatePaste => "粘贴(全屏程序中除外)",
L10nKey::CmdSelectAll => "全选",
L10nKey::CmdSshAddConnection => "SSH:添加连接…",
L10nKey::CmdSshManageProfiles => "SSH:管理主机配置…",
+151 -25
View File
@@ -3,8 +3,8 @@ use gpui::{App, Global, KeyBinding, Keystroke, NoAction};
use crate::core::actions::*;
use crate::core::config::Config;
use crate::terminal::view::{
ClearScrollback, CopyText, FindInTerminal, FindNext, FindPrevious, InsertNewline,
InsertNewlineFallback, PasteText,
AlternatePaste, ClearScrollback, CopyText, FindInTerminal, FindNext, FindPrevious,
InsertNewline, InsertNewlineFallback, PasteText,
};
use crate::ui::i18n::{L10nKey, t, t_fmt};
use crate::ui::palette::CommandGroup;
@@ -119,6 +119,18 @@ fn paste_text_default() -> &'static str {
per_platform("", "ctrl-shift-v")
}
/// Windows and Linux paste with Ctrl+V everywhere else in the desktop, so the
/// terminal answers it too — but only off the alternate screen, where the key
/// is a control code a full-screen program is waiting for (#677). It is a
/// binding of its own rather than a second keystroke on `PasteText` so that it
/// can carry that narrower context, and so that a user who wants the Windows
/// Terminal behaviour back can say so: `"AlternatePaste": ""` hands Ctrl+V to
/// the shell at the prompt as well, and `"PasteText": "ctrl-v"` pastes with it
/// on every screen.
fn alternate_paste_default() -> &'static str {
per_platform("", "ctrl-v")
}
fn extra_defaults() -> Vec<(&'static str, &'static str, &'static str)> {
vec![
(
@@ -166,6 +178,20 @@ fn action_bindings(effective: &[(String, String)]) -> Vec<KeyBinding> {
log::warn!("ignoring keybinding for '{action}': invalid keystroke '{key}'");
continue;
}
// Said once, and the binding still installs: a chord the user asked
// for by name is the user's to spend, the way the tmux preset spends
// Ctrl+B. The invariant this guards is that no *default* spends one
// without saying so — `no_default_binding_sits_on_a_terminal_control_code`
// is the half of it that fails a build. A single chord only, since a
// prefix like `ctrl-b n` is that choice made deliberately.
if !key.contains(' ')
&& steals_a_control_code(key)
&& !control_code_binding_allowed(action, key)
{
log::warn!(
"keybinding '{key}' for '{action}' takes a control code away from the shell"
);
}
match make_binding(action, key) {
Some(b) => {
bindings.push(b);
@@ -183,6 +209,44 @@ fn action_bindings(effective: &[(String, String)]) -> Vec<KeyBinding> {
bindings
}
/// Whether a chord is one the terminal owes the PTY as a control code.
///
/// Ctrl and nothing else, over the keys that carry a C0 byte: the alphabet,
/// `[ \ ] ^ _ / ?`, the digits 2..8 and Space. A binding sitting on one of
/// these does not merely shadow the shell, it deletes a byte the program on
/// the far end is waiting for — Ctrl+D is EOF, Ctrl+W deletes a word, Ctrl+^
/// is vim's alternate file.
fn steals_a_control_code(chord: &str) -> bool {
let Ok(ks) = Keystroke::parse(chord) else {
return false;
};
let m = &ks.modifiers;
if !m.control || m.alt || m.shift || m.platform || m.function {
return false;
}
ks.key == "space"
|| ks.key.len() == 1
&& ks.key.chars().next().is_some_and(|c| {
c.is_ascii_alphabetic() || "[]\\`^_/?@".contains(c) || ('2'..='8').contains(&c)
})
}
/// The bindings allowed to sit on a control code anyway.
///
/// `EditorSave` stays on Ctrl+S because its handler in `app.rs` calls
/// `cx.propagate()` whenever the editor does not have focus, so the keystroke
/// reaches the terminal as XOFF instead of dying at the window. Ctrl+V is the
/// paste chord every Windows and Linux desktop trains its users on; tty7
/// answers it the way Windows Terminal does, and keeps it off the alternate
/// screen (see `alternate_paste_default`), so it is allowed under any action —
/// including a `PasteText` a user deliberately moves onto it (#677).
///
/// Anything else added here needs a fall-through of its own; a binding that
/// simply swallows the byte does not belong on this list.
fn control_code_binding_allowed(action: &str, chord: &str) -> bool {
action == "EditorSave" || (cfg!(not(target_os = "macos")) && chord == "ctrl-v")
}
fn per_platform(mac: &'static str, other: &'static str) -> &'static str {
if cfg!(target_os = "macos") {
mac
@@ -323,6 +387,7 @@ pub(crate) fn default_bindings() -> Vec<(&'static str, &'static str)> {
("InsertNewline", INSERT_NEWLINE_DEFAULT),
("CopyText", per_platform("", "ctrl-shift-c")),
("PasteText", paste_text_default()),
("AlternatePaste", alternate_paste_default()),
("OpenSettings", "secondary-,"),
(
"ShowKeyboardShortcuts",
@@ -595,6 +660,10 @@ fn authored_entry(action: &str) -> Option<(CommandGroup, String)> {
),
"CopyText" => (CommandGroup::Terminal, t(L10nKey::CmdCopy).to_string()),
"PasteText" => (CommandGroup::Terminal, t(L10nKey::CmdPaste).to_string()),
"AlternatePaste" => (
CommandGroup::Terminal,
t(L10nKey::CmdAlternatePaste).to_string(),
),
"InsertNewline" => (
CommandGroup::Terminal,
t(L10nKey::KeybindInsertNewline).to_string(),
@@ -921,6 +990,10 @@ fn action_context(action: &str) -> Option<&'static str> {
match action {
"FindInTerminal" | "FindNext" | "FindPrevious" | "ClearScrollback" | "InsertNewline"
| "CopyText" | "PasteText" => Some("Terminal"),
// `alt_screen` is declared by the pane whenever a full-screen program
// owns the grid, so this binding is simply absent there and Ctrl+V
// carries on to the PTY as SYN (#677).
"AlternatePaste" => Some("Terminal && !alt_screen"),
"ScmCommit" | "ScmCommitAmend" => Some("ScmCommit"),
_ => None,
}
@@ -1015,6 +1088,7 @@ fn make_binding(action: &str, keystroke: &str) -> Option<KeyBinding> {
"InsertNewline" => KeyBinding::new(keystroke, InsertNewline, action_context(action)),
"CopyText" => KeyBinding::new(keystroke, CopyText, action_context(action)),
"PasteText" => KeyBinding::new(keystroke, PasteText, action_context(action)),
"AlternatePaste" => KeyBinding::new(keystroke, AlternatePaste, action_context(action)),
"OpenSettings" => KeyBinding::new(keystroke, OpenSettings, None),
"ShowKeyboardShortcuts" => KeyBinding::new(keystroke, ShowKeyboardShortcuts, None),
"About" => KeyBinding::new(keystroke, About, None),
@@ -1328,11 +1402,34 @@ 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.
// Plain Ctrl+V is `AlternatePaste`, and only where no full-screen
// program is running: on the alternate screen the chord belongs to
// that program and reaches it as SYN.
assert!(
dispatched(&effective, "ctrl-v", "Terminal").is_empty(),
"ctrl-v is a control code and no default may claim it"
dispatched(&effective, "ctrl-v", "Terminal").contains(&AlternatePaste::name_for_type()),
"ctrl-v pastes at a prompt off macOS"
);
assert!(
dispatched(&effective, "ctrl-v", "Terminal alt_screen").is_empty(),
"a full-screen program owns ctrl-v"
);
assert!(
dispatched(&effective, "ctrl-shift-v", "Terminal alt_screen")
.contains(&PasteText::name_for_type()),
"Ctrl+Shift+V is the paste that works on every screen"
);
// The two ways out, both of which the control-code validator has to
// let through: retire the chord, or hand it the whole screen.
let retired = vec![("AlternatePaste".to_string(), String::new())];
assert!(
dispatched(&retired, "ctrl-v", "Terminal").is_empty(),
"an emptied AlternatePaste gives Ctrl+V back to the shell"
);
let everywhere = vec![("PasteText".to_string(), "ctrl-v".to_string())];
assert!(
dispatched(&everywhere, "ctrl-v", "Terminal alt_screen")
.contains(&PasteText::name_for_type()),
"a user may put Paste itself on Ctrl+V and have it everywhere"
);
let rebound = vec![("PasteText".to_string(), "ctrl-alt-v".to_string())];
assert!(
@@ -1449,28 +1546,14 @@ mod tests {
#[test]
fn no_default_binding_sits_on_a_terminal_control_code() {
// The invariant is "no default may *swallow* a terminal control code".
// EditorSave deliberately stays on Ctrl+S: its handler in `app.rs` calls
// `cx.propagate()` whenever the editor does not have focus, so the
// keystroke falls through to the terminal as XOFF instead of dying at
// the window. Anything added here must have such a fall-through.
const FALLS_THROUGH_TO_TERMINAL: [&str; 1] = ["EditorSave"];
// The exceptions are named and justified in
// `control_code_binding_allowed`; anything new needs a fall-through of
// its own to join them.
for (action, spec) in default_bindings() {
if FALLS_THROUGH_TO_TERMINAL.contains(&action) {
continue;
}
for chord in spec.split_whitespace() {
let ks = Keystroke::parse(chord).expect("default chords parse");
let m = &ks.modifiers;
if !m.control || m.alt || m.shift || m.platform || m.function {
continue;
}
let steals = ks.key.len() == 1
&& ks.key.chars().next().is_some_and(|c| {
c.is_ascii_alphabetic() || "[]\\`".contains(c) || ('2'..='8').contains(&c)
})
|| ks.key == "space";
Keystroke::parse(chord).expect("default chords parse");
assert!(
!steals,
!steals_a_control_code(chord) || control_code_binding_allowed(action, chord),
"{action} is bound to {chord}, which the shell needs as a control code \
(Ctrl+[ is ESC, Ctrl+D is EOF, Ctrl+W deletes a word, \
Ctrl+2..8 are NUL/ESC/FS/GS/RS/US/DEL). \
@@ -1480,6 +1563,49 @@ mod tests {
}
}
#[test]
fn the_control_code_rule_knows_what_the_shell_needs() {
// The keys with a C0 byte behind them, and the modifier shape that
// reaches it: Ctrl alone. This is the predicate the defaults are held
// to above and the one `action_bindings` warns on.
for chord in [
"ctrl-d",
"ctrl-c",
"ctrl-[",
"ctrl-2",
"ctrl-6",
"ctrl-8",
"ctrl-/",
"ctrl-space",
] {
assert!(steals_a_control_code(chord), "{chord} is a control code");
}
for chord in [
"ctrl-shift-v",
"ctrl-alt-v",
"secondary-shift-t",
"ctrl-1",
"ctrl-9",
"ctrl--",
"ctrl-f3",
"alt-enter",
] {
assert!(
!steals_a_control_code(chord),
"{chord} carries no control code"
);
}
// Ctrl+V is allowed to anyone off macOS — it is how the default paste
// reaches the chord, and how a user moves the full-screen paste onto
// it — while Ctrl+D stays refused whoever asks.
assert_eq!(
control_code_binding_allowed("PasteText", "ctrl-v"),
cfg!(not(target_os = "macos"))
);
assert!(!control_code_binding_allowed("PasteText", "ctrl-d"));
assert!(control_code_binding_allowed("EditorSave", "secondary-s"));
}
#[test]
fn every_default_chord_is_claimed_by_exactly_one_action() {
// Per context, not globally: gpui resolves a keystroke by walking the