diff --git a/docs/explanations/2026-07-18-explanation-claude-tty-fix-wrapper.html b/docs/explanations/2026-07-18-explanation-claude-tty-fix-wrapper.html new file mode 100644 index 00000000..bda2356d --- /dev/null +++ b/docs/explanations/2026-07-18-explanation-claude-tty-fix-wrapper.html @@ -0,0 +1,480 @@ + + + + + +Surviving early keystrokes — the claude() tty-fix wrapper + + + +
+
+ +
Change explanation · shell / terminals
+

Surviving early keystrokes

+
A tiny claude() wrapper that stops your terminal from freezing and spewing garbage when you type too fast at Claude Code's startup.
+
commit 4be57f0 · linux_configuration/zsh/claude-tty-fix.zsh · +58 lines
+
+ + + + +

1 · Background

+ +

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.

+ +

The terminal is a two-way pipe

+

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.

+ +

Process groups and the one "foreground" seat

+

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.

+ +
+
Terminal remembers
foreground group = whoever holds the seat
+
With job control on
zsh group A · claude group B (new)
+
+ +

SIGTTIN — the "you're not allowed to listen" signal

+

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.

+ +
+ ★ Why it's not your config: nothing in your zsh, terminator, or i3 setup turns on mouse tracking or fires those probes — that's Claude Code's own startup behavior. Anthropic has this filed and closed as "not planned" + (#7807, + #11898, + #23581, + #50032), and there is no flag or env var to disable the probes. So the fix has to live outside the binary — in the shell that launches it. +
+ + +

2 · Intuition

+ +

The failure is a three-step chain. Follow one keystroke through it.

+ +
+
① claude startsTurns on mouse tracking, fires probes. Still setting up its raw-mode input.
+
② you typeA keystroke lands in the terminal's input buffer while claude is mid-init.
+
③ read → SIGTTINclaude reads stdin while not the foreground group → kernel stops 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:

+ +
$ 5;44;41M35;44;41M35;44;41M +997;1n ← mouse-move reports + the dark-mode probe reply, now landing on the shell
+ +

So there are really two separate problems to solve, and the wrapper attacks each with its own lever:

+ +
+
Lever 1 — PREVENTKeep claude in the foreground group so a 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.
+
Lever 2 — SELF-HEALIf a garble ever does happen, undo it on exit: send the "mouse off / cursor on" reset sequences and run stty sane so the terminal is clean the moment claude quits — no manual reset needed.
+
+ +
+
Default (job control on)
claude in its own group → brief window where it isn't the foreground seat → read can stop it
+
Wrapper (nomonitor)
claude shares the shell's foreground group → it always holds the seat → read never stops it
+
+ +
+ ★ The honest caveat baked into the code: the exact reason the read lands in a non-foreground group is a startup race we can't observe from outside the binary — so lever 1 is a well-reasoned hypothesis, confirmed by testing (it worked). Lever 2 is the guarantee that always holds regardless. That's why the wrapper ships both. +
+ + +

3 · Play with it

+

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.

+ +
+

Startup race simulator

+
+ Job control: + + + + + + +
+ +
+
claude's process group
— not launched —
+
terminal's foreground seat
shell
+
+ + +
(terminal is calm)
+
+
+ + +

4 · Code

+

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.

+ +

a. The escape sequences it will use to clean up

+
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.

+ +

b. An escape hatch

+
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.

+ +

c. Lever 1 — prevent the stop

+
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.

+ +

d. Lever 2 — self-heal, but only a real terminal

+
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:

+ + +
+ Trade-off: while lever 1 is active you can't 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. +
+ + +

5 · Quiz

+
Score: 0 / 0
+
+ + +
+ + + + diff --git a/docs/explanations/index.html b/docs/explanations/index.html index fa9a39e0..94171a54 100644 --- a/docs/explanations/index.html +++ b/docs/explanations/index.html @@ -3,7 +3,7 @@ -explanations — change explanations +testsAndMisc — change explanations