fix(tiles): wire the Android soft-keyboard controller into every tile (#541 parity)

#541 fixed Android autocorrect duplicating the typed line in the primary
pane: xterm's keyCode-229 textarea diff is append-only, so an autocorrect on
space (delete a word, insert the corrected one) sent the whole line again.
The fix, an edit-based diff that sends one DEL per deleted code point and
then the inserted text, lives in terminal-keycode229-recovery.js together
with #441's next-keydown drain (a character committed in the same task as
Enter goes out ahead of the \r) and the original orphaned-insertText
recovery. Only the primary pane created that controller, so a grid tile or
the split's Pane B still ran xterm's stock behaviour. Both are gated on
width alone (1180 CSS px), which a wide Android tablet clears.

TerminalTile now creates its own controller in connect(), after the xterm
opens and before the first await, handed this tile's textarea, this tile's
CompositionHelper and _onTerminalData as the send path, so recovered bytes
go to the tile's own session through the exactly-once queue. As in the
primary pane, handleKeyEvent runs first in the custom key handler, above the
keyCode-229 early return, and notifyCanonicalData sits in the onData lambda,
gated on the same two CodemanTerminalInput predicates, never in
_onTerminalData, which the recovered bytes also take. destroy() tears the
controller down before disposing the xterm, which restores xterm's own diff
and removes the capture listeners. No mode or device gate, matching the
primary. The module itself is unchanged apart from its header; terminal-ui.js
gains only a comment naming the twin.

Tests: test/terminal-tile-input.test.ts now loads the real module into its
vm harness (with window timers, without which create() would silently throw
and every test would run against no controller) and drives a fake
CompositionHelper carrying xterm's own append-only diff. It covers install
and restore on the tile's own helper and textarea, autocorrect sent as an
edit (with a control reproducing the device-log duplicate), the last
character and an autocorrect each followed by Enter in one task, a
self-rescued 229 key delivered once, the onData gate ignoring query replies
and focus reports, two refused inserts after one keydown both recovered,
robustness when the controller throws, per-tile controllers, and a source pin
keeping the call above the early return. Removing the create, the
handleKeyEvent call, the notify, its gate, or the destroy each turns at least
one of them red, as does moving the notify into _onTerminalData. The browser
suite gains a TerminalTile block in
test/terminal-keycode229-recovery.browser.test.ts (real xterm, trusted
execCommand input, chunks asserted to address the tile's session, with a
destroyed-controller control).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-10-09 08:20:02 +02:00
parent 4fe843a94e
commit 312a8faa06
10 changed files with 745 additions and 22 deletions
+2 -2
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+8 -3
View File
@@ -93,9 +93,14 @@ view needs a wide viewport). So:
instance with no overlay is exactly how Codeman behaved before the local-
echo overlay existed for touch devices — normal, not degraded, for a
keyboard-and-mouse user. (Since moved to `TerminalTile`, terminal-tile.js,
which has gained two pieces of the primary pane through its own gates aimed
at the tile: hollow-buffer wheel paging (#555) and the desktop click report
for a CLI with `cliMouseTracking` on. See that file's fileoverview.)
which has gained three pieces of the primary pane: hollow-buffer wheel
paging (#555) and the desktop click report for a CLI with
`cliMouseTracking` on, both through the primary pane's gates aimed at the
tile, and its own keyCode-229 soft-keyboard controller
(terminal-keycode229-recovery.js: the #441 next-keydown drain and #541's
edit-based diff, so an Android autocorrect is not sent twice). The 1180px
width gate is all that keeps a phone out, and a wide Android tablet clears
it. See that file's fileoverview.)
If this asymmetry actually bothers you in daily use, promoting Pane B to full
parity is a scoped v2 (extract the shared logic already once you have two
+5 -1
View File
@@ -224,7 +224,11 @@ no `+`, owner decision 9).
mouse-wheel forwarding to Claude's fullscreen renderer, the "Load full
history" banner. These exist for touch devices or rare cases; a desktop
keyboard user types straight into xterm, which is how Codeman behaved before
those features existed.
those features existed. (One exception, since the 1180 px gate is width
only and a wide Android tablet clears it: every tile wires the main
terminal's keyCode-229 soft-keyboard controller, terminal-keycode229-recovery.js,
so an Android autocorrect is sent as an edit rather than a duplicated line,
#541, and a character committed with Enter is not lost, #441.)
- Server-side persistence of grids (named presets per owner).
- Pop-out windows (`/session/:id`, solo mode) showing a grid.
@@ -42,8 +42,9 @@
* also be a CAPTURE listener; see the measured table at the addEventListener
* call below.
*
* @dependency none (standalone IIFE; consumed by terminal-ui.js)
* @loadorder 5.55 (before app.js/terminal-ui.js, which create the controller)
* @dependency none (standalone IIFE; consumed by terminal-ui.js for the primary pane and by
* terminal-tile.js for every grid tile and the split's Pane B, one controller per xterm)
* @loadorder 5.55 (before app.js/terminal-ui.js/terminal-tile.js, which create controllers)
*/
(function (global) {
'use strict';
+91 -3
View File
@@ -10,7 +10,7 @@
* synchronous tmux call that blocks the server's event loop.
*
* Deliberately plainer than the primary pane (this.terminal/this._ws in
* terminal-ui.js): no local-echo overlay, no CJK IME, no touch/mobile
* terminal-ui.js): no local-echo overlay, no CJK IME textarea, no touch/mobile
* handlers (a swipe on a touch screen pages nothing), no keyboard accessory
* bar, and no SGR wheel forwarding to Claude's fullscreen renderer
* (docs/tile-grid-plan.md follow-up 4). Built for wide screens; see
@@ -28,10 +28,21 @@
* - The desktop click report: a plain left-click hand-encoded as SGR while
* the session's CLI has mouse tracking on (cliMouseTracking), for the modes
* whose mouse DECSETs the server strips (_installClickListener).
* - The soft-keyboard controller (terminal-keycode229-recovery.js), one per
* pane, on this pane's own textarea and composition helper and sending to
* this pane's session (_createKeyCode229Recovery): it forwards an
* `insertText` xterm refused, settles a pending textarea edit at the next
* keydown ahead of that key (#441: the last character an Android keyboard
* commits in the same task as Enter), and replaces xterm's append-only
* keyCode-229 diff with an edit-based one (#541: autocorrect on space
* duplicated the line). Not a desktop-only concern: the grid and the split
* are gated on width alone (SPLIT_PANE_MIN_WIDTH, 1180 CSS px), which a wide
* Android tablet, or a large foldable unfolded in landscape, reaches.
*
* @dependency vendor/xterm.js, vendor/xterm-addon-fit.js
* @dependency constants.js (window.CodemanTerminalFont, window.CodemanFetchDeadline, DEFAULT_SCROLLBACK, TERMINAL_TAIL_SIZE, TERMINAL_CHUNK_SIZE)
* @dependency terminal-ui.js (codemanCurrentXtermTheme, codemanCurrentSkinIsLight, CodemanTerminalInput.wheelDeltaLines/pageKeysForTravel, app._shouldForwardWheelToApp/_localScrollbackIsHollow/_handleDesktopTerminalClick)
* @dependency terminal-ui.js (codemanCurrentXtermTheme, codemanCurrentSkinIsLight, CodemanTerminalInput.shouldSuppressTerminalQueryResponse/isTerminalFocusOrMouseReport/wheelDeltaLines/pageKeysForTravel, app._shouldForwardWheelToApp/_localScrollbackIsHollow/_handleDesktopTerminalClick)
* @dependency terminal-keycode229-recovery.js (window.CodemanKeyCode229Recovery, optional: absent, xterm's own textarea handling stands)
* @loadorder 7.4 of 16, loaded after terminal-ui.js and before terminal-split.js
*/
@@ -185,6 +196,9 @@
this._overflowRows = 0;
// The desktop click reporter (_installClickListener).
this._onClick = null;
// The soft-keyboard controller (terminal-keycode229-recovery.js): created
// in connect() once the xterm is open, torn down in destroy().
this._keyCode229Recovery = null;
}
async connect() {
@@ -227,7 +241,26 @@
this._onFocusIn = () => global.app?._noteFocusedTile?.(this);
this.terminal.textarea?.addEventListener('focus', this._onFocusIn);
this.terminal.onData((data) => this._onTerminalData(data));
this._createKeyCode229Recovery();
// The twin of terminal-ui.js's onData gate (initTerminal; keep the two in
// step). Canonical xterm data tells the controller this keystroke was
// delivered, but not a query reply or a focus/mouse report, which xterm
// emits on its own and which would otherwise stand a pending recovery
// down. The notify lives HERE and not in _onTerminalData(): the
// controller's own recovered bytes go through _onTerminalData() too, and
// must never count as xterm's, or a second pending character from the
// same keystroke window would stand down and be lost.
this.terminal.onData((data) => {
try {
const input = global.CodemanTerminalInput;
if (!input?.shouldSuppressTerminalQueryResponse?.(data) && !input?.isTerminalFocusOrMouseReport?.(data)) {
this._keyCode229Recovery?.notifyCanonicalData?.();
}
} catch {
/* Bookkeeping must never block real input. */
}
this._onTerminalData(data);
});
// xterm has no gates of its own, so every app-level chord that the
// document capture-phase handler (app.js) only preventDefault()s (never
@@ -242,6 +275,19 @@
// here too. Ctrl+V goes through the primary pane's paste trap
// (image-input.js), aimed at this pane (below).
this.terminal.attachCustomKeyEventHandler((ev) => {
// FIRST, above the IME early return below, as in terminal-ui.js: every
// keydown settles this pane's pending textarea edit and drains a
// pending recovery BEFORE xterm handles the key, so a character an
// Android keyboard committed in the same task as Enter is sent ahead
// of the \r. Below that return a keyCode-229 keydown would skip the
// settle, the drain and the snapshot, and the panes would differ.
// Read at call time, never captured, so the controller can be swapped
// (the tests count xterm's emissions through it).
try {
this._keyCode229Recovery?.handleKeyEvent?.(ev);
} catch {
/* The controller must never interfere with xterm's own handling. */
}
if (ev.isComposing || ev.key === 'Process' || ev.keyCode === 229) return true;
if (
ev.altKey &&
@@ -552,6 +598,44 @@
app?._sendInputAsync?.(this.sessionId, data);
}
// The soft-keyboard controller, the twin of the primary pane's wiring in
// terminal-ui.js initTerminal() (keep the two in step); the behaviour lives
// once, in terminal-keycode229-recovery.js. Everything it is handed is THIS
// pane's: its textarea, its xterm's CompositionHelper (whose
// `_handleAnyTextareaChanges` it patches, per instance) and its send path.
// Recovered text goes straight to _onTerminalData(), never through xterm's
// onData, so it is not counted as xterm's own (see connect()'s onData).
// Created after terminal.open(): xterm's capture `input` listener on the
// textarea is registered there, and must run before the controller's. No
// device or mode gate, as in the primary pane: with a hardware keyboard it
// costs one assignment per keydown. A failure leaves xterm's own handling.
_createKeyCode229Recovery() {
this._destroyKeyCode229Recovery();
if (!this.terminal) return;
try {
this._keyCode229Recovery =
global.CodemanKeyCode229Recovery?.create?.({
textarea: this.terminal.textarea,
emitRecovered: (data) => this._onTerminalData(data),
getCompositionHelper: () => this.terminal?._core?._compositionHelper,
isScreenReaderMode: () => this.terminal?.options?.screenReaderMode === true,
}) ?? null;
} catch {
this._keyCode229Recovery = null;
}
}
// Restores xterm's own textarea diff and removes the controller's capture
// listeners from the live textarea, so it runs before terminal.dispose().
_destroyKeyCode229Recovery() {
try {
this._keyCode229Recovery?.destroy?.();
} catch {
/* Optional; teardown must continue. */
}
this._keyCode229Recovery = null;
}
// Joins the app's input-socket map for this session and flushes anything
// already queued for it (typed while the socket was down, or left over from
// a reload) over the fresh socket. Called from onopen.
@@ -1154,6 +1238,10 @@
this.terminal?.textarea?.removeEventListener('focus', this._onFocusIn);
this._onFocusIn = null;
}
// Before dispose(): puts xterm's own textarea diff back and takes the
// controller's listeners off the textarea; its pending timers are inert
// once it is destroyed.
this._destroyKeyCode229Recovery();
// A destroyed pane cannot hold the keyboard: shortcuts fall back to the
// primary terminal (_focusedPane also skips a destroyed tile on its own).
if (global.app?._focusedTile === this) global.app._noteFocusedTile?.(null);
+3
View File
@@ -1838,6 +1838,9 @@ Object.assign(CodemanApp.prototype, {
// registers its own listener with `capture: true`; on bubble xterm's
// `cancel()` (stopPropagation) would swallow exactly the handled events —
// see the measured table in terminal-keycode229-recovery.js.
// Twin: TerminalTile (terminal-tile.js _createKeyCode229Recovery and its
// connect() key handler and onData) wires its own controller the same way
// for every grid tile and the split's Pane B; keep the two in step.
try {
this._keyCode229Recovery = window.CodemanKeyCode229Recovery?.create?.({
textarea: this.terminal.textarea,
+27 -4
View File
@@ -70,6 +70,12 @@ export class FakeTerminal {
* puts it.
*/
static emulateScroll = false;
/**
* Opt-in, set by a test BEFORE the tile connects (and reset after): extra
* fields merged into `_core`, e.g. xterm's `_compositionHelper` for the
* keyCode-229 controller, which reads it once when the tile creates it.
*/
static coreFactory: ((term: FakeTerminal) => Record<string, unknown>) | null = null;
options: Record<string, unknown>;
cols = 80;
rows = 24;
@@ -88,7 +94,10 @@ export class FakeTerminal {
querySelector: (sel: string) =>
sel === '.xterm-screen' ? { getBoundingClientRect: () => ({ ...this.screenRect }) } : null,
};
_core = { _renderService: { dimensions: { css: { cell: { width: 8, height: 16 } } } } };
_core: Record<string, unknown> = {
_renderService: { dimensions: { css: { cell: { width: 8, height: 16 } } } },
...(FakeTerminal.coreFactory?.(this) ?? {}),
};
constructor(options: Record<string, unknown>) {
this.options = { ...options };
FakeTerminal.last = this;
@@ -109,12 +118,26 @@ export class FakeTerminal {
}
keyHandler: ((ev: Record<string, unknown>) => boolean) | null = null;
focusListeners: Array<() => void> = [];
/** Every other textarea listener, with the capture flag it was added with (the keyCode-229 controller's). */
textareaListeners: Array<{ type: string; fn: (ev: Record<string, unknown>) => void; capture: unknown }> = [];
textarea = {
addEventListener: (type: string, fn: () => void) => {
if (type === 'focus') this.focusListeners.push(fn);
/** The helper textarea's text, which xterm's keyCode-229 diff (and the controller's) reads. */
value: '',
addEventListener: (type: string, fn: (ev?: Record<string, unknown>) => void, capture?: unknown) => {
if (type === 'focus') this.focusListeners.push(fn as () => void);
else this.textareaListeners.push({ type, fn, capture });
},
removeEventListener: (type: string, fn: () => void) => {
removeEventListener: (type: string, fn: (ev?: Record<string, unknown>) => void, capture?: unknown) => {
if (type === 'focus') this.focusListeners = this.focusListeners.filter((f) => f !== fn);
else {
this.textareaListeners = this.textareaListeners.filter(
(l) => !(l.type === type && l.fn === fn && Boolean(l.capture) === Boolean(capture))
);
}
},
/** Delivers `ev` to the textarea's listeners of `type`, in registration order. */
fire: (type: string, ev: Record<string, unknown> = {}) => {
for (const l of this.textareaListeners.filter((x) => x.type === type)) l.fn({ type, ...ev });
},
};
focusTextarea() {
@@ -12,7 +12,9 @@
* - a keystroke xterm DOES handle is delivered exactly once, not twice;
* - a character committed in the SAME page task as Enter reaches the send
* path ahead of the `\r`, which is the ordering the zero-delay timer
* alone cannot produce.
* alone cannot produce;
* - a TerminalTile (grid tile, split Pane B) wires its own controller, so
* the same shapes come out right there too, addressed to the tile's session.
*
* Browser-driven, so it is excluded from `npm run test:ci` like the other
* Playwright suites. Run locally:
@@ -393,4 +395,192 @@ describe('orphaned terminal input recovery wiring', () => {
// Byte for byte what the phone sent in the device log.
expect(line).toBe('testing the peompttesting the prompt rompt ');
});
/**
* The same keyboard shapes through a TerminalTile, the pane a grid tile and the split view's
* Pane B are made of. It wires its OWN controller (terminal-tile.js _createKeyCode229Recovery):
* its own xterm, helper textarea and composition helper, sending to its own session through
* `app._sendInputAsync(tileSessionId, …)`, never the active one. Declared after the primary
* pane's control above, which only switched the PRIMARY controller off.
*/
describe("in a TerminalTile (a grid tile, the split view's Pane B)", () => {
const TILE_ID = 'cod541-tile-browser';
beforeAll(async () => {
await page.evaluate(async (id) => {
const w = window as any;
const mount = document.createElement('div');
mount.id = 'tile229Mount';
mount.style.cssText = 'position:fixed;left:0;top:0;width:480px;height:320px;z-index:9999;';
document.body.appendChild(mount);
// A session that does not exist: its socket is refused (4004) and the load finds
// nothing, neither of which the controller needs. Input is stubbed per test below.
const tile = new w.TerminalTile(id, mount, { mode: 'shell' });
w.__tile229 = tile;
await tile.connect();
}, TILE_ID);
await page.waitForFunction(() => (window as any).__tile229?._keyCode229Recovery, null, { timeout: 30000 });
}, 60000);
afterAll(async () => {
await page.evaluate(() => {
const w = window as any;
w.__tile229?.destroy();
delete w.__tile229;
document.getElementById('tile229Mount')?.remove();
});
});
type Scenario = 'autocorrect' | 'lastCharThenEnter' | 'autocorrectThenEnter' | 'orphanThenEnter' | 'selfRescued';
/**
* Runs one keyboard shape against the tile's own textarea (scoped to its mount:
* `document.querySelector` would find the PRIMARY pane's) and reports what reached the send
* path, which session each chunk was addressed to, and how often xterm itself spoke.
*/
async function inTile(scenario: Scenario) {
return page.evaluate(
async ({ scenario, tileId }) => {
const w = window as any;
const app = w.app;
const tile = w.__tile229;
const textarea = document.querySelector('#tile229Mount .xterm-helper-textarea') as HTMLTextAreaElement;
const originalSendInput = app._sendInputAsync;
const originalSessionId = app.activeSessionId;
const rec = tile._keyCode229Recovery;
const sent: Array<[string, string]> = [];
let xtermEmitted = 0;
const keydown = (init: KeyboardEventInit, keyCode: number) => {
const down = new KeyboardEvent('keydown', { bubbles: true, cancelable: true, composed: true, ...init });
Object.defineProperties(down, { keyCode: { value: keyCode }, which: { value: keyCode } });
textarea.dispatchEvent(down);
};
const key229 = () => keydown({ key: 'Unidentified' }, 229);
const enter = () => keydown({ key: 'Enter', code: 'Enter' }, 13);
const tick = () => new Promise((resolve) => setTimeout(resolve, 20));
const typeKeys = async (text: string) => {
for (const ch of text) {
key229();
document.execCommand('insertText', false, ch);
await tick();
}
};
const autocorrectEdit = () => {
key229();
textarea.setSelectionRange(textarea.value.length - 5, textarea.value.length);
document.execCommand('delete');
key229();
document.execCommand('insertText', false, 'rompt ');
};
try {
app.activeSessionId = 'cod541-not-the-tile';
app._sendInputAsync = (sessionId: string, chunk: string) => sent.push([sessionId, chunk]);
if (scenario === 'selfRescued') {
// Counts xterm's own canonical emissions: the tile's onData reads the property at
// call time, and the controller object itself is frozen.
tile._keyCode229Recovery = {
handleKeyEvent: (e: any) => rec.handleKeyEvent(e),
notifyCanonicalData: () => {
xtermEmitted += 1;
return rec.notifyCanonicalData();
},
destroy: () => rec.destroy(),
};
}
textarea.value = '';
textarea.focus();
if (scenario === 'autocorrect') {
await typeKeys('testing the peompt');
autocorrectEdit(); // both edits in one task, before any timer runs
} else if (scenario === 'lastCharThenEnter') {
await typeKeys('hell');
key229();
document.execCommand('insertText', false, 'o');
enter(); // same task
} else if (scenario === 'autocorrectThenEnter') {
await typeKeys('testing the peompt');
autocorrectEdit();
enter(); // same task
} else if (scenario === 'orphanThenEnter') {
// #441's batched shape: a keydown, the composed insertText xterm refuses, Enter.
keydown({ key: 'Unidentified' }, 65);
textarea.value = 'o';
textarea.dispatchEvent(
new InputEvent('input', { data: 'o', inputType: 'insertText', bubbles: true, composed: true })
);
enter();
} else {
key229();
textarea.value = 'y';
textarea.dispatchEvent(
new InputEvent('input', { data: 'y', inputType: 'insertText', bubbles: true, composed: true })
);
}
await new Promise((resolve) => setTimeout(resolve, 120));
const raw = sent.map(([, chunk]) => chunk).join('');
const line: string[] = [];
for (const ch of raw) {
if (ch === '\x7f') line.pop();
else line.push(ch);
}
return {
raw,
line: line.join(''),
textarea: textarea.value,
sessions: [...new Set(sent.map(([sessionId]) => sessionId))],
xtermEmitted,
tileId,
};
} finally {
app._sendInputAsync = originalSendInput;
app.activeSessionId = originalSessionId;
tile._keyCode229Recovery = rec;
textarea.value = '';
}
},
{ scenario, tileId: TILE_ID }
);
}
it('an autocorrect on space reaches the shell once, not duplicated', async () => {
const r = await inTile('autocorrect');
expect(r.textarea).toBe('testing the prompt ');
expect(r.line).toBe('testing the prompt ');
expect(r.sessions).toEqual([TILE_ID]);
});
it('a 229 last character in the same task as Enter submits the whole line', async () => {
const r = await inTile('lastCharThenEnter');
expect(r.raw).not.toContain('\x7f');
expect(r.line).toBe('hello\r');
expect(r.sessions).toEqual([TILE_ID]);
});
it('an autocorrect plus Enter in one task submits the corrected line', async () => {
const r = await inTile('autocorrectThenEnter');
expect(r.line).toBe('testing the prompt \r');
expect(r.sessions).toEqual([TILE_ID]);
});
it('a refused insertText committed in the same task as Enter goes out BEFORE the carriage return', async () => {
const r = await inTile('orphanThenEnter');
expect(r.raw).toBe('o\r');
expect(r.sessions).toEqual([TILE_ID]);
});
it('a 229 keystroke xterm diffed itself is delivered once', async () => {
const r = await inTile('selfRescued');
expect(r.xtermEmitted).toBe(1);
expect(r.raw).toBe('y');
});
it("control: with the tile's controller destroyed, xterm alone duplicates the line", async () => {
// Keep LAST in this block: it leaves the tile's controller off.
await page.evaluate(() => (window as any).__tile229._keyCode229Recovery.destroy());
const r = await inTile('autocorrect');
expect(r.line).toBe('testing the peompttesting the prompt rompt ');
});
});
});
+414 -5
View File
@@ -16,10 +16,16 @@
* the pane once (`onExit`); a late close from a REPLACED socket is ignored; and
* destroy() cancels a pending reconnect.
*
* Real code under test: constants.js + app.js (the queue) + terminal-ui.js (the
* shared input predicates) + terminal-tile.js, in one `vm` context. xterm, the
* fit addon and WebSocket are fakes (test/mocks/terminal-tile-fakes.ts);
* `connect()` runs for real.
* The last blocks pin the soft-keyboard controller every tile wires
* (terminal-keycode229-recovery.js, the primary pane's #441/#541 fixes): an
* Android autocorrect is sent as an edit, not a duplicated line, a character
* committed in the same task as Enter goes out ahead of the \r, and the
* controller is bound to THIS tile's textarea, composition helper and session.
*
* Real code under test: constants.js + terminal-keycode229-recovery.js +
* app.js (the queue) + terminal-ui.js (the shared input predicates) +
* terminal-tile.js, in one `vm` context. xterm, the fit addon and WebSocket are
* fakes (test/mocks/terminal-tile-fakes.ts); `connect()` runs for real.
*/
import { readFileSync } from 'node:fs';
import { performance } from 'node:perf_hooks';
@@ -36,6 +42,12 @@ function loadContext() {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
CodemanBase: { base: '' },
// The keyCode-229 controller defaults its timers to window's. Late-bound,
// like the context's own, so vi.useFakeTimers() reaches it; without them
// its create() throws into the tile's catch and every controller test
// would run against no controller at all.
setTimeout: (fn: () => void, ms?: number) => globalThis.setTimeout(fn, ms),
clearTimeout: (id: ReturnType<typeof setTimeout>) => globalThis.clearTimeout(id),
};
const context = vm.createContext({
console: { ...console, log: vi.fn(), debug: vi.fn() },
@@ -63,7 +75,8 @@ function loadContext() {
},
});
vm.runInContext(
`${read('constants.js')}\n${read('app.js')}\n${read('terminal-ui.js')}\n${read('terminal-tile.js')}\n` +
`${read('constants.js')}\n${read('terminal-keycode229-recovery.js')}\n${read('app.js')}\n` +
`${read('terminal-ui.js')}\n${read('terminal-tile.js')}\n` +
'globalThis.__CodemanApp = CodemanApp;',
context
);
@@ -138,6 +151,7 @@ afterEach(() => {
});
beforeEach(() => {
FakeTerminal.coreFactory = null;
FakeFit.proposed = { cols: 80, rows: 24 };
FakeSocket.instances = [];
fetchMock.mockReset();
@@ -684,3 +698,398 @@ describe('the server coming back kicks Pane B', () => {
expect(branch).toContain('this._splitPane?.reconnectNow?.();');
});
});
/**
* xterm's CompositionHelper, reduced to what the keyCode-229 controller touches.
* Its `_handleAnyTextareaChanges` is xterm's own append-only diff as shipped
* (node_modules/@xterm/xterm/src/browser/input/CompositionHelper.ts), so a
* control without the controller reproduces the device-log duplicate, and its
* `triggerDataEvent` feeds the tile's onData, as xterm's core service does.
*/
type Helper = {
_isComposing: boolean;
_isSendingComposition: boolean;
_dataAlreadySent: string;
_coreService: { triggerDataEvent: (data: string, wasUserInput?: boolean) => void };
_handleAnyTextareaChanges: () => void;
};
/**
* Gives every FakeTerminal created from now on a composition helper. Returns
* them in creation order, with xterm's own diff each one started with.
*/
function withCompositionHelpers() {
const helpers: Helper[] = [];
const originals: Array<Helper['_handleAnyTextareaChanges']> = [];
FakeTerminal.coreFactory = (term) => {
const helper: Helper = {
_isComposing: false,
_isSendingComposition: false,
_dataAlreadySent: '',
_coreService: { triggerDataEvent: (data: string) => term.type(data) },
_handleAnyTextareaChanges(this: Helper) {
const oldValue = term.textarea.value;
setTimeout(() => {
if (this._isComposing) return;
const newValue = term.textarea.value;
const diff = newValue.replace(oldValue, '');
this._dataAlreadySent = diff;
if (newValue.length > oldValue.length) this._coreService.triggerDataEvent(diff, true);
else if (newValue.length < oldValue.length) this._coreService.triggerDataEvent('\x7f', true);
else if (newValue !== oldValue) this._coreService.triggerDataEvent(newValue, true);
}, 0);
},
};
helpers.push(helper);
originals.push(helper._handleAnyTextareaChanges);
return { _compositionHelper: helper };
};
return Object.assign(helpers, { originals });
}
/**
* Drives a tile the way an Android soft keyboard drives xterm. The fake xterm
* runs no CompositionHelper.keydown of its own, so `key229()` does what xterm
* does, in xterm's order: the custom key handler first, then (keyCode 229, no
* composition) the helper's `_handleAnyTextareaChanges()`, read off the helper
* at call time so the controller's patch is what runs.
*/
function softKeyboard(term: FakeTerminal, helper: Helper) {
const textarea = term.textarea;
const key229 = () => {
term.keyHandler!({ type: 'keydown', key: 'Unidentified', keyCode: 229 });
helper._handleAnyTextareaChanges();
};
return {
key229,
/** One appended character, settled on its own timer before the next key. */
typeKeys(text: string) {
for (const ch of text) {
key229();
textarea.value += ch;
vi.advanceTimersByTime(1);
}
},
/** The textarea now reads `value` (what the keyboard's input event left there). */
edit(value: string) {
textarea.value = value;
},
/** Enter: the custom handler, then xterm's own \r, then xterm clearing its textarea. */
enter() {
const passed = term.keyHandler!({ type: 'keydown', key: 'Enter', keyCode: 13 });
if (passed) term.type('\r');
textarea.value = '';
},
};
}
/** Every byte the tile sent as input, in order. */
const joinFrames = (frames: Array<{ d?: string }>) => frames.map((f) => f.d ?? '').join('');
const wireOf = (ws: FakeSocket) => joinFrames(ws.inputFrames());
/** The line a shell ends up with: every DEL erases the character before it. */
function lineOf(frames: Array<{ d?: string }>) {
const out: string[] = [];
for (const ch of joinFrames(frames)) {
if (ch === '\x7f') out.pop();
else out.push(ch);
}
return out.join('');
}
type ControllerTile = Tile & { _keyCode229Recovery: unknown };
describe("TerminalTile wires the primary pane's soft-keyboard controller (#441, #541)", () => {
it("installs on THIS tile's composition helper and textarea, and destroy() restores xterm's own", async () => {
const helpers = withCompositionHelpers();
const { tile, term } = await connectTile(makeApp());
const helper = helpers[0];
expect((tile as ControllerTile)._keyCode229Recovery).not.toBeNull();
// The controller patched this tile's helper (xterm's diff is no longer the one that runs) and
// listens on this tile's textarea in the CAPTURE phase (see the module's measured table).
expect(helper._handleAnyTextareaChanges).not.toBe(helpers.originals[0]);
const captured = term.textareaListeners.filter((l) => l.capture === true).map((l) => l.type);
expect(captured.sort()).toEqual(['compositionend', 'compositionstart', 'input']);
tile.destroy();
expect((tile as ControllerTile)._keyCode229Recovery).toBeNull();
expect(helper._handleAnyTextareaChanges).toBe(helpers.originals[0]);
expect(term.textareaListeners).toEqual([]);
});
it('an autocorrect on space is sent as an edit, not a duplicated line', async () => {
vi.useFakeTimers();
const helpers = withCompositionHelpers();
const app = makeApp();
app.activeSessionId = 'some-other-session';
const { tile, ws, term } = await connectTile(app);
expect((tile as ControllerTile)._keyCode229Recovery).not.toBeNull();
ws.open();
const kb = softKeyboard(term, helpers[0]);
kb.typeKeys('testing the peompt');
// The device log's shape: ONE keydown deleting five characters, a second inserting `rompt `,
// both before any timer runs.
kb.key229();
kb.edit('testing the p');
kb.key229();
kb.edit('testing the prompt ');
vi.advanceTimersByTime(1);
const frames = ws.inputFrames();
expect(lineOf(frames)).toBe('testing the prompt ');
expect(frames.filter((f) => f.d === '\x7f')).toHaveLength(5);
// Every byte went to THIS tile's session through the exactly-once queue, never the active one.
expect(frames.every((f) => Number.isInteger(f.seq))).toBe(true);
expect(app._pendingDeliveries.get('s-tile')?.map((r) => r.data)).toEqual(frames.map((f) => f.d));
expect(app._pendingDeliveries.has('some-other-session')).toBe(false);
});
it('control: without the controller, xterm alone duplicates the line exactly as the device did', async () => {
vi.useFakeTimers();
const helpers = withCompositionHelpers();
const saved = windowStub.CodemanKeyCode229Recovery;
delete windowStub.CodemanKeyCode229Recovery;
try {
const { tile, ws, term } = await connectTile(makeApp());
expect((tile as ControllerTile)._keyCode229Recovery).toBeNull();
ws.open();
const kb = softKeyboard(term, helpers[0]);
kb.typeKeys('testing the peompt');
kb.key229();
kb.edit('testing the p');
kb.key229();
kb.edit('testing the prompt ');
vi.advanceTimersByTime(1);
expect(lineOf(ws.inputFrames())).toBe('testing the peompttesting the prompt rompt ');
} finally {
windowStub.CodemanKeyCode229Recovery = saved;
}
});
it('a 229 last character in the same task as Enter goes out ahead of the \\r (#441 + #541)', async () => {
vi.useFakeTimers();
const helpers = withCompositionHelpers();
const { ws, term } = await connectTile(makeApp());
ws.open();
const kb = softKeyboard(term, helpers[0]);
kb.typeKeys('hell');
// One task: the last character's keydown and edit, then Enter, no timer in between.
kb.key229();
kb.edit('hello');
kb.enter();
// Settled synchronously at the Enter keydown, not by its timer.
expect(wireOf(ws)).toBe('hello\r');
vi.advanceTimersByTime(1);
const wire = wireOf(ws);
expect(wire).toBe('hello\r');
expect(wire).not.toContain('\x7f');
});
it('an autocorrect plus Enter in one task submits the corrected line', async () => {
vi.useFakeTimers();
const helpers = withCompositionHelpers();
const { ws, term } = await connectTile(makeApp());
ws.open();
const kb = softKeyboard(term, helpers[0]);
kb.typeKeys('testing the peompt');
kb.key229();
kb.edit('testing the p');
kb.key229();
kb.edit('testing the prompt ');
kb.enter();
vi.advanceTimersByTime(1);
const frames = ws.inputFrames();
expect(lineOf(frames)).toBe('testing the prompt \r');
expect(frames.filter((f) => f.d === '\x7f')).toHaveLength(5);
});
it('a 229 keystroke xterm diffed itself is delivered once, not again by the recovery', async () => {
vi.useFakeTimers();
const helpers = withCompositionHelpers();
const { tile, ws, term } = await connectTile(makeApp());
expect((tile as ControllerTile)._keyCode229Recovery).not.toBeNull();
ws.open();
const kb = softKeyboard(term, helpers[0]);
kb.key229();
kb.edit('y');
term.textarea.fire('input', { inputType: 'insertText', data: 'y', isComposing: false });
vi.advanceTimersByTime(1);
expect(ws.inputFrames().map((f) => f.d)).toEqual(['y']);
});
});
describe("the tile's onData tells the controller only about what a human typed", () => {
/** A keystroke xterm refused: a keydown, then the committed `insertText` it did not forward. */
const orphan = (term: FakeTerminal, data: string) => {
term.keyHandler!({ type: 'keydown', key: 'Unidentified', keyCode: 65 });
term.textarea.fire('input', { inputType: 'insertText', data, isComposing: false });
};
it('recovers a refused insertText through a query reply and a focus report, to this tile', async () => {
vi.useFakeTimers();
const app = makeApp();
const { tile, ws, term } = await connectTile(app);
expect((tile as ControllerTile)._keyCode229Recovery).not.toBeNull();
ws.open();
orphan(term, 'x');
term.type('\x1b[?1;2c'); // a DA reply xterm answers on its own: dropped, and not "xterm spoke"
term.type('\x1b[I'); // a focus report: sent ephemeral, and not "xterm spoke" either
vi.advanceTimersByTime(1);
const frames = ws.inputFrames();
expect(frames.map((f) => f.d)).toEqual(['\x1b[I', 'x']);
const recovered = frames.find((f) => f.d === 'x')!;
expect(Number.isInteger(recovered.seq)).toBe(true);
expect(app._pendingDeliveries.get('s-tile')?.map((r) => r.data)).toEqual(['x']);
});
it("never counts the controller's own recovered bytes as xterm's: two refused inserts after one keydown both arrive", async () => {
// Pins WHERE the notify lives: in the onData lambda, not in _onTerminalData(), which the
// recovered bytes also go through. Counted there, the first recovery would read as "xterm
// spoke" for the second candidate, which shares its keydown snapshot, and drop it.
vi.useFakeTimers();
const { ws, term } = await connectTile(makeApp());
ws.open();
term.keyHandler!({ type: 'keydown', key: 'Unidentified', keyCode: 65 });
term.textarea.fire('input', { inputType: 'insertText', data: 'a', isComposing: false });
term.textarea.fire('input', { inputType: 'insertText', data: 'b', isComposing: false });
vi.advanceTimersByTime(1);
expect(ws.inputFrames().map((f) => f.d)).toEqual(['a', 'b']);
});
it('stands down when xterm really did deliver the keystroke', async () => {
vi.useFakeTimers();
const { ws, term } = await connectTile(makeApp());
ws.open();
orphan(term, 'x');
term.type('x'); // xterm's own canonical emission for this keystroke
vi.advanceTimersByTime(1);
expect(ws.inputFrames().map((f) => f.d)).toEqual(['x']);
});
});
describe('the controller can never break a tile', () => {
type FakeController = {
handleKeyEvent: ReturnType<typeof vi.fn>;
notifyCanonicalData: ReturnType<typeof vi.fn>;
destroy: ReturnType<typeof vi.fn>;
};
let saved: unknown;
beforeEach(() => {
saved = windowStub.CodemanKeyCode229Recovery;
});
afterEach(() => {
windowStub.CodemanKeyCode229Recovery = saved;
});
const fakeController = (overrides: Partial<FakeController> = {}): FakeController => ({
handleKeyEvent: vi.fn(),
notifyCanonicalData: vi.fn(),
destroy: vi.fn(),
...overrides,
});
it('a create() that throws leaves the tile connected and typing', async () => {
windowStub.CodemanKeyCode229Recovery = {
create: () => {
throw new Error('broken');
},
};
const { tile, ws, term } = await connectTile(makeApp());
expect((tile as ControllerTile)._keyCode229Recovery).toBeNull();
ws.open();
term.type('a');
expect(ws.inputFrames().map((f) => f.d)).toEqual(['a']);
});
it("a handleKeyEvent that throws leaves every one of the tile's key gates working", async () => {
const controller = fakeController({
handleKeyEvent: vi.fn(() => {
throw new Error('broken');
}),
});
windowStub.CodemanKeyCode229Recovery = { create: () => controller };
const { term } = await connectTile(makeApp());
expect(term.keyHandler!({ type: 'keydown', key: '1', code: 'Digit1', altKey: true })).toBe(false);
expect(term.keyHandler!({ type: 'keydown', key: 'z', code: 'KeyZ', ctrlKey: true })).toBe(false);
expect(term.keyHandler!({ type: 'keydown', key: 'Unidentified', keyCode: 229 })).toBe(true);
expect(term.keyHandler!({ type: 'keydown', key: 'a', code: 'KeyA', keyCode: 65 })).toBe(true);
// It still ran first, for every one of them.
expect(controller.handleKeyEvent).toHaveBeenCalledTimes(4);
});
it('a destroy() that throws still lets the tile dispose its xterm', async () => {
const controller = fakeController({
destroy: vi.fn(() => {
throw new Error('broken');
}),
});
windowStub.CodemanKeyCode229Recovery = { create: () => controller };
const { tile, term } = await connectTile(makeApp());
const dispose = vi.spyOn(term, 'dispose');
tile.destroy();
expect(controller.destroy).toHaveBeenCalledTimes(1);
expect(dispose).toHaveBeenCalledTimes(1);
expect((tile as ControllerTile)._keyCode229Recovery).toBeNull();
});
it('each tile gets its own controller on its own textarea, and destroys only its own', async () => {
const made: Array<{ options: { textarea: unknown }; controller: FakeController }> = [];
windowStub.CodemanKeyCode229Recovery = {
create: (options: { textarea: unknown }) => {
const controller = fakeController();
made.push({ options, controller });
return controller;
},
};
const app = makeApp();
const a = await connectTile(app);
const b = await connectTile(app);
expect(made).toHaveLength(2);
expect(made[0].options.textarea).toBe(a.term.textarea);
expect(made[1].options.textarea).toBe(b.term.textarea);
a.tile.destroy();
expect(made[0].controller.destroy).toHaveBeenCalledTimes(1);
expect(made[1].controller.destroy).not.toHaveBeenCalled();
});
});
describe('terminal-tile.js keeps the controller call where it works (source pin)', () => {
const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/terminal-tile.js'), 'utf8');
it('calls handleKeyEvent ABOVE the IME early return, so a 229 keydown reaches it', () => {
const call = source.indexOf('this._keyCode229Recovery?.handleKeyEvent?.(ev)');
const earlyReturn = source.indexOf("ev.key === 'Process' || ev.keyCode === 229) return true");
expect(call).toBeGreaterThan(-1);
expect(earlyReturn).toBeGreaterThan(-1);
expect(call).toBeLessThan(earlyReturn);
});
it("hands the controller this tile's own composition helper", () => {
expect(source).toMatch(/getCompositionHelper:\s*\(\)\s*=>\s*this\.terminal\?\._core\?\._compositionHelper/);
});
});