fix(ui-tui): read a coalesced byte run as typing, not as a paste - #355
fix(ui-tui): read a coalesced byte run as typing, not as a paste#355gloryfromca wants to merge 4 commits into
Conversation
Typing and pasting are the same bytes on stdin, so the parser merged any multi-character run into one nameless keypress and the composer treated it as an unbracketed paste held behind a 50ms debounce. SSH, tmux and a blocked event loop all coalesce keystrokes, so continuous typing kept resetting that debounce and the input box stalled for the whole burst. Split a plain byte run into one keypress per code point. Bracketed paste (DEC 2004) is the only reliable paste signal, so App now asks the terminal with DECRQM whether the mode took, riding the batch that already carries XTVERSION, and the parser records the answer in its own state. Confirmed: every unmarked run is typing. Not confirmed: only runs that could not be a paste line -- free of control bytes and no longer than a typing burst -- split, so multi-line paste stays whole on terminals without the markers. Also fixes three input bugs with the same root cause: an auto-repeated backspace inserted literal DEL characters instead of deleting, a return arriving in the same read as the text before it was swallowed, and a control key arriving alongside text inserted a literal control character. Co-authored-by: Claude (claude-opus-5) <noreply@anthropic.com>
Fast echo writes a character straight to the terminal and defers the React update to the next frame, so a keystroke that takes it costs no render. Its guard required the display width to equal the string length, which a CJK character or an emoji can never satisfy, so every wide character forced a full tree reconcile instead. The terminal advances two cells for a wide character on its own, so the guard only has to hold what the terminal cannot: a single grapheme, at the end of a single line that still has room. This pairs with the byte-run split. Without it a two-character IME commit would trade one delayed render for two immediate ones. Memoize the useVirtualHistory return value as well: useMainApp feeds it into the appTranscript memo, so a fresh object on every render re-rendered the whole transcript on every keystroke. Co-authored-by: Claude (claude-opus-5) <noreply@anthropic.com>
gloryfromca
left a comment
There was a problem hiding this comment.
Blocking: confirmed bracketed-paste mode still breaks raw compound keys; see the inline finding.
This change correctly targets coalesced typing by splitting unmarked byte runs after DEC 2004 is confirmed, and the accompanying fast-echo and virtual-history changes are internally consistent. The new head only merges current main; the reviewed TUI patch is unchanged from 7a7dc1a34c67.
I covered AGENTS.md, CONTEXT-MAP.md, ui-tui/CONTEXT.md, the full diff against the PR base, callers through InputEvent and TextInput, relevant history, backward compatibility, and the test changes. No tests were removed or weakened, but the confirmed-state suite omits the existing raw compound-key contracts. No CLAUDE.md is present.
Verification:
npm test --prefix ui-tui: 90 files and 1031 tests passed.npm run type-check --prefix ui-tui: passed.- Direct parser probe on
f8905957c534: reproducedESC+CRandESC+DELsplitting after DECRPM 2004 SET. git diff --check: passed.
Low-confidence boundary: I did not manually test a terminal/multiplexer matrix. That does not affect the blocker because the parser transition and caller behavior were reproduced deterministically.
| return false | ||
| } | ||
|
|
||
| if (bracketedPaste === 'confirmed') { |
There was a problem hiding this comment.
[P1] Preserve compound raw keys before confirmed-state splitting
When DEC 2004 is confirmed, this unconditional branch splits every multi-character text token, but raw compound keys are text tokens too. Reproduced on this head: ESC+CR changes from one empty-named event to Escape plus an unmodified Return; ESC+DEL changes from Meta+Backspace to Escape plus an unmodified Backspace. TextInput therefore submits instead of inserting a newline, and word deletion falls back to deleting one character.
The existing raw Alt+Enter tests use INITIAL_STATE; the confirmed-state block never repeats those contracts, so the full suite stays green. Extended-key protocols do not remove the fallback requirement: textInput.tsx explicitly documents that they are not reliable across SSH, zellij, and VS Code chains.
Please preserve recognized ESC plus control-key chords before code-point splitting, and add confirmed-state cases for ESC+CR, ESC+LF, ESC+DEL, and ESC+BS.
There was a problem hiding this comment.
Resolved in b93caedd8fe3. I reran the confirmed state directly: ESC+CR, ESC+LF, ESC+DEL, and ESC+BS now stay compound, including when embedded in a mixed typing burst. The new tests cover all four forms, and the full TUI suite and type-check pass.
gloryfromca
left a comment
There was a problem hiding this comment.
No blockers; this can merge as far as I am concerned.
The prior compound-key finding is resolved in b93caedd8fe3. pushTextKeys now preserves ESC+CR, ESC+LF, ESC+DEL, and ESC+BS before splitting the surrounding run, so Alt+Enter keeps the newline path and Meta+Backspace keeps its modifier. The confirmed-state tests cover all four contracts.
I reviewed the new delta and rechecked the full change against AGENTS.md, CONTEXT-MAP.md, ui-tui/CONTEXT.md, the tokenizer/parser callers through InputEvent and TextInput, relevant history, backward compatibility, and the test changes. No CLAUDE.md is present. No tests were removed or weakened.
Verification:
npm test --prefix ui-tui: 90 files and 1035 tests passed.npm run type-check --prefix ui-tui: passed.- Direct confirmed-state probes preserved all four raw compound keys and a compound key embedded in a mixed typing burst.
git diff --check: passed.
Low-confidence boundary: I did not manually repeat a terminal/multiplexer matrix; the parser state transition and downstream contract were exercised deterministically.
Summary
A colleague reported the TUI composer feeling laggy while typing; it did not
reproduce on a fast local terminal with short English input. Two causes, and
they amplify each other.
Typing was being read as a paste. Typing and pasting are the same bytes on
stdin, so the parser merged any multi-character run into one nameless keypress
and the composer treated it as an unbracketed paste held behind a 50ms
debounce. SSH, tmux and a blocked event loop all coalesce keystrokes, so
continuous typing kept resetting that debounce and the input box stalled for
the whole burst. A CJK input method commits several characters at once, so it
took this path on almost every word.
The fix stops guessing from length. A plain byte run is split into one keypress
per code point, and bracketed paste (DEC 2004) becomes the paste signal it was
always meant to be. App asks the terminal with DECRQM whether the mode actually
took, riding the query batch that already carries XTVERSION, and the parser
records the answer in its own state. Confirmed: every unmarked run is typing.
Not confirmed: only runs that could not be a paste line - free of control bytes
and no longer than a typing burst (32 characters) - split, so multi-line paste
still stays whole on terminals without the markers.
Three input bugs shared that root cause and go away with it: an auto-repeated
backspace inserted literal DEL characters instead of deleting, a return
arriving in the same read as the text before it was swallowed without sending,
and a control key arriving alongside text inserted a literal control character.
Wide characters could never take the fast-echo path. Fast echo writes a
character straight to the terminal and defers the React update to the next
frame, so a keystroke that takes it costs no render. Its guard required display
width to equal string length, which a CJK character or an emoji can never
satisfy, so every wide character forced a full tree reconcile. The terminal
advances two cells for a wide character on its own, so the guard now only holds
what the terminal cannot: a single grapheme, at the end of a single line that
still has room.
The two halves ship together on purpose. Without the second, a two-character
IME commit would trade one delayed render for two immediate ones.
Also memoizes the
useVirtualHistoryreturn value.useMainAppfeeds it intothe
appTranscriptmemo, so a fresh object identity on every renderre-rendered the whole transcript on every keystroke - the amplifier that made
long sessions worse.
Note for review: this is the first change to the ink fork's implementation in
the repository's visible history (four earlier commits touched only its README,
export surface and lockfile). Its README asks for the same test and review bar
as first-party code, so
parse-keypress.tsis the part worth reading closely.Type
Verification
Run from
ui-tui/, on this branch rebased ontomainat 0e544ec, after aclean
npm ci:npm test- 90 files, 1031 tests passed, 0 failednpm run type-check- cleannpm run lint- 0 errors, 22 warnings, identical to the pre-change baseline(compared warning-by-warning against a clean checkout of
main; only linenumbers moved)
Three load-bearing new tests were mutation-checked: the implementation was
broken on purpose and each test was confirmed to fail, then restored. One of
them silently passed on the first attempt because component tests load the
bundled
packages/hermes-ink/distrather than its source; it was re-checkedafter rebuilding.
Manual, in
raven tui --devafter rebuilding the ink bundle: holding backspacedeletes instead of inserting
^?; typing a sentence and pressing returnimmediately sends it; CJK input keeps up with typing; a several-hundred-line
paste still collapses into a single placeholder; a dropped file path is still
recognised.
New coverage:
parse-keypress.test.tstextInputFastAppend.test.tstextInputTypingBurst.test.tsxvirtualHistoryIdentity.test.tsxThree existing tests in
parse-keypress.test.tswere updated. They assertedthat a text run arrives as a single key, which is the shape this change
replaces; they now assert the intent instead - every event is a key and their
sequences join back to the original text. That also checks each event's kind,
which the old form did not.
Risk
User-visible behaviour changes, all in the composer:
what they say instead of inserting literal control characters.
The regression surface is paste. On a terminal that confirms DEC 2004 nothing
about paste handling changes: markers still delimit it and the parser already
reassembled marked pastes across reads. On a terminal that does not confirm it,
short printable runs now arrive as typing, which is why the 32-character
threshold is there - it keeps long single-line pastes whole so placeholder
collapsing and dropped-path detection still work. A paste of a short
single-line string on such a terminal is the one case that changes: it inserts
character by character instead of going through the paste handler.
Rollback is a revert of the two commits; they touch no state, no protocol and
no persisted format.
Related Issues
N/A