fix(ui-tui): read a coalesced byte run as typing, not as a paste - #355
Open
gloryfromca wants to merge 2 commits into
Open
fix(ui-tui): read a coalesced byte run as typing, not as a paste#355gloryfromca wants to merge 2 commits into
gloryfromca wants to merge 2 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) <[email protected]>
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) <[email protected]>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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