Skip to content

fix(input): deliver a recovered keystroke before the Enter that submits it - #441

Open
shenlvkang-collab wants to merge 1 commit into
Ark0N:masterfrom
shenlvkang-collab:fix/android-last-char
Open

shenlvkang-collab wants to merge 1 commit into
Ark0N:masterfrom
shenlvkang-collab:fix/android-last-char

Conversation

@shenlvkang-collab

Copy link
Copy Markdown
Contributor

The report

Every message typed on an Android phone arrives at the CLI with its last character missing帮我看看代码 is submitted as 帮我看看代. Reproducible on every send, in both Latin and CJK text, with the local-echo composer on (the mobile default, cjkInputEnabled: false).

Root cause

An Android soft keyboard commits the last typed character and sends the Enter key in one InputConnection transaction, so the committed-text input event and the Enter keydown are both processed before any zero-delay timer runs.

The orphaned-input recovery added in #388 resolves its candidate only on such a timer, and that loses the character twice over:

  1. Order — xterm emits \r synchronously from the Enter keydown, and terminal-ui.js's local-echo branch submits pendingText right there. The recovered character arrived one macrotask too late to be part of the prompt.
  2. Loss — that same \r bumps canonicalCount, so by the time the candidate resolved, canonicalCount > candidate.snapshot read as "xterm spoke for this keystroke" and stood the recovery down. The character was not merely late, it was dropped outright.

The file already guards the counter against xterm's self-generated output (DA/DSR/CPR/mouse reports) for exactly this reason, but a genuinely different keystroke's canonical data — the Enter — still falsely answered the question asked about the previous one.

The fix

Drain pending candidates synchronously at the next keydown, from xterm's custom key handler, which runs before xterm processes that key. The counter then still holds the value it had while the candidate's own keystroke was current, so the stand-down decision is made against the right keystroke, and the recovered byte reaches the composer ahead of whatever the new key emits. The zero-delay timer stays as the fallback for a keystroke with no key after it.

Physical keyboards are unaffected: there the timer has already resolved the candidate long before the next key arrives.

Tests

test/terminal-keycode229-recovery.test.ts gains two cases, both failing before the change:

  • defect 4 — after keydown → refused insertText → Enter keydown, the character is already emitted at the Enter keydown, exactly once, and no timer is left pending.
  • a companion case pinning that the synchronous drain does not become a "forward everything" path: a keystroke xterm delivered itself is still stood down at the next keydown.

npm run test:ci is green (7252 passed). test/terminal-keycode229-recovery.browser.test.ts does not start in my environment either before or after this change (the Playwright harness times out waiting for app.terminal), so it is unverified here rather than regressed.

🤖 Generated with Claude Code

…ts it

Every message typed on an Android phone lost its last character.

An Android soft keyboard commits the last typed character and sends the
Enter key in ONE InputConnection transaction, so the committed-text
`input` event and the Enter keydown are both processed before any
zero-delay timer runs. The orphaned-input recovery from Ark0N#388 resolved
its candidate only on such a timer, and that lost the character twice
over:

  * ORDER — xterm emits `\r` synchronously from the Enter keydown, and
    the local-echo composer submits `pendingText` right there. The
    recovered character arrived one macrotask too late to be part of the
    prompt.
  * LOSS — that same `\r` bumps the canonical counter, so by the time
    the candidate resolved, `canonicalCount > snapshot` read as "xterm
    spoke for this keystroke" and stood the recovery down. The character
    was not merely late, it was dropped.

Drain pending candidates synchronously at the next keydown instead, from
xterm's custom key handler, which runs before xterm processes that key.
The counter then still holds the value it had while the candidate's own
keystroke was current, so the stand-down decision is made against the
right keystroke, and the recovered byte reaches the composer ahead of
whatever the new key emits. The timer stays as the fallback for a
keystroke with no key after it.

Physical keyboards are unaffected: there the timer has already resolved
the candidate long before the next key arrives.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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