Merge #541: stop Android autocorrect duplicating the typed line

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-10-09 05:40:31 +02:00
7 changed files with 562 additions and 3 deletions
@@ -231,4 +231,166 @@ 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 ');
});
/**
* The batched Android shape from #441, now with an edit that REWRITES text: the last
* character's 229 keydown and insertText land in the same page task as Enter's keydown.
* xterm clears the textarea for Enter before the edit-sync timer would run, so a timer
* left pending diffed the whole line against '' and sent one DEL per character ahead of
* the submitted line (with local echo on, `hello` + Enter submitted `h`). The edit is
* settled at the next keydown, before xterm sees it. Run with local echo both ways: it
* changes which of the DELs and the `\r` reaches the wire first.
*/
async function lastEditThenEnter(options: { localEcho: boolean; autocorrect: boolean }) {
return page.evaluate(async ({ localEcho, autocorrect }) => {
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 keydown = (init: KeyboardEventInit, keyCode: number) => {
const down = new KeyboardEvent('keydown', { bubbles: true, cancelable: true, ...init });
Object.defineProperties(down, { keyCode: { value: keyCode }, which: { value: keyCode } });
textarea.dispatchEvent(down);
};
const key229 = () => keydown({ key: 'Unidentified' }, 229);
const tick = () => new Promise((resolve) => setTimeout(resolve, 20));
try {
app.activeSessionId = 'cod388-browser-edit-enter';
app._localEchoEnabled = localEcho;
app._pendingInput = '';
app._lastKeystrokeTime = 0;
app._sendInputAsync = (_sessionId: string, chunk: string) => sent.push(chunk);
textarea.value = '';
textarea.focus();
const typed = autocorrect ? 'testing the peompt' : 'hell';
for (const ch of typed) {
key229();
document.execCommand('insertText', false, ch);
await tick();
}
// ONE task: no awaits between the edit(s) and Enter.
if (autocorrect) {
key229();
textarea.setSelectionRange(textarea.value.length - 5, textarea.value.length);
document.execCommand('delete');
key229();
document.execCommand('insertText', false, 'rompt ');
} else {
key229();
document.execCommand('insertText', false, 'o');
}
keydown({ key: 'Enter', code: 'Enter' }, 13);
await new Promise((resolve) => setTimeout(resolve, 250)); // local echo delays the \r by 80 ms
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('') };
} finally {
app.activeSessionId = originalSessionId;
app._localEchoEnabled = originalLocalEcho;
app._sendInputAsync = originalSendInput;
app._pendingInput = originalPendingInput;
app._lastKeystrokeTime = originalLastKeystrokeTime;
textarea.value = '';
}
}, options);
}
for (const localEcho of [true, false]) {
it(`a 229 last character in the same task as Enter submits the whole line (local echo ${localEcho ? 'on' : 'off'})`, async () => {
const { raw, line } = await lastEditThenEnter({ localEcho, autocorrect: false });
expect(raw).not.toContain('\x7f');
expect(line).toBe('hello\r');
});
it(`an autocorrect plus Enter in one task submits the corrected line (local echo ${localEcho ? 'on' : 'off'})`, async () => {
const { line } = await lastEditThenEnter({ localEcho, autocorrect: true });
expect(line).toBe('testing the prompt \r');
});
}
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 ');
});
});
+250
View File
@@ -422,3 +422,253 @@ 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();
};
// The batched Android shape (#441): the last character's keydown and `insertText` arrive in the
// SAME page task as Enter's keydown. xterm's own Enter handling clears the textarea before the
// edit's timer runs, so a timer left pending would diff the whole line against '' and send one
// DEL per character AHEAD of the submitted line. The edit is therefore settled at the next
// keydown, before xterm sees that key.
it('settles a pending edit at the next keydown, so Enter in the same task cannot erase the line', () => {
const h = editSyncHarness();
const controller = h.create(true);
typeKeys(h, 'hell');
h.keydown();
h.edit('hello');
controller.handleKeyEvent({ type: 'keydown', key: 'Enter', keyCode: 13 });
h.textarea.value = ''; // xterm's CR handling, which runs after the custom key handler
h.flush();
expect(h.sent.join('')).toBe('hello');
expect(h.sent).not.toContain('\x7f');
});
it('autocorrect and Enter in one task submits the corrected line, not a run of DELs', () => {
const h = editSyncHarness();
const controller = h.create(true);
typeKeys(h, 'testing the peompt');
h.keydown();
h.edit('testing the p');
h.keydown();
h.edit('testing the prompt ');
controller.handleKeyEvent({ type: 'keydown', key: 'Enter', keyCode: 13 });
h.textarea.value = '';
h.flush();
expect(h.line()).toBe('testing the prompt ');
expect(h.sent.filter((c) => c === '\x7f')).toHaveLength(5); // the five deleted characters, nothing more
});
it('settling first stands the same keystroke’s orphan candidate down (no double send)', () => {
const h = editSyncHarness();
const controller = h.create(true);
// xterm's canonical-data hook, as terminal-ui.js wires it.
const origTrigger = h.helper._coreService.triggerDataEvent;
h.helper._coreService.triggerDataEvent = (data: string) => {
origTrigger(data);
controller.notifyCanonicalData();
};
controller.handleKeyEvent({ type: 'keydown', key: 'Unidentified', keyCode: 229 });
h.keydown();
h.edit('o');
h.textarea.fire('input', inputEvent('o'));
controller.handleKeyEvent({ type: 'keydown', key: 'Enter', keyCode: 13 });
h.flush();
expect(h.sent.join('')).toBe('o');
});
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: '' });
});
});
+26 -1
View File
@@ -47,12 +47,37 @@ describe('xterm private-API dependency guard', () => {
expect(
lock.packages['node_modules/@xterm/xterm']?.version,
'xterm moved off the verified version — re-verify _kickRenderer in a real browser ' +
'(terminal-ui.js: _core._renderService._renderDebouncer._animationFrame), then update ' +
'(terminal-ui.js: _core._renderService._renderDebouncer._animationFrame) AND the ' +
'CompositionHelper fields installEditSync() uses (terminal-keycode229-recovery.js: ' +
'_handleAnyTextareaChanges, _coreService, _isComposing, _dataAlreadySent), then update ' +
'VERIFIED_XTERM_VERSION here. The accessor is optional-chained, so a renamed field ' +
'degrades to a silent no-op and the freeze it heals comes back unnoticed.'
).toBe(VERIFIED_XTERM_VERSION);
});
// terminal-keycode229-recovery.js swaps in an edit-based replacement for xterm's
// CompositionHelper._handleAnyTextareaChanges (Android autocorrect = delete + insert, which xterm's
// append-only diff duplicates). It reaches `_compositionHelper`, `_coreService`, `_isComposing`
// and `_dataAlreadySent`; if xterm renames any of them the install quietly falls back to xterm's own
// handler and the duplication returns. Property names survive minification, so a string check on
// the shipped bundle catches a rename on upgrade.
it('still ships the composition-helper fields the edit-based 229 sync depends on', () => {
const bundle = readFileSync(resolve(root, 'node_modules/@xterm/xterm/lib/xterm.js'), 'utf8');
for (const name of [
'_handleAnyTextareaChanges',
'_compositionHelper',
'_coreService',
'_isComposing',
'_dataAlreadySent',
]) {
expect(
bundle,
`xterm no longer mentions ${name}: re-verify terminal-keycode229-recovery.js installEditSync() ` +
'(src/web/public) against the new CompositionHelper before bumping VERIFIED_XTERM_VERSION'
).toContain(name);
}
});
// If someone deletes the watchdog, this guard is pointless noise — keep the
// two tied together so the range check cannot outlive what it protects.
it('is guarding a watchdog that still exists', () => {