diff --git a/src/core/config.rs b/src/core/config.rs index e6b0dc6b..f8bd9b51 100644 --- a/src/core/config.rs +++ b/src/core/config.rs @@ -80,6 +80,13 @@ pub struct Config { pub cursor_style: CursorStyle, // ── Input / Mouse ─────────────────────────────────────────────────────── + /// macOS only: treat the Option (⌥) key as Alt/Meta. On, an Option chord + /// sends the ESC-prefixed sequence Meta bindings expect (Option+B → `ESC b`, + /// readline's backward-word), like Ghostty's `macos-option-as-alt` / + /// iTerm2's "Option as Meta". Off (the default), Option keeps its macOS + /// role of composing special characters (Option+B → `∫`). Ignored on other + /// platforms, where Alt always carries the Meta meaning. + pub macos_option_as_alt: bool, /// Hide the OS mouse pointer while typing; it reappears on the next mouse /// move. Off by default. pub mouse_hide_while_typing: bool, @@ -243,10 +250,12 @@ impl Default for Config { // outdated is the status quo we're fixing. One cheap GET at startup. check_for_updates: true, cursor_style: CursorStyle::Block, - // Input/mouse defaults preserve today's behavior: GPUI already hides - // the pointer while typing (its `CursorHideMode` default), so this - // starts `true`; no focus-follows-mouse, raw 1× scroll, no copy trim, - // a normal centered window. + // Input/mouse defaults preserve today's behavior: Option composes + // characters as macOS ships it (opt into Option-as-Meta); GPUI + // already hides the pointer while typing (its `CursorHideMode` + // default), so that starts `true`; no focus-follows-mouse, raw 1× + // scroll, no copy trim, a normal centered window. + macos_option_as_alt: false, mouse_hide_while_typing: true, focus_follows_mouse: false, mouse_scroll_multiplier: 1.0, diff --git a/src/terminal/input.rs b/src/terminal/input.rs index 7af8754f..88242281 100644 --- a/src/terminal/input.rs +++ b/src/terminal/input.rs @@ -41,6 +41,70 @@ impl KittyFlags { } } +/// Reshape a keystroke according to the macOS Option-key policy, before any +/// encoding runs. macOS gives Option two jobs that a terminal can't serve at +/// once: the OS composes a special character (Option+B types `∫`, delivered in +/// `key_char`), while Meta bindings need an ESC-prefixed chord (Option+B → +/// `ESC b`, readline's backward-word). `Config::macos_option_as_alt` picks: +/// +/// * **On** — the chord is Meta: `key_char` is replaced with the plain key +/// (uppercased under Shift, matching xterm's `metaSendsEscape` output), so +/// the legacy encoder's Alt branch emits `ESC` + the base character instead +/// of `ESC` + the composed one. +/// * **Off** (default) — the chord is text input: the alt bit is dropped so the +/// composed character is sent bare. (Without this, the legacy encoder bolts +/// an ESC prefix onto the composed char — `ESC ∫` — a sequence that is wrong +/// under either reading; and the prompt editor swallows the chord entirely.) +/// +/// Only Option chords that produce a single text key are reshaped: named keys +/// (arrows, Enter, …) and Ctrl/Cmd combinations keep their existing encodings +/// on both settings. Returns `None` when the keystroke needs no reshaping, so +/// callers only clone on the affected chords. Callers gate on macOS — the +/// composed-character split doesn't exist elsewhere — but the function itself +/// is platform-neutral so it can be tested everywhere. +pub(super) fn reshape_option_keystroke( + ks: &gpui::Keystroke, + option_as_alt: bool, +) -> Option { + let m = &ks.modifiers; + if !m.alt || m.platform || m.control { + return None; + } + if option_as_alt { + // Meta semantics: the byte after ESC must be the key itself. Only + // single-character keys compose; named keys already encode off `key`. + let mut chars = ks.key.chars(); + let base = chars.next()?; + if chars.next().is_some() { + return None; + } + // gpui reports shifted letters as a lowercase key + the shift bit; + // Meta follows the shifted character (Option+Shift+B → `ESC B`). + let ch = if m.shift { + base.to_uppercase().to_string() + } else { + base.to_string() + }; + if ks.key_char.as_deref() == Some(ch.as_str()) { + return None; // already the base character — nothing to reshape + } + let mut out = ks.clone(); + out.key_char = Some(ch); + Some(out) + } else { + // macOS convention: the chord is ordinary text input. A chord that + // composed no printable text (named keys, Enter's "\n") stays a real + // Alt chord — dropping alt there would break Alt+arrow and friends. + let ch = ks.key_char.as_deref()?; + if ch.is_empty() || ch.chars().any(|c| c < '\u{20}' || c == '\u{7f}') { + return None; + } + let mut out = ks.clone(); + out.modifiers.alt = false; + Some(out) + } +} + /// Translate a GPUI keystroke into the bytes a PTY expects. /// /// When the app has enabled the Kitty keyboard protocol (`kitty.active()`) we try @@ -478,7 +542,7 @@ impl InputHandler for TerminalInputHandler { #[cfg(test)] mod tests { - use super::{KittyFlags, keystroke_to_bytes, tab_bytes}; + use super::{KittyFlags, keystroke_to_bytes, reshape_option_keystroke, tab_bytes}; use gpui::{Keystroke, Modifiers}; /// The legacy call shape used by the pre-existing tests: encode with the Kitty @@ -889,6 +953,117 @@ mod tests { ); } + /// Encode through the Option-key policy the way `on_key_down` does: reshape + /// first (macOS semantics), then hand the result to the shared encoder. + fn reshaped_bytes(ks: &Keystroke, option_as_alt: bool, kitty: KittyFlags) -> Option> { + let reshaped = reshape_option_keystroke(ks, option_as_alt); + keystroke_to_bytes(reshaped.as_ref().unwrap_or(ks), kitty) + } + + /// An Option+B chord as gpui reports it on macOS: base key "b", the alt + /// bit, and the OS-composed character in `key_char`. + fn option_b() -> Keystroke { + let alt = Modifiers { + alt: true, + ..Default::default() + }; + ks(alt, "b", Some("∫")) + } + + #[test] + fn option_as_alt_on_sends_esc_plus_base_key() { + // Meta semantics: ESC + the plain key, not ESC + the composed char. + assert_eq!( + reshaped_bytes(&option_b(), true, KittyFlags::default()), + Some(b"\x1bb".to_vec()) + ); + // Shifted letters follow the shifted character: Option+Shift+B → ESC B. + let alt_shift = Modifiers { + alt: true, + shift: true, + ..Default::default() + }; + assert_eq!( + reshaped_bytes(&ks(alt_shift, "b", Some("ı")), true, KittyFlags::default()), + Some(b"\x1bB".to_vec()) + ); + // Non-letter keys too: Option+2 composes "™" but Meta sends ESC 2. + let alt = Modifiers { + alt: true, + ..Default::default() + }; + assert_eq!( + reshaped_bytes(&ks(alt, "2", Some("™")), true, KittyFlags::default()), + Some(b"\x1b2".to_vec()) + ); + } + + #[test] + fn option_as_alt_off_sends_composed_text_bare() { + // macOS convention: the chord is text input — the composed character + // goes out with NO ESC prefix. (The unreshaped legacy path used to emit + // `ESC ∫`, wrong under either reading of the Option key.) + assert_eq!( + reshaped_bytes(&option_b(), false, KittyFlags::default()), + Some("∫".as_bytes().to_vec()) + ); + } + + #[test] + fn option_reshape_leaves_named_keys_and_ctrl_chords_alone() { + let alt = Modifiers { + alt: true, + ..Default::default() + }; + // Named keys compose nothing: Alt+Up keeps its ESC-prefixed form on + // both settings. + 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()) + ); + } + // Enter's key_char is a control char ("\n"), not composed text: the + // chord stays a real Alt chord with the setting off. + assert!(reshape_option_keystroke(&ks(alt, "enter", Some("\n")), false).is_none()); + // Ctrl+Alt chords keep the C0 + Meta-ESC encoding on both settings. + let ctrl_alt = Modifiers { + control: true, + alt: true, + ..Default::default() + }; + 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()), + Some(vec![0x1b, 0x03]) + ); + } + // No alt held → nothing to reshape, either setting. + assert!( + reshape_option_keystroke(&ks(Modifiers::default(), "a", Some("a")), true).is_none() + ); + // A key_char already equal to the base key needs no clone. + assert!(reshape_option_keystroke(&ks(alt, "b", Some("b")), true).is_none()); + } + + #[test] + fn option_reshape_composes_with_the_kitty_encoder() { + // Option-as-Meta keeps the alt bit, so a Kitty-aware app still sees the + // spec's alt-modified base key. + assert_eq!( + reshaped_bytes(&option_b(), true, kitty()), + Some(b"\x1b[98;3u".to_vec()) + ); + // Option-as-composed drops the alt bit: at the disambiguate level the + // chord is plain text, sent raw like any other typed character. + assert_eq!( + reshaped_bytes(&option_b(), false, kitty()), + Some("∫".as_bytes().to_vec()) + ); + } + #[test] fn kitty_never_encodes_cmd_chords() { // Cmd (platform) chords stay app-shortcut territory even with Kitty on: diff --git a/src/terminal/view.rs b/src/terminal/view.rs index 3d1a144a..f33f3043 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -711,7 +711,19 @@ impl TerminalView { if self.terminal.exited { return; } - let ks = &ev.keystroke; + // macOS Option-key policy (see `input::reshape_option_keystroke`): + // reshape the chord once, up front, so every consumer below — the ⌘ + // dispatcher, the prompt editor, the raw PTY encoder — sees the same + // story. Other platforms have no composed-character split to resolve. + let reshaped = if cfg!(target_os = "macos") { + super::input::reshape_option_keystroke( + &ev.keystroke, + cx.global::().macos_option_as_alt, + ) + } else { + None + }; + let ks = reshaped.as_ref().unwrap_or(&ev.keystroke); let m = &ks.modifiers; // While the search field is focused it owns the keyboard — typing, caret @@ -1048,6 +1060,36 @@ impl TerminalView { return; } + // Readline-style Meta word chords on the edited line: M-b / M-f motions + // and M-d delete-word, mirroring the Alt+←/→/Delete handling below. On + // macOS these are reachable only with `macos_option_as_alt` on — with it + // off the chord composes a character upstream and arrives here altless, + // through the printable-text arm. Other Alt+letter chords stay swallowed + // no-ops as before (the local editor can't mirror every zle widget). + if m.alt && !m.platform && !m.control { + match key { + "b" => { + self.editor_move_h(false, m.shift, true); + cx.notify(); + return; + } + "f" => { + self.editor_move_h(true, m.shift, true); + cx.notify(); + return; + } + "d" => { + if !self.cmd.delete_selection() { + self.cmd.delete_word_right(); + } + self.history_nav = None; + cx.notify(); + return; + } + _ => {} + } + } + match key { "enter" => { self.submit_command(cx); @@ -3904,6 +3946,45 @@ mod gpui_tests { } } + /// Readline's Meta word chords act on the local prompt editor: M-b / M-f + /// move by word, M-d deletes the word right of the caret. (On macOS these + /// chords reach the editor only with `macos_option_as_alt` on — the + /// `on_key_down` reshape otherwise strips the alt bit; here we drive the + /// editor dispatcher directly with the post-reshape keystroke.) + #[gpui::test] + fn meta_word_chords_edit_the_prompt_line(cx: &mut TestAppContext) { + let (window, _daemon) = harness(cx); + window + .update(cx, |view, _, cx| { + let meta = |key: &str| gpui::Keystroke { + modifiers: gpui::Modifiers { + alt: true, + ..Default::default() + }, + key: key.to_string(), + key_char: None, + }; + view.cmd.set("echo hello"); + // M-b from the end lands at the start of "hello". + view.handle_editor_key(&meta("b"), cx); + assert_eq!(view.cmd.cursor(), 5); + // M-d deletes the word right of the caret. + view.handle_editor_key(&meta("d"), cx); + assert_eq!(view.cmd.text(), "echo "); + // M-b / M-f hop the remaining word: back to its start, then + // forward to its end. + view.handle_editor_key(&meta("b"), cx); + assert_eq!(view.cmd.cursor(), 0); + view.handle_editor_key(&meta("f"), cx); + assert_eq!(view.cmd.cursor(), 4); + // Other Meta letters stay swallowed no-ops (line untouched). + view.handle_editor_key(&meta("z"), cx); + assert_eq!(view.cmd.text(), "echo "); + assert_eq!(view.cmd.cursor(), 4); + }) + .unwrap(); + } + /// A `PtyWrite` raised by the VT layer (query replies, bracketed-paste /// wrapping…) must come out of the client socket as an `Input` frame — /// this is the half of the query round-trip the remote tests can't see. diff --git a/src/ui/app.rs b/src/ui/app.rs index 2f23f43c..fae4c770 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -604,6 +604,12 @@ impl Tty7App { // ── Input / Mouse setters ─────────────────────────────────────────────── + /// Takes effect on the next keystroke — the terminal reads the flag per + /// key event, so nothing needs pushing to open panes. + pub(crate) fn set_macos_option_as_alt(&mut self, on: bool, cx: &mut Context) { + self.update_config(cx, |cfg| cfg.macos_option_as_alt = on); + } + pub(crate) fn set_mouse_hide_while_typing(&mut self, on: bool, cx: &mut Context) { self.update_config(cx, |cfg| cfg.mouse_hide_while_typing = on); // Push the new policy to GPUI right away (same call the hot-reload uses). diff --git a/src/ui/settings.rs b/src/ui/settings.rs index 8b5f2a4f..db59d356 100644 --- a/src/ui/settings.rs +++ b/src/ui/settings.rs @@ -845,6 +845,7 @@ impl Tty7App { let link_url = cfg.link_url; let mouse_hide = cfg.mouse_hide_while_typing; let focus_follows = cfg.focus_follows_mouse; + let option_as_alt = cfg.macos_option_as_alt; let scroll_mult = cfg.mouse_scroll_multiplier; let clip_trim = cfg.clipboard_trim_trailing_spaces; // Map the persisted scrollback depth onto its preset radio index (default @@ -911,6 +912,23 @@ impl Tty7App { .checked(clip_trim) .on_click(cx.listener(|this, on: &bool, _w, cx| this.set_clipboard_trim(*on, cx))) .into_any_element(); + // macOS only: the Option/special-character split this toggle resolves + // doesn't exist on other platforms, where Alt always carries Meta. + let option_alt_row = cfg!(target_os = "macos").then(|| { + let switch = Switch::new("term-option-as-alt") + .checked(option_as_alt) + .on_click( + cx.listener(|this, on: &bool, _w, cx| this.set_macos_option_as_alt(*on, cx)), + ) + .into_any_element(); + self.settings_row( + "Option (⌥) acts as Meta", + "⌥+key sends the escape chord shells expect (⌥B = back one word) \ + instead of typing a special character (∫).", + switch, + cx, + ) + }); // Slider + a live readout of the current multiplier beside it. let scroll_control = h_flex() .items_center() @@ -954,6 +972,11 @@ impl Tty7App { mouse_hide_switch, cx, )) + .when_some(option_alt_row, |v, row| { + v.child(self.section_rule(cx)) + .child(self.section_header("Keyboard", cx)) + .child(row) + }) .child(self.section_rule(cx)) .child(self.section_header("Links", cx)) .child(self.settings_row(