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, right up until you open Emacs in a terminal and everything falls apart.
This post covers how I made TUI Emacs running inside Ghostty feel identical to my GUI Emacs setup, including Cmd key bindings, Ctrl+Shift selection, and clipboard integration. It was a fight, and I learned a lot about how terminals 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 and Super all just work. Terminals are a different beast.
Why terminals make this hard
The core problem is that terminals send byte sequences, not key events.
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, which is essentially a pipe of bytes. The encoding of keystrokes into bytes dates back to the VT100 era of the 1970s. In practice, that means:
- Ctrl works because it's baked into ASCII.
Ctrl-Ais byte0x01,Ctrl-Cis0x03, etc. This is literally how ASCII was designed. - Meta/Alt works by convention: the terminal sends
ESC(0x1b) followed by the key. SoAlt-fbecomes the two-byte sequenceESC f. - Arrow keys and function keys use escape sequences like
ESC [ A(up arrow) orESC [ 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 no standard way to encode them.
- Ctrl+Shift+letter is indistinguishable from Ctrl+letter in traditional terminal encoding, because
Ctrlalready collapses the letter to a control code.
So my entire GUI config, built on hyper, is incompatible with how terminals work. We need a translation layer.
The solution: a two-part translation pipeline
The plan has two halves:
- In Ghostty, intercept
Cmd+<key>andCtrl+Shift+<key>presses and send unique, made-up escape sequences. - In Emacs, 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:\x1bZbThe [1;8 prefix for Cmd keys uses CSI parameter 8, which encodes "Meta+Ctrl+Shift" in xterm modifier convention. Nothing sends that modifier combo, 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.
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 was to use key-translation-map instead of input-decode-map. It 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 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, because 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. 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:\x1bZbEmacs (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, these are the tools that saved me:
C-h lorM-x view-lossageshows 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 likeESC [ 1 ; 8 cinstead of a single event, your decode map isn't matching.M-: (read-key-sequence-vector "Press key: ")prompts you to press a key and shows exactly what Emacs decodes it as, which tells you whether yourinput-decode-mapentries are working.M-: (lookup-key input-decode-map "\e[1")shows what's already bound at a given prefix in a keymap. This is how I discovered the prefix collision problem.- Learn the three translation keymaps. Emacs processes terminal input through a pipeline:
input-decode-map→local-function-key-map→key-translation-map. If one stage has a prefix collision, try a later stage. - Add
&rest _to your function signatures. When you bind commands through keymaps that dispatch via intermediate lookup, unexpected arguments can be passed, and&rest _absorbs them. - Test interactively with
M-:before committing to your config. Evaluatedefine-keyexpressions 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 encode keys. 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 plumbing.