Skip to content

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

Open
gloryfromca wants to merge 4 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 4 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) <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 gloryfromca left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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: reproduced ESC+CR and ESC+DEL splitting 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') {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 gloryfromca left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

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