feat(input): treat Option as Alt/Meta on macOS behind a setting

macOS gives the Option key two jobs a terminal can't serve at once: the
OS composes a special character (Option+B types ∫), while Meta bindings
need an ESC-prefixed chord (Option+B → ESC b, readline's backward-word).
A new `macos_option_as_alt` config (Settings → Terminal → Keyboard, off
by default) picks which one wins, matching Ghostty's `macos-option-as-alt`
and iTerm2's "Option as Meta".

The keystroke is reshaped once at the top of `on_key_down` — on: the
composed `key_char` is replaced with the base key so the encoder emits
`ESC` + the plain character (uppercased under Shift, like xterm's
metaSendsEscape); off: the alt bit is dropped so the composed character
goes out bare. Every consumer downstream (the ⌘ dispatcher, the prompt
editor, the PTY encoder, the Kitty encoder) then sees one consistent
story.

This also fixes two existing defects, both settings:

* The raw path used to emit `ESC ∫` for Option+B — an ESC prefix bolted
  onto the composed character, wrong under either reading of Option.
* The prompt editor swallowed Option+letter chords entirely: no composed
  character, no motion. Off now inserts the composed character; on maps
  M-b / M-f / M-d to the readline word motions (which also lights them
  up on Linux, where Alt is natively Meta but the chords were no-ops).

Known limitation: with the setting on and a non-ASCII IME input source
active (e.g. Pinyin), gpui routes printable keys — Option chords
included — to the IME first, which commits the composed character before
the key event reaches the view; Meta chords need an ASCII-capable source
(e.g. ABC). No regression: that state behaved identically before.

Closes #13
This commit is contained in:
l0ng-ai
2026-07-07 18:42:47 +08:00
parent 5ae867297f
commit 6789f944ae
5 changed files with 300 additions and 6 deletions
+13 -4
View File
@@ -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,
+176 -1
View File
@@ -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<gpui::Keystroke> {
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<Vec<u8>> {
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:
+82 -1
View File
@@ -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::<Config>().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.
+6
View File
@@ -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>) {
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>) {
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).
+23
View File
@@ -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(