Terminal Setup for a Non-Mac Keyboard: Ghostty, tmux, and Zellij

I gave Zellij about a month. During that period, Zellij panicked and crashed a few times, leaving my running processes orphaned and forcing me to hunt them down and kill them one by one. I decided to take a closer look at tmux again.
As of March 25, 2026, Claude Code 2.1.83 fixed the Shift+Enter problem in tmux without any configuration.
So I’m back to tmux. Everything else is the same.
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.
This post documents the problems, the dead ends, and the solutions I landed on, including eventually migrating from tmux to Zellij.
The Workflow
For years, I’ve used tmux and tmuxinator to manage my terminal sessions. The idea is simple: 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.
When I switch to a different project, I do the same thing: 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 in tmux
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 in Claude Code Inside tmux
At the time, I thought this one was unsolvable with tmux (it wasn’t, see the update above). To understand why it’s tricky, it helps to understand how keyboard input works in terminals versus GUI applications. Here’s what I learned.
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 characters: printable (letters, digits, punctuation) and control (newline, tab, carriage return, etc.). When you press Enter, the terminal sends ASCII byte 0x0D (non-printable carriage return). When you press Shift+Enter, the Shift modifier has no effect, and the terminal sends the same byte 0x0D.
This limitation was recognized early on. Over the years, different terminals tried to fix it 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. - 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 partially interoperable. 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 Claude Code, and tmux doesn’t support the kitty keyboard protocol.
Keyboard → Ghostty → tmux → Claude Code
↑
doesn't speak
kitty keyboard protocol
I 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: tmux sends the xterm format (ESC [ 27 ; 2 ; 13 ~), but Claude Code expects the kitty format (ESC [ 13 ; 2 u). The raw escape sequence gets printed as garbage text.
There’s an open feature request and a pull request to add kitty keyboard protocol support to tmux, but it hasn’t landed yet.
Migrating to Zellij
Since I thought the Shift+Enter issue was a fundamental tmux limitation, I decided to try Zellij, a modern terminal multiplexer written in Rust that supports the kitty keyboard protocol out of the box.
My tmuxinator workflow was simple: tmuxinator start in a project directory starts a named session or attaches to an existing one. The Zellij equivalent is a one-liner that I wrapped with a zsh alias:
alias zxs='zellij attach $(basename "$PWD") -c'
Shift+Enter in Claude Code works out of the box.