From 17c0ea29356d42d1186cd967969810c323408f78 Mon Sep 17 00:00:00 2001 From: l0ng-ai Date: Fri, 7 Aug 2026 11:13:37 +0800 Subject: [PATCH] fix(terminal): honour application cursor key mode (#366) * fix(terminal): honour application cursor key mode (#361) Arrow, Home and End were always sent as their CSI form, no matter what the foreground program had asked for. Programs that turn on DECCKM via smkx -- which is every ncurses full-screen app -- expect the SS3 form, because that is what xterm-256color spells kcuu1 & co. as, and ncurses matches terminfo byte for byte. htop was the report: ncurses failed to match \E[A, handed the bytes to htop one at a time, and htop binds `[` to "lower priority", so every Up or Down bumped the selected process's nice value instead of moving the selection. The same breakage hits ncdu, mc, dialog, menuconfig, nmtui. Shells were unaffected because readline and zle bind both forms, and the mouse wheel already got this right in wheel_route(). Named keys now also carry their modifiers the way terminfo declares them (kLFT=\E[1;2D, kUP5=\E[1;5A, kDC3=\E[3;3~), instead of dropping Shift/Ctrl entirely and prefixing Alt with a bare ESC. Cmd stays out of the modifier parameter -- xterm has no encoding for it. The legacy and kitty encoders shared this table already; they now share one function, so both follow DECCKM. KittyFlags grew past its name and becomes KeyFlags. Verified in a dev instance: with DECCKM on the arrows arrive as ^[OA ^[OB ^[OD, with it off as ^[[A ^[[B ^[[D. * test(terminal): guard the SS3 cursor walk-back on line handoff --------- Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> --- src/terminal/input.rs | 278 ++++++++++++++++++++++++++++++------------ src/terminal/view.rs | 72 +++++++++-- 2 files changed, 264 insertions(+), 86 deletions(-) diff --git a/src/terminal/input.rs b/src/terminal/input.rs index 464e0c18..b5473dd1 100644 --- a/src/terminal/input.rs +++ b/src/terminal/input.rs @@ -5,25 +5,35 @@ use super::view::TerminalView; #[cfg(target_os = "macos")] use crate::core::config::Config; +/// Everything about the terminal's current state that changes how a keystroke +/// is encoded: the kitty protocol flags, plus DECCKM (application cursor keys). #[derive(Clone, Copy, Default)] -pub(super) struct KittyFlags { +pub(super) struct KeyFlags { disambiguate: bool, report_all_keys: bool, report_text: bool, + /// DECCKM. ncurses apps turn this on via `smkx` and then only recognise the + /// SS3 form of the arrow keys, because that is what `kcuu1` & co. spell. + app_cursor: bool, } -impl KittyFlags { +impl KeyFlags { pub(super) fn from_mode(mode: &TermMode) -> Self { Self { disambiguate: mode.contains(TermMode::DISAMBIGUATE_ESC_CODES), report_all_keys: mode.contains(TermMode::REPORT_ALL_KEYS_AS_ESC), report_text: mode.contains(TermMode::REPORT_ASSOCIATED_TEXT), + app_cursor: mode.contains(TermMode::APP_CURSOR), } } - pub(super) fn active(self) -> bool { + pub(super) fn kitty_active(self) -> bool { self.disambiguate || self.report_all_keys } + + pub(super) fn app_cursor(self) -> bool { + self.app_cursor + } } pub(super) fn reshape_option_keystroke( @@ -63,8 +73,8 @@ pub(super) fn reshape_option_keystroke( } #[cfg(any(target_os = "macos", test))] -pub(super) fn defer_to_ime(ks: &gpui::Keystroke, kitty: KittyFlags) -> bool { - if kitty.report_all_keys { +pub(super) fn defer_to_ime(ks: &gpui::Keystroke, flags: KeyFlags) -> bool { + if flags.report_all_keys { return false; } let m = &ks.modifiers; @@ -82,17 +92,17 @@ pub(super) fn meta_chord_bypasses_ime(ks: &gpui::Keystroke, option_as_alt: bool) option_as_alt && m.alt && !m.platform && !m.control } -pub(super) fn keystroke_to_bytes(ks: &gpui::Keystroke, kitty: KittyFlags) -> Option> { - if kitty.active() && !ks.modifiers.platform { - if let Some(bytes) = encode_kitty(ks, kitty) { +pub(super) fn keystroke_to_bytes(ks: &gpui::Keystroke, flags: KeyFlags) -> Option> { + if flags.kitty_active() && !ks.modifiers.platform { + if let Some(bytes) = encode_kitty(ks, flags) { return Some(bytes); } } - legacy_keystroke_to_bytes(ks) + legacy_keystroke_to_bytes(ks, flags) } -pub(super) fn tab_bytes(shift: bool, kitty: KittyFlags) -> Vec { - if kitty.active() && (shift || kitty.report_all_keys) { +pub(super) fn tab_bytes(shift: bool, flags: KeyFlags) -> Vec { + if flags.kitty_active() && (shift || flags.report_all_keys) { if shift { b"\x1b[9;2u".to_vec() } else { @@ -105,18 +115,15 @@ pub(super) fn tab_bytes(shift: bool, kitty: KittyFlags) -> Vec { } } -fn encode_kitty(ks: &gpui::Keystroke, kitty: KittyFlags) -> Option> { +/// xterm's modifier parameter: 1 plus a bitmask of shift/alt/control. The +/// platform (cmd) modifier has no xterm encoding and is deliberately left out. +fn xterm_mods(m: &gpui::Modifiers) -> u32 { + 1 + u32::from(m.shift) + 2 * u32::from(m.alt) + 4 * u32::from(m.control) +} + +fn encode_kitty(ks: &gpui::Keystroke, kitty: KeyFlags) -> Option> { let m = &ks.modifiers; - let mut mods = 1u32; - if m.shift { - mods += 1; - } - if m.alt { - mods += 2; - } - if m.control { - mods += 4; - } + let mods = xterm_mods(m); if ks.key.as_str() == "escape" { return Some(csi_u(27, mods, None)); @@ -135,7 +142,7 @@ fn encode_kitty(ks: &gpui::Keystroke, kitty: KittyFlags) -> Option> { return Some(csi_u(code, mods, None)); } - if let Some(seq) = kitty_functional(ks.key.as_str(), mods) { + if let Some(seq) = functional_key(ks.key.as_str(), mods, kitty.app_cursor()) { return Some(seq); } @@ -164,7 +171,15 @@ fn csi_u(code: u32, mods: u32, text: Option<&[u32]>) -> Vec { s.into_bytes() } -fn kitty_functional(key: &str, mods: u32) -> Option> { +/// The cursor and editing 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. +/// +/// Unmodified, the letter keys follow DECCKM: `CSI A` normally, `SS3 A` once +/// the app has turned on application cursor keys (`smkx`). Modified, they are +/// always the `CSI 1;A` form — xterm ignores DECCKM there, and so does +/// terminfo (`kUP3=\E[1;3A` and friends). +fn functional_key(key: &str, mods: u32, app_cursor: bool) -> Option> { let letter = match key { "up" => Some('A'), "down" => Some('B'), @@ -175,10 +190,10 @@ fn kitty_functional(key: &str, mods: u32) -> Option> { _ => None, }; if let Some(l) = letter { - let s = if mods != 1 { - format!("\x1b[1;{mods}{l}") - } else { - format!("\x1b[{l}") + let s = match (mods, app_cursor) { + (1, false) => format!("\x1b[{l}"), + (1, true) => format!("\x1bO{l}"), + _ => format!("\x1b[1;{mods}{l}"), }; return Some(s.into_bytes()); } @@ -224,7 +239,7 @@ fn associated_text(ks: &gpui::Keystroke) -> Option> { (!cps.is_empty()).then_some(cps) } -fn legacy_keystroke_to_bytes(ks: &gpui::Keystroke) -> Option> { +fn legacy_keystroke_to_bytes(ks: &gpui::Keystroke, flags: KeyFlags) -> Option> { let m = &ks.modifiers; let key = ks.key.as_str(); @@ -270,21 +285,15 @@ fn legacy_keystroke_to_bytes(ks: &gpui::Keystroke) -> Option> { } } + if let Some(seq) = functional_key(key, xterm_mods(m), flags.app_cursor()) { + return Some(seq); + } + let seq: Option<&[u8]> = match key { "enter" => Some(b"\r"), "tab" => Some(b"\t"), "backspace" => Some(b"\x7f"), "escape" => Some(b"\x1b"), - "up" => Some(b"\x1b[A"), - "down" => Some(b"\x1b[B"), - "right" => Some(b"\x1b[C"), - "left" => Some(b"\x1b[D"), - "home" => Some(b"\x1b[H"), - "end" => Some(b"\x1b[F"), - "pageup" => Some(b"\x1b[5~"), - "pagedown" => Some(b"\x1b[6~"), - "delete" => Some(b"\x1b[3~"), - "insert" => Some(b"\x1b[2~"), _ => None, }; if let Some(seq) = seq { @@ -429,7 +438,7 @@ impl InputHandler for TerminalInputHandler { if meta_chord_bypasses_ime(keystroke, cx.global::().macos_option_as_alt) { return false; } - if self.view.read(cx).kitty_flags().report_all_keys { + if self.view.read(cx).key_flags().report_all_keys { return false; } if window.has_pending_keystrokes() { @@ -442,29 +451,31 @@ impl InputHandler for TerminalInputHandler { #[cfg(test)] mod tests { use super::{ - KittyFlags, defer_to_ime, keystroke_to_bytes, meta_chord_bypasses_ime, + KeyFlags, defer_to_ime, keystroke_to_bytes, meta_chord_bypasses_ime, reshape_option_keystroke, tab_bytes, }; use gpui::{Keystroke, Modifiers}; - fn full_mode() -> KittyFlags { - KittyFlags { + fn full_mode() -> KeyFlags { + KeyFlags { disambiguate: true, report_all_keys: true, report_text: true, + app_cursor: false, } } - fn disambiguate_only() -> KittyFlags { - KittyFlags { + fn disambiguate_only() -> KeyFlags { + KeyFlags { disambiguate: true, report_all_keys: false, report_text: false, + app_cursor: false, } } fn legacy(ks: &Keystroke) -> Option> { - keystroke_to_bytes(ks, KittyFlags::default()) + keystroke_to_bytes(ks, KeyFlags::default()) } fn ks(mods: Modifiers, key: &str, key_char: Option<&str>) -> Keystroke { @@ -480,7 +491,7 @@ mod tests { let plain = Modifiers::default(); let a = ks(plain, "a", Some("a")); - assert!(defer_to_ime(&a, KittyFlags::default())); + assert!(defer_to_ime(&a, KeyFlags::default())); assert!(defer_to_ime(&a, disambiguate_only())); assert!(!defer_to_ime(&a, full_mode())); @@ -490,7 +501,7 @@ mod tests { ); let space = ks(plain, "space", Some(" ")); - assert!(defer_to_ime(&space, KittyFlags::default())); + assert!(defer_to_ime(&space, KeyFlags::default())); assert!(!defer_to_ime(&space, full_mode())); } @@ -501,28 +512,25 @@ mod tests { ..Default::default() }; let upper = ks(shift, "a", Some("A")); - assert!(defer_to_ime(&upper, KittyFlags::default())); + assert!(defer_to_ime(&upper, KeyFlags::default())); assert!(!defer_to_ime(&upper, full_mode())); } #[test] fn non_text_keys_never_defer_to_the_ime() { let plain = Modifiers::default(); - assert!(!defer_to_ime( - &ks(plain, "left", None), - KittyFlags::default() - )); + assert!(!defer_to_ime(&ks(plain, "left", None), KeyFlags::default())); assert!(!defer_to_ime( &ks(plain, "backspace", None), - KittyFlags::default() + KeyFlags::default() )); assert!(!defer_to_ime( &ks(plain, "enter", Some("\n")), - KittyFlags::default() + KeyFlags::default() )); assert!(!defer_to_ime( &ks(plain, "tab", Some("\t")), - KittyFlags::default() + KeyFlags::default() )); let ctrl = Modifiers { control: true, @@ -530,7 +538,7 @@ mod tests { }; assert!(!defer_to_ime( &ks(ctrl, "c", Some("c")), - KittyFlags::default() + KeyFlags::default() )); } @@ -571,7 +579,10 @@ mod tests { alt: true, ..Default::default() }; - assert_eq!(legacy(&ks(alt, "up", None)), Some(b"\x1b\x1b[A".to_vec())); + // Named keys carry their modifiers in the sequence (kUP3), rather than + // taking the meta-ESC prefix the way enter/tab/backspace do. + assert_eq!(legacy(&ks(alt, "up", None)), Some(b"\x1b[1;3A".to_vec())); + assert_eq!(legacy(&ks(alt, "enter", None)), Some(b"\x1b\r".to_vec())); } #[test] @@ -635,6 +646,118 @@ mod tests { } } + fn app_cursor() -> KeyFlags { + KeyFlags { + app_cursor: true, + ..Default::default() + } + } + + /// 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 + /// priority", so every Up/Down bumped the selected process's nice value. + #[test] + fn app_cursor_mode_switches_the_arrow_keys_to_ss3() { + let none = Modifiers::default(); + let cases: &[(&str, &[u8])] = &[ + ("up", b"\x1bOA"), + ("down", b"\x1bOB"), + ("right", b"\x1bOC"), + ("left", b"\x1bOD"), + ("home", b"\x1bOH"), + ("end", b"\x1bOF"), + ]; + for (key, seq) in cases { + assert_eq!( + keystroke_to_bytes(&ks(none, key, None), app_cursor()).as_deref(), + Some(*seq), + "{key} under DECCKM" + ); + // The kitty encoder shares the same table, so it has to agree. + let kitty_app = KeyFlags { + app_cursor: true, + ..kitty() + }; + assert_eq!( + keystroke_to_bytes(&ks(none, key, None), kitty_app).as_deref(), + Some(*seq), + "{key} under DECCKM + kitty" + ); + } + } + + /// DECCKM only governs the unmodified form. xterm -- and terminfo's `kUP5` + /// & co. -- keep the CSI form once a modifier is in play. + #[test] + fn app_cursor_mode_leaves_modified_arrows_and_tilde_keys_alone() { + let ctrl = Modifiers { + control: true, + ..Default::default() + }; + assert_eq!( + keystroke_to_bytes(&ks(ctrl, "up", None), app_cursor()), + Some(b"\x1b[1;5A".to_vec()) + ); + let none = Modifiers::default(); + for key in ["pageup", "pagedown", "delete", "insert"] { + assert_eq!( + keystroke_to_bytes(&ks(none, key, None), app_cursor()), + legacy(&ks(none, key, None)), + "{key} does not follow DECCKM" + ); + } + } + + #[test] + fn keystroke_to_bytes_encodes_modified_named_keys_like_xterm() { + let shift = Modifiers { + shift: true, + ..Default::default() + }; + let ctrl = Modifiers { + control: true, + ..Default::default() + }; + let ctrl_shift = Modifiers { + control: true, + shift: true, + ..Default::default() + }; + assert_eq!( + legacy(&ks(shift, "left", None)), + Some(b"\x1b[1;2D".to_vec()) + ); + assert_eq!( + legacy(&ks(ctrl, "right", None)), + Some(b"\x1b[1;5C".to_vec()) + ); + assert_eq!(legacy(&ks(ctrl, "home", None)), Some(b"\x1b[1;5H".to_vec())); + assert_eq!( + legacy(&ks(ctrl_shift, "up", None)), + Some(b"\x1b[1;6A".to_vec()) + ); + assert_eq!( + legacy(&ks(shift, "delete", None)), + Some(b"\x1b[3;2~".to_vec()) + ); + assert_eq!( + legacy(&ks(ctrl, "pageup", None)), + Some(b"\x1b[5;5~".to_vec()) + ); + } + + /// Cmd has no xterm modifier encoding, so it must not leak into the + /// parameter and turn a bare arrow into a modified one. + #[test] + fn keystroke_to_bytes_ignores_cmd_when_encoding_named_keys() { + let cmd = Modifiers { + platform: true, + ..Default::default() + }; + assert_eq!(legacy(&ks(cmd, "up", None)), Some(b"\x1b[A".to_vec())); + } + #[test] fn keystroke_to_bytes_alt_prefixes_printable_and_ignores_empty_char() { let alt = Modifiers { @@ -656,11 +779,12 @@ mod tests { ); } - fn kitty() -> KittyFlags { - KittyFlags { + fn kitty() -> KeyFlags { + KeyFlags { disambiguate: true, report_all_keys: false, report_text: false, + app_cursor: false, } } @@ -737,10 +861,11 @@ mod tests { #[test] fn kitty_report_all_keys_escapes_plain_enter_tab_backspace() { - let full = KittyFlags { + let full = KeyFlags { disambiguate: true, report_all_keys: true, report_text: false, + app_cursor: false, }; let none = Modifiers::default(); assert_eq!( @@ -759,15 +884,16 @@ mod tests { #[test] fn tab_bytes_follows_the_disambiguate_rule() { - let off = KittyFlags::default(); + let off = KeyFlags::default(); assert_eq!(tab_bytes(false, off), b"\t".to_vec()); assert_eq!(tab_bytes(true, off), b"\x1b[Z".to_vec()); assert_eq!(tab_bytes(false, kitty()), b"\t".to_vec()); assert_eq!(tab_bytes(true, kitty()), b"\x1b[9;2u".to_vec()); - let full = KittyFlags { + let full = KeyFlags { disambiguate: true, report_all_keys: true, report_text: false, + app_cursor: false, }; assert_eq!(tab_bytes(false, full), b"\x1b[9u".to_vec()); } @@ -824,10 +950,11 @@ mod tests { #[test] fn kitty_report_all_keys_escapes_plain_text_with_associated_text() { - let full = KittyFlags { + let full = KeyFlags { disambiguate: true, report_all_keys: true, report_text: true, + app_cursor: false, }; let none = Modifiers::default(); assert_eq!( @@ -838,10 +965,11 @@ mod tests { #[test] fn kitty_associated_text_drops_del_and_c1_controls() { - let full = KittyFlags { + let full = KeyFlags { disambiguate: true, report_all_keys: true, report_text: true, + app_cursor: false, }; let none = Modifiers::default(); assert_eq!( @@ -860,8 +988,8 @@ mod tests { #[test] fn kitty_off_is_byte_identical_to_legacy() { - let none = KittyFlags::default(); - assert!(!none.active()); + let none = KeyFlags::default(); + assert!(!none.kitty_active()); let mods = Modifiers::default(); assert_eq!( keystroke_to_bytes(&ks(mods, "tab", None), none), @@ -877,7 +1005,7 @@ mod tests { ); } - fn reshaped_bytes(ks: &Keystroke, option_as_alt: bool, kitty: KittyFlags) -> Option> { + fn reshaped_bytes(ks: &Keystroke, option_as_alt: bool, kitty: KeyFlags) -> Option> { let reshaped = reshape_option_keystroke(ks, option_as_alt); keystroke_to_bytes(reshaped.as_ref().unwrap_or(ks), kitty) } @@ -893,7 +1021,7 @@ mod tests { #[test] fn option_as_alt_on_sends_esc_plus_base_key() { assert_eq!( - reshaped_bytes(&option_b(), true, KittyFlags::default()), + reshaped_bytes(&option_b(), true, KeyFlags::default()), Some(b"\x1bb".to_vec()) ); let alt_shift = Modifiers { @@ -902,7 +1030,7 @@ mod tests { ..Default::default() }; assert_eq!( - reshaped_bytes(&ks(alt_shift, "b", Some("ı")), true, KittyFlags::default()), + reshaped_bytes(&ks(alt_shift, "b", Some("ı")), true, KeyFlags::default()), Some(b"\x1bB".to_vec()) ); let alt = Modifiers { @@ -910,7 +1038,7 @@ mod tests { ..Default::default() }; assert_eq!( - reshaped_bytes(&ks(alt, "2", Some("™")), true, KittyFlags::default()), + reshaped_bytes(&ks(alt, "2", Some("™")), true, KeyFlags::default()), Some(b"\x1b2".to_vec()) ); } @@ -918,7 +1046,7 @@ mod tests { #[test] fn option_as_alt_off_sends_composed_text_bare() { assert_eq!( - reshaped_bytes(&option_b(), false, KittyFlags::default()), + reshaped_bytes(&option_b(), false, KeyFlags::default()), Some("∫".as_bytes().to_vec()) ); } @@ -972,8 +1100,8 @@ mod tests { for on in [true, false] { assert!(reshape_option_keystroke(&ks(alt, "up", None), on).is_none()); assert_eq!( - reshaped_bytes(&ks(alt, "up", None), on, KittyFlags::default()), - Some(b"\x1b\x1b[A".to_vec()) + reshaped_bytes(&ks(alt, "up", None), on, KeyFlags::default()), + Some(b"\x1b[1;3A".to_vec()) ); } assert!(reshape_option_keystroke(&ks(alt, "enter", Some("\n")), false).is_none()); @@ -985,7 +1113,7 @@ mod tests { for on in [true, false] { assert!(reshape_option_keystroke(&ks(ctrl_alt, "c", None), on).is_none()); assert_eq!( - reshaped_bytes(&ks(ctrl_alt, "c", None), on, KittyFlags::default()), + reshaped_bytes(&ks(ctrl_alt, "c", None), on, KeyFlags::default()), Some(vec![0x1b, 0x03]) ); } diff --git a/src/terminal/view.rs b/src/terminal/view.rs index d20c0d9a..30983145 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -1268,7 +1268,7 @@ impl TerminalView { } #[cfg(target_os = "macos")] - if !window.has_pending_keystrokes() && super::input::defer_to_ime(ks, self.kitty_flags()) { + if !window.has_pending_keystrokes() && super::input::defer_to_ime(ks, self.key_flags()) { return; } @@ -1296,7 +1296,7 @@ impl TerminalView { self.editor_handoff_interrupt_seq = Some(self.terminal.prompt_seq()); } - let kitty = self.kitty_flags(); + let kitty = self.key_flags(); if let Some(bytes) = super::input::keystroke_to_bytes(ks, kitty) { let plain = !m.control && !m.alt && !m.platform; let interrupt = is_typeahead_interrupt(ks.key.as_str(), m); @@ -1548,7 +1548,7 @@ impl TerminalView { } if self.apply_readline_ctrl(key) { cx.notify(); - } else if let Some(bytes) = super::input::keystroke_to_bytes(ks, self.kitty_flags()) { + } else if let Some(bytes) = super::input::keystroke_to_bytes(ks, self.key_flags()) { self.handoff_line_to_shell(&bytes, cx); } else { cx.notify(); @@ -1642,7 +1642,7 @@ impl TerminalView { return; } "escape" => { - let bytes = super::input::keystroke_to_bytes(ks, self.kitty_flags()) + let bytes = super::input::keystroke_to_bytes(ks, self.key_flags()) .unwrap_or_else(|| vec![0x1b]); self.terminal.write(bytes); return; @@ -1657,7 +1657,7 @@ impl TerminalView { } } if m.alt && !m.control && !m.platform && key.chars().count() == 1 { - let bytes = super::input::keystroke_to_bytes(ks, self.kitty_flags()) + let bytes = super::input::keystroke_to_bytes(ks, self.key_flags()) .unwrap_or_else(|| { let name = if m.shift { key.to_uppercase() @@ -1843,12 +1843,12 @@ impl TerminalView { self.has_selection() || (self.input_active() && self.cmd.selected_text().is_some()) } - pub(super) fn kitty_flags(&self) -> super::input::KittyFlags { - super::input::KittyFlags::from_mode(self.terminal.term.lock().mode()) + pub(super) fn key_flags(&self) -> super::input::KeyFlags { + super::input::KeyFlags::from_mode(self.terminal.term.lock().mode()) } fn tab_bytes(&self, shift: bool) -> Vec { - super::input::tab_bytes(shift, self.kitty_flags()) + super::input::tab_bytes(shift, self.key_flags()) } fn jump_to_prompt(&mut self) { @@ -3015,7 +3015,7 @@ impl TerminalView { if self.input_active() { self.insert_newline_action(cx); } else if (self.search.is_some() && self.search_focused) - || self.kitty_flags().active() + || self.key_flags().kitty_active() || !self.accepts_input(cx) { cx.propagate(); @@ -3314,7 +3314,12 @@ impl TerminalView { if !line.is_empty() { self.terminal.write(line.into_bytes()); if tail > 0 { - self.terminal.write(b"\x1b[D".repeat(tail)); + let left: &[u8] = if self.key_flags().app_cursor() { + b"\x1bOD" + } else { + b"\x1b[D" + }; + self.terminal.write(left.repeat(tail)); } } self.cmd.clear(); @@ -6737,6 +6742,51 @@ mod gpui_tests { panic!("the local editor never engaged at the prompt"); } + /// Handing the line back to the shell walks the cursor left once per + /// character it sat before. Those are arrow keys like any other, so under + /// DECCKM they have to be SS3 — and zsh's zle does turn DECCKM on, so this + /// is the ordinary case rather than the exotic one. + #[gpui::test] + fn a_handoff_walks_the_cursor_back_in_ss3_under_app_cursor_mode(cx: &mut TestAppContext) { + crate::core::config::pin_test_config_dir(); + let (window, mut daemon) = harness(cx); + DaemonMsg::Output(b"\x1b[?1h".to_vec()) + .encode(&mut daemon) + .unwrap(); + DaemonMsg::Prompt { + active: true, + at_prompt: true, + last_exit: None, + } + .encode(&mut daemon) + .unwrap(); + wait_for_input_active(&window, cx); + + window + .update(cx, |view, window, cx| { + assert!( + view.key_flags().app_cursor(), + "the shell asked for application cursor keys" + ); + for ch in ["z", "z", "q", "q", "x"] { + type_char(view, ch, window, cx); + } + view.handle_editor_key(&key("left"), cx); + view.handle_editor_key(&key("left"), cx); + view.complete_tab(true, cx); + }) + .unwrap(); + assert_eq!( + next_input_until_timeout(&mut daemon), + Some(b"zzqqx".to_vec()) + ); + assert_eq!( + next_input_until_timeout(&mut daemon), + Some(b"\x1bOD\x1bOD".to_vec()), + "the cursor walks back in SS3, not CSI" + ); + } + #[gpui::test] fn tab_with_no_candidates_hands_the_line_to_the_shell(cx: &mut TestAppContext) { let (window, mut daemon) = harness(cx); @@ -7407,7 +7457,7 @@ mod gpui_tests { for _ in 0..200 { cx.run_until_parked(); if window - .update(cx, |view, _, _| view.kitty_flags().active()) + .update(cx, |view, _, _| view.key_flags().kitty_active()) .unwrap() { break;