Skip to content

Translate a lone TTY ESC to the escape key in terminal-input buffers - #595

Merged
dakra merged 1 commit into
mainfrom
fix/tty-lone-esc
Aug 3, 2026
Merged

dakra merged 1 commit into
mainfrom
fix/tty-lone-esc

Conversation

@dakra

@dakra dakra commented Aug 3, 2026

Copy link
Copy Markdown
Owner

Problem

On a TTY frame, pressing ESC in a ghostel buffer sent nothing: the mode maps bind <escape>, a function-key event legacy TTYs never generate, while the TTY delivers the raw byte 27. The M-<char> bindings make [27] a prefix keymap, and Emacs has no ESC timeout, so read-key-sequence blocked with [27] pending forever. Collateral: ESC ESC also wedged (global ESC ESC ESC prefix), and C-g pressed while ESC was pending completed as C-M-g, injecting ^[^G into the PTY.

Fix

  • New ghostel-tty-escape-delay (default 0.01 s, nil disables): a terminal-local menu-item :filter on [?\e] in input-decode-map (the evil-esc-mode technique) yields [escape] when no follow-up byte arrives within the delay — only in ghostel terminal-input buffers (semi-char/char with a live term). Everywhere else the filter returns the saved map, so escape sequences, ESC-as-meta, and non-ghostel buffers decode byte-identically to before. The translated event goes through the native key encoder, so kitty-protocol children get CSI 27 u and legacy children a raw ESC.
  • Installer runs from the ghostel-mode body and from a buffer-local window-buffer-change-functions handler (covers later emacsclient -t frames). TTY terminals only; idempotent per terminal; re-wraps if another package replaces the entry. It reads the raw entry structurally because lookup-key resolves menu-item filters (which would drop e.g. evil's wrapper), and detects its own wrapper by the :filter tag because define-key copies menu-item lists.
  • ESC ESC is bound to the key encoder as alt+escape: the filter deliberately leaves the second ESC of a fast pair raw, so the pair reaches [27 27] instead of pending on the global keyboard-escape-quit prefix.
  • Composes with evil's evil-esc filter in either install order; at most one translation delay is paid per key.

Verification

  • 8 new untagged ERT tests (filter guard matrix, installer wrap/idempotence/re-wrap/GUI-skip, mode-body integration pin, ESC ESC bindings); existing ESC decode invariants (C-[ not hijacked, ESC [/ESC O unbound) still pass. make -j8 all green.
  • Live-verified in sandboxed TTY Emacs 31: lone ESC → ^[ promptly in semi-char and char with no pending prefix; C-g clean; fast ESC x → ^[x; arrows → ^[[A; slow ESC ESC → two ^[; fast ESC ESC → ^[^[; kitty protocol → ^[[27u; non-ghostel buffers and M-x untouched. GUI <escape> regression-checked.
  • evil-ghostel live matrix: filters compose (ghostel wrapping evil's entry), evil's ESC still works in plain buffers, primary-screen ESC goes to evil, alt-screen ESC goes to the TUI, fast C-c M-d still decodes as meta, C-c + paused ESC runs evil-force-normal-state.

Known limitation (deliberately deferred): no defining-kbd-macro fixup à la evil — a macro recording a lone ESC records the raw byte and may not replay as escape.

On text terminals the escape key arrives as a bare 0x1b byte, which
the M-<char> bindings turn into a pending meta prefix, so a lone ESC
never reached the PTY.  Install a terminal-local menu-item filter on
the ESC entry of input-decode-map (the evil-esc-mode technique) that
yields the escape event when no follow-up byte arrives within the new
ghostel-tty-escape-delay, keeping escape sequences and ESC-as-meta
decoding intact and composing with other packages' filters such as
evil's.  The translated event goes through the native key encoder, so
kitty-protocol children receive CSI 27 u and legacy children a raw ESC.

Bind ESC ESC to the key encoder as alt+escape so a fast double-tap,
whose second byte the filter deliberately leaves raw, no longer pends
on the global keyboard-escape-quit prefix.

Fixes #592
@dakra
dakra merged commit 6fe0b06 into main Aug 3, 2026
29 checks passed
@dakra
dakra deleted the fix/tty-lone-esc branch August 3, 2026 13:09

This branch was previously deployed

1 inactive deployment
github-pages — 6fe0b062 Deployed Aug 3, 2026 by dakra via deploy #142
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant