Skip to content

fix(ui-tui): read a coalesced byte run as typing, not as a paste - #355

Open
gloryfromca wants to merge 2 commits into
mainfrom
fix/tui_typing_misread_as_paste
Open

fix(ui-tui): read a coalesced byte run as typing, not as a paste#355
gloryfromca wants to merge 2 commits into
mainfrom
fix/tui_typing_misread_as_paste

Conversation

@gloryfromca

Copy link
Copy Markdown
Contributor

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 useVirtualHistory return value. useMainApp feeds it into
the appTranscript memo, so a fresh object identity on every render
re-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.ts is the part worth reading closely.

Type

  • Fix
  • Feature
  • Docs
  • CI / tooling
  • Refactor
  • Other

Verification

Run from ui-tui/, on this branch rebased onto main at 0e544ec, after a
clean npm ci:

  • npm test - 90 files, 1031 tests passed, 0 failed
  • npm run type-check - clean
  • npm run lint - 0 errors, 22 warnings, identical to the pre-change baseline
    (compared warning-by-warning against a clean checkout of main; only line
    numbers 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/dist rather than its source; it was re-checked
after rebuilding.

Manual, in raven tui --dev after rebuilding the ink bundle: holding backspace
deletes instead of inserting ^?; typing a sentence and pressing return
immediately 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:

File Tests Covers
parse-keypress.test.ts 28 -> 47 split rules in both modes, five DECRQM reply statuses, paste content untouched
textInputFastAppend.test.ts 10 wide characters and emoji accepted; line end, newline, column limit, multi-grapheme and zero-width rejected
textInputTypingBurst.test.tsx 2 end to end, a coalesced run from real stdin through the parser into the composer
virtualHistoryIdentity.test.tsx 2 identity survives an unrelated re-render, changes when the item list does

Three existing tests in parse-keypress.test.ts were updated. They asserted
that 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.

  • Relevant tests pass locally
  • Relevant lint / type checks pass locally
  • User-facing docs or screenshots are updated when needed

Risk

User-visible behaviour changes, all in the composer:

  • typing no longer stalls behind the paste debounce;
  • CJK and emoji no longer force a full re-render per character;
  • backspace auto-repeat, return-after-typing and control-key-with-text now do
    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.

  • Security impact considered
  • Backward compatibility considered
  • Rollback path is clear for risky changes

Related Issues

N/A

gloryfromca and others added 2 commits August 21, 2026 18:14
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]>
@gloryfromca
gloryfromca requested a review from 0xKT August 21, 2026 10:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant