Ctrl-R did nothing in my terminal app. The cause was EDITOR=vi.
We ship a macOS app that runs a grid of terminal panels — each panel is a real PTY driving a coding agent. One day I noticed that Ctrl-R did nothing in those panels.
Not an error. Not a beep. Nothing at all. The cursor sat there.
In Terminal.app, the same shell, the same machine, the same .zshrc: bck-i-search: appeared instantly. Inside a running claude session in our own panel it also worked — but that's the CLI's own history search, not the shell's, so it told me nothing.
That asymmetry — works in Terminal.app, dead in our PTY — made me confident the bug was ours. It was not.
What I checked first (and why it was a waste)
The obvious hypothesis: the keystroke never reaches the shell. Something in our stack eats it. Electron's renderer, xterm.js's key handler, the IPC hop, node-pty's write — any of them could swallow \x12.
So I instrumented all four layers:
- renderer
keydown— fires,ctrlKey: true,key: "r" - xterm.js custom key handler — passes through
onData— emits\x12- main process
term:input— receives\x12, writes to the PTY
Every layer was clean. The byte was arriving at the shell. Two hours gone.
This is the point where I should have stopped guessing. Our own rule after an earlier debugging failure is: if two hypotheses miss, stop patching and start measuring. I hadn't been measuring the right process.
The false negative that nearly threw me off
Next I tried to reproduce outside the app: a bare node-pty harness spawning zsh with the same environment, then writing \x12.
bindkey | grep R looked normal. Emacs bindings, ^R bound to history-incremental-search-backward. Which suggested the environment was innocent.
It wasn't. It was a timing artefact. I was writing to the PTY before the shell had finished loading its rc files. At that moment ZLE hadn't initialised its keymap yet, so I was reading a state that never actually exists by the time a human presses a key.
If you drive a PTY in a test, wait for the prompt before sending anything. A shell that hasn't finished sourcing its rc files will happily give you a confident, wrong answer.
Once I waited for the prompt, the same command told the truth.
The decisive measurement
Two commands ended it.
Inside the affected panel:
$ bindkey '^R'
"^R" redisplay
redisplay means repaint the line. Visually: nothing happens. That is not an emacs-mode binding — that's vi insert mode. In viins, ^R is bound to redisplay, and the reverse search you expect simply isn't there.
So the shell was in vi mode. But .zshrc had no bindkey -v. Where did it come from?
$ ps eww <electron-pid>
... EDITOR=vi ...
There it was.
Root cause
zsh decides its default keymap when ZLE initialises. If $EDITOR or $VISUAL contains the string vi, zsh selects the vi keymap automatically. No bindkey -v required. That behaviour is long-standing and documented, and it is very easy to forget.
The chain was:
- Some shell startup file exported
EDITOR=vi. - The app was launched from that shell, so the Electron process inherited
EDITOR=vi. - Every PTY we spawned inherited it from the Electron process.
- zsh saw
viand switched all our panels to vi mode. - Terminal.app, launched from the GUI without that variable, stayed in emacs mode.
That's the whole asymmetry. Nothing in our key pipeline was ever broken.
Note the trap in the matching rule: it is a substring match. EDITOR=vim, EDITOR=nvim, and EDITOR=/usr/bin/view all trigger it too.
The fix
Strip vi-flavoured editor variables from the environment we hand to each PTY, at spawn time:
for (const k of ['EDITOR', 'VISUAL']) {
if (env[k] && /vi/i.test(env[k])) delete env[k];
}
Deliberately narrow. People who actually want vi mode put bindkey -v in their .zshrc, and that still works — it comes from the rc file, not from the environment, so we don't touch it. We only remove the implicit trigger that the user never asked for and cannot see.
After that, bindkey '^R' in a panel reports history-incremental-search-backward, and Ctrl-R behaves.
What I'd tell my past self
- A key that "does nothing" inside a terminal is a shell-state question before it is an app question.
bindkey '^R'would have found this in ten seconds. ps eww <pid>is the fastest way to see what a running process actually inherited. Not your shell'senv— the process's. They differ more often than you'd like, and GUI-launched apps are where they differ most.- In PTY tests, wait for the prompt. A pre-rc shell answers confidently and wrongly.
- Environment inheritance bugs are invisible by construction. Nothing is logged, nothing errors, and the behaviour only differs between launch contexts — which is exactly the comparison people skip.