claude() wrapper that stops your terminal from freezing and spewing garbage when you type too fast at Claude Code's startup.When you run a program in a terminal, three separate things are quietly cooperating, and this bug lives in the seams between them. Let's meet them.
A terminal (here, terminator) isn't just where text appears. It's a bidirectional channel. Your program writes characters out to be displayed, and your keystrokes flow in. Crucially, programs can also send the terminal control sequences — invisible commands that begin with an escape byte. One turns on the cursor, another sets the window title, and some ask the terminal a question and wait for a typed-back answer.
A modern text UI like Claude Code does exactly this at startup. It sends things like:
\e[?1003h → "start reporting every mouse movement to me"
\e[?996n → "are you in light or dark mode?" (a question)
The terminal answers the question by typing back a reply into the input stream — for the dark-mode query that reply is \e[?997;1n. Normally Claude reads that reply itself and you never see it. Remember this: the reply arrives as input, and mouse-movement reports (bytes like 35;44;41M) also arrive as input, continuously, once mouse tracking is on.
Only one program at a time is allowed to own the terminal's keyboard — the foreground. The kernel enforces this per process group: the terminal remembers a single "foreground process group," and that group gets the keystrokes. When your interactive zsh launches claude with job control turned on (its default, the MONITOR option), it does something specific: it puts claude in its own new process group and hands that group the foreground seat.
Here's the rule that bites us. If a process tries to read() the terminal while its group is not the foreground group, the kernel doesn't return the data — it sends the whole group SIGTTIN, whose default action is to stop (suspend) the process. That's the exact wording you saw:
[1] + 96758 suspended (tty input)
It literally means "a background process tried to read your typing, so I paused it." Put these three facts together and the whole bug falls out — which is the next section.
The failure is a three-step chain. Follow one keystroke through it.
And because it was stopped mid-init, it never got to send the "turn mouse tracking off" sequence. So mouse mode stays on, the shell is now back in the foreground, and every mouse twitch plus that leftover dark-mode reply gets echoed as garbage:
So there are really two separate problems to solve, and the wrapper attacks each with its own lever:
read can never trip SIGTTIN. Do this by turning off job control (nomonitor) just for claude: with no job control, zsh does not give claude a separate group — it shares the shell's group, which already holds the seat.stty sane so the terminal is clean the moment claude quits — no manual reset needed.Step one keystroke through Claude's startup. Flip job control on (the default) versus off (what the wrapper does) and watch where the read lands.
The whole change is one shell function that shadows the claude command. It auto-loads because it's symlinked into ~/.oh-my-zsh/custom/, which oh-my-zsh sources for every shell — so .zshrc is never touched. Here's the function, grouped by concern.
local -r _claude_tty_reset=$'\e[?1000l\e[?1002l\e[?1003l\e[?1006l\e[?1004l\e[?2004l\e[?25h'
Each ?Nl is the "low / disable" counterpart of the "high / enable" sequences Claude sent: mouse tracking (1000/1002/1003/1006), focus reporting (1004), bracketed paste (2004), and 25h to make the cursor visible again. This string is the exact antidote to the modes a mid-init stop leaves on.
if [[ -n "${CLAUDE_TTY_FIX_OFF:-}" ]]; then
command claude "$@"
return $?
fi
Set CLAUDE_TTY_FIX_OFF=1 and the wrapper steps aside entirely — handy for A/B testing the old behavior. command claude means "run the real binary on PATH, not this function," which is how the function avoids calling itself forever.
setopt localoptions nomonitor
command claude "$@"
local -r rc=$?
nomonitor disables zsh job control; localoptions scopes that change to this function so the rest of your shell keeps job control. With monitoring off, zsh launches claude without carving out a new process group — claude shares the shell's group, which already owns the foreground seat, so a read can't trip SIGTTIN. We capture the exit code in rc because cleanup comes next and we must return claude's real status, not stty's.
if [[ -t 1 || -t 2 ]]; then
printf '%s' "$_claude_tty_reset" > /dev/tty 2>/dev/null
stty sane < /dev/tty 2>/dev/null
fi
return $rc
Two deliberate choices here:
/dev/tty, not stdout. /dev/tty is always the real controlling terminal. If we printed the reset codes to stdout, a piped run like claude -p '…' | jq would get escape bytes injected into the data going to jq — corrupting it. Routing to /dev/tty heals the screen without polluting the pipe.[[ -t 1 || -t 2 ]]. Only bother cleaning up if claude was actually attached to a terminal. In a fully headless/cron context there's no screen to heal, so we skip it silently.Ctrl-Z-suspend a claude session (that's a job-control feature, and job control is what we turned off). In practice nobody suspends an interactive claude, so this is a cheap price. Opt back out any time with CLAUDE_TTY_FIX_OFF=1.