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
@@ -231,4 +231,81 @@ describe('orphaned terminal input recovery wiring', () => {
const { sent } = await keystroke({ data: 'q', dispatchInput: false, keyCode: 65 });
expect(sent).toEqual([]);
});
/**
* The sequence a real Android device logged (SwiftKey in Edge) when autocorrect fired on
* space: every key is a keyCode-229 keydown plus a plain `insertText`, then ONE keydown that
* deletes five characters and a second that inserts `rompt `. Trusted events through the real
* textarea and the real xterm, so `execCommand` produces the same `beforeinput`/`input`
* pairs the keyboard does. The byte stream is replayed (DEL erases a character) to get the
* line the shell ends up with.
*/
async function autocorrectOnSpace() {
return page.evaluate(async () => {
const app = (window as any).app;
const textarea = document.querySelector('.xterm-helper-textarea') as HTMLTextAreaElement;
const originalSessionId = app.activeSessionId;
const originalLocalEcho = app._localEchoEnabled;
const originalSendInput = app._sendInputAsync;
const originalPendingInput = app._pendingInput;
const originalLastKeystrokeTime = app._lastKeystrokeTime;
const sent: string[] = [];
const key229 = () => {
const down = new KeyboardEvent('keydown', { key: 'Unidentified', bubbles: true, cancelable: true });
Object.defineProperties(down, { keyCode: { value: 229 }, which: { value: 229 } });
textarea.dispatchEvent(down);
};
const tick = () => new Promise((resolve) => setTimeout(resolve, 20));
try {
app.activeSessionId = 'cod388-browser-autocorrect';
app._localEchoEnabled = false;
app._pendingInput = '';
app._lastKeystrokeTime = 0;
app._sendInputAsync = (_sessionId: string, chunk: string) => sent.push(chunk);
textarea.value = '';
textarea.focus();
for (const ch of 'testing the peompt') {
key229();
document.execCommand('insertText', false, ch);
await tick();
}
// SwiftKey's autocorrect: both edits in one task, before any timer runs.
key229();
textarea.setSelectionRange(textarea.value.length - 5, textarea.value.length);
document.execCommand('delete');
key229();
document.execCommand('insertText', false, 'rompt ');
await new Promise((resolve) => setTimeout(resolve, 80));
const line: string[] = [];
for (const ch of sent.join('')) {
if (ch === '\x7f') line.pop();
else line.push(ch);
}
return { raw: sent.join(''), line: line.join(''), textarea: textarea.value };
} finally {
app.activeSessionId = originalSessionId;
app._localEchoEnabled = originalLocalEcho;
app._sendInputAsync = originalSendInput;
app._pendingInput = originalPendingInput;
app._lastKeystrokeTime = originalLastKeystrokeTime;
textarea.value = '';
}
});
}
it('an autocorrect that deletes a word and retypes it reaches the shell once, not duplicated', async () => {
const { line, textarea } = await autocorrectOnSpace();
expect(textarea).toBe('testing the prompt ');
expect(line).toBe('testing the prompt ');
});
it('control: without the edit sync, xterm alone reproduces the duplicated line', async () => {
// destroy() puts xterm's own handler back. Keep this LAST: it leaves the controller off.
await page.evaluate(() => (window as any).app._keyCode229Recovery.destroy());
const { line } = await autocorrectOnSpace();
// Byte for byte what the phone sent in the device log.
expect(line).toBe('testing the peompttesting the prompt rompt ');
});
});
+199
View File
@@ -422,3 +422,202 @@ describe('terminal-ui wiring: what counts as "xterm spoke for this keystroke"',
expect(typed.emitted, 'xterm really did deliver this one').toEqual([]);
});
});
/**
* xterm's `_handleAnyTextareaChanges` diffs the helper textarea with
* `newValue.replace(oldValue, '')`, which only works when the keyboard appended.
* Autocorrect on space (SwiftKey, Gboard) deletes the word and inserts the corrected
* one, and xterm answers by sending the WHOLE value, then the inserted text again:
* a real device log produced `testing the peompttesting the prompt rompt `.
*/
function editSyncHarness() {
const source = readFileSync(new URL('../src/web/public/terminal-keycode229-recovery.js', import.meta.url), 'utf8');
const exposed: Record<string, any> = {};
vm.runInNewContext(source, { window: exposed, globalThis: exposed }, { filename: 'terminal-keycode229-recovery.js' });
const textarea = Object.assign(makeTextarea(), { value: '' });
const sent: string[] = [];
const timers = new Map<number, () => void>();
let timerId = 0;
// xterm's own (flawed) diff, as shipped, so the control case can prove the bug.
const xtermOriginal = function (this: any) {
const oldValue = textarea.value;
exposed.__timers.set(++timerId, () => {
const newValue = textarea.value;
const diff = newValue.replace(oldValue, '');
if (newValue.length > oldValue.length) sent.push(diff);
else if (newValue.length < oldValue.length) sent.push('\x7f');
else if (newValue !== oldValue) sent.push(newValue);
});
};
exposed.__timers = timers;
const helper = {
_isComposing: false,
_dataAlreadySent: '',
_handleAnyTextareaChanges: xtermOriginal,
_coreService: { triggerDataEvent: (data: string) => sent.push(data) },
};
const create = (withSync: boolean) =>
exposed.CodemanKeyCode229Recovery.create({
textarea,
emitRecovered: (data: string) => sent.push(data),
getCompositionHelper: withSync ? () => helper : undefined,
setTimer: (callback: () => void) => {
const id = ++timerId;
timers.set(id, callback);
return id;
},
clearTimer: (id: number) => timers.delete(id),
});
return {
exposed,
helper,
textarea,
sent,
create,
/** One keydown, whose xterm timer is registered at keydown time like the real one. */
keydown() {
helper._handleAnyTextareaChanges();
},
edit(value: string) {
textarea.value = value;
},
flush() {
for (const [id, callback] of [...timers]) {
timers.delete(id);
callback();
}
},
/** What the shell line holds once every DEL has been applied. */
line() {
const out: string[] = [];
for (const ch of sent.join('')) {
if (ch === '\x7f') out.pop();
else out.push(ch);
}
return out.join('');
},
};
}
describe('edit-based sync of the helper textarea (autocorrect replacements)', () => {
const typeKeys = (h: ReturnType<typeof editSyncHarness>, text: string) => {
for (const ch of text) {
h.keydown();
h.edit(h.textarea.value + ch);
h.flush();
}
};
// The exact event shape from a device log: per-key appends, then ONE keydown deleting five
// characters and a SECOND keydown inserting `rompt `, both landing before any timer runs.
const autocorrect = (h: ReturnType<typeof editSyncHarness>) => {
typeKeys(h, 'testing the peompt');
h.keydown();
h.edit('testing the p');
h.keydown();
h.edit('testing the prompt ');
h.flush();
};
it('control: xterm alone duplicates the line when the keyboard autocorrects', () => {
const h = editSyncHarness();
h.create(false);
autocorrect(h);
expect(h.sent.join('')).toBe('testing the peompttesting the prompt rompt ');
});
it('with edit sync the line ends up exactly as the textarea reads', () => {
const h = editSyncHarness();
h.create(true);
autocorrect(h);
expect(h.line()).toBe('testing the prompt ');
expect(h.sent.filter((s) => s === '\x7f')).toHaveLength(5);
});
it('plain typing is still one chunk per keystroke, and a single delete is one DEL', () => {
const h = editSyncHarness();
h.create(true);
typeKeys(h, 'abc');
h.keydown();
h.edit('ab');
h.flush();
expect(h.sent).toEqual(['a', 'b', 'c', '\x7f']);
});
it('a multi-character delete sends one DEL per character, not one DEL in total', () => {
const h = editSyncHarness();
h.create(true);
typeKeys(h, 'hello');
h.keydown();
h.edit('he');
h.flush();
expect(h.sent.slice(5)).toEqual(['\x7f', '\x7f', '\x7f']);
});
it('an equal-length rewrite is a delete and a retype, not the whole value again', () => {
const h = editSyncHarness();
h.create(true);
typeKeys(h, 'cat');
h.keydown();
h.edit('cut');
h.flush();
expect(h.sent.slice(3)).toEqual(['\x7f', '\x7f', 'ut']);
expect(h.line()).toBe('cut');
});
it('does not resend after xterm clears the textarea (Enter), and counts emoji as one character', () => {
const h = editSyncHarness();
h.create(true);
typeKeys(h, 'hi');
h.textarea.value = ''; // xterm's own reset after Enter: no input event, no emission
typeKeys(h, 'yo');
expect(h.sent).toEqual(['h', 'i', 'y', 'o']);
const e = editSyncHarness();
e.create(true);
typeKeys(e, 'a😀');
e.keydown();
e.edit('a');
e.flush();
expect(e.sent.slice(2)).toEqual(['\x7f']);
});
it('stays out of the way while composing, and leaves xterm alone without its internals', () => {
const h = editSyncHarness();
h.create(true);
h.helper._isComposing = true;
h.keydown();
h.edit('x');
h.flush();
expect(h.sent).toEqual([]);
const bare = editSyncHarness();
const original = bare.helper._handleAnyTextareaChanges;
bare.create(false);
expect(bare.helper._handleAnyTextareaChanges).toBe(original);
});
it('restores xterm’s own handler on destroy', () => {
const h = editSyncHarness();
const original = h.helper._handleAnyTextareaChanges;
const controller = h.create(true);
expect(h.helper._handleAnyTextareaChanges).not.toBe(original);
controller.destroy();
expect(h.helper._handleAnyTextareaChanges).toBe(original);
});
it('terminal-ui.js hands the controller xterm’s composition helper', () => {
const terminalSource = readFileSync(new URL('../src/web/public/terminal-ui.js', import.meta.url), 'utf8');
expect(terminalSource).toMatch(/getCompositionHelper:\s*\(\)\s*=>\s*this\.terminal\?\._core\?\._compositionHelper/);
});
it('editBetween counts code points and replaces everything after the common prefix', () => {
const { editBetween } = editSyncHarness().exposed.CodemanKeyCode229Recovery;
expect(editBetween('abc', 'abcd')).toEqual({ deleted: 0, inserted: 'd' });
expect(editBetween('abc', 'ab')).toEqual({ deleted: 1, inserted: '' });
expect(editBetween('testing the peompt', 'testing the prompt ')).toEqual({ deleted: 5, inserted: 'rompt ' });
expect(editBetween('x😀', 'x')).toEqual({ deleted: 1, inserted: '' });
expect(editBetween('', '')).toEqual({ deleted: 0, inserted: '' });
});
});