fix(terminal): recover dropped keyCode 229 input (Android/IME) - #388
fix(terminal): recover dropped keyCode 229 input (Android/IME)#388aakhter wants to merge 2 commits into
Conversation
Android/GBoard-style keyboards fire keydown with keyCode 229 and, on some paths, never mutate xterm's helper textarea. xterm has nothing to diff, so it emits no data and the typed character is silently dropped: it never reaches the PTY and never appears on screen. terminal-keycode229-recovery.js is a standalone controller that re-emits exactly those keys, and only once. xterm stays authoritative throughout: - Only an explicit keyCode 229 keydown carrying a single printable key (or Enter) is eligible; Process/Unidentified/Dead, modifiers, AltGraph and a live composition are all left alone. - The re-emit is scheduled from a microtask and then a zero-delay timer, so xterm's own textarea diff always gets the first opportunity; canonical data for the same key cancels the pending fallback. - compositionstart and blur drop every pending candidate, so a real IME composition lifecycle is never second-guessed. - After a recovery, one late canonical value attributed to that key token (via beforeinput/input on the helper textarea) is suppressed so the character cannot be delivered twice; the record expires after 250ms and an unattributed byte is never suppressed. terminal-ui.js wires it at the two existing choke points — the custom key handler and the onData registration, the latter now a named handler so the recovery path can re-enter it — with both hooks wrapped so a failure in the fallback can never break canonical input. Unit coverage drives the module directly in a vm; the wiring itself is covered end-to-end in the (browser-only) terminal-copy-shortcut suite.
|
Thanks for chasing this, and for the write-up. Losing a keystroke on an Android soft keyboard is a real and miserable bug. I do not want to merge this version though, because on the trace it targets I could reproduce it dropping a different keystroke, and double-delivering two others. Reproduced in headless Chromium against the PR's own controller: 1. A recovered key claims the next keystroke's 2. Double delivery whenever the committed text differs from 3. The trigger has not been shown on hardware ( That last point is the one I would start with, because it may change the design. It is worth saying that the pre- If the current shape stays, the dedupe needs to stop relying on microtask ordering surviving across trusted events, and candidates need to be cancelled on any textarea mutation. Two smaller things whenever this comes back:
Thanks again, and please do come back with the device trace. The diagnosis is worth landing once the delivery path is safe. |
…a guessed key The previous shape guessed the character from `event.key` on keydown, re-emitted it, and then tried to suppress a late canonical copy with a 250 ms character-keyed dedupe. Review found three defects in that, all reproducible: the dedupe matched on the character alone with nothing scoping a candidate to the keydown that created it, so the same character typed twice inside the window had its second, real byte swallowed; anything whose committed text differed from `event.key` (Enter, IME punctuation) was delivered twice, because the dedupe could never match it; and the trigger ignored `key === 'Unidentified'`, which is what a soft keyboard reports, so it may never have fired where it was needed. The input event already carries the committed text in `ev.data` — exactly what xterm itself would have forwarded — so nothing has to be guessed. The controller now only decides WHETHER to forward, by asking whether xterm produced canonical data since the keydown that began the keystroke. No character-keyed matching survives, so the first two defects are structurally impossible rather than defended against, and nothing reads `key`/`keyCode`, so the third cannot recur. Three details are load-bearing and each has a test that fails without it: - The "did xterm speak?" snapshot is taken at KEYDOWN, not at the input event. `_keyPress` emits and sets `_keyPressHandled` before `input` fires, so a snapshot read at input time already contains that emission, reads it as silence, and delivers the character twice. - Our `input` listener is registered with `capture: true`. The target is visited twice in the event path, so a capture listener calling `stopPropagation()` stops later BUBBLE listeners on that same target; xterm's `cancel()` runs exactly in the branch where it handled the input, so on bubble we would never observe handled events, and whether we observed them at all would hang off `options.cancelEvents`. Measured in jsdom and headless chromium; the table is in the module header. - Enter is deliberately no longer special-cased. That mapping is what made the committed text differ from the re-emitted value in the first place. The scope is also narrower than the old name suggests, and the browser test now proves it rather than assuming it. For a keydown that reports keyCode 229 xterm ALREADY self-rescues, via `CompositionHelper._handleAnyTextareaChanges()` diffing the helper textarea on a 0 ms timer. A test asserting "we recovered it" there passes while xterm does all the work, so the browser tests assert WHO delivered the byte: zero canonical emissions for the genuinely orphaned case, exactly one delivery for the case xterm rescues itself. Also addresses review notes: the module gains an `@fileoverview` with `@dependency`/`@loadorder` and an entry in the load-order list and module inventory, and the wiring test moves out of the Ctrl+C smart-copy file into its own. The keydown hook deliberately still runs for every key event rather than moving behind the 229 gate: gating it would reinstate exactly the blindness described above, and it is now a single counter assignment.
|
Thanks — this was a good catch and all three findings reproduce. I checked each against the branch rather than taking them on trust:
I've rebuilt it on the shape you suggested — forward the orphaned What changedThe controller no longer guesses. The Three details are load-bearing, each with a test that fails without it:
The part worth your attention: the gap is narrower than "keyCode 229"Verifying the browser test, I found that xterm already self-rescues keyCode 229. Measured in headless chromium against a real terminal: for a 229 keydown, xterm emits and this controller correctly stands down. Which means my original browser test asserting "recovers a committed character xterm dropped, exactly once" was passing vacuously — xterm delivered the byte, not the recovery. It proved nothing. So the browser tests now assert who delivered, via a count of xterm's own canonical emissions: zero for the genuinely orphaned case, exactly one for the case xterm rescues itself. The remaining gap is a refused This does sharpen your third point rather than answer it. If the Android traces behind this are all keyCode 229, xterm may already handle them and this may be unnecessary in the field. I still don't have a device trace, and I'm not going to pretend the synthetic tests substitute for one — happy for this to sit until I can get one, or to close it if you'd rather not carry the surface area on an unproven trigger. Smaller itemsDone: the module has an One I didn't do, deliberately — moving the keydown hook behind the 229 gate. That was right for the old design, but it would now reinstate exactly the |
Claude Code answers an exhausted model budget INSIDE the turn ("You've
reached your Fable limit. Run /usage-credits to continue or switch models
with /model.") and then sits there with nothing to write. The reviewer never
produces a report, so `runTurn` waited out its full 40-minute deadline and
reported a bare "timed out after 40 min without a report", which reads as a
hung reviewer rather than an account that needs attention.
Measured on 2026-09-08: #388, #393, #394 and #377 each lost 40 minutes this
way, and because every attempt counted, all four reached MAX_AUTO_RETRIES and
would NOT have been picked up again once the budget returned. One spent
afternoon quietly took the whole queue out of service.
`findModelLimitNotice()` reads the notice off the pane and `runTurn` returns
a new `limit` outcome instead of waiting. It is consulted in exactly two
places, both of which mean "the turn produced nothing": on a stop where
`isDone()` is still false, and on each timed-out wait slice. A review that
merely discusses usage limits in its own findings therefore cannot be
mistaken for one that hit the wall, and the pattern matches neither the model
name nor a straight apostrophe, since the pane renders a typographic one and
every model prints the same sentence.
A spent budget is an account condition, not a bad PR, so it no longer spends
the per-head retry budget: the queue resumes by itself when the budget does.
Telegram now names the cause and the file to change.
Tests use the pane captured verbatim off the run that lost the 40 minutes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The bug
Android/GBoard-style keyboards fire
keydownwithkeyCode 229and, on some paths, never mutate xterm's helper textarea. xterm has nothing to diff, so it emits no data — the typed character is silently dropped. It never reaches the PTY and never appears on screen.Today
terminal-ui.js,input-cjk.js,app.jsandsession-ui.jsall deliberately pass throughkeyCode === 229so xterm'sCompositionHelpercan own the event. That is correct when a real composition follows. When none does, the key is simply lost.The fix
terminal-keycode229-recovery.jsis a standalone controller that re-emits exactly those keys, and only once. xterm stays authoritative throughout:keyCode 229keydown carrying a single printable key (or Enter) is eligible.Process/Unidentified/Dead, modifier chords, AltGraph and a live composition are all left alone.onDatapath (local echo, CJK gate, predictive echo, flush batching), so a recovered key takes the identical wire path to a canonical one.The controller is optional at every call site: if the script is absent or construction throws, the terminal behaves exactly as it does today.
Changes
src/web/public/terminal-keycode229-recovery.js{textarea, emitRecovered})src/web/public/terminal-ui.jssrc/web/public/index.htmlterminal-ui.jsscripts/build.mjstest/terminal-keycode229-recovery.test.tstest/terminal-copy-shortcut.test.tsThe
onDatabody is unchanged; it is now a namedconst handleTerminalDatainvoked from a one-line delegate, so the recovery path can re-enter it.Verification
tsc --noEmit,lint,check:frontend-syntax,check:public-assets,format:check,build— all pass.test/terminal-keycode229-recovery.test.ts13 passed;test/terminal-copy-shortcut.test.ts9 passed undertest:browser(8 before). Sixteen neighbouring terminal/input test files were run individually and all pass.The assertions were checked by mutation: forcing
duplicateIndexto-1fails 2 tests, nulling the canonical lookup fails 1, removing thekeyCode !== 229gate fails 1, and breaking theterminal-ui.jswiring fails the browser test.Reviewer notes
terminal-copy-shortcut.test.ts, which is excluded fromconfig/vitest.ci.config.ts, so CI runs the 13 unit tests but not the integration one.keydownwithkeyCode 229that never mutates the textarea). The double-delivery guard is the risk-bearing half: a browser reporting canonical data with nobeforeinput/inputon the helper textarea and later than the 250ms window could double a character. The design fails toward "deliver once, never suppress an unattributed byte" — the safer direction, since dropping input is the bug being fixed.