fix(terminal): stop Android autocorrect duplicating the typed line

xterm diffs the helper textarea with newValue.replace(oldValue, ''), which only works when the keyboard appended. SwiftKey/Gboard autocorrect on space deletes a word and inserts the corrected one, so xterm sent the whole value and then the inserted text again (testing the peompt + space became 'testing the peompttesting the prompt rompt '), and a multi-character delete was one DEL. The keyCode-229 controller now swaps in an edit-based diff against what was already sent.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JrzFKEdBLwVfu6ev2ZscJS
This commit is contained in:
Devvyn
2026-10-06 15:51:26 +08:00
co-authored by Claude Sonnet 5.5
parent ac94f339ac
commit 0c71b753ef
5 changed files with 368 additions and 1 deletions
+86 -1
View File
@@ -18,6 +18,15 @@
* the case this module exists for, and the case its browser test asserts by
* checking WHO delivered the byte rather than merely that one arrived.
*
* A SECOND failure lives in that same self-rescue: xterm diffs `newValue.replace(oldValue, '')`,
* which only works when the keyboard APPENDED. A soft keyboard that autocorrects on space
* (SwiftKey, Gboard) rewrites the tail instead: it deletes the word and inserts the corrected
* one, and xterm answers by sending the WHOLE new textarea value (the old value is not a
* substring of it), then sends the inserted text AGAIN from the second keydown's timer. One
* autocorrect turned `testing the peompt` + <space> into
* `testing the peompttesting the prompt rompt `. A multi-character delete is also sent as ONE
* DEL. `installEditSync` below replaces that diff with an edit-based one.
*
* The recovery never guesses the character: the `input` event already carries
* the real committed text in `ev.data`, which is exactly what xterm itself
* would have forwarded. We only decide WHETHER to forward it, by asking
@@ -39,6 +48,21 @@
(function (global) {
'use strict';
/**
* What turns `previous` into `next`, as a terminal sees it: how many characters to delete from
* the END of the line, then what to type. Everything after the common prefix is treated as
* replaced, because a terminal can only edit at its cursor. Counts are code points, so an
* emoji is one DEL, as it is one backspace.
*/
function editBetween(previous, next) {
const a = Array.from(previous);
const b = Array.from(next);
let prefix = 0;
const max = Math.min(a.length, b.length);
while (prefix < max && a[prefix] === b[prefix]) prefix += 1;
return { deleted: a.length - prefix, inserted: b.slice(prefix).join('') };
}
function create(options) {
const textarea = options?.textarea;
const emitRecovered = options?.emitRecovered;
@@ -176,6 +200,62 @@
}
}
/**
* Replace xterm's `_handleAnyTextareaChanges` (see the header) with an edit-based diff against
* the value the PTY side has already been told about. `synced` is that value; it is shared by
* every keydown's timer, so two timers that both see the final textarea value cannot both
* send it. Returns an uninstall function, or null when xterm's internals are not as expected
* (then xterm's own, flawed, behaviour is left in place).
*/
function installEditSync() {
let helper;
try {
helper = options.getCompositionHelper?.();
} catch {
return null;
}
const original = helper?._handleAnyTextareaChanges;
const coreService = helper?._coreService;
if (typeof original !== 'function' || typeof coreService?.triggerDataEvent !== 'function') return null;
let synced = textarea.value;
let outstanding = 0;
helper._handleAnyTextareaChanges = function handleAnyTextareaChanges() {
if (destroyed) return original.call(this);
// No edit in flight and the value is not what we last sent: something outside the IME
// changed it (xterm clears it after Enter, a composition committed). Nothing to send;
// resynchronise.
if (outstanding === 0 && synced !== textarea.value) synced = textarea.value;
outstanding += 1;
setTimer(() => {
outstanding -= 1;
if (destroyed || helper._isComposing) return; // xterm's composition path owns this one
const current = textarea.value;
if (current === synced) return;
const { deleted, inserted } = editBetween(synced, current);
synced = current;
try {
// One DEL per character, like repeated backspace presses: the local-echo composer and
// the PTY both treat each as a single edit.
for (let i = 0; i < deleted; i += 1) coreService.triggerDataEvent('\x7f', true);
if (inserted) {
helper._dataAlreadySent = inserted;
coreService.triggerDataEvent(inserted, true);
}
} catch {
// Delivery is best effort; never throw into the browser's timer queue.
}
}, 0);
};
return () => {
if (helper._handleAnyTextareaChanges !== original) helper._handleAnyTextareaChanges = original;
};
}
const uninstallEditSync = installEditSync();
function onCompositionStart() {
if (destroyed) return;
composing = true;
@@ -191,6 +271,11 @@
if (destroyed) return;
destroyed = true;
cancelPending();
try {
uninstallEditSync?.();
} catch {
// Best effort.
}
try {
textarea.removeEventListener('input', onInput, true);
textarea.removeEventListener('compositionstart', onCompositionStart, true);
@@ -228,5 +313,5 @@
return Object.freeze({ handleKeyEvent, notifyCanonicalData, destroy });
}
global.CodemanKeyCode229Recovery = Object.freeze({ create });
global.CodemanKeyCode229Recovery = Object.freeze({ create, editBetween });
})(typeof window !== 'undefined' ? window : globalThis);