mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-10 01:09:43 +02:00
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:
co-authored by
Claude Sonnet 5.5
parent
ac94f339ac
commit
0c71b753ef
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"aicodeman": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Fix text being duplicated when an Android keyboard autocorrects while typing straight into the terminal prompt. SwiftKey and Gboard autocorrect on space by deleting the word and inserting the corrected one; xterm answered by sending the whole helper-textarea value (it diffs with `newValue.replace(oldValue, '')`, which only works for appends) and then the inserted text a second time, so `testing the peompt` + space reached the shell as `testing the peompttesting the prompt rompt `. A multi-character delete was also sent as a single DEL. The keyCode-229 controller now replaces xterm's `_handleAnyTextareaChanges` with an edit-based diff against the value already sent (DEL per deleted character, then the new text, once), and puts xterm's own handler back on teardown.
|
||||||
@@ -18,6 +18,15 @@
|
|||||||
* the case this module exists for, and the case its browser test asserts by
|
* 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.
|
* 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 recovery never guesses the character: the `input` event already carries
|
||||||
* the real committed text in `ev.data`, which is exactly what xterm itself
|
* 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
|
* would have forwarded. We only decide WHETHER to forward it, by asking
|
||||||
@@ -39,6 +48,21 @@
|
|||||||
(function (global) {
|
(function (global) {
|
||||||
'use strict';
|
'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) {
|
function create(options) {
|
||||||
const textarea = options?.textarea;
|
const textarea = options?.textarea;
|
||||||
const emitRecovered = options?.emitRecovered;
|
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() {
|
function onCompositionStart() {
|
||||||
if (destroyed) return;
|
if (destroyed) return;
|
||||||
composing = true;
|
composing = true;
|
||||||
@@ -191,6 +271,11 @@
|
|||||||
if (destroyed) return;
|
if (destroyed) return;
|
||||||
destroyed = true;
|
destroyed = true;
|
||||||
cancelPending();
|
cancelPending();
|
||||||
|
try {
|
||||||
|
uninstallEditSync?.();
|
||||||
|
} catch {
|
||||||
|
// Best effort.
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
textarea.removeEventListener('input', onInput, true);
|
textarea.removeEventListener('input', onInput, true);
|
||||||
textarea.removeEventListener('compositionstart', onCompositionStart, true);
|
textarea.removeEventListener('compositionstart', onCompositionStart, true);
|
||||||
@@ -228,5 +313,5 @@
|
|||||||
return Object.freeze({ handleKeyEvent, notifyCanonicalData, destroy });
|
return Object.freeze({ handleKeyEvent, notifyCanonicalData, destroy });
|
||||||
}
|
}
|
||||||
|
|
||||||
global.CodemanKeyCode229Recovery = Object.freeze({ create });
|
global.CodemanKeyCode229Recovery = Object.freeze({ create, editBetween });
|
||||||
})(typeof window !== 'undefined' ? window : globalThis);
|
})(typeof window !== 'undefined' ? window : globalThis);
|
||||||
|
|||||||
@@ -1793,6 +1793,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
this._keyCode229Recovery = window.CodemanKeyCode229Recovery?.create?.({
|
this._keyCode229Recovery = window.CodemanKeyCode229Recovery?.create?.({
|
||||||
textarea: this.terminal.textarea,
|
textarea: this.terminal.textarea,
|
||||||
emitRecovered: (data) => handleTerminalData(data),
|
emitRecovered: (data) => handleTerminalData(data),
|
||||||
|
getCompositionHelper: () => this.terminal?._core?._compositionHelper,
|
||||||
isScreenReaderMode: () => this.terminal?.options?.screenReaderMode === true,
|
isScreenReaderMode: () => this.terminal?.options?.screenReaderMode === true,
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -231,4 +231,81 @@ describe('orphaned terminal input recovery wiring', () => {
|
|||||||
const { sent } = await keystroke({ data: 'q', dispatchInput: false, keyCode: 65 });
|
const { sent } = await keystroke({ data: 'q', dispatchInput: false, keyCode: 65 });
|
||||||
expect(sent).toEqual([]);
|
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 ');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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([]);
|
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: '' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user