fix(input): keep Option chords out of the IME when Option is Meta

Option-as-Meta had no effect for anyone typing with a CJK input source.
The whole setting was dead for them: with Pinyin selected, macOS reports
Option chords as printable text (Option+B composes the special
character), so gpui routed them to the IME before the key handler ran.
The IME committed the composed character and swallowed the event --
on_key_down never ran, and reshape_option_keystroke never got a say.
Switching to ABC made it work again, which is why this looked
intermittent. Verified on the wire: with Pinyin active Option+F/Option+B
put c692 / e288ab on the PTY where ESC f / ESC b belong.

The routing decision lives in gpui's macOS backend and is asked once per
view, with no keystroke in hand, so it could not answer "IME for text,
but not for this chord". gpui now comes from our fork, whose one commit
passes the keystroke to prefers_ime_for_printable_keys; the default
implementation ignores it, so no existing handler changes behavior. The
terminal answers per key: an Option chord with the setting on stays on
the dispatch path, everything else still prefers the IME.

Gated on the setting deliberately. With Option-as-Meta off the chord is
text input and the IME is the right owner -- it is what makes dead keys
(Option+E then E -> e-acute) compose at all.

The fork is wired in with [patch] on the source rather than by editing
the gpui pins, because gpui-component declares its own gpui from the
upstream URL and a pin swap would put two incompatible copies of gpui in
the tree. Fetching a repo that size needs the git CLI; cargo's built-in
libgit2 transfer times out partway through.

Fixes #177
This commit is contained in:
l0ng-ai
2026-07-26 20:31:32 +08:00
parent fdd9b1adbb
commit ecb5ab1ed7
4 changed files with 156 additions and 27 deletions
+6
View File
@@ -3,3 +3,9 @@
# config/session/history. Extra args pass through, e.g. `cargo dev -- --foo`.
[alias]
dev = "run -- --config-dir .tty7-dev"
# The gpui fork lives in a fork of the Zed monorepo, and cargo's built-in libgit2
# fetch reliably times out partway through a clone that size. The git CLI handles
# it (resumable, packed transfer), and is present on every platform we build on.
[net]
git-fetch-with-cli = true
Generated
+25 -25
View File
@@ -1369,7 +1369,7 @@ dependencies = [
[[package]]
name = "collections"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed?rev=1d217ee39d381ac101b7cf49d3d22451ac1093fe#1d217ee39d381ac101b7cf49d3d22451ac1093fe"
source = "git+https://github.com/l0ng-ai/zed?branch=tty7#24ed55f4cf1c6f90c2fb4a1db0f33aca949a7f02"
dependencies = [
"gpui_util",
"indexmap",
@@ -1927,7 +1927,7 @@ dependencies = [
[[package]]
name = "derive_refineable"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed?rev=1d217ee39d381ac101b7cf49d3d22451ac1093fe#1d217ee39d381ac101b7cf49d3d22451ac1093fe"
source = "git+https://github.com/l0ng-ai/zed?branch=tty7#24ed55f4cf1c6f90c2fb4a1db0f33aca949a7f02"
dependencies = [
"proc-macro2",
"quote",
@@ -3096,7 +3096,7 @@ dependencies = [
[[package]]
name = "gpui"
version = "0.2.2"
source = "git+https://github.com/zed-industries/zed?rev=1d217ee39d381ac101b7cf49d3d22451ac1093fe#1d217ee39d381ac101b7cf49d3d22451ac1093fe"
source = "git+https://github.com/l0ng-ai/zed?branch=tty7#24ed55f4cf1c6f90c2fb4a1db0f33aca949a7f02"
dependencies = [
"accesskit",
"anyhow",
@@ -3287,7 +3287,7 @@ dependencies = [
[[package]]
name = "gpui_linux"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed?rev=1d217ee39d381ac101b7cf49d3d22451ac1093fe#1d217ee39d381ac101b7cf49d3d22451ac1093fe"
source = "git+https://github.com/l0ng-ai/zed?branch=tty7#24ed55f4cf1c6f90c2fb4a1db0f33aca949a7f02"
dependencies = [
"accesskit",
"accesskit_unix",
@@ -3338,7 +3338,7 @@ dependencies = [
[[package]]
name = "gpui_macos"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed?rev=1d217ee39d381ac101b7cf49d3d22451ac1093fe#1d217ee39d381ac101b7cf49d3d22451ac1093fe"
source = "git+https://github.com/l0ng-ai/zed?branch=tty7#24ed55f4cf1c6f90c2fb4a1db0f33aca949a7f02"
dependencies = [
"accesskit",
"accesskit_macos",
@@ -3385,7 +3385,7 @@ dependencies = [
[[package]]
name = "gpui_macros"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed?rev=1d217ee39d381ac101b7cf49d3d22451ac1093fe#1d217ee39d381ac101b7cf49d3d22451ac1093fe"
source = "git+https://github.com/l0ng-ai/zed?branch=tty7#24ed55f4cf1c6f90c2fb4a1db0f33aca949a7f02"
dependencies = [
"heck 0.5.0",
"proc-macro2",
@@ -3396,7 +3396,7 @@ dependencies = [
[[package]]
name = "gpui_platform"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed?rev=1d217ee39d381ac101b7cf49d3d22451ac1093fe#1d217ee39d381ac101b7cf49d3d22451ac1093fe"
source = "git+https://github.com/l0ng-ai/zed?branch=tty7#24ed55f4cf1c6f90c2fb4a1db0f33aca949a7f02"
dependencies = [
"console_error_panic_hook",
"gpui",
@@ -3409,7 +3409,7 @@ dependencies = [
[[package]]
name = "gpui_shared_string"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed?rev=1d217ee39d381ac101b7cf49d3d22451ac1093fe#1d217ee39d381ac101b7cf49d3d22451ac1093fe"
source = "git+https://github.com/l0ng-ai/zed?branch=tty7#24ed55f4cf1c6f90c2fb4a1db0f33aca949a7f02"
dependencies = [
"schemars",
"serde",
@@ -3419,7 +3419,7 @@ dependencies = [
[[package]]
name = "gpui_util"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed?rev=1d217ee39d381ac101b7cf49d3d22451ac1093fe#1d217ee39d381ac101b7cf49d3d22451ac1093fe"
source = "git+https://github.com/l0ng-ai/zed?branch=tty7#24ed55f4cf1c6f90c2fb4a1db0f33aca949a7f02"
dependencies = [
"anyhow",
"log",
@@ -3428,7 +3428,7 @@ dependencies = [
[[package]]
name = "gpui_web"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed?rev=1d217ee39d381ac101b7cf49d3d22451ac1093fe#1d217ee39d381ac101b7cf49d3d22451ac1093fe"
source = "git+https://github.com/l0ng-ai/zed?branch=tty7#24ed55f4cf1c6f90c2fb4a1db0f33aca949a7f02"
dependencies = [
"anyhow",
"console_error_panic_hook",
@@ -3452,7 +3452,7 @@ dependencies = [
[[package]]
name = "gpui_wgpu"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed?rev=1d217ee39d381ac101b7cf49d3d22451ac1093fe#1d217ee39d381ac101b7cf49d3d22451ac1093fe"
source = "git+https://github.com/l0ng-ai/zed?branch=tty7#24ed55f4cf1c6f90c2fb4a1db0f33aca949a7f02"
dependencies = [
"anyhow",
"bytemuck",
@@ -3481,7 +3481,7 @@ dependencies = [
[[package]]
name = "gpui_windows"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed?rev=1d217ee39d381ac101b7cf49d3d22451ac1093fe#1d217ee39d381ac101b7cf49d3d22451ac1093fe"
source = "git+https://github.com/l0ng-ai/zed?branch=tty7#24ed55f4cf1c6f90c2fb4a1db0f33aca949a7f02"
dependencies = [
"accesskit",
"accesskit_windows",
@@ -3800,7 +3800,7 @@ dependencies = [
[[package]]
name = "http_client"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed?rev=1d217ee39d381ac101b7cf49d3d22451ac1093fe#1d217ee39d381ac101b7cf49d3d22451ac1093fe"
source = "git+https://github.com/l0ng-ai/zed?branch=tty7#24ed55f4cf1c6f90c2fb4a1db0f33aca949a7f02"
dependencies = [
"anyhow",
"async-compression",
@@ -3825,7 +3825,7 @@ dependencies = [
[[package]]
name = "http_client_tls"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed?rev=1d217ee39d381ac101b7cf49d3d22451ac1093fe#1d217ee39d381ac101b7cf49d3d22451ac1093fe"
source = "git+https://github.com/l0ng-ai/zed?branch=tty7#24ed55f4cf1c6f90c2fb4a1db0f33aca949a7f02"
dependencies = [
"rustls",
"rustls-platform-verifier",
@@ -4946,7 +4946,7 @@ checksum = "7ebb8d8732c6a6df3d8f032a82911cfc747e00efb95cc46e8d0acd5b5b88570c"
[[package]]
name = "media"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed?rev=1d217ee39d381ac101b7cf49d3d22451ac1093fe#1d217ee39d381ac101b7cf49d3d22451ac1093fe"
source = "git+https://github.com/l0ng-ai/zed?branch=tty7#24ed55f4cf1c6f90c2fb4a1db0f33aca949a7f02"
dependencies = [
"anyhow",
"bindgen",
@@ -6044,7 +6044,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "perf"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed?rev=1d217ee39d381ac101b7cf49d3d22451ac1093fe#1d217ee39d381ac101b7cf49d3d22451ac1093fe"
source = "git+https://github.com/l0ng-ai/zed?branch=tty7#24ed55f4cf1c6f90c2fb4a1db0f33aca949a7f02"
dependencies = [
"collections",
"serde",
@@ -7021,7 +7021,7 @@ dependencies = [
[[package]]
name = "refineable"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed?rev=1d217ee39d381ac101b7cf49d3d22451ac1093fe#1d217ee39d381ac101b7cf49d3d22451ac1093fe"
source = "git+https://github.com/l0ng-ai/zed?branch=tty7#24ed55f4cf1c6f90c2fb4a1db0f33aca949a7f02"
dependencies = [
"derive_refineable",
]
@@ -7064,7 +7064,7 @@ checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832"
[[package]]
name = "reqwest_client"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed?rev=1d217ee39d381ac101b7cf49d3d22451ac1093fe#1d217ee39d381ac101b7cf49d3d22451ac1093fe"
source = "git+https://github.com/l0ng-ai/zed?branch=tty7#24ed55f4cf1c6f90c2fb4a1db0f33aca949a7f02"
dependencies = [
"anyhow",
"bytes",
@@ -7589,7 +7589,7 @@ dependencies = [
[[package]]
name = "scheduler"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed?rev=1d217ee39d381ac101b7cf49d3d22451ac1093fe#1d217ee39d381ac101b7cf49d3d22451ac1093fe"
source = "git+https://github.com/l0ng-ai/zed?branch=tty7#24ed55f4cf1c6f90c2fb4a1db0f33aca949a7f02"
dependencies = [
"async-task",
"backtrace",
@@ -8396,7 +8396,7 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "sum_tree"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed?rev=1d217ee39d381ac101b7cf49d3d22451ac1093fe#1d217ee39d381ac101b7cf49d3d22451ac1093fe"
source = "git+https://github.com/l0ng-ai/zed?branch=tty7#24ed55f4cf1c6f90c2fb4a1db0f33aca949a7f02"
dependencies = [
"heapless",
"log",
@@ -9775,7 +9775,7 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]]
name = "util"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed?rev=1d217ee39d381ac101b7cf49d3d22451ac1093fe#1d217ee39d381ac101b7cf49d3d22451ac1093fe"
source = "git+https://github.com/l0ng-ai/zed?branch=tty7#24ed55f4cf1c6f90c2fb4a1db0f33aca949a7f02"
dependencies = [
"anyhow",
"async-fs",
@@ -9814,7 +9814,7 @@ dependencies = [
[[package]]
name = "util_macros"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed?rev=1d217ee39d381ac101b7cf49d3d22451ac1093fe#1d217ee39d381ac101b7cf49d3d22451ac1093fe"
source = "git+https://github.com/l0ng-ai/zed?branch=tty7#24ed55f4cf1c6f90c2fb4a1db0f33aca949a7f02"
dependencies = [
"perf",
"quote",
@@ -11620,7 +11620,7 @@ dependencies = [
[[package]]
name = "zlog"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed?rev=1d217ee39d381ac101b7cf49d3d22451ac1093fe#1d217ee39d381ac101b7cf49d3d22451ac1093fe"
source = "git+https://github.com/l0ng-ai/zed?branch=tty7#24ed55f4cf1c6f90c2fb4a1db0f33aca949a7f02"
dependencies = [
"anyhow",
"chrono",
@@ -11665,7 +11665,7 @@ dependencies = [
[[package]]
name = "ztracing"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed?rev=1d217ee39d381ac101b7cf49d3d22451ac1093fe#1d217ee39d381ac101b7cf49d3d22451ac1093fe"
source = "git+https://github.com/l0ng-ai/zed?branch=tty7#24ed55f4cf1c6f90c2fb4a1db0f33aca949a7f02"
dependencies = [
"tracing",
"tracing-subscriber",
@@ -11676,7 +11676,7 @@ dependencies = [
[[package]]
name = "ztracing_macro"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed?rev=1d217ee39d381ac101b7cf49d3d22451ac1093fe#1d217ee39d381ac101b7cf49d3d22451ac1093fe"
source = "git+https://github.com/l0ng-ai/zed?branch=tty7#24ed55f4cf1c6f90c2fb4a1db0f33aca949a7f02"
[[package]]
name = "zune-core"
+23
View File
@@ -307,3 +307,26 @@ alacritty_terminal = { opt-level = 3 }
[profile.release]
lto = "thin"
codegen-units = 1
# ---- gpui fork ------------------------------------------------------------
# Our `tty7` branch (cut from the pinned upstream rev, one commit on top) carries
# a single patch: `prefers_ime_for_printable_keys` takes the keystroke, so an
# input handler can answer per key instead of per view. tty7 needs it for
# Option-as-Meta — macOS routes ⌥-chords to the IME whenever a CJK input source
# is active, and without the keystroke there is no way to decline just those
# chords (see `terminal::input::prefers_ime_for_printable_keys`, issue #177).
#
# Patching by source rather than editing the `gpui`/`gpui_platform` pins above is
# deliberate: `gpui-component` declares its own `gpui` from the upstream URL, and
# a plain pin swap would put two incompatible copies of gpui in the tree. `[patch]`
# rewrites the source for every dependent at once, so the fork's sibling crates
# (`gpui_macos`, `gpui_web`, …) come along through their in-repo path deps.
#
# When bumping the upstream rev: rebase the fork's `tty7` branch onto the new rev,
# push, then update the `rev` pins above — the branch here follows automatically.
[patch."https://github.com/zed-industries/zed"]
gpui = { git = "https://github.com/l0ng-ai/zed", branch = "tty7" }
gpui_platform = { git = "https://github.com/l0ng-ai/zed", branch = "tty7" }
gpui_macros = { git = "https://github.com/l0ng-ai/zed", branch = "tty7" }
gpui_web = { git = "https://github.com/l0ng-ai/zed", branch = "tty7" }
reqwest_client = { git = "https://github.com/l0ng-ai/zed", branch = "tty7" }
+102 -2
View File
@@ -6,6 +6,10 @@ use alacritty_terminal::term::TermMode;
use gpui::{App, Bounds, InputHandler, Pixels, UTF16Selection, Window};
use super::view::TerminalView;
// Only the macOS Option/Meta split reads config from this file; elsewhere the import
// would be dead.
#[cfg(target_os = "macos")]
use crate::core::config::Config;
/// The Kitty keyboard-protocol progressive-enhancement flags currently active in
/// the terminal, distilled from `TermMode`. We read them straight off the client's
@@ -150,6 +154,30 @@ pub(super) fn defer_to_ime(ks: &gpui::Keystroke, kitty: KittyFlags) -> bool {
.is_some_and(|ch| !ch.is_empty() && ch.chars().all(|c| c >= '\u{20}' && c != '\u{7f}'))
}
/// Whether an Option chord must be kept away from the IME so the Meta policy in
/// [`reshape_option_keystroke`] can claim it.
///
/// macOS counts ⌥-chords as printable text — ⌥B composes `∫` — so while a CJK input
/// source is active gpui routes them to the IME before the key handler ever runs. The
/// IME commits the composed character and swallows the event, and Option-as-Meta
/// silently does nothing (#177). This is the predicate
/// [`TerminalInputHandler::prefers_ime_for_printable_keys`] answers `false` on.
///
/// Only ⌥ alone (optionally with Shift) counts: ⌘ chords are app shortcuts and Ctrl
/// chords already bypass the IME upstream, and both keep their existing routing.
///
/// With the setting off the chord is text input and the IME is the right owner — it is
/// what makes dead keys (⌥E then E → `é`) compose at all — so this returns `false` and
/// nothing changes.
///
/// Compiled under `test` on every platform so CI covers the rule everywhere, not just
/// on the macOS runner.
#[cfg(any(target_os = "macos", test))]
pub(super) fn meta_chord_bypasses_ime(ks: &gpui::Keystroke, option_as_alt: bool) -> bool {
let m = &ks.modifiers;
option_as_alt && m.alt && !m.platform && !m.control
}
/// Translate a GPUI keystroke into the bytes a PTY expects.
///
/// When the app has enabled the Kitty keyboard protocol (`kitty.active()`) we try
@@ -577,7 +605,23 @@ impl InputHandler for TerminalInputHandler {
false
}
fn prefers_ime_for_printable_keys(&mut self, window: &mut Window, cx: &mut App) -> bool {
// `keystroke` only feeds the macOS Option/Meta split; elsewhere Alt already carries
// Meta and never reaches an IME.
#[cfg_attr(not(target_os = "macos"), allow(unused_variables))]
fn prefers_ime_for_printable_keys(
&mut self,
keystroke: &gpui::Keystroke,
window: &mut Window,
cx: &mut App,
) -> bool {
// An Option chord under Option-as-Meta belongs to `reshape_option_keystroke`,
// not the IME — see `meta_chord_bypasses_ime`. Answering per keystroke is why
// tty7 carries a gpui patch: upstream asks this once per view, with no key in
// hand, so it cannot say "IME for text, but not for this chord".
#[cfg(target_os = "macos")]
if meta_chord_bypasses_ime(keystroke, cx.global::<Config>().macos_option_as_alt) {
return false;
}
// REPORT_ALL_KEYS_AS_ESC wants every key as `CSI <code>;<mods>[;<text>]u`,
// which only `keystroke_to_bytes` produces — the IME path commits raw
// UTF-8. Keep printable keys on the dispatch path so they get encoded,
@@ -619,7 +663,8 @@ impl InputHandler for TerminalInputHandler {
#[cfg(test)]
mod tests {
use super::{
KittyFlags, defer_to_ime, keystroke_to_bytes, reshape_option_keystroke, tab_bytes,
KittyFlags, defer_to_ime, keystroke_to_bytes, meta_chord_bypasses_ime,
reshape_option_keystroke, tab_bytes,
};
use gpui::{Keystroke, Modifiers};
@@ -1172,6 +1217,61 @@ mod tests {
);
}
/// The routing half of Option-as-Meta (#177): with a CJK input source active,
/// macOS hands ⌥-chords to the IME before the key handler runs, because ⌥B
/// composes printable text. The IME commits `∫` and eats the event, so the
/// reshape above never gets a say — unless the handler declines IME for exactly
/// these chords. Everything else keeps composing.
#[test]
fn meta_chords_skip_the_ime_only_when_option_is_meta() {
let alt = Modifiers {
alt: true,
..Default::default()
};
// The bug: ⌥B with the setting on must reach `on_key_down`, not the IME.
assert!(meta_chord_bypasses_ime(&option_b(), true));
// Shift rides along — ⌥⇧B is still a Meta chord.
let alt_shift = Modifiers {
alt: true,
shift: true,
..Default::default()
};
assert!(meta_chord_bypasses_ime(
&ks(alt_shift, "b", Some("ı")),
true
));
// Setting off: ⌥ is text input, and the IME owns it — this is what makes
// dead keys (⌥E then E → `é`) compose.
assert!(!meta_chord_bypasses_ime(&option_b(), false));
// Plain text is never claimed, on either setting: CJK composition is the
// whole reason the handler prefers the IME in the first place.
assert!(!meta_chord_bypasses_ime(
&ks(Modifiers::default(), "n", Some("n")),
true
));
// ⌘ chords are app shortcuts and ⌃ chords already bypass the IME upstream;
// both keep their existing routing rather than being claimed here.
let cmd_alt = Modifiers {
alt: true,
platform: true,
..Default::default()
};
assert!(!meta_chord_bypasses_ime(&ks(cmd_alt, "b", None), true));
let ctrl_alt = Modifiers {
alt: true,
control: true,
..Default::default()
};
assert!(!meta_chord_bypasses_ime(&ks(ctrl_alt, "b", None), true));
// Named keys carry the alt bit too and take the same route — Alt+Left must
// not be diverted into a composition either.
assert!(meta_chord_bypasses_ime(&ks(alt, "left", None), true));
}
#[test]
fn option_reshape_leaves_named_keys_and_ctrl_chords_alone() {
let alt = Modifiers {