Roman Imankulov

Roman Imankulov

Full-stack Python developer. Building Smello.

search results (esc to close)
12 Feb 2026

Fixing Keyboard Input in tmux

Fixing Keyboard Input in tmux

Two independent things happened that drove me to reconsider my terminal workflow.

First, I started relying more and more on Claude Code as my AI coding assistant running inside the terminal. Shift+Enter (for inserting newlines without submitting) didn’t work inside tmux, so I had to use a workaround. It was annoying, but acceptable.

Then I switched to a 96-key keyboard with dual Bluetooth, because I wanted a single keyboard that can switch between two computers. That’s when my tmux scrollback navigation broke too. With two things broken, I decided to look deeper into both issues and understand what’s actually going on.

The Workflow

For years, I’ve used tmux and tmuxinator to manage my terminal sessions. Every project gets its own tmux session. When I start working on a project, I cd into it and run txs (an alias for tmuxinator start). If the session already exists, it attaches to it. If not, it creates a new one based on a .tmuxinator.yml config in the project root.

Switching projects is the same cd and txs. Each project lives in its own session with its own windows and panes. Projects I’m not actively working on stay in the background, invisible, creating no mental load.

For the terminal emulator, I use Ghostty. The issues described below are not Ghostty-specific, though. Any modern terminal emulator that supports the kitty keyboard protocol (iTerm2, WezTerm, Kitty, etc.) would behave the same way.

On my MacBook’s built-in keyboard and the Magic Keyboard, everything worked fine with tmux.

Problem 1: Scrollback Navigation

When a command spits out more lines than fit on the screen, you scroll back to see what you missed. In tmux, that’s called scrollback navigation.

I used to scroll through tmux history with Ctrl+B, then Page Up/Down. The new keyboard doesn’t have dedicated Page Up/Down keys, and fn+Arrow (which emulates them on Mac keyboards) doesn’t work on third-party keyboards.

The fix is to enable mouse scrolling and add Alt+Arrow bindings as a keyboard alternative. Here are the changes to ~/.tmux.conf:

# Mouse scroll enters copy mode automatically
set -g mouse on

# Alt+Up enters copy mode and pages up in one keystroke
bind -n M-Up copy-mode \; send-keys -X page-up

# Alt+arrows for navigation inside copy mode
bind -T copy-mode M-Up send-keys -X page-up
bind -T copy-mode M-Down send-keys -X page-down

Problem 2: Shift+Enter Inside tmux

Claude Code, Codex, and Pi all use Shift+Enter for inserting newlines without submitting. None of them received the keystroke correctly inside tmux. To understand why, it helps to understand how keyboard input works in terminals versus GUI applications.

When you press Shift+Enter in a GUI app like a browser or a text editor, the app receives a rich event: “the Enter key was pressed, and the Shift modifier was held.” The operating system sends the full key identity and a bitmask of modifiers. The app can easily distinguish Shift+Enter from plain Enter and do something different with each.

Terminal emulators, like Ghostty, emulate hardware terminals from the 1970s, which communicated over serial lines carrying bytes. The bytes are encoded in the ASCII standard that defines 128 printable and control characters. When you press Enter, the terminal sends ASCII byte 0x0D (carriage return). When you press Shift+Enter, the Shift modifier has no effect, and the terminal sends the same byte 0x0D.

Different terminals tried to fix this by sending escape sequences: strings of ASCII bytes starting with ESC [ (two bytes: 0x1B and 0x5B) that together encode a richer event. For example, ESC [ 1 ; 3 A means Alt+Up.

The various protocols disagree on the format of these sequences:

  • xterm introduced modifyOtherKeys, encoding Shift+Enter as ESC [ 27 ; 2 ; 13 ~.
  • Paul LeoNerd Evans proposed a cleaner fixterms encoding: ESC [ 13 ; 2 u. Because sequences end with u after the CSI escape prefix, this style is commonly called CSI-u.
  • The kitty terminal then built on fixterms to create the kitty keyboard protocol, which adds support for even more keys and modifiers.

These approaches are mutually incompatible. An app expecting kitty protocol won’t understand xterm’s encoding, and vice versa. For a deeper dive into why terminal input is so convoluted, Warp has a good blog post on the topic.

Modern terminals implement the kitty keyboard protocol. Claude Code and Ghostty speak this protocol, so Shift+Enter works directly.

The problem is that tmux sits between Ghostty and these apps. tmux does not pass the kitty keyboard protocol through in the way they expect.

Keyboard → Ghostty → tmux → Claude Code / Codex / Pi
                      ↑
              needs the right
             extended-key format

I initially tried two tmux settings, both dead ends:

  • extended-keys on: tmux only forwards extended sequences if the app requests them using tmux’s own method. Claude Code requests them using the kitty protocol, which tmux ignores. Nothing happens.
  • extended-keys always: this gets closer, but if tmux uses the xterm format (ESC [ 27 ; 2 ; 13 ~), apps expecting CSI-u-style input may not interpret Shift+Enter correctly.

The second setting was on the right track. It just needed the right output format. extended-keys always sends extended sequences unconditionally, extended-keys-format csi-u switches to the CSI-u format that these apps understand, and terminal-features 'xterm*:extkeys' tells tmux that the outer terminal supports extended keys:

set -g extended-keys always
set -g extended-keys-format csi-u
set -as terminal-features 'xterm*:extkeys'

With that, Shift+Enter works in all three apps inside tmux.

There’s an open feature request and a pull request to add kitty keyboard protocol support to tmux, but I don’t need that for this workflow anymore.

Detour: Zellij

Before I found the CSI-u setting, I thought the Shift+Enter issue was a fundamental tmux limitation and tried Zellij, a modern terminal multiplexer written in Rust that supports the kitty keyboard protocol natively. zellij attach $(basename "$PWD") -c was a clean replacement for tmuxinator. I gave it about a month, but Zellij panicked and crashed a few times, orphaning my running processes and forcing me to hunt them down and kill them one by one. Once I found the tmux fix, I switched back.

Both fixes live in ~/.tmux.conf and have been stable for months.

Roman Imankulov

Hey, I am Roman

I am a full-stack Python developer. Currently building Smello, a local-first HTTP traffic inspector for Python.

More about me