From e320f70fb05068471882c012b787164863798e73 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Wed, 9 Sep 2026 22:36:17 +0800 Subject: [PATCH 1/2] fix(terminal): encode the function keys and stop swallowing them (#834) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit F1 through F12 produced no bytes at all. `functional_key` in `src/terminal/input.rs` is the one place a named key becomes an escape sequence, and it knew the cursor and editing keys and nothing else; the `key_char` fallback below it cannot help, because a function key arrives with no character, and `text_key_code` gives up the moment a key name is longer than one character. Both encoders funnel through that function, so the kitty path was equally blank. This was never a Windows or a PowerShell problem — it was every platform, every shell — but it shows up first on Windows because PSReadLine keeps CharacterSearch on F3, HistorySearch on F8 and ClearHistory on Alt+F7, so the missing keys are part of the shell's ordinary editing surface rather than something exotic. The table is `xterm-256color`'s, since that is what we advertise in `$TERM`: `kf1`..`kf4` are the SS3 letters, `kf5`..`kf12` the numbered tilde forms whose numbering starts at 15 and skips both 16 and 22. Modified, they take the same `CSI 1;` and `CSI ;` shapes the cursor keys already use, which reproduces `kf13` onwards exactly — `kf13` is Shift+F1, `kf25` is Ctrl+F1. DECCKM is deliberately not consulted: unlike `kcuu1`, `kf1` is SS3 under both `smkx` and `rmkx`. It stops at F12 on purpose. In the entry we advertise there is no capability left for a thirteenth key — `kf13` and up are already the modified forms of F1..F8 — so sending the VT220 `\E[25~` for a physical F13 would hand ncurses a sequence its own table reads back as Shift+F1. Silence is the more honest answer, and F13+ keys are close to nonexistent on the keyboards this bug was filed from. Encoding alone would not have reached the shell, because two things upstream were eating the keys first. The inline prompt editor had no arm for a named key it does not bind, so F8 fell out of the bottom of `handle_editor_key` and died on a `cx.notify()`; it now takes the same route an unknown Ctrl chord takes, handing the line to the shell before sending the key, which is what PSReadLine's history and character searches need in order to act on it. And Find Next / Previous sit on F3 and Shift+F3 off macOS, where gpui matches the binding before the pane's key handler ever runs; with no find bar open there is no match to step to, so those listeners now give the keystroke back the way `EditorSave` gives back Ctrl+S. F11 keeps fullscreen. It is the chord Windows Terminal, GNOME Terminal and konsole all use, no shell binds it, the modified forms still reach the PTY, and it is one line of config away from being retired — but it is now a stated choice with a test standing on it rather than an accident. The reporter's second clause is explained rather than fixed: `prompt_editor` is a real gate, and turning it off could not help while the bytes did not exist, which is exactly what they saw. Claude-Session: https://claude.ai/code/session_01UUyWQXzcBAoBzaSX8pc7nU --- src/terminal/input.rs | 252 +++++++++++++++++++++++++++++++++++++++++- src/terminal/view.rs | 115 +++++++++++++++++++ src/ui/keymap.rs | 55 +++++++++ 3 files changed, 419 insertions(+), 3 deletions(-) diff --git a/src/terminal/input.rs b/src/terminal/input.rs index 0fe392ac..e198bc77 100644 --- a/src/terminal/input.rs +++ b/src/terminal/input.rs @@ -171,7 +171,7 @@ fn csi_u(code: u32, mods: u32, text: Option<&[u32]>) -> Vec { 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 +197,19 @@ fn functional_key(key: &str, mods: u32, app_cursor: bool) -> Option> { }; 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;` 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 +228,53 @@ fn functional_key(key: &str, mods: u32, app_cursor: bool) -> Option> { None } +/// The two shapes `xterm-256color` gives a function key. +enum FunctionKey { + /// `SS3 ` unmodified, `CSI 1; ` with a modifier. + Ss3(char), + /// `CSI ~`, or `CSI ; ~` 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. +fn function_key(key: &str) -> Option { + 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, + }) +} + +/// Whether a key name is one `functional_key` turns into a function-key +/// sequence. The inline editor asks this to know a keystroke it holds no +/// meaning for but the shell does. +pub(crate) fn is_function_key(key: &str) -> bool { + function_key(key).is_some() +} + fn text_key_code(ks: &gpui::Keystroke) -> Option { match ks.key.as_str() { "space" => Some(0x20), @@ -682,6 +742,123 @@ 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. + #[test] + fn function_keys_stop_at_f12() { + let none = Modifiers::default(); + let ctrl = Modifiers { + control: true, + ..Default::default() + }; + for key in ["f13", "f14", "f20", "f24"] { + assert_eq!(legacy(&ks(none, key, None)), None, "{key} is unencodable"); + assert_eq!( + keystroke_to_bytes(&ks(none, key, None), kitty()), + None, + "{key} is unencodable under kitty too" + ); + } + // Ctrl+F13 must not fold into a C0 byte on its first letter either. + assert_eq!(legacy(&ks(ctrl, "f13", None)), None); + } + + /// 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 +866,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 +993,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 +1178,58 @@ 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. + /// + /// `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", "f3", "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 { diff --git a/src/terminal/view.rs b/src/terminal/view.rs index 50f6c259..e5c2f206 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -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(); diff --git a/src/ui/keymap.rs b/src/ui/keymap.rs index 615b63e0..6fb2d808 100644 --- a/src/ui/keymap.rs +++ b/src/ui/keymap.rs @@ -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;~`, 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 From e7145eabd89ad5b205440b177999d2e38f64e5cb Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Thu, 10 Sep 2026 14:00:41 +0800 Subject: [PATCH 2/2] fix(terminal): give the kitty protocol its own F3 and F13..F24 The shared table sent F3 as `SS3 R` / `CSI 1;R` to a client that had negotiated the kitty keyboard protocol. Kitty's first version allowed that letter form and then removed it: `CSI 1;2R` is also a Cursor Position Report for row 1, column 2, so a client cannot tell Shift+F3 from an answer to its own DSR. The protocol's table gives F3 as `CSI 13~` alone -- the VT220 `kf3` -- and alacritty special-cases exactly this. Only the kitty path moves; everyone reading terminfo still gets the `kf3=\EOR` we advertise. F13..F24 were left unencodable on both paths for a terminfo reason that holds only on the legacy one: `kf13` onwards are already the modified F1..F8, so there is nothing to send. Kitty has no such clash -- it names them in the private use area, `CSI 57376 u` for F13 -- so a client that asked for the protocol now gets them, and the legacy path still sends nothing. Claude-Session: https://claude.ai/code/session_01JRqYZ9E153WpSHGS2AW3BM --- src/terminal/input.rs | 137 ++++++++++++++++++++++++++++++++++++++---- 1 file changed, 126 insertions(+), 11 deletions(-) diff --git a/src/terminal/input.rs b/src/terminal/input.rs index e198bc77..03ec4344 100644 --- a/src/terminal/input.rs +++ b/src/terminal/input.rs @@ -142,10 +142,31 @@ fn encode_kitty(ks: &gpui::Keystroke, kitty: KeyFlags) -> Option> { 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) { @@ -250,6 +271,8 @@ enum FunctionKey { /// 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 { Some(match key { "f1" => FunctionKey::Ss3('P'), @@ -268,11 +291,24 @@ fn function_key(key: &str) -> Option { }) } -/// Whether a key name is one `functional_key` turns into a function-key -/// sequence. The inline editor asks this to know a keystroke it holds no -/// meaning for but the shell does. +/// `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 { + 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() + function_key(key).is_some() || kitty_function_key(key).is_some() } fn text_key_code(ks: &gpui::Keystroke) -> Option { @@ -829,23 +865,100 @@ mod tests { /// 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 function_keys_stop_at_f12() { + 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() }; - for key in ["f13", "f14", "f20", "f24"] { - assert_eq!(legacy(&ks(none, key, None)), None, "{key} is unencodable"); + 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!( - keystroke_to_bytes(&ks(none, key, None), kitty()), + legacy(&ks(none, key, None)), None, - "{key} is unencodable under kitty too" + "{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 @@ -1181,7 +1294,9 @@ 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. + /// 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. @@ -1202,7 +1317,7 @@ mod tests { report_text: true, app_cursor: false, }; - for key in ["f1", "f2", "f3", "f4", "f5", "f7", "f10", "f11", "f12"] { + 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");