Skip to content

iOS: Option-as-Meta drops Shift — ESC sequences send the unshifted character (M-: arrives as M-;, M-A as M-a) #591

Description

@mclearc

On iOS with a hardware keyboard and optionAsMetaKey enabled, any Meta chord that involves Shift is mistranslated before it reaches the host application. Examples with a US layout (Emacs notation, but the defect is application-independent):

  • Option+Shift+; (intended ESC :) sends ESC ;
  • Option+Shift+[ (intended ESC {) sends ESC [ — a bare CSI introducer, so the terminal-side parser consumes the NEXT keystroke as part of a phantom control sequence
  • Option+Shift+a (intended ESC A) sends ESC a

Root cause

iOSTerminalView.swift, the legacy Meta branch in pressesBegan:

data = .text("\u{1b}\(key.charactersIgnoringModifiers)")

On iOS — unlike macOS's NSEvent.charactersIgnoringModifiers, which strips Option but keeps Shift — UIKey.charactersIgnoringModifiers strips Shift as well and delivers the BASE unshifted character (lowercased for letters). So the shifted character is lost.

key.characters cannot be used instead: with Option held it carries the OS-composed character from the macOS keymap bridge (Ú for Option+Shift+;), and for dead-key initiators (Option+`, Option+e, ...) it arrives empty or with doubled accents.

Observed on the iOS 26.3 simulator via an instrumented pressesBegan across all 21 US shift pairs, letters under all four Shift × CapsLock states, and the dead-key initiators; the same holds on a physical iPad + Magic Keyboard (US).

The same defect reaches the kitty keyboard protocol path: kittyTextEvent builds shiftedKey from key.characters, so a CSI-u-negotiated application receives the composed character (Ú) as the shifted key instead of :.

Affected: every release that has the iOS Meta branch; verified byte-identical v1.13.0 through v1.14.0 and current main.

Notes on a fix

The workable approach is a pure US-ANSI shift-pair table applied at the two call sites (the legacy Meta branch and kittyTextEvent's shiftedKey), guarded to single-scalar payloads so multi-scalar/empty cases keep the current path. This matches the existing precedent in the file — kittyBaseLayoutKey already hardcodes the same US table. It is US-layout-only, since iOS exposes no public API for a hardware keyboard's shift mapping; non-US base scalars outside the table keep today's behavior.

I have a working, unit-tested patch against this and can open a PR if that would be useful.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions