Taming Emacs Keybindings in Ghostty

February 3, 2025

If you use GUI Emacs on macOS, you've probably customized Cmd to behave like a proper modifier — Cmd-C to copy, Cmd-V to paste, Cmd-S to save. It feels native. It feels right. Then you open Emacs in a terminal and everything falls apart.

This post documents my journey to make TUI Emacs running inside Ghostty feel identical to my GUI Emacs setup — from Cmd key bindings to Ctrl+Shift selection to clipboard integration. It was a fight, and I learned a lot about how terminals actually send keystrokes.

My GUI Emacs Config

My GUI Emacs config maps macOS modifiers to Emacs modifiers and binds standard macOS shortcuts:

(setq mac-option-modifier 'meta)
(setq mac-command-modifier 'hyper)
(setq mac-right-command-modifier 'super)

(global-set-key [(hyper a)] 'mark-whole-buffer)
(global-set-key [(hyper v)] 'yank)
(global-set-key [(hyper c)] 'kill-ring-save)
(global-set-key [(hyper x)] 'kill-region)
(global-set-key [(hyper k)] 'kill-current-buffer)
(global-set-key [(hyper s)] 'save-buffer)
(global-set-key [(hyper l)] 'goto-line)
(global-set-key [(hyper w)] (lambda () (interactive) (delete-window)))
(global-set-key [(hyper z)] 'undo)
(global-set-key [(hyper q)] 'save-buffers-kill-emacs)

In GUI Emacs, this is trivial. The GUI framework receives the full modifier state from the OS, so Cmd, Hyper, Super — they all just work. Terminals are a different beast entirely.

Why Terminals Make This Hard

Here's the fundamental problem: terminals don't send key events — they send byte sequences.

When you press a key in a GUI app, the OS sends a rich event: "the user pressed the s key while holding Cmd and Shift." The app gets the full picture.

A terminal emulator, by contrast, communicates with the shell (and programs like Emacs) through a pseudo-TTY — essentially a pipe of bytes. The encoding of keystrokes into bytes dates back to the VT100 era of the 1970s. Here's what that means in practice:

  • Ctrl works because it's baked into ASCII. Ctrl-A is byte 0x01, Ctrl-C is 0x03, etc. This is literally how ASCII was designed.
  • Meta/Alt works by convention: the terminal sends ESC (0x1b) followed by the key. So Alt-f becomes the two-byte sequence ESC f.
  • Arrow keys and function keys use escape sequences like ESC [ A (up arrow) or ESC [ 1 ; 5 A (Ctrl+Up). These are called CSI sequences (Control Sequence Introducer).
  • Hyper and Super modifiers don't exist in the terminal protocol. There is simply no standard way to encode them.
  • Ctrl+Shift+letter is indistinguishable from Ctrl+letter in traditional terminal encoding, because Ctrl already collapses the letter to a control code.

So my entire GUI config — built on hyper — is fundamentally incompatible with how terminals work. We need a translation layer.

The Solution: A Two-Part Translation Pipeline

The strategy is:

  1. Ghostty side: Intercept Cmd+<key> and Ctrl+Shift+<key> presses and send unique, made-up escape sequences.
  2. Emacs side: Decode those sequences back into key events and bind them to commands.

Part 1: Ghostty Sends Custom Escape Sequences

Ghostty's keybind directive can map key combinations to arbitrary byte sequences using the text: or esc: prefix. Here's what I added to my Ghostty config:

# Cmd key bindings → unique escape sequences
keybind = super+a=esc:[1;8a
keybind = super+v=esc:[1;8v
keybind = super+c=esc:[1;8c
keybind = super+x=esc:[1;8x
keybind = super+k=esc:[1;8k
keybind = super+s=esc:[1;8s
keybind = super+l=esc:[1;8l
keybind = super+w=esc:[1;8w
keybind = super+z=esc:[1;8z
keybind = super+q=esc:[1;8q

# Ctrl+Shift movement → unique escape sequences
keybind = ctrl+shift+e=text:\x1bZe
keybind = ctrl+shift+n=text:\x1bZn
keybind = ctrl+shift+p=text:\x1bZp
keybind = ctrl+shift+a=text:\x1bZa
keybind = ctrl+shift+f=text:\x1bZf
keybind = ctrl+shift+b=text:\x1bZb

The [1;8 prefix for Cmd keys uses CSI parameter 8, which encodes "Meta+Ctrl+Shift" in xterm modifier convention — a modifier combo nothing actually sends, so there are no collisions.

For Ctrl+Shift, I use ESC Z as a prefix, which is not used by any standard terminal protocol.

Part 2: Emacs Decodes and Binds

This is where I hit the most obstacles. Here's what I learned:

input-decode-map has prefix collisions. Emacs's terminal input handling already has entries for ESC [1 (used by arrow keys, function keys, etc.). When my ESC [1;8c sequence arrives, Emacs matches ESC [1 against an existing keymap entry and then treats ;8c as separate characters. This is why I kept seeing things like M-[ 1 ; 8 c is undefined in the minibuffer.

The fix: use key-translation-map instead of input-decode-map. The key-translation-map is processed after input-decode-map, so it doesn't fight with existing terminal key definitions. It translates the raw byte sequence into an intermediate key chord (C-c h <letter>), which is then bound to the actual command.

Hyper doesn't work in terminal Emacs. Even if you manage to decode a sequence into [C-S-e] via input-decode-map, the hyper modifier simply isn't recognized by terminal Emacs. Don't waste time trying.

Ctrl+Shift DOES work — but shift-selection doesn't. Terminal Emacs can recognize [C-S-e] as a key event, but the shift-selection machinery (shift-select-mode) doesn't trigger. In GUI Emacs, pressing Ctrl+Shift+E automatically starts/extends the region because the movement commands use interactive "^". In terminal Emacs, even when the key is decoded correctly, this automatic shift-selection doesn't kick in. The fix is to write explicit wrapper commands that manage the mark manually.

tty-setup-hook is the right place for terminal config. Wrapping your terminal-specific config in (unless (display-graphic-p) ...) at the top level doesn't always work — it evaluates at init time, before the terminal frame may be fully set up. Use tty-setup-hook instead, which fires when a terminal frame is created.

Part 3: Clipboard Integration

In GUI Emacs, yank automatically pulls from the system clipboard. In terminal Emacs, the kill ring and system clipboard are completely separate worlds. The cleanest fix is to set Emacs's interprogram-cut-function and interprogram-paste-function to pipe through pbcopy and pbpaste:

(setq interprogram-paste-function
      (lambda () (shell-command-to-string "pbpaste")))
(setq interprogram-cut-function
      (lambda (text &optional push)
        (let ((process-connection-type nil))
          (let ((proc (start-process "pbcopy" nil "pbcopy")))
            (process-send-string proc text)
            (process-send-eof proc)))))

This makes every kill and yank in Emacs sync with the macOS clipboard transparently, just like GUI Emacs.

The Complete Config

Ghostty (~/.config/ghostty/config)

macos-option-as-alt = left

# Alt key bindings for word movement
keybind = alt+b=esc:b
keybind = alt+f=esc:f
keybind = alt+d=esc:d
keybind = ctrl+backspace=text:\x17
keybind = super+backspace=text:\x15

# Cmd key bindings
keybind = super+a=esc:[1;8a
keybind = super+v=esc:[1;8v
keybind = super+c=esc:[1;8c
keybind = super+x=esc:[1;8x
keybind = super+k=esc:[1;8k
keybind = super+s=esc:[1;8s
keybind = super+l=esc:[1;8l
keybind = super+w=esc:[1;8w
keybind = super+z=esc:[1;8z
keybind = super+q=esc:[1;8q

# Ctrl+Shift selection movement
keybind = ctrl+shift+e=text:\x1bZe
keybind = ctrl+shift+n=text:\x1bZn
keybind = ctrl+shift+p=text:\x1bZp
keybind = ctrl+shift+a=text:\x1bZa
keybind = ctrl+shift+f=text:\x1bZf
keybind = ctrl+shift+b=text:\x1bZb

Emacs (add to init.el)

;; Clipboard-aware copy/cut (defined at top level to avoid closure issues)
(defun my/copy-to-clipboard (&rest _)
  (interactive)
  (when (use-region-p)
    (kill-ring-save (region-beginning) (region-end))))

(defun my/cut-to-clipboard (&rest _)
  (interactive)
  (when (use-region-p)
    (kill-region (region-beginning) (region-end))))

(defun my/setup-terminal-keys ()
  (unless (display-graphic-p)
    ;; Clipboard sync via pbcopy/pbpaste
    (setq interprogram-paste-function
          (lambda () (shell-command-to-string "pbpaste")))
    (setq interprogram-cut-function
          (lambda (text &optional push)
            (let ((process-connection-type nil))
              (let ((proc (start-process "pbcopy" nil "pbcopy")))
                (process-send-string proc text)
                (process-send-eof proc)))))

    ;; Cmd key bindings via key-translation-map
    ;; (using key-translation-map to avoid prefix collisions
    ;;  with existing ESC[1... entries in input-decode-map)
    (define-key key-translation-map "\e[1;8a" (kbd "C-c h a"))
    (define-key key-translation-map "\e[1;8v" (kbd "C-c h v"))
    (define-key key-translation-map "\e[1;8c" (kbd "C-c h c"))
    (define-key key-translation-map "\e[1;8x" (kbd "C-c h x"))
    (define-key key-translation-map "\e[1;8k" (kbd "C-c h k"))
    (define-key key-translation-map "\e[1;8s" (kbd "C-c h s"))
    (define-key key-translation-map "\e[1;8l" (kbd "C-c h l"))
    (define-key key-translation-map "\e[1;8w" (kbd "C-c h w"))
    (define-key key-translation-map "\e[1;8z" (kbd "C-c h z"))
    (define-key key-translation-map "\e[1;8q" (kbd "C-c h q"))

    (global-set-key (kbd "C-c h a") 'mark-whole-buffer)
    (global-set-key (kbd "C-c h v") 'yank)
    (global-set-key (kbd "C-c h c") 'my/copy-to-clipboard)
    (global-set-key (kbd "C-c h x") 'my/cut-to-clipboard)
    (global-set-key (kbd "C-c h k") 'kill-current-buffer)
    (global-set-key (kbd "C-c h s") 'save-buffer)
    (global-set-key (kbd "C-c h l") 'goto-line)
    (global-set-key (kbd "C-c h w") (lambda (&rest _) (interactive) (delete-window)))
    (global-set-key (kbd "C-c h z") 'undo)
    (global-set-key (kbd "C-c h q") 'save-buffers-kill-emacs)

    ;; Ctrl+Shift movement with explicit shift-selection
    (define-key input-decode-map "\eZe" [C-S-e])
    (define-key input-decode-map "\eZn" [C-S-n])
    (define-key input-decode-map "\eZp" [C-S-p])
    (define-key input-decode-map "\eZa" [C-S-a])
    (define-key input-decode-map "\eZf" [C-S-f])
    (define-key input-decode-map "\eZb" [C-S-b])

    (global-set-key [C-S-e] (lambda () (interactive) (or (region-active-p) (set-mark (point))) (move-end-of-line nil)))
    (global-set-key [C-S-n] (lambda () (interactive) (or (region-active-p) (set-mark (point))) (next-line)))
    (global-set-key [C-S-p] (lambda () (interactive) (or (region-active-p) (set-mark (point))) (previous-line)))
    (global-set-key [C-S-a] (lambda () (interactive) (or (region-active-p) (set-mark (point))) (move-beginning-of-line nil)))
    (global-set-key [C-S-f] (lambda () (interactive) (or (region-active-p) (set-mark (point))) (forward-char)))
    (global-set-key [C-S-b] (lambda () (interactive) (or (region-active-p) (set-mark (point))) (backward-char)))
    ))

(add-hook 'tty-setup-hook #'my/setup-terminal-keys)

Debugging Tips

If you're trying something similar and things aren't working, here are the tools that saved me:

  1. C-h l / M-x view-lossage — Shows the last 300 keystrokes Emacs received. This is your best friend. Press your key combo, then check lossage to see exactly what bytes Emacs got. If you see individual characters like ESC [ 1 ; 8 c instead of a single event, your decode map isn't matching.
  2. M-: (read-key-sequence-vector "Press key: ") — Prompts you to press a key and shows exactly what Emacs decodes it as. This tells you whether your input-decode-map entries are working.
  3. M-: (lookup-key input-decode-map "\e[1") — Inspect what's already bound at a given prefix in a keymap. This is how I discovered the prefix collision problem.
  4. Understand the three translation keymaps. Emacs processes terminal input through a pipeline: input-decode-maplocal-function-key-mapkey-translation-map. If one stage has a prefix collision, try a later stage.
  5. &rest _ is your friend. When binding commands through keymaps that dispatch via intermediate lookup, unexpected arguments can be passed. Adding &rest _ to your function signatures absorbs them gracefully.
  6. Test interactively with M-: before committing to your config. Evaluate define-key expressions one at a time in a running Emacs session to isolate what works and what doesn't.

Final Thoughts

The gap between GUI and terminal Emacs is a relic of how terminals work at a fundamental level. But with a modern terminal like Ghostty that lets you remap keys to arbitrary byte sequences, and Emacs's flexible key translation pipeline, you can bridge it. The trick is understanding that you're building a two-hop translation: physical key → terminal escape sequence → Emacs key event → command. Once that mental model clicks, the rest is just plumbing.