Merge pull request #835 from l0ng-ai/fix/function-keys-834

fix(terminal): encode the function keys and stop swallowing them (#834)
This commit is contained in:
l0ng-ai
2026-09-10 14:27:50 +08:00
committed by GitHub
3 changed files with 534 additions and 3 deletions
+364 -3
View File
@@ -142,10 +142,31 @@ fn encode_kitty(ks: &gpui::Keystroke, kitty: KeyFlags) -> Option<Vec<u8>> {
return Some(csi_u(code, mods, None));
}
// F3 is the one function key the kitty protocol does not share with
// terminfo. Its first version allowed both `CSI R` and `CSI 13~`, then
// dropped the letter form outright: `CSI 1;2R` is also a Cursor Position
// Report for row 1, column 2, so a client that negotiated the protocol
// cannot tell Shift+F3 from an answer to its own DSR. The table gives F3
// as `CSI 13~` alone -- the VT220 `kf3` -- so that is what an app that
// asked for the protocol is told, while the legacy path below keeps the
// `\EOR` our `$TERM` spells.
if ks.key.as_str() == "f3" {
let s = if mods == 1 {
"\x1b[13~".to_string()
} else {
format!("\x1b[13;{mods}~")
};
return Some(s.into_bytes());
}
if let Some(seq) = functional_key(ks.key.as_str(), mods, kitty.app_cursor()) {
return Some(seq);
}
if let Some(code) = kitty_function_key(ks.key.as_str()) {
return Some(csi_u(code, mods, None));
}
let modified = m.control || m.alt;
if modified || kitty.report_all_keys {
if let Some(code) = text_key_code(ks) {
@@ -171,7 +192,7 @@ fn csi_u(code: u32, mods: u32, text: Option<&[u32]>) -> Vec<u8> {
s.into_bytes()
}
/// The cursor and editing keys, encoded the way `xterm-256color`'s terminfo
/// The cursor, editing and function keys, encoded the way `xterm-256color`'s terminfo
/// says they are — which is what we advertise in `$TERM`, and what ncurses
/// matches against byte for byte.
///
@@ -197,6 +218,19 @@ fn functional_key(key: &str, mods: u32, app_cursor: bool) -> Option<Vec<u8>> {
};
return Some(s.into_bytes());
}
if let Some(form) = function_key(key) {
let s = match form {
// `kf1=\EOP` .. `kf4=\EOS`, and modified the `CSI 1;<mods>` form
// the cursor keys use — terminfo spells Shift+F1 `kf13=\E[1;2P`.
// DECCKM has no say here: unlike `kcuu1`, `kf1` is SS3 under both
// `smkx` and `rmkx`.
FunctionKey::Ss3(l) if mods == 1 => format!("\x1bO{l}"),
FunctionKey::Ss3(l) => format!("\x1b[1;{mods}{l}"),
FunctionKey::Tilde(n) if mods == 1 => format!("\x1b[{n}~"),
FunctionKey::Tilde(n) => format!("\x1b[{n};{mods}~"),
};
return Some(s.into_bytes());
}
let num = match key {
"insert" => Some(2u32),
"delete" => Some(3),
@@ -215,6 +249,68 @@ fn functional_key(key: &str, mods: u32, app_cursor: bool) -> Option<Vec<u8>> {
None
}
/// The two shapes `xterm-256color` gives a function key.
enum FunctionKey {
/// `SS3 <letter>` unmodified, `CSI 1;<mods> <letter>` with a modifier.
Ss3(char),
/// `CSI <n> ~`, or `CSI <n>;<mods> ~` with a modifier.
Tilde(u32),
}
/// `f1`..`f12` — the name gpui gives these keys on all three platforms, and
/// the range `xterm-256color` gives a key of its own.
///
/// The numbering is the PC-style table xterm has used since patch #94 and the
/// one `kf5`..`kf12` spell: it starts at 15 and skips both 16 and 22, because
/// those two were DEC's "do" and "help" on the VT220 keypad. Guessing a
/// contiguous run here is the classic way to make F6 arrive as F5.
///
/// This deliberately stops at F12. In the entry we advertise, `kf13` onwards
/// are not further keys — they are the *modified* forms of F1..F8 (`kf13` is
/// `\E[1;2P`, Shift+F1), which the `mods` parameter above already produces.
/// A physical F13 therefore has no encoding of its own under this `$TERM`;
/// sending the VT220 `\E[25~` for it would hand ncurses a sequence its own
/// table reads back as Shift+F1, which is worse than sending nothing.
/// `kitty_function_key` picks them up for the one protocol that *can* name
/// them without that clash.
fn function_key(key: &str) -> Option<FunctionKey> {
Some(match key {
"f1" => FunctionKey::Ss3('P'),
"f2" => FunctionKey::Ss3('Q'),
"f3" => FunctionKey::Ss3('R'),
"f4" => FunctionKey::Ss3('S'),
"f5" => FunctionKey::Tilde(15),
"f6" => FunctionKey::Tilde(17),
"f7" => FunctionKey::Tilde(18),
"f8" => FunctionKey::Tilde(19),
"f9" => FunctionKey::Tilde(20),
"f10" => FunctionKey::Tilde(21),
"f11" => FunctionKey::Tilde(23),
"f12" => FunctionKey::Tilde(24),
_ => return None,
})
}
/// `f13`..`f24`, encodable only once the kitty protocol is negotiated. Kitty
/// gives them codepoints of its own in the private use area — `CSI 57376 u` is
/// F13, up to `CSI 57387 u` for F24 — so the ambiguity that stops
/// `function_key` at F12 does not arise: nothing else in that protocol spells
/// 57376. An app that never asked for the protocol still gets nothing, because
/// there is nothing in `xterm-256color` to send it.
fn kitty_function_key(key: &str) -> Option<u32> {
let n: u32 = key.strip_prefix('f')?.parse().ok()?;
(13..=24).contains(&n).then(|| 57376 + (n - 13))
}
/// Whether a key name is one of the function keys — the range that means
/// nothing to a text editor and everything to a shell. The inline editor asks
/// this to know a keystroke it holds no meaning for but the shell does; it
/// still only hands over what actually encodes, which for F13 and up is the
/// kitty path alone.
pub(crate) fn is_function_key(key: &str) -> bool {
function_key(key).is_some() || kitty_function_key(key).is_some()
}
fn text_key_code(ks: &gpui::Keystroke) -> Option<u32> {
match ks.key.as_str() {
"space" => Some(0x20),
@@ -682,6 +778,200 @@ mod tests {
}
}
/// Issue #834: the function keys produced no bytes at all, on any
/// platform. PSReadLine puts CharacterSearch on F3 and HistorySearch on
/// F8, so on Windows this took part of the shell's normal editing surface
/// away.
///
/// The table is `xterm-256color`'s `kf1`..`kf12` verbatim, gaps included:
/// F5 is 15 and F6 is 17, F10 is 21 and F11 is 23.
#[test]
fn keystroke_to_bytes_encodes_f1_through_f12_like_terminfo() {
let none = Modifiers::default();
let cases: &[(&str, &[u8])] = &[
("f1", b"\x1bOP"),
("f2", b"\x1bOQ"),
("f3", b"\x1bOR"),
("f4", b"\x1bOS"),
("f5", b"\x1b[15~"),
("f6", b"\x1b[17~"),
("f7", b"\x1b[18~"),
("f8", b"\x1b[19~"),
("f9", b"\x1b[20~"),
("f10", b"\x1b[21~"),
("f11", b"\x1b[23~"),
("f12", b"\x1b[24~"),
];
for (key, seq) in cases {
assert_eq!(
legacy(&ks(none, key, None)).as_deref(),
Some(*seq),
"{key} unmodified"
);
// gpui hands a function key over with no `key_char`, but the
// Windows backend has been known to attach an empty one; neither
// may reach the `key_char` fallback and send nothing.
assert_eq!(
legacy(&ks(none, key, Some(""))).as_deref(),
Some(*seq),
"{key} with an empty key_char"
);
}
}
/// The modified forms are the same ones terminfo lists under `kf13`
/// onwards: `kf13=\E[1;2P` is Shift+F1, `kf17=\E[15;2~` is Shift+F5,
/// `kf25=\E[1;5P` is Ctrl+F1. Alt is xterm's 3, which PSReadLine wants for
/// Alt+F7 (ClearHistory).
#[test]
fn keystroke_to_bytes_modifies_function_keys_like_terminfo() {
let shift = Modifiers {
shift: true,
..Default::default()
};
let alt = Modifiers {
alt: true,
..Default::default()
};
let ctrl = Modifiers {
control: true,
..Default::default()
};
let ctrl_shift = Modifiers {
control: true,
shift: true,
..Default::default()
};
// kf13, kf16
assert_eq!(legacy(&ks(shift, "f1", None)), Some(b"\x1b[1;2P".to_vec()));
assert_eq!(legacy(&ks(shift, "f4", None)), Some(b"\x1b[1;2S".to_vec()));
// kf25
assert_eq!(legacy(&ks(ctrl, "f1", None)), Some(b"\x1b[1;5P".to_vec()));
// kf37
assert_eq!(
legacy(&ks(ctrl_shift, "f1", None)),
Some(b"\x1b[1;6P".to_vec())
);
// kf49
assert_eq!(legacy(&ks(alt, "f1", None)), Some(b"\x1b[1;3P".to_vec()));
// kf20 -- Shift+F8, PSReadLine's HistorySearchForward
assert_eq!(legacy(&ks(shift, "f8", None)), Some(b"\x1b[19;2~".to_vec()));
// kf55 -- Alt+F7, PSReadLine's ClearHistory
assert_eq!(legacy(&ks(alt, "f7", None)), Some(b"\x1b[18;3~".to_vec()));
// kf35 -- Ctrl+F11
assert_eq!(legacy(&ks(ctrl, "f11", None)), Some(b"\x1b[23;5~".to_vec()));
}
/// The `mods` parameter is what makes F13 onwards ambiguous: in
/// `xterm-256color` those capability names are already spoken for by the
/// modified F1..F8, so a physical F13 has no sequence of its own to send.
///
/// The kitty protocol has no such clash — it puts F13..F24 in the private
/// use area, `CSI 57376 u` upwards — so a client that negotiated it does
/// get those keys, and only those twelve: F25 is past the end of the range
/// gpui names.
#[test]
fn terminfo_stops_at_f12_and_kitty_carries_on_to_f24() {
let none = Modifiers::default();
let shift = Modifiers {
shift: true,
..Default::default()
};
let ctrl = Modifiers {
control: true,
..Default::default()
};
let kitty_cases: &[(&str, &[u8])] = &[
("f13", b"\x1b[57376u"),
("f14", b"\x1b[57377u"),
("f20", b"\x1b[57383u"),
("f24", b"\x1b[57387u"),
];
for (key, seq) in kitty_cases {
assert_eq!(
legacy(&ks(none, key, None)),
None,
"{key} has no terminfo capability"
);
assert_eq!(
keystroke_to_bytes(&ks(none, key, None), kitty()).as_deref(),
Some(*seq),
"{key} under kitty"
);
}
assert_eq!(
keystroke_to_bytes(&ks(shift, "f13", None), kitty()),
Some(b"\x1b[57376;2u".to_vec())
);
// Ctrl+F13 must not fold into a C0 byte on its first letter either.
assert_eq!(legacy(&ks(ctrl, "f13", None)), None);
// And the range ends where gpui's names do.
assert_eq!(keystroke_to_bytes(&ks(none, "f25", None), kitty()), None);
assert_eq!(legacy(&ks(none, "f25", None)), None);
}
/// The one function key where the two protocols disagree. Kitty's spec
/// allowed `CSI R` for F3 in its first version and then removed it,
/// because `CSI 1;2R` is also a Cursor Position Report for row 1, column
/// 2 — so a client that negotiated the protocol is given `CSI 13~`, the
/// VT220 `kf3`, while `$TERM`'s own `kf3=\EOR` still goes to everyone
/// else. alacritty draws the same line.
#[test]
fn kitty_spells_f3_thirteen_because_csi_r_is_a_cursor_report() {
let none = Modifiers::default();
let shift = Modifiers {
shift: true,
..Default::default()
};
let ctrl = Modifiers {
control: true,
..Default::default()
};
assert_eq!(legacy(&ks(none, "f3", None)), Some(b"\x1bOR".to_vec()));
assert_eq!(legacy(&ks(shift, "f3", None)), Some(b"\x1b[1;2R".to_vec()));
let full = KeyFlags {
disambiguate: true,
report_all_keys: true,
report_text: true,
app_cursor: false,
};
for flags in [kitty(), full] {
assert_eq!(
keystroke_to_bytes(&ks(none, "f3", None), flags),
Some(b"\x1b[13~".to_vec())
);
assert_eq!(
keystroke_to_bytes(&ks(shift, "f3", None), flags),
Some(b"\x1b[13;2~".to_vec())
);
assert_eq!(
keystroke_to_bytes(&ks(ctrl, "f3", None), flags),
Some(b"\x1b[13;5~".to_vec())
);
}
// Cmd is not a kitty modifier either, and the platform key sends the
// keystroke down the legacy path in the first place.
let cmd = Modifiers {
platform: true,
..Default::default()
};
assert_eq!(
keystroke_to_bytes(&ks(cmd, "f3", None), kitty()),
Some(b"\x1bOR".to_vec())
);
}
/// Cmd is not an xterm modifier, so it must not turn a bare F-key into a
/// modified one — and on macOS Cmd+F-keys belong to the window anyway.
#[test]
fn cmd_does_not_modify_a_function_key() {
let cmd = Modifiers {
platform: true,
..Default::default()
};
assert_eq!(legacy(&ks(cmd, "f5", None)), Some(b"\x1b[15~".to_vec()));
}
fn app_cursor() -> KeyFlags {
KeyFlags {
app_cursor: true,
@@ -689,6 +979,21 @@ mod tests {
}
}
/// DECCKM governs `kcuu1` and friends, not `kf1`: terminfo spells `kf1`
/// `\EOP` under both `smkx` and `rmkx`, so the F keys must not move when
/// an ncurses app turns application cursor keys on.
#[test]
fn app_cursor_mode_leaves_the_function_keys_alone() {
let none = Modifiers::default();
for key in ["f1", "f4", "f5", "f12"] {
assert_eq!(
keystroke_to_bytes(&ks(none, key, None), app_cursor()),
legacy(&ks(none, key, None)),
"{key} does not follow DECCKM"
);
}
}
/// The bug behind issue #361: htop turns on DECCKM, `xterm-256color` spells
/// `kcuu1` as `\EOA`, and ncurses matches nothing else. Sending `\E[A` there
/// left htop reading the bytes one at a time -- and `[` is bound to "lower
@@ -801,9 +1106,11 @@ mod tests {
..Default::default()
};
assert_eq!(legacy(&ks(alt, "b", Some("b"))), Some(b"\x1bb".to_vec()));
// `f13` stands in for "a named key with nothing behind it" — it used
// to be `f7`, back when no function key encoded at all (#834).
let none = Modifiers::default();
assert_eq!(legacy(&ks(none, "f7", Some(""))), None);
assert_eq!(legacy(&ks(none, "f7", None)), None);
assert_eq!(legacy(&ks(none, "f13", Some(""))), None);
assert_eq!(legacy(&ks(none, "f13", None)), None);
}
#[test]
@@ -984,6 +1291,60 @@ mod tests {
);
}
/// The kitty encoder shares `functional_key`, so the F keys have to come
/// out of it byte-identical to the legacy path — kitty's own spec keeps
/// the legacy CSI/SS3 forms for F1..F12 and only appends the modifier
/// parameter, which is what that shared table already does. F3 is the sole
/// exception and has a test of its own:
/// `kitty_spells_f3_thirteen_because_csi_r_is_a_cursor_report`.
///
/// `report_all_keys` changes nothing here either: the F keys are already
/// escape sequences, so there is no bare byte for it to promote.
#[test]
fn kitty_encodes_function_keys_like_the_legacy_path() {
let none = Modifiers::default();
let shift = Modifiers {
shift: true,
..Default::default()
};
let ctrl = Modifiers {
control: true,
..Default::default()
};
let full = KeyFlags {
disambiguate: true,
report_all_keys: true,
report_text: true,
app_cursor: false,
};
for key in ["f1", "f2", "f4", "f5", "f7", "f10", "f11", "f12"] {
for mods in [none, shift, ctrl] {
let want = legacy(&ks(mods, key, None));
assert!(want.is_some(), "{key} encodes on the legacy path");
assert_eq!(
keystroke_to_bytes(&ks(mods, key, None), kitty()),
want,
"{key} under kitty disambiguate"
);
assert_eq!(
keystroke_to_bytes(&ks(mods, key, None), full),
want,
"{key} under kitty report-all-keys"
);
}
}
// Spot-check the actual bytes, so a change to `legacy` cannot quietly
// move both sides at once.
assert_eq!(
keystroke_to_bytes(&ks(none, "f7", None), full),
Some(b"\x1b[18~".to_vec())
);
assert_eq!(
keystroke_to_bytes(&ks(shift, "f1", None), full),
Some(b"\x1b[1;2P".to_vec())
);
}
#[test]
fn kitty_report_all_keys_escapes_plain_text_with_associated_text() {
let full = KeyFlags {
+115
View File
@@ -2482,6 +2482,19 @@ impl TerminalView {
self.close_completion();
// A function key means nothing to this editor and everything to the
// shell — PSReadLine puts CharacterSearch on F3 and HistorySearch on
// F8, and both of those act on the line that is currently on the
// prompt. So it takes the same route an unknown Ctrl chord takes:
// hand the line over first, then send the key, with every modifier
// combination going the same way (Alt+F7 is ClearHistory).
if super::input::is_function_key(key) && !m.platform {
if let Some(bytes) = super::input::keystroke_to_bytes(ks, self.key_flags()) {
self.handoff_line_to_shell(&bytes, cx);
return;
}
}
if m.control && !m.platform && !m.alt {
if cfg!(not(target_os = "macos")) {
match key {
@@ -6859,10 +6872,24 @@ impl Render for TerminalView {
.on_action(
cx.listener(|this, _: &FindInTerminal, window, cx| this.open_search(window, cx)),
)
// Off macOS these two live on F3 and Shift+F3, which is also where
// PSReadLine keeps CharacterSearch and readline users put their
// own widgets. With no find bar open there is no next match to
// step to, so the keystroke is given back the way `EditorSave`
// gives back Ctrl+S — otherwise the action swallows the key and
// the shell never sees it (#834).
.on_action(cx.listener(|this, _: &FindNext, _w, cx| {
if this.search.is_none() {
cx.propagate();
return;
}
this.step_match(Direction::Right, cx);
}))
.on_action(cx.listener(|this, _: &FindPrevious, _w, cx| {
if this.search.is_none() {
cx.propagate();
return;
}
this.step_match(Direction::Left, cx);
}))
.on_action(cx.listener(|this, _: &ClearScrollback, _w, cx| this.clear_scrollback(cx)))
@@ -11960,6 +11987,59 @@ mod gpui_tests {
.unwrap();
}
/// The whole chain for #834, through the real dispatch tree: F3 is bound
/// to Find Next off macOS, and gpui matches bindings before the pane's key
/// handler. With no find bar open the action gives the key back, the pane
/// encodes it, and PSReadLine's CharacterSearch gets its `\EOR`.
///
/// F7 has no binding at all and is the control: it takes the same route
/// with nothing to fall through.
#[cfg(not(target_os = "macos"))]
#[gpui::test]
fn an_unused_find_binding_gives_f3_back_to_the_shell(cx: &mut TestAppContext) {
let (window, mut daemon) = harness(cx);
cx.update(|cx| crate::ui::keymap::init(cx));
prompt_ready(&window, cx, &mut daemon);
window
.update(cx, |view, window, cx| {
window.activate_window();
view.focus_handle.focus(window, cx);
view.commit_text("echo a", cx);
})
.unwrap();
let mut vcx = gpui::VisualTestContext::from_window(window.into(), cx);
for (chord, seq) in [("f3", b"\x1bOR".to_vec()), ("f7", b"\x1b[18~".to_vec())] {
window
.update(cx, |view, _, _| {
// The previous handoff gave this prompt to the shell for
// good; take it back so both keys are tested from the
// same starting state.
view.editor_handoff = None;
view.cmd.set("echo a");
assert!(view.search.is_none(), "no find bar is open");
})
.unwrap();
vcx.simulate_keystrokes(chord);
window
.update(cx, |view, _, _| {
assert!(view.search.is_none(), "{chord} did not open the find bar");
assert_eq!(view.cmd.text(), "", "{chord} handed the line over");
})
.unwrap();
assert_eq!(
next_input_until_timeout(&mut daemon),
Some(b"echo a".to_vec()),
"{chord} puts the line on the shell's prompt first"
);
assert_eq!(
next_input_until_timeout(&mut daemon),
Some(seq),
"{chord} reaches the PTY"
);
}
}
#[gpui::test]
fn ctrl_r_fuzzy_search_accepts_into_the_editor(cx: &mut TestAppContext) {
let (window, _daemon) = harness(cx);
@@ -12027,6 +12107,41 @@ mod gpui_tests {
);
}
/// The second half of #834. Even once the encoder knew the F keys, the
/// inline editor still ate them: `handle_editor_key` had no arm for a
/// named key it does not bind, so F8 fell out of the bottom of the match
/// and died on a `cx.notify()`. PSReadLine's HistorySearchBackward acts on
/// the line that is on the prompt, so the fix is the unknown-chord route —
/// the line goes over first, then the key.
#[gpui::test]
fn function_keys_hand_the_line_to_the_shell(cx: &mut TestAppContext) {
let (window, mut daemon) = harness(cx);
for (chord, seq) in [
("f8", b"\x1b[19~".to_vec()),
("shift-f8", b"\x1b[19;2~".to_vec()),
("alt-f7", b"\x1b[18;3~".to_vec()),
("f3", b"\x1bOR".to_vec()),
] {
window
.update(cx, |view, _, cx| {
view.cmd.set("git st");
view.handle_editor_key(&key(chord), cx);
assert_eq!(view.cmd.text(), "", "{chord} handed the line over");
})
.unwrap();
assert_eq!(
next_input_until_timeout(&mut daemon),
Some(b"git st".to_vec()),
"{chord} puts the line on the shell's prompt first"
);
assert_eq!(
next_input_until_timeout(&mut daemon),
Some(seq),
"{chord} follows the line"
);
}
}
#[gpui::test]
fn ctrl_j_and_ctrl_m_submit_the_line_like_enter(cx: &mut TestAppContext) {
crate::core::config::pin_test_config_dir();
+55
View File
@@ -402,6 +402,15 @@ pub(crate) fn default_bindings() -> Vec<(&'static str, &'static str)> {
per_platform("secondary-shift-t", "alt-shift-t"),
),
("ToggleMaximizePane", "secondary-shift-enter"),
// The one default that sits on a bare function key, and it stays
// there: F11 is the fullscreen chord Windows Terminal, GNOME Terminal
// and konsole all train their users on, and no shell binds it — the
// keys PSReadLine actually wants are F3, F7 and F8. gpui matches this
// binding before the pane's key handler runs, so the terminal never
// sees a bare F11; Shift/Ctrl/Alt+F11 are not bound and still reach
// the PTY as `\E[23;<mods>~`, and the whole chord is one line of
// config away from being retired. See
// `f11_is_the_only_default_on_a_bare_function_key`.
("ToggleFullscreen", per_platform("secondary-enter", "f11")),
("ToggleTabSidebar", ""),
(
@@ -1873,6 +1882,52 @@ mod tests {
}
}
/// F1..F12 now encode (#834), so a default sitting on one takes it away
/// from the shell the same way a Ctrl binding takes a control code: gpui
/// matches bindings before the pane's key handler runs, so a bound
/// function key never reaches the PTY at all.
///
/// The three that do are named here rather than left to be discovered,
/// and each answers for the shell key it stands on:
///
/// * F11 keeps fullscreen outright. It is the chord Windows Terminal,
/// GNOME Terminal and konsole all use, and no shell binds it —
/// PSReadLine's F keys are F3, F7 and F8.
/// * F3 and Shift+F3 are Find Next / Previous, and they fall through:
/// with no find bar open the listener in `terminal::view` calls
/// `cx.propagate()`, so PSReadLine's CharacterSearch still gets the key.
///
/// A fourth needs a fall-through of its own to join them.
#[test]
fn f11_is_the_only_default_on_a_bare_function_key() {
let mut bound = Vec::new();
for (action, spec) in default_bindings() {
for chord in spec.split_whitespace() {
let ks = Keystroke::parse(chord).expect("default chords parse");
if crate::terminal::input::is_function_key(&ks.key) {
bound.push((action, chord));
}
}
}
let expected: &[(&str, &str)] = if cfg!(target_os = "macos") {
&[]
} else {
&[
("FindNext", "f3"),
("FindPrevious", "shift-f3"),
("ToggleFullscreen", "f11"),
]
};
bound.sort();
let mut expected = expected.to_vec();
expected.sort();
assert_eq!(
bound, expected,
"a default on a function key hides it from the shell; \
PSReadLine wants F3, F7 and F8, readline's `bind -x` any of them"
);
}
#[test]
fn the_control_code_rule_knows_what_the_shell_needs() {
// The keys with a C0 byte behind them, and the modifier shape that