mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-06 15:39:41 +02:00
feat(terminal): preview IME composition text on iOS Safari
WebKit on iOS does not show text being composed by an IME inside the terminal, so users type blind until it commits. Add mobile-ime-preview.js, a visual-only controller that renders the composition in the xterm helper layer and holds a committed chunk until local echo, parsed terminal output or a 2s fallback shows it. Wire it into terminal-ui.js, the script order, the build minify/hash lists and styles, with unit and wiring tests.
This commit is contained in:
@@ -312,7 +312,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
### Frontend
|
||||
|
||||
Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. Load order: `constants.js`(1) → `i18n.js`(1.5) → `mobile-handlers.js`(2) → `voice-input.js`(3) → `notification-manager.js`(4) → `keyboard-accessory.js`(5) → `input-cjk.js`(5.5) → `terminal-keycode229-recovery.js`(5.55) → `sanitize-html.js`(5.6) → `app.js`(6) → `tab-rail-resize.js`(6.5) → `terminal-ui.js`(7) → `terminal-split.js`(7.5) → `respawn-ui.js`(8) → `ralph-panel.js`(9) → `orchestrator-panel.js`(9.5) → `cron-ui.js`(9.7) → `settings-ui.js`(10) → `panels-ui.js`(11) → `readmymind-ui.js`(11.3) → `ultracode-panel.js`(11.5) → `approvals-ui.js`(11.6) → `reboot-restore-ui.js`(11.65) → `admin-ui.js`(11.7) → `session-ui.js`(12) → `host-wake-ui.js`(12.2) → `webview-tabs.js`(12.5) → `mobile-overview.js`(12.55) → `home-sessions.js`(12.56) → `entrance-animations.js`(12.6) → `ralph-wizard.js`(13) → `api-client.js`(14) → `subagent-windows.js`(15) → `ultracode-windows.js`(15.5) → `session-lineage.js`(15.6) → `image-input.js`(16). `i18n.js` translates static + newly inserted application DOM while skipping terminal/response/file/user-name surfaces; `input-cjk.js` handles CJK IME composition via an always-visible textarea below the terminal (`window.cjkActive` blocks xterm's onData). `terminal-keycode229-recovery.js` forwards a committed `input` event that xterm's `_inputEvent` guard drops (Chrome-on-Android soft keyboards send `composed: true` after a keydown), and only when xterm emitted no canonical data for that keystroke. ⚠️ **That decision is settled at the NEXT keydown as well as on its own zero-delay timer** (#441): the drain runs from xterm's custom key handler, which fires BEFORE xterm processes that key, so a soft keyboard that commits the last character and sends Enter in one InputConnection transaction puts the character on the wire ahead of the `\r`. On the timer alone that character is not merely late, it is LOST: xterm emits the `\r` first and bumps the canonical counter past the candidate's snapshot, so the candidate stands down (measured, `hell\r` where the user typed `hello`). The trade is that a keydown decides with less evidence than the timer did, since xterm's own keyCode-229 rescue has not run yet; that is safe for Enter, which clears the textarea so the pending diff emits nothing. Ordering is pinned by `test/terminal-keycode229-recovery.browser.test.ts`, which the CI gate does NOT run.
|
||||
Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. Load order: `constants.js`(1) → `i18n.js`(1.5) → `mobile-handlers.js`(2) → `voice-input.js`(3) → `notification-manager.js`(4) → `keyboard-accessory.js`(5) → `input-cjk.js`(5.5) → `mobile-ime-preview.js`(5.52) → `terminal-keycode229-recovery.js`(5.55) → `sanitize-html.js`(5.6) → `app.js`(6) → `tab-rail-resize.js`(6.5) → `terminal-ui.js`(7) → `terminal-split.js`(7.5) → `respawn-ui.js`(8) → `ralph-panel.js`(9) → `orchestrator-panel.js`(9.5) → `cron-ui.js`(9.7) → `settings-ui.js`(10) → `panels-ui.js`(11) → `readmymind-ui.js`(11.3) → `ultracode-panel.js`(11.5) → `approvals-ui.js`(11.6) → `reboot-restore-ui.js`(11.65) → `admin-ui.js`(11.7) → `session-ui.js`(12) → `host-wake-ui.js`(12.2) → `webview-tabs.js`(12.5) → `mobile-overview.js`(12.55) → `home-sessions.js`(12.56) → `entrance-animations.js`(12.6) → `ralph-wizard.js`(13) → `api-client.js`(14) → `subagent-windows.js`(15) → `ultracode-windows.js`(15.5) → `session-lineage.js`(15.6) → `image-input.js`(16). `i18n.js` translates static + newly inserted application DOM while skipping terminal/response/file/user-name surfaces; `input-cjk.js` handles CJK IME composition via an always-visible textarea below the terminal (`window.cjkActive` blocks xterm's onData). `terminal-keycode229-recovery.js` forwards a committed `input` event that xterm's `_inputEvent` guard drops (Chrome-on-Android soft keyboards send `composed: true` after a keydown), and only when xterm emitted no canonical data for that keystroke. ⚠️ **That decision is settled at the NEXT keydown as well as on its own zero-delay timer** (#441): the drain runs from xterm's custom key handler, which fires BEFORE xterm processes that key, so a soft keyboard that commits the last character and sends Enter in one InputConnection transaction puts the character on the wire ahead of the `\r`. On the timer alone that character is not merely late, it is LOST: xterm emits the `\r` first and bumps the canonical counter past the candidate's snapshot, so the candidate stands down (measured, `hell\r` where the user typed `hello`). The trade is that a keydown decides with less evidence than the timer did, since xterm's own keyCode-229 rescue has not run yet; that is safe for Enter, which clears the textarea so the pending diff emits nothing. Ordering is pinned by `test/terminal-keycode229-recovery.browser.test.ts`, which the CI gate does NOT run.
|
||||
|
||||
**Entrance animations** (`entrance-animations.js`, all OFF by default): opt-in animations for tabs, terminal, windows and connection lines, chosen via `data-tab-anim` / `data-term-anim` / `data-win-anim` / `data-line-anim` on `<html>`; the default `legacy` theme short-circuits every hook. ⚠️ Tabs and lines are destroyed mid-animation on re-render, so re-apply to the fresh element by id with a negative `animation-delay` (resume, never restart). ⚠️ Terminal-pane styles may animate only transform / opacity / clip-path (anything else resizes the PTY via FitAddon); `blur` is the ONE sanctioned `filter` exception, do not generalise it. ⚠️ Line glow lives in `--line-glow` so blur keyframes interpolate. Persisted per-device in `codeman:*Anim` localStorage keys, never in `SettingsUpdateSchema`; lab at `?animlab=1`. Test: `test/entrance-animations.test.ts`. → [architecture-invariants#entrance-animations](docs/architecture-invariants.md#entrance-animations)
|
||||
|
||||
|
||||
@@ -83,6 +83,7 @@ appendFileSync(
|
||||
|
||||
// 4. Minify frontend assets
|
||||
run('minify input-cjk.js', 'npx esbuild dist/web/public/input-cjk.js --minify --outfile=dist/web/public/input-cjk.js --allow-overwrite');
|
||||
run('minify mobile-ime-preview.js', 'npx esbuild dist/web/public/mobile-ime-preview.js --minify --outfile=dist/web/public/mobile-ime-preview.js --allow-overwrite');
|
||||
run('minify terminal-keycode229-recovery.js', 'npx esbuild dist/web/public/terminal-keycode229-recovery.js --minify --outfile=dist/web/public/terminal-keycode229-recovery.js --allow-overwrite');
|
||||
run('minify i18n.js', 'npx esbuild dist/web/public/i18n.js --minify --outfile=dist/web/public/i18n.js --allow-overwrite');
|
||||
run('minify sanitize-html.js', 'npx esbuild dist/web/public/sanitize-html.js --minify --outfile=dist/web/public/sanitize-html.js --allow-overwrite');
|
||||
@@ -111,6 +112,7 @@ console.log('\n[build] content-hash cache busting');
|
||||
'notification-manager.js',
|
||||
'keyboard-accessory.js',
|
||||
'input-cjk.js',
|
||||
'mobile-ime-preview.js',
|
||||
'terminal-keycode229-recovery.js',
|
||||
'sanitize-html.js',
|
||||
'app.js',
|
||||
|
||||
@@ -3735,6 +3735,8 @@
|
||||
<script defer src="notification-manager.js"></script>
|
||||
<script defer src="keyboard-accessory.js"></script>
|
||||
<script defer src="input-cjk.js"></script>
|
||||
<!-- Shows iOS Safari IME composition text inside the terminal. Must precede terminal-ui.js. -->
|
||||
<script defer src="mobile-ime-preview.js"></script>
|
||||
<!-- Forwards committed input events that xterm drops on Android/GBoard soft keyboards. Must precede terminal-ui.js. -->
|
||||
<script defer src="terminal-keycode229-recovery.js"></script>
|
||||
<!-- Hardened markdown HTML sanitizer (wires DOMPurify). Must precede app.js. -->
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
/**
|
||||
* @fileoverview In-terminal preview of IME composition text on iOS Safari.
|
||||
*
|
||||
* On iOS WebKit touch devices the text an IME is composing (Japanese, Chinese,
|
||||
* Korean, dictation) is not visible inside the terminal until it commits, so
|
||||
* the user types blind. The controller listens to the helper textarea's
|
||||
* composition events and asks the caller to render the latest composition
|
||||
* (`phase: 'provisional'`), coalesced to one render per animation frame and
|
||||
* capped at 2048 characters. When xterm emits the committed text through
|
||||
* onData, the caller hands it to `consumeTerminalData()`, which switches the
|
||||
* preview to `phase: 'committed'` until something else shows the text: the
|
||||
* local echo overlay or a prediction (`completeCommit`), authoritative
|
||||
* terminal output (`noteAuthoritativeOutput`), or a 2 s fallback timer.
|
||||
*
|
||||
* VISUAL ONLY: the controller never sends, consumes or reorders input bytes,
|
||||
* and every callback is wrapped so a failing render cannot block the wire.
|
||||
* `isIosWebKitTouch()` gates creation; other platforms keep xterm's own
|
||||
* composition view untouched.
|
||||
*
|
||||
* @dependency none (standalone IIFE; consumed by terminal-ui.js)
|
||||
* @loadorder 5.52 (before app.js/terminal-ui.js, which create the controller)
|
||||
*/
|
||||
(function (global) {
|
||||
'use strict';
|
||||
|
||||
const COMMITTED_VISUAL_TTL = 2000;
|
||||
const PREVIEW_CAP = 2048;
|
||||
const CONTROL_OR_LINE_BREAK = /[\u0000-\u001f\u007f-\u009f\u2028\u2029]/;
|
||||
|
||||
function isIosWebKitTouch(nav = navigator) {
|
||||
const userAgent = String(nav && nav.userAgent ? nav.userAgent : '');
|
||||
const platform = String(nav && nav.platform ? nav.platform : '');
|
||||
const touchPoints = Number(nav && nav.maxTouchPoints ? nav.maxTouchPoints : 0);
|
||||
const iosDevice = /iPhone|iPad|iPod/.test(userAgent);
|
||||
const desktopIpad = platform === 'MacIntel' && touchPoints > 1;
|
||||
return touchPoints > 0 && /AppleWebKit/.test(userAgent) && (iosDevice || desktopIpad);
|
||||
}
|
||||
|
||||
function create(options) {
|
||||
const textarea = options.textarea;
|
||||
const render = typeof options.render === 'function' ? options.render : function () {};
|
||||
const clear = typeof options.clear === 'function' ? options.clear : function () {};
|
||||
const onCommit = typeof options.onCommit === 'function' ? options.onCommit : function () {};
|
||||
const scheduleFrame = options.scheduleFrame || global.requestAnimationFrame.bind(global);
|
||||
const cancelFrame = options.cancelFrame || global.cancelAnimationFrame.bind(global);
|
||||
const setTimer = options.setTimer || global.setTimeout.bind(global);
|
||||
const clearTimer = options.clearTimer || global.clearTimeout.bind(global);
|
||||
|
||||
let generation = 0;
|
||||
let composing = false;
|
||||
let awaitingCommit = false;
|
||||
let committed = false;
|
||||
let latestValue = '';
|
||||
let renderPhase = null;
|
||||
let frameToken = null;
|
||||
let timerToken = null;
|
||||
let finalizedByKeydown = false;
|
||||
let destroyed = false;
|
||||
let invokingClear = false;
|
||||
|
||||
function safely(callback, ...args) {
|
||||
try {
|
||||
return callback(...args);
|
||||
} catch (_error) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function cancelScheduledFrame() {
|
||||
const token = frameToken;
|
||||
frameToken = null;
|
||||
if (token && token.id !== undefined) safely(cancelFrame, token.id);
|
||||
}
|
||||
|
||||
function cancelCommittedTimer() {
|
||||
const token = timerToken;
|
||||
timerToken = null;
|
||||
if (token && token.id !== undefined) safely(clearTimer, token.id);
|
||||
}
|
||||
|
||||
function clearVisual() {
|
||||
if (invokingClear) return;
|
||||
invokingClear = true;
|
||||
safely(clear);
|
||||
invokingClear = false;
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
generation += 1;
|
||||
cancelScheduledFrame();
|
||||
cancelCommittedTimer();
|
||||
composing = false;
|
||||
awaitingCommit = false;
|
||||
committed = false;
|
||||
latestValue = '';
|
||||
renderPhase = null;
|
||||
finalizedByKeydown = false;
|
||||
clearVisual();
|
||||
}
|
||||
|
||||
function scheduleLatestPreview(phase) {
|
||||
if (destroyed) return;
|
||||
renderPhase = phase;
|
||||
if (frameToken) return;
|
||||
const token = { generation, id: undefined };
|
||||
frameToken = token;
|
||||
const callback = function () {
|
||||
if (destroyed || frameToken !== token || token.generation !== generation || renderPhase === null) return;
|
||||
frameToken = null;
|
||||
const value = latestValue.slice(0, PREVIEW_CAP);
|
||||
const phaseToRender = renderPhase;
|
||||
safely(render, { text: value, phase: phaseToRender });
|
||||
};
|
||||
const id = safely(scheduleFrame, callback);
|
||||
if (frameToken === token) {
|
||||
if (id === undefined) frameToken = null;
|
||||
else token.id = id;
|
||||
}
|
||||
}
|
||||
|
||||
function beginComposition() {
|
||||
cleanup();
|
||||
if (destroyed) return;
|
||||
composing = true;
|
||||
}
|
||||
|
||||
function updateComposition(event) {
|
||||
if (!composing) return;
|
||||
latestValue = event.data == null ? '' : String(event.data);
|
||||
scheduleLatestPreview('provisional');
|
||||
}
|
||||
|
||||
function onComposingInput(event) {
|
||||
if (!event.isComposing) return;
|
||||
updateComposition({ data: event.data == null ? textarea.value : event.data });
|
||||
}
|
||||
|
||||
function finalizeComposition(value, fromKeydown) {
|
||||
if (!composing) return;
|
||||
composing = false;
|
||||
awaitingCommit = true;
|
||||
committed = false;
|
||||
finalizedByKeydown = fromKeydown;
|
||||
latestValue = value == null ? latestValue : String(value);
|
||||
scheduleLatestPreview('provisional');
|
||||
}
|
||||
|
||||
function onCompositionEnd(event) {
|
||||
if (finalizedByKeydown) {
|
||||
finalizedByKeydown = false;
|
||||
return;
|
||||
}
|
||||
finalizeComposition(event.data, false);
|
||||
}
|
||||
|
||||
function onKeydown(event) {
|
||||
if (composing && event.isComposing === false && event.key !== 'Process' && event.key !== 'Unidentified') {
|
||||
finalizeComposition(latestValue, true);
|
||||
}
|
||||
}
|
||||
|
||||
function reset() {
|
||||
if (destroyed) return;
|
||||
cleanup();
|
||||
}
|
||||
|
||||
function consumeTerminalData(data) {
|
||||
if (
|
||||
destroyed ||
|
||||
!awaitingCommit ||
|
||||
typeof data !== 'string' ||
|
||||
data.length === 0 ||
|
||||
CONTROL_OR_LINE_BREAK.test(data)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
generation += 1;
|
||||
const owner = generation;
|
||||
cancelScheduledFrame();
|
||||
cancelCommittedTimer();
|
||||
composing = false;
|
||||
awaitingCommit = false;
|
||||
committed = true;
|
||||
latestValue = data;
|
||||
renderPhase = 'committed';
|
||||
safely(onCommit, data);
|
||||
if (destroyed || generation !== owner || !committed) return true;
|
||||
|
||||
scheduleLatestPreview('committed');
|
||||
if (destroyed || generation !== owner || !committed) return true;
|
||||
|
||||
const token = { generation, id: undefined };
|
||||
timerToken = token;
|
||||
const callback = function () {
|
||||
if (destroyed || timerToken !== token || token.generation !== generation || !committed) return;
|
||||
timerToken = null;
|
||||
cleanup();
|
||||
};
|
||||
const id = safely(setTimer, callback, COMMITTED_VISUAL_TTL);
|
||||
if (timerToken === token) {
|
||||
if (id === undefined) {
|
||||
timerToken = null;
|
||||
if (!destroyed && generation === owner && committed) cleanup();
|
||||
} else {
|
||||
token.id = id;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function completeCommit(result) {
|
||||
if (destroyed || !result || result.predicted !== true || !committed) return;
|
||||
cleanup();
|
||||
}
|
||||
|
||||
function noteAuthoritativeOutput() {
|
||||
if (destroyed || !committed) return;
|
||||
cleanup();
|
||||
}
|
||||
|
||||
const listeners = [
|
||||
['compositionstart', beginComposition],
|
||||
['compositionupdate', updateComposition],
|
||||
['input', onComposingInput],
|
||||
['compositionend', onCompositionEnd],
|
||||
['keydown', onKeydown, true],
|
||||
['blur', reset],
|
||||
];
|
||||
for (const [type, listener, capture] of listeners) textarea.addEventListener(type, listener, capture);
|
||||
|
||||
function destroy() {
|
||||
if (destroyed) return;
|
||||
destroyed = true;
|
||||
for (const [type, listener, capture] of listeners) textarea.removeEventListener(type, listener, capture);
|
||||
cleanup();
|
||||
}
|
||||
|
||||
return {
|
||||
consumeTerminalData,
|
||||
completeCommit,
|
||||
noteAuthoritativeOutput,
|
||||
reset,
|
||||
destroy,
|
||||
get state() {
|
||||
return {
|
||||
generation,
|
||||
composing,
|
||||
awaitingCommit,
|
||||
committed,
|
||||
latest: latestValue,
|
||||
framePending: frameToken !== null,
|
||||
timerPending: timerToken !== null,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
global.MobileImePreview = { create, isIosWebKitTouch };
|
||||
})(globalThis);
|
||||
@@ -460,6 +460,31 @@ textarea:focus-visible {
|
||||
font-size: 16px !important; /* prevent iOS auto-zoom on focus */
|
||||
}
|
||||
|
||||
.xterm-helpers .codeman-ime-preview {
|
||||
position: absolute;
|
||||
left: var(--xterm-helper-left, 0px);
|
||||
top: var(--xterm-helper-top, 0px);
|
||||
z-index: 6;
|
||||
pointer-events: none;
|
||||
white-space: pre;
|
||||
color: var(--terminal-foreground, var(--text, #fff));
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
line-height: 1.2;
|
||||
height: 1.2em;
|
||||
}
|
||||
|
||||
.xterm-helpers .codeman-ime-preview[data-phase='provisional'] {
|
||||
text-decoration: underline;
|
||||
text-decoration-style: dotted;
|
||||
}
|
||||
|
||||
.touch-device .xterm-helpers.codeman-ime-preview-owned .composition-view.active {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Session tab focus */
|
||||
.session-tab:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
|
||||
@@ -250,6 +250,189 @@ Object.assign(CodemanApp.prototype, {
|
||||
this._keyCode229Recovery = null;
|
||||
},
|
||||
|
||||
_destroyMobileImePreview() {
|
||||
try {
|
||||
this._mobileImePreview?.destroy?.();
|
||||
} catch {
|
||||
// The preview is visual-only; terminal replacement must continue.
|
||||
}
|
||||
this._mobileImePreview = null;
|
||||
this._mobileImePreviewSessionId = null;
|
||||
this._mobileImeCommitOutputSeq = null;
|
||||
try {
|
||||
this._mobileImePreviewNode?.remove?.();
|
||||
} catch {
|
||||
// Best-effort node cleanup only.
|
||||
}
|
||||
try {
|
||||
this._mobileImePreviewHelpers?.classList?.remove('codeman-ime-preview-owned');
|
||||
} catch {
|
||||
// Best-effort ownership cleanup only.
|
||||
}
|
||||
this._mobileImePreviewNode = null;
|
||||
this._mobileImePreviewHelpers = null;
|
||||
try {
|
||||
if (this._mobileImePreviewOfflineHandler) {
|
||||
window.removeEventListener('offline', this._mobileImePreviewOfflineHandler);
|
||||
}
|
||||
if (this._mobileImePreviewPagehideHandler) {
|
||||
window.removeEventListener('pagehide', this._mobileImePreviewPagehideHandler);
|
||||
}
|
||||
} catch {
|
||||
// Best-effort listener cleanup only.
|
||||
}
|
||||
this._mobileImePreviewOfflineHandler = null;
|
||||
this._mobileImePreviewPagehideHandler = null;
|
||||
},
|
||||
|
||||
/**
|
||||
* iOS Safari IME preview (mobile-ime-preview.js). WebKit does not show the
|
||||
* text an IME is composing inside the terminal, so the user types blind; this
|
||||
* paints it in a span inside `.xterm-helpers`, positioned by the same
|
||||
* --xterm-helper-left/top vars as the helper textarea. Visual only: nothing
|
||||
* here touches the input path, and every failure leaves no DOM behind.
|
||||
*/
|
||||
_initMobileImePreview() {
|
||||
this._destroyMobileImePreview();
|
||||
let preview = null;
|
||||
let helpers = null;
|
||||
try {
|
||||
if (typeof MobileImePreview === 'undefined' || !MobileImePreview?.isIosWebKitTouch?.()) return;
|
||||
const textarea = this.terminal?.textarea;
|
||||
helpers = this.terminal?.element?.querySelector?.('.xterm-helpers');
|
||||
if (!textarea || !helpers) return;
|
||||
|
||||
preview = document.createElement('span');
|
||||
this._mobileImePreviewNode = preview;
|
||||
this._mobileImePreviewHelpers = helpers;
|
||||
preview.className = 'codeman-ime-preview';
|
||||
preview.setAttribute('aria-hidden', 'true');
|
||||
preview.hidden = true;
|
||||
helpers.appendChild(preview);
|
||||
const syncPreviewTypography = () => {
|
||||
try {
|
||||
const compositionView =
|
||||
helpers.querySelector?.('.composition-view') || this.terminal?.element?.querySelector?.('.composition-view');
|
||||
if (!compositionView || !preview.style) return;
|
||||
const style = typeof getComputedStyle === 'function' ? getComputedStyle(compositionView) : compositionView.style;
|
||||
for (const property of ['fontFamily', 'fontSize', 'fontWeight', 'fontStyle', 'lineHeight', 'height']) {
|
||||
const value = style?.[property] || compositionView.style?.[property];
|
||||
if (value) preview.style[property] = value;
|
||||
}
|
||||
let foreground = this.terminal?.options?.theme?.foreground;
|
||||
if (!foreground) {
|
||||
try {
|
||||
foreground = window.codemanCurrentXtermTheme?.()?.foreground;
|
||||
} catch {
|
||||
// Theme lookup is best-effort; retain the safe terminal fallback.
|
||||
}
|
||||
}
|
||||
preview.style.color = foreground || '#e0e0e0';
|
||||
} catch {
|
||||
// Typography matching is visual-only and must not block input.
|
||||
}
|
||||
};
|
||||
const clearPreview = () => {
|
||||
try {
|
||||
preview.hidden = true;
|
||||
} catch {}
|
||||
try {
|
||||
preview.textContent = '';
|
||||
} catch {}
|
||||
try {
|
||||
delete preview.dataset.phase;
|
||||
} catch {}
|
||||
try {
|
||||
helpers.classList.remove('codeman-ime-preview-owned');
|
||||
} catch {}
|
||||
};
|
||||
const controller = MobileImePreview.create({
|
||||
textarea,
|
||||
render: ({ text, phase }) => {
|
||||
try {
|
||||
syncPreviewTypography();
|
||||
preview.textContent = text;
|
||||
preview.dataset.phase = phase;
|
||||
preview.hidden = !text;
|
||||
helpers.classList.toggle('codeman-ime-preview-owned', !!text);
|
||||
} catch {
|
||||
clearPreview();
|
||||
}
|
||||
},
|
||||
clear: clearPreview,
|
||||
});
|
||||
this._mobileImePreview = controller;
|
||||
this._mobileImePreviewSessionId = this.activeSessionId;
|
||||
|
||||
this._mobileImePreviewOfflineHandler = () => {
|
||||
try {
|
||||
this._mobileImePreview?.reset?.();
|
||||
} catch {
|
||||
// Disconnect cleanup is visual-only.
|
||||
}
|
||||
};
|
||||
this._mobileImePreviewPagehideHandler = () => this._destroyMobileImePreview();
|
||||
window.addEventListener('offline', this._mobileImePreviewOfflineHandler);
|
||||
window.addEventListener('pagehide', this._mobileImePreviewPagehideHandler);
|
||||
} catch {
|
||||
this._destroyMobileImePreview();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Tell the IME preview about a chunk xterm emitted through onData. Returns
|
||||
* true when the chunk is the IME's committed text, in which case the preview
|
||||
* holds it (phase 'committed') until something else shows it. Never throws.
|
||||
*/
|
||||
_consumeMobileImeTerminalData(data) {
|
||||
let isImeCommit = false;
|
||||
try {
|
||||
isImeCommit = this._mobileImePreview?.consumeTerminalData?.(data) === true;
|
||||
} catch {
|
||||
// The preview is visual-only; normal terminal input must continue.
|
||||
}
|
||||
// Output accepted from here on can carry the echo of this commit.
|
||||
if (isImeCommit) this._mobileImeCommitOutputSeq = this._terminalOutputSeq || 0;
|
||||
return isImeCommit;
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear a committed IME preview once terminal output accepted AFTER the
|
||||
* commit has been parsed. `flushedOutputSeq` is the output sequence a fully
|
||||
* written flush covered (null when part of it was deferred), so output that
|
||||
* was already queued before the commit can never clear it early.
|
||||
*/
|
||||
_noteMobileImeAuthoritativeOutput(flushedOutputSeq, sessionId) {
|
||||
try {
|
||||
const commitSeq = this._mobileImeCommitOutputSeq;
|
||||
if (commitSeq === null || commitSeq === undefined || flushedOutputSeq === null) return;
|
||||
if (sessionId !== this.activeSessionId || !(flushedOutputSeq > commitSeq)) return;
|
||||
this._mobileImeCommitOutputSeq = null;
|
||||
this._mobileImePreview?.noteAuthoritativeOutput?.();
|
||||
} catch {
|
||||
// Authoritative output is never delayed or consumed by the preview.
|
||||
}
|
||||
},
|
||||
|
||||
/** Hand a committed IME chunk to the local echo overlay. False = not taken. */
|
||||
_transferMobileImeCommitToLocalEcho(data) {
|
||||
const overlay = this._localEchoOverlay;
|
||||
try {
|
||||
const update = data.length === 1 ? overlay?.addChar : overlay?.appendText;
|
||||
if (typeof update !== 'function') return false;
|
||||
update.call(overlay, data);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
this._mobileImeCommitOutputSeq = null;
|
||||
try {
|
||||
this._mobileImePreview?.completeCommit?.({ predicted: true });
|
||||
} catch {
|
||||
// Ownership transfer is visual-only.
|
||||
}
|
||||
return true;
|
||||
},
|
||||
|
||||
initTerminal() {
|
||||
// Load scrollback setting from localStorage, treating DEFAULT_SCROLLBACK as a floor
|
||||
// so users who picked up the previous (smaller) default get the new minimum on upgrade.
|
||||
@@ -307,6 +490,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
const container = document.getElementById('terminalContainer');
|
||||
this.terminal.open(container);
|
||||
this._initMobileImePreview();
|
||||
this._installMobileTapMouseGuard();
|
||||
this._installShiftDragSelection();
|
||||
this._installTouchSelectionFocusGuard();
|
||||
@@ -1211,6 +1395,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
// survives tab switches and reconnects.
|
||||
|
||||
const handleTerminalData = (data) => {
|
||||
// Before anything can rewrite `data`: is this chunk the IME's commit?
|
||||
const isImeCommit = this._consumeMobileImeTerminalData(data);
|
||||
// Mouse SGR reports (tap-to-position) are NOT IME input — they must reach
|
||||
// the PTY even while the CJK input field owns focus. Without this exception
|
||||
// tapping to move the cursor silently does nothing whenever Chinese input
|
||||
@@ -1288,7 +1474,16 @@ Object.assign(CodemanApp.prototype, {
|
||||
// When enabled, keystrokes are buffered locally in the overlay for
|
||||
// instant visual feedback. Nothing is sent to the PTY until Enter
|
||||
// (or a control char) is pressed — avoids out-of-order char delivery.
|
||||
if (this._localEchoEnabled && !echoPassthrough) {
|
||||
// An IME commit moves into the overlay, which then shows it in place of
|
||||
// the preview. The charCode check skips a commit the one-shot Ctrl above
|
||||
// turned into a control byte. If the overlay cannot take it, the text is
|
||||
// sent directly rather than dropped.
|
||||
let imeCommitBypassesEcho = false;
|
||||
if (isImeCommit && this._localEchoEnabled && !echoPassthrough && data.charCodeAt(0) >= 32) {
|
||||
if (this._transferMobileImeCommitToLocalEcho(data)) return;
|
||||
imeCommitBypassesEcho = true;
|
||||
}
|
||||
if (this._localEchoEnabled && !echoPassthrough && !imeCommitBypassesEcho) {
|
||||
if (data === '\x7f') {
|
||||
const source = this._localEchoOverlay?.removeChar();
|
||||
if (source === 'flushed') {
|
||||
@@ -3517,6 +3712,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
},
|
||||
|
||||
batchTerminalWrite(data) {
|
||||
// Arrival order of output, so the IME preview can tell output that
|
||||
// followed a commit from output that was already queued before it.
|
||||
this._terminalOutputSeq = (this._terminalOutputSeq || 0) + 1;
|
||||
// Feed the renderer watchdog. Recorded before the buffer-load early return
|
||||
// below: a write that is queued rather than written still means the pipeline
|
||||
// owes us a frame once it drains.
|
||||
@@ -3580,6 +3778,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
// Accumulate raw data (may contain DEC 2026 markers)
|
||||
this.pendingWrites.push(data);
|
||||
this._pendingWritesOutputSeq = this._terminalOutputSeq;
|
||||
this._scheduleTerminalWriteFlush();
|
||||
},
|
||||
|
||||
@@ -3611,6 +3810,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
// Transfer buffered data to normal pending writes
|
||||
this.pendingWrites.push(this.flickerFilterBuffer);
|
||||
this._pendingWritesOutputSeq = this._terminalOutputSeq;
|
||||
this.flickerFilterBuffer = '';
|
||||
this.flickerFilterActive = false;
|
||||
|
||||
@@ -3641,6 +3841,15 @@ Object.assign(CodemanApp.prototype, {
|
||||
* Position is tracked dynamically by _findPrompt() on every render.
|
||||
*/
|
||||
_updateLocalEchoState() {
|
||||
if (this._mobileImePreviewSessionId !== this.activeSessionId) {
|
||||
this._mobileImePreviewSessionId = this.activeSessionId;
|
||||
this._mobileImeCommitOutputSeq = null;
|
||||
try {
|
||||
this._mobileImePreview?.reset?.();
|
||||
} catch {
|
||||
// The preview is visual-only; session switching must continue.
|
||||
}
|
||||
}
|
||||
const settings = this.loadAppSettingsFromStorage();
|
||||
const session = this.activeSessionId ? this.sessions.get(this.activeSessionId) : null;
|
||||
const echoEnabled = settings.localEchoEnabled ?? MobileDetection.isTouchDevice();
|
||||
@@ -3846,6 +4055,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.pendingWrites.push(joined.slice(MAX_FRAME_BYTES));
|
||||
deferred = true;
|
||||
}
|
||||
// Newest output this chunk fully contains, for the IME preview. A split
|
||||
// chunk may not hold that output yet, so it reports nothing.
|
||||
const flushedOutputSeq = deferred ? null : (this._pendingWritesOutputSeq ?? null);
|
||||
this._terminalWriteInFlight = true;
|
||||
this._terminalWriteInFlightBytes = writeChunk.length;
|
||||
try {
|
||||
@@ -3863,6 +4075,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
// because the test's write mock moved the viewport synchronously.)
|
||||
this._restoreTerminalViewport(preserveViewportY, flushSessionId);
|
||||
this._scheduleTerminalWriteFlush();
|
||||
this._noteMobileImeAuthoritativeOutput(flushedOutputSeq, flushSessionId);
|
||||
});
|
||||
} catch (err) {
|
||||
this._terminalWriteInFlight = false;
|
||||
|
||||
@@ -0,0 +1,595 @@
|
||||
/**
|
||||
* @fileoverview Wiring tests for the iOS IME preview (mobile-ime-preview.js).
|
||||
*
|
||||
* The controller itself is covered by test/mobile-ime-preview.test.ts. These
|
||||
* pin how terminal-ui.js and the delivery graph consume it: script order,
|
||||
* build registration and CSS, the _init/_destroyMobileImePreview lifecycle,
|
||||
* the onData routing of an IME commit, and the rule that only output accepted
|
||||
* AFTER a commit may clear the committed preview.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const read = (path: string) => readFileSync(resolve(import.meta.dirname, '..', path), 'utf8');
|
||||
const indexSource = read('src/web/public/index.html');
|
||||
const buildSource = read('scripts/build.mjs');
|
||||
const terminalSource = read('src/web/public/terminal-ui.js');
|
||||
const cssSource = read('src/web/public/styles.css');
|
||||
|
||||
type Fn = ReturnType<typeof vi.fn>;
|
||||
type App = Record<string, any>;
|
||||
|
||||
function loadMixin(globals: Record<string, unknown> = {}) {
|
||||
const FakeCodemanApp = function () {} as unknown as { prototype: Record<string, unknown> };
|
||||
const windowStub = {
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
...(globals.window as object),
|
||||
} as Record<string, unknown>;
|
||||
const context = vm.createContext({
|
||||
console,
|
||||
performance,
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
setInterval: vi.fn(),
|
||||
clearInterval: vi.fn(),
|
||||
requestAnimationFrame: vi.fn(),
|
||||
cancelAnimationFrame: vi.fn(),
|
||||
URLSearchParams,
|
||||
location: { search: '' },
|
||||
localStorage: { getItem: vi.fn(), setItem: vi.fn(), removeItem: vi.fn() },
|
||||
document: { addEventListener: vi.fn(), createElement: vi.fn() },
|
||||
MobileDetection: { isTouchDevice: () => false },
|
||||
CodemanApp: FakeCodemanApp,
|
||||
_crashDiag: { log: vi.fn() },
|
||||
...globals,
|
||||
window: windowStub,
|
||||
});
|
||||
vm.runInContext(terminalSource, context, { filename: 'terminal-ui.js' });
|
||||
return { mixin: FakeCodemanApp.prototype, context, windowStub };
|
||||
}
|
||||
|
||||
function fakeClassList() {
|
||||
const values = new Set<string>();
|
||||
return {
|
||||
add: vi.fn((value: string) => values.add(value)),
|
||||
remove: vi.fn((value: string) => values.delete(value)),
|
||||
toggle: vi.fn((value: string, force?: boolean) => {
|
||||
const enabled = force === undefined ? !values.has(value) : force;
|
||||
if (enabled) values.add(value);
|
||||
else values.delete(value);
|
||||
return enabled;
|
||||
}),
|
||||
contains: (value: string) => values.has(value),
|
||||
};
|
||||
}
|
||||
|
||||
function createPreviewHarness(
|
||||
options: {
|
||||
eligible?: boolean;
|
||||
createThrows?: boolean;
|
||||
omitGlobal?: boolean;
|
||||
themeForeground?: string;
|
||||
themeGetterThrows?: boolean;
|
||||
} = {}
|
||||
) {
|
||||
const compositionView = {
|
||||
style: {
|
||||
fontFamily: '"Fira Code"',
|
||||
fontSize: '10px',
|
||||
fontWeight: '500',
|
||||
fontStyle: 'italic',
|
||||
lineHeight: '12px',
|
||||
height: '12px',
|
||||
color: 'rgb(255, 255, 255)',
|
||||
},
|
||||
};
|
||||
const helpers = {
|
||||
classList: fakeClassList(),
|
||||
children: [] as Array<Record<string, unknown>>,
|
||||
querySelector: (selector: string) => (selector === '.composition-view' ? compositionView : null),
|
||||
appendChild(node: Record<string, unknown>) {
|
||||
this.children.push(node);
|
||||
},
|
||||
};
|
||||
const createdControllers: Array<Record<string, any>> = [];
|
||||
const previewNodes: Array<Record<string, any>> = [];
|
||||
const documentStub = {
|
||||
addEventListener: vi.fn(),
|
||||
createElement: vi.fn(() => {
|
||||
const node = {
|
||||
className: '',
|
||||
hidden: false,
|
||||
textContent: '',
|
||||
dataset: {} as Record<string, string>,
|
||||
style: {} as Record<string, string>,
|
||||
attributes: {} as Record<string, string>,
|
||||
setAttribute(name: string, value: string) {
|
||||
this.attributes[name] = value;
|
||||
},
|
||||
remove: vi.fn(),
|
||||
};
|
||||
previewNodes.push(node);
|
||||
return node;
|
||||
}),
|
||||
};
|
||||
const mobileImePreview = options.omitGlobal
|
||||
? undefined
|
||||
: {
|
||||
isIosWebKitTouch: vi.fn(() => options.eligible ?? true),
|
||||
create: vi.fn((callbacks: Record<string, unknown>) => {
|
||||
if (options.createThrows) throw new Error('controller unavailable');
|
||||
const controller = {
|
||||
destroy: vi.fn(),
|
||||
reset: vi.fn(),
|
||||
consumeTerminalData: vi.fn(() => false),
|
||||
completeCommit: vi.fn(),
|
||||
noteAuthoritativeOutput: vi.fn(),
|
||||
callbacks,
|
||||
};
|
||||
createdControllers.push(controller);
|
||||
return controller;
|
||||
}),
|
||||
};
|
||||
const { mixin, windowStub } = loadMixin({
|
||||
document: documentStub,
|
||||
MobileImePreview: mobileImePreview,
|
||||
getComputedStyle: (node: { style: Record<string, string> }) => node.style,
|
||||
});
|
||||
windowStub.codemanCurrentXtermTheme = () => {
|
||||
if (options.themeGetterThrows) throw new Error('theme unavailable');
|
||||
return { foreground: '#334455' };
|
||||
};
|
||||
const app: App = Object.assign(Object.create(mixin), {
|
||||
terminal: {
|
||||
textarea: {},
|
||||
options: { theme: options.themeForeground ? { foreground: options.themeForeground } : undefined },
|
||||
element: { querySelector: (selector: string) => (selector === '.xterm-helpers' ? helpers : null) },
|
||||
},
|
||||
activeSessionId: 'session-a',
|
||||
});
|
||||
return { app, helpers, compositionView, previewNodes, createdControllers, mobileImePreview, windowStub };
|
||||
}
|
||||
|
||||
describe('mobile IME preview delivery graph', () => {
|
||||
it('loads the controller after xterm and before terminal wiring', () => {
|
||||
const at = indexSource.indexOf('<script defer src="mobile-ime-preview.js"></script>');
|
||||
expect(at).toBeGreaterThan(indexSource.indexOf('vendor/xterm.min.js'));
|
||||
expect(at).toBeLessThan(indexSource.indexOf('<script defer src="terminal-ui.js"></script>'));
|
||||
expect(at).toBeLessThan(indexSource.indexOf('<script defer src="app.js"></script>'));
|
||||
});
|
||||
|
||||
it('is minified and content-hashed by the build', () => {
|
||||
expect(buildSource).toContain("run('minify mobile-ime-preview.js'");
|
||||
expect(buildSource).toMatch(/const HASHABLE = \[[^\]]*'mobile-ime-preview\.js'/);
|
||||
});
|
||||
|
||||
it('scopes preview presentation and native composition suppression to touch ownership', () => {
|
||||
expect(cssSource).toContain('.xterm-helpers .codeman-ime-preview {');
|
||||
expect(cssSource).toContain(".xterm-helpers .codeman-ime-preview[data-phase='provisional'] {");
|
||||
expect(cssSource).toContain('.touch-device .xterm-helpers.codeman-ime-preview-owned .composition-view.active {');
|
||||
const rule = cssSource.slice(cssSource.indexOf('.xterm-helpers .codeman-ime-preview {'));
|
||||
expect(rule.slice(0, rule.indexOf('}'))).toContain('left: var(--xterm-helper-left, 0px)');
|
||||
expect(rule.slice(0, rule.indexOf('}'))).toContain('top: var(--xterm-helper-top, 0px)');
|
||||
});
|
||||
|
||||
it('initializes the preview right after the terminal opens', () => {
|
||||
expect(terminalSource).toMatch(/this\.terminal\.open\(container\);\s*this\._initMobileImePreview\(\);/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mobile IME preview lifecycle', () => {
|
||||
it('fails open when the global is absent or create throws', () => {
|
||||
for (const options of [{ omitGlobal: true }, { createThrows: true }]) {
|
||||
const { app } = createPreviewHarness(options);
|
||||
expect(() => app._initMobileImePreview()).not.toThrow();
|
||||
expect(app._mobileImePreview).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not create a controller on unsupported input platforms', () => {
|
||||
const { app, mobileImePreview, previewNodes } = createPreviewHarness({ eligible: false });
|
||||
app._initMobileImePreview();
|
||||
expect(mobileImePreview?.create).not.toHaveBeenCalled();
|
||||
expect(previewNodes).toHaveLength(0);
|
||||
expect(app._mobileImePreview).toBeNull();
|
||||
});
|
||||
|
||||
it('creates one controller bound to the terminal textarea and one hidden preview node', () => {
|
||||
const { app, helpers, previewNodes, mobileImePreview } = createPreviewHarness();
|
||||
app._initMobileImePreview();
|
||||
expect(mobileImePreview?.create).toHaveBeenCalledOnce();
|
||||
expect(mobileImePreview?.create.mock.calls[0][0].textarea).toBe(app.terminal.textarea);
|
||||
expect(helpers.children).toEqual([previewNodes[0]]);
|
||||
expect(previewNodes[0]).toMatchObject({ className: 'codeman-ime-preview', hidden: true });
|
||||
expect(previewNodes[0].attributes['aria-hidden']).toBe('true');
|
||||
});
|
||||
|
||||
it('destroys prior ownership on repeated initialization and keeps one active controller', () => {
|
||||
const { app, createdControllers, previewNodes, windowStub } = createPreviewHarness();
|
||||
app._initMobileImePreview();
|
||||
const first = createdControllers[0];
|
||||
app._initMobileImePreview();
|
||||
expect(first.destroy).toHaveBeenCalledOnce();
|
||||
expect(previewNodes[0].remove).toHaveBeenCalledOnce();
|
||||
expect(createdControllers).toHaveLength(2);
|
||||
expect(app._mobileImePreview).toBe(createdControllers[1]);
|
||||
// Window listeners are released with the controller that owned them.
|
||||
expect((windowStub.removeEventListener as Fn).mock.calls.map((call) => call[0]).sort()).toEqual([
|
||||
'offline',
|
||||
'pagehide',
|
||||
]);
|
||||
});
|
||||
|
||||
it('destroy releases the controller, the node and the listeners', () => {
|
||||
const { app, createdControllers, previewNodes, windowStub } = createPreviewHarness();
|
||||
app._initMobileImePreview();
|
||||
app._destroyMobileImePreview();
|
||||
expect(createdControllers[0].destroy).toHaveBeenCalledOnce();
|
||||
expect(previewNodes[0].remove).toHaveBeenCalledOnce();
|
||||
expect(app._mobileImePreview).toBeNull();
|
||||
expect(windowStub.removeEventListener).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('resets the controller exactly once when the active session changes', () => {
|
||||
const { app, createdControllers } = createPreviewHarness();
|
||||
app._initMobileImePreview();
|
||||
app.activeSessionId = 'session-b';
|
||||
app.loadAppSettingsFromStorage = () => ({ localEchoEnabled: false });
|
||||
app.sessions = new Map();
|
||||
app._updateLocalEchoState();
|
||||
app._updateLocalEchoState();
|
||||
expect(createdControllers[0].reset).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('renders and clears owned preview state', () => {
|
||||
const { app, helpers, previewNodes, createdControllers } = createPreviewHarness();
|
||||
app._initMobileImePreview();
|
||||
const callbacks = createdControllers[0].callbacks;
|
||||
callbacks.render({ text: '你好', phase: 'provisional' });
|
||||
expect(previewNodes[0]).toMatchObject({ textContent: '你好', hidden: false, dataset: { phase: 'provisional' } });
|
||||
expect(helpers.classList.contains('codeman-ime-preview-owned')).toBe(true);
|
||||
callbacks.clear();
|
||||
expect(previewNodes[0]).toMatchObject({ textContent: '', hidden: true, dataset: {} });
|
||||
expect(helpers.classList.contains('codeman-ime-preview-owned')).toBe(false);
|
||||
});
|
||||
|
||||
it('uses the terminal foreground while mirroring native composition font metrics', () => {
|
||||
const { app, compositionView, previewNodes, createdControllers } = createPreviewHarness({
|
||||
themeForeground: '#1f2328',
|
||||
});
|
||||
app._initMobileImePreview();
|
||||
createdControllers[0].callbacks.render({ text: '入力', phase: 'provisional' });
|
||||
expect(previewNodes[0].style).toMatchObject({
|
||||
fontFamily: compositionView.style.fontFamily,
|
||||
fontSize: compositionView.style.fontSize,
|
||||
fontWeight: compositionView.style.fontWeight,
|
||||
fontStyle: compositionView.style.fontStyle,
|
||||
lineHeight: compositionView.style.lineHeight,
|
||||
height: compositionView.style.height,
|
||||
color: '#1f2328',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps rendering with a safe foreground when the theme getter throws', () => {
|
||||
const { app, previewNodes, createdControllers } = createPreviewHarness({ themeGetterThrows: true });
|
||||
app._initMobileImePreview();
|
||||
expect(() => createdControllers[0].callbacks.render({ text: '安全', phase: 'provisional' })).not.toThrow();
|
||||
expect(previewNodes[0].textContent).toBe('安全');
|
||||
expect(previewNodes[0].style.color).toBe('#e0e0e0');
|
||||
});
|
||||
|
||||
it.each(['query', 'create', 'append', 'className', 'hidden'] as const)(
|
||||
'removes partial DOM ownership when %s fails',
|
||||
(failure) => {
|
||||
const removed = vi.fn();
|
||||
const owner = fakeClassList();
|
||||
const preview = new Proxy(
|
||||
{ dataset: {}, remove: removed, setAttribute: vi.fn() },
|
||||
{
|
||||
set(target, property, value) {
|
||||
if (property === failure) throw new Error(`${failure} failed`);
|
||||
return Reflect.set(target, property, value);
|
||||
},
|
||||
}
|
||||
);
|
||||
const helpers = {
|
||||
classList: owner,
|
||||
appendChild:
|
||||
failure === 'append'
|
||||
? () => {
|
||||
throw new Error('append failed');
|
||||
}
|
||||
: vi.fn(),
|
||||
};
|
||||
const documentStub = {
|
||||
addEventListener: vi.fn(),
|
||||
createElement:
|
||||
failure === 'create'
|
||||
? () => {
|
||||
throw new Error('create failed');
|
||||
}
|
||||
: () => preview,
|
||||
};
|
||||
const MobileImePreview = { isIosWebKitTouch: () => true, create: vi.fn() };
|
||||
const { mixin } = loadMixin({ document: documentStub, MobileImePreview });
|
||||
const app: App = Object.assign(Object.create(mixin), {
|
||||
terminal: {
|
||||
textarea: {},
|
||||
element: {
|
||||
querySelector:
|
||||
failure === 'query'
|
||||
? () => {
|
||||
throw new Error('query failed');
|
||||
}
|
||||
: () => helpers,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(() => app._initMobileImePreview()).not.toThrow();
|
||||
expect(app._mobileImePreview).toBeNull();
|
||||
expect(MobileImePreview.create).not.toHaveBeenCalled();
|
||||
expect(owner.contains('codeman-ime-preview-owned')).toBe(false);
|
||||
if (!['query', 'create'].includes(failure)) expect(removed).toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Rebuilds the real `handleTerminalData` closure from initTerminal's source,
|
||||
* so these cases exercise the shipped routing rather than a copy of it.
|
||||
*/
|
||||
function loadHandleTerminalData(app: App, sent: string[]) {
|
||||
const marker = 'const handleTerminalData = (data) => {';
|
||||
const start = terminalSource.indexOf(marker);
|
||||
if (start < 0) throw new Error('handleTerminalData definition not found');
|
||||
const bodyStart = start + marker.length;
|
||||
const end = terminalSource.indexOf('\n };', bodyStart);
|
||||
if (end < 0) throw new Error('handleTerminalData boundary not found');
|
||||
const timers: Array<() => void> = [];
|
||||
app._sendInputAsync = (_sessionId: string, data: string) => sent.push(data);
|
||||
const context = vm.createContext({
|
||||
console,
|
||||
performance,
|
||||
setTimeout: (callback: () => void) => {
|
||||
timers.push(callback);
|
||||
return timers.length;
|
||||
},
|
||||
clearTimeout: vi.fn(),
|
||||
document: { activeElement: null, getElementById: vi.fn(() => null) },
|
||||
window: {
|
||||
cjkActive: false,
|
||||
CodemanTerminalInput: {
|
||||
BRACKETED_PASTE_START: '\x1b[200~',
|
||||
shouldSuppressTerminalQueryResponse: () => false,
|
||||
isTerminalFocusOrMouseReport: () => false,
|
||||
isComposerNavKey: () => false,
|
||||
},
|
||||
},
|
||||
_crashDiag: { log: vi.fn() },
|
||||
flushInput: () => {
|
||||
app._inputFlushTimeout = null;
|
||||
if (app._pendingInput && app.activeSessionId) {
|
||||
const input = app._pendingInput;
|
||||
app._pendingInput = '';
|
||||
app._sendInputAsync(app.activeSessionId, input);
|
||||
}
|
||||
},
|
||||
});
|
||||
const handler = vm.runInContext(`(function (data) {${terminalSource.slice(bodyStart, end)}\n})`, context) as (
|
||||
this: App,
|
||||
data: string
|
||||
) => void;
|
||||
return { handle: (data: string) => handler.call(app, data), timers };
|
||||
}
|
||||
|
||||
describe('mobile IME commit onData routing', () => {
|
||||
function onDataApp(options: {
|
||||
localEcho: boolean;
|
||||
tagged?: boolean;
|
||||
overlayMissing?: boolean;
|
||||
addThrows?: boolean;
|
||||
appendThrows?: boolean;
|
||||
consumeThrows?: boolean;
|
||||
}) {
|
||||
const { mixin } = loadMixin();
|
||||
const sent: string[] = [];
|
||||
const controller = {
|
||||
consumeTerminalData: options.consumeThrows
|
||||
? vi.fn(() => {
|
||||
throw new Error('consume failed');
|
||||
})
|
||||
: vi
|
||||
.fn()
|
||||
.mockReturnValueOnce(options.tagged ?? true)
|
||||
.mockReturnValue(false),
|
||||
completeCommit: vi.fn(),
|
||||
noteAuthoritativeOutput: vi.fn(),
|
||||
};
|
||||
const overlay = {
|
||||
pendingText: '',
|
||||
appendText: vi.fn((data: string) => {
|
||||
if (options.appendThrows) throw new Error('overlay failed');
|
||||
overlay.pendingText += data;
|
||||
}),
|
||||
addChar: vi.fn((data: string) => {
|
||||
if (options.addThrows) throw new Error('overlay failed');
|
||||
overlay.pendingText += data;
|
||||
}),
|
||||
clear: vi.fn(() => {
|
||||
overlay.pendingText = '';
|
||||
}),
|
||||
suppressBufferDetection: vi.fn(),
|
||||
};
|
||||
const app: App = Object.assign(Object.create(mixin), {
|
||||
activeSessionId: 'session-a',
|
||||
_localEchoEnabled: options.localEcho,
|
||||
_localEchoOverlay: options.overlayMissing ? null : overlay,
|
||||
_echoPassthroughSessions: new Set(),
|
||||
_flushedOffsets: new Map(),
|
||||
_flushedTexts: new Map(),
|
||||
_pendingInput: '',
|
||||
_inputFlushTimeout: null,
|
||||
_lastKeystrokeTime: 0,
|
||||
_terminalOutputSeq: 5,
|
||||
_mobileImePreview: controller,
|
||||
});
|
||||
return { app, controller, overlay, sent, ...loadHandleTerminalData(app, sent) };
|
||||
}
|
||||
|
||||
it('moves a multi-character commit into local echo and submits it only on Enter', () => {
|
||||
const { app, controller, overlay, sent, handle, timers } = onDataApp({ localEcho: true });
|
||||
handle('你好');
|
||||
expect(controller.consumeTerminalData).toHaveBeenCalledOnce();
|
||||
expect(overlay.appendText).toHaveBeenCalledWith('你好');
|
||||
expect(controller.completeCommit).toHaveBeenCalledWith({ predicted: true });
|
||||
expect(app._mobileImeCommitOutputSeq).toBeNull();
|
||||
expect(sent).toEqual([]);
|
||||
|
||||
handle('\r');
|
||||
expect(sent).toEqual(['你好']);
|
||||
timers.shift()?.();
|
||||
expect(sent).toEqual(['你好', '\r']);
|
||||
});
|
||||
|
||||
it('moves a single-character commit into local echo through addChar', () => {
|
||||
const { controller, overlay, sent, handle } = onDataApp({ localEcho: true });
|
||||
handle('界');
|
||||
expect(overlay.addChar).toHaveBeenCalledWith('界');
|
||||
expect(overlay.appendText).not.toHaveBeenCalled();
|
||||
expect(controller.completeCommit).toHaveBeenCalledWith({ predicted: true });
|
||||
expect(sent).toEqual([]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['the overlay is missing', { overlayMissing: true }, '日本'],
|
||||
['appendText throws', { appendThrows: true }, '失敗'],
|
||||
['addChar throws', { addThrows: true }, '字'],
|
||||
])('sends the committed text exactly once when %s', (_label, extra, text) => {
|
||||
const { controller, sent, handle } = onDataApp({ localEcho: true, ...extra });
|
||||
expect(() => handle(text)).not.toThrow();
|
||||
expect(sent).toEqual([text]);
|
||||
// Nothing else shows the text yet, so the preview keeps it.
|
||||
expect(controller.completeCommit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps an untagged paste on the existing local echo path', () => {
|
||||
const { controller, overlay, sent, handle } = onDataApp({ localEcho: true, tagged: false });
|
||||
handle('plain paste');
|
||||
expect(overlay.pendingText).toBe('plain paste');
|
||||
expect(controller.completeCommit).not.toHaveBeenCalled();
|
||||
expect(sent).toEqual([]);
|
||||
});
|
||||
|
||||
it('sends a commit once without local echo and holds the preview until output arrives', () => {
|
||||
const { app, controller, sent, handle } = onDataApp({ localEcho: false });
|
||||
handle('日本語');
|
||||
expect(controller.consumeTerminalData).toHaveBeenCalledOnce();
|
||||
expect(sent).toEqual(['日本語']);
|
||||
expect(controller.completeCommit).not.toHaveBeenCalled();
|
||||
expect(app._mobileImeCommitOutputSeq).toBe(5);
|
||||
});
|
||||
|
||||
it('sends the original bytes exactly once when the controller throws', () => {
|
||||
const { sent, handle } = onDataApp({ localEcho: false, consumeThrows: true });
|
||||
expect(() => handle('你好')).not.toThrow();
|
||||
expect(sent).toEqual(['你好']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mobile IME commit and authoritative terminal output', () => {
|
||||
function outputHarness(mode = 'claude') {
|
||||
const { mixin } = loadMixin();
|
||||
const parses: Array<() => void> = [];
|
||||
const written: string[] = [];
|
||||
const controller = {
|
||||
consumeTerminalData: vi.fn(() => true),
|
||||
noteAuthoritativeOutput: vi.fn(),
|
||||
};
|
||||
const app: App = Object.assign(Object.create(mixin), {
|
||||
pendingWrites: [],
|
||||
terminal: {
|
||||
rows: 24,
|
||||
write: vi.fn((data: string, callback?: () => void) => {
|
||||
written.push(data);
|
||||
if (callback) parses.push(callback);
|
||||
}),
|
||||
buffer: { active: { viewportY: 0 } },
|
||||
scrollToBottom: vi.fn(),
|
||||
},
|
||||
activeSessionId: 'session-a',
|
||||
sessions: new Map([['session-a', { mode }]]),
|
||||
isTerminalAtBottom: () => true,
|
||||
_hasRecentUserScrollUp: () => false,
|
||||
_safeYield: vi.fn(),
|
||||
_localEchoOverlay: null,
|
||||
_mobileImePreview: controller,
|
||||
});
|
||||
const flush = () => {
|
||||
app.writeFrameScheduled = false;
|
||||
app.flushPendingWrites();
|
||||
};
|
||||
const parseNext = () => parses.shift()?.();
|
||||
return { app, controller, written, flush, parseNext };
|
||||
}
|
||||
|
||||
it('clears the committed preview once output accepted after the commit is parsed', () => {
|
||||
const { app, controller, flush, parseNext } = outputHarness();
|
||||
app._consumeMobileImeTerminalData('你好');
|
||||
app.batchTerminalWrite('echo');
|
||||
flush();
|
||||
expect(controller.noteAuthoritativeOutput).not.toHaveBeenCalled();
|
||||
parseNext();
|
||||
expect(controller.noteAuthoritativeOutput).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('does not let output queued before the commit clear it, even when it parses after', () => {
|
||||
const { app, controller, flush, parseNext } = outputHarness();
|
||||
app.batchTerminalWrite('before');
|
||||
flush();
|
||||
app._consumeMobileImeTerminalData('你好');
|
||||
parseNext();
|
||||
expect(controller.noteAuthoritativeOutput).not.toHaveBeenCalled();
|
||||
|
||||
app.batchTerminalWrite('after');
|
||||
flush();
|
||||
parseNext();
|
||||
expect(controller.noteAuthoritativeOutput).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('does not let a split chunk clear the commit before its remainder is written', () => {
|
||||
const { app, controller, written, flush, parseNext } = outputHarness('codex');
|
||||
app._consumeMobileImeTerminalData('你好');
|
||||
app.batchTerminalWrite('x'.repeat(40000));
|
||||
flush();
|
||||
parseNext();
|
||||
expect(controller.noteAuthoritativeOutput).not.toHaveBeenCalled();
|
||||
flush();
|
||||
parseNext();
|
||||
expect(written.join('')).toBe('x'.repeat(40000));
|
||||
expect(controller.noteAuthoritativeOutput).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('does not let output parsed after a session switch clear the new session preview', () => {
|
||||
const { app, controller, flush, parseNext } = outputHarness();
|
||||
app._consumeMobileImeTerminalData('你好');
|
||||
app.batchTerminalWrite('echo');
|
||||
flush();
|
||||
app.activeSessionId = 'session-b';
|
||||
parseNext();
|
||||
expect(controller.noteAuthoritativeOutput).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('notifies once per commit, never for later output', () => {
|
||||
const { app, controller, flush, parseNext } = outputHarness();
|
||||
app._consumeMobileImeTerminalData('你好');
|
||||
for (const chunk of ['a', 'b']) {
|
||||
app.batchTerminalWrite(chunk);
|
||||
flush();
|
||||
parseNext();
|
||||
}
|
||||
expect(controller.noteAuthoritativeOutput).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,502 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import vm from 'node:vm';
|
||||
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
type Listener = (event: Record<string, unknown>) => void;
|
||||
type ListenerOptions = boolean | { capture?: boolean };
|
||||
type RegisteredListener = { listener: Listener; capture: boolean };
|
||||
|
||||
class FakeTextarea {
|
||||
value = 'unchanged';
|
||||
private listeners = new Map<string, RegisteredListener[]>();
|
||||
|
||||
addEventListener(type: string, listener: Listener, options?: ListenerOptions) {
|
||||
const listeners = this.listeners.get(type) ?? [];
|
||||
listeners.push({ listener, capture: options === true || options?.capture === true });
|
||||
this.listeners.set(type, listeners);
|
||||
}
|
||||
|
||||
removeEventListener(type: string, listener: Listener, options?: ListenerOptions) {
|
||||
const capture = options === true || options?.capture === true;
|
||||
const listeners = this.listeners.get(type) ?? [];
|
||||
const index = listeners.findIndex(
|
||||
(registered) => registered.listener === listener && registered.capture === capture
|
||||
);
|
||||
if (index >= 0) listeners.splice(index, 1);
|
||||
}
|
||||
|
||||
dispatch(type: string, event: Record<string, unknown> = {}) {
|
||||
const listeners = [...(this.listeners.get(type) ?? [])];
|
||||
for (const phase of [true, false]) {
|
||||
for (const registered of listeners) {
|
||||
if (registered.capture === phase) registered.listener({ type, ...event });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
listenerCount() {
|
||||
return [...this.listeners.values()].reduce((total, listeners) => total + listeners.length, 0);
|
||||
}
|
||||
}
|
||||
|
||||
type Scheduled = { id: number; callback: () => void; delay?: number };
|
||||
|
||||
function harness(
|
||||
overrides: Record<string, unknown> = {},
|
||||
beforeCreate?: (textarea: FakeTextarea, getController: () => Record<string, unknown> | undefined) => void
|
||||
) {
|
||||
const source = readFileSync(new URL('../src/web/public/mobile-ime-preview.js', import.meta.url), 'utf8');
|
||||
const context = vm.createContext({ navigator: {} });
|
||||
vm.runInContext(source, context, { filename: 'mobile-ime-preview.js' });
|
||||
const api = vm.runInContext('MobileImePreview', context);
|
||||
const textarea = new FakeTextarea();
|
||||
const frames: Scheduled[] = [];
|
||||
const timers: Scheduled[] = [];
|
||||
let nextId = 1;
|
||||
const render = vi.fn();
|
||||
const clear = vi.fn();
|
||||
const onCommit = vi.fn();
|
||||
const scheduleFrame = vi.fn((callback: () => void) => {
|
||||
const id = nextId++;
|
||||
frames.push({ id, callback });
|
||||
return id;
|
||||
});
|
||||
const cancelFrame = vi.fn((id: number) => {
|
||||
const index = frames.findIndex((frame) => frame.id === id);
|
||||
if (index >= 0) frames.splice(index, 1);
|
||||
});
|
||||
const setTimer = vi.fn((callback: () => void, delay: number) => {
|
||||
const id = nextId++;
|
||||
timers.push({ id, callback, delay });
|
||||
return id;
|
||||
});
|
||||
const clearTimer = vi.fn((id: number) => {
|
||||
const index = timers.findIndex((timer) => timer.id === id);
|
||||
if (index >= 0) timers.splice(index, 1);
|
||||
});
|
||||
let controller: Record<string, unknown> | undefined;
|
||||
beforeCreate?.(textarea, () => controller);
|
||||
controller = api.create({
|
||||
textarea,
|
||||
render,
|
||||
clear,
|
||||
onCommit,
|
||||
scheduleFrame,
|
||||
cancelFrame,
|
||||
setTimer,
|
||||
clearTimer,
|
||||
...overrides,
|
||||
});
|
||||
const flushFrame = () => frames.shift()?.callback();
|
||||
const flushTimer = () => timers.shift()?.callback();
|
||||
|
||||
return {
|
||||
api,
|
||||
textarea,
|
||||
frames,
|
||||
timers,
|
||||
render,
|
||||
clear,
|
||||
onCommit,
|
||||
scheduleFrame,
|
||||
cancelFrame,
|
||||
setTimer,
|
||||
clearTimer,
|
||||
controller,
|
||||
flushFrame,
|
||||
flushTimer,
|
||||
};
|
||||
}
|
||||
|
||||
describe('MobileImePreview', () => {
|
||||
beforeEach(() => vi.restoreAllMocks());
|
||||
|
||||
test('collapses 500 composition updates into one latest-state frame', () => {
|
||||
const h = harness();
|
||||
h.textarea.dispatch('compositionstart');
|
||||
for (let i = 0; i < 500; i += 1) h.textarea.dispatch('compositionupdate', { data: `value-${i}` });
|
||||
|
||||
expect(h.scheduleFrame).toHaveBeenCalledTimes(1);
|
||||
expect(h.render).not.toHaveBeenCalled();
|
||||
h.flushFrame();
|
||||
expect(h.render).toHaveBeenCalledOnce();
|
||||
expect(h.render).toHaveBeenLastCalledWith({ text: 'value-499', phase: 'provisional' });
|
||||
expect(h.onCommit).not.toHaveBeenCalled();
|
||||
expect(h.textarea.value).toBe('unchanged');
|
||||
});
|
||||
|
||||
test('replaces provisional text for replacement, backspace, and composing input', () => {
|
||||
const h = harness();
|
||||
h.textarea.dispatch('compositionstart');
|
||||
h.textarea.dispatch('compositionupdate', { data: 'abcdef' });
|
||||
h.textarea.dispatch('compositionupdate', { data: 'xy' });
|
||||
h.flushFrame();
|
||||
expect(h.render).toHaveBeenLastCalledWith({ text: 'xy', phase: 'provisional' });
|
||||
|
||||
h.textarea.dispatch('input', { data: '', isComposing: true });
|
||||
h.flushFrame();
|
||||
expect(h.render).toHaveBeenLastCalledWith({ text: '', phase: 'provisional' });
|
||||
expect(h.onCommit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('caps the preview without changing the terminal handoff value', () => {
|
||||
const h = harness();
|
||||
const value = '界'.repeat(2050);
|
||||
h.textarea.dispatch('compositionstart');
|
||||
h.textarea.dispatch('compositionupdate', { data: value });
|
||||
h.flushFrame();
|
||||
expect(h.render.mock.calls[0][0].text).toHaveLength(2048);
|
||||
h.textarea.dispatch('compositionend', { data: value });
|
||||
expect(h.controller.consumeTerminalData(value)).toBe(true);
|
||||
expect(h.onCommit).toHaveBeenCalledWith(value);
|
||||
});
|
||||
|
||||
test('hands off only the first safe xterm onData value after finalization', () => {
|
||||
const h = harness();
|
||||
h.textarea.dispatch('compositionstart');
|
||||
h.textarea.dispatch('compositionupdate', { data: '日本語' });
|
||||
h.textarea.dispatch('compositionend', { data: '日本語' });
|
||||
|
||||
expect(h.controller.consumeTerminalData('日本語')).toBe(true);
|
||||
expect(h.controller.consumeTerminalData('日本語')).toBe(false);
|
||||
expect(h.onCommit).toHaveBeenCalledOnce();
|
||||
expect(h.render).not.toHaveBeenCalled();
|
||||
expect(h.frames).toHaveLength(1);
|
||||
h.flushFrame();
|
||||
expect(h.render).toHaveBeenLastCalledWith({ text: '日本語', phase: 'committed' });
|
||||
expect(h.setTimer).toHaveBeenCalledWith(expect.any(Function), 2000);
|
||||
});
|
||||
|
||||
test('defers finalization rendering and updates the queued frame phase to committed', () => {
|
||||
const h = harness();
|
||||
h.textarea.dispatch('compositionstart');
|
||||
h.textarea.dispatch('compositionupdate', { data: 'draft' });
|
||||
h.textarea.dispatch('compositionend');
|
||||
expect(h.render).not.toHaveBeenCalled();
|
||||
expect(h.frames).toHaveLength(1);
|
||||
|
||||
expect(h.controller.consumeTerminalData('final')).toBe(true);
|
||||
expect(h.frames).toHaveLength(1);
|
||||
expect(h.render).not.toHaveBeenCalled();
|
||||
h.flushFrame();
|
||||
expect(h.render).toHaveBeenCalledOnce();
|
||||
expect(h.render).toHaveBeenCalledWith({ text: 'final', phase: 'committed' });
|
||||
});
|
||||
|
||||
test('treats the first safe xterm onData value as authoritative over stale provisional data', () => {
|
||||
const h = harness();
|
||||
h.textarea.dispatch('compositionstart');
|
||||
h.textarea.dispatch('compositionupdate', { data: 'teh' });
|
||||
h.textarea.dispatch('compositionend');
|
||||
|
||||
expect(h.controller.consumeTerminalData('the')).toBe(true);
|
||||
expect(h.onCommit).toHaveBeenCalledOnce();
|
||||
expect(h.onCommit).toHaveBeenCalledWith('the');
|
||||
expect(h.controller.consumeTerminalData('teh')).toBe(false);
|
||||
});
|
||||
|
||||
test.each(['', '\n', 'line\rbreak', 'two\nlines', '\u0003', '\u007f'])(
|
||||
'rejects non-printable or multiline terminal data %j without consuming the pending value',
|
||||
(rejected) => {
|
||||
const h = harness();
|
||||
h.textarea.dispatch('compositionstart');
|
||||
h.textarea.dispatch('compositionend', { data: rejected });
|
||||
expect(h.controller.consumeTerminalData(rejected)).toBe(false);
|
||||
expect(h.onCommit).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
test.each(['line\u2028break', 'line\u2029break'])(
|
||||
'rejects Unicode line separator terminal data %j without consuming the finalization fence',
|
||||
(rejected) => {
|
||||
const h = harness();
|
||||
h.textarea.dispatch('compositionstart');
|
||||
h.textarea.dispatch('compositionend');
|
||||
expect(h.controller.consumeTerminalData(rejected)).toBe(false);
|
||||
expect(h.controller.consumeTerminalData('safe')).toBe(true);
|
||||
}
|
||||
);
|
||||
|
||||
test('keydown can finalize composition before a late compositionend', () => {
|
||||
const h = harness();
|
||||
h.textarea.dispatch('compositionstart');
|
||||
h.textarea.dispatch('compositionupdate', { data: '確定' });
|
||||
h.textarea.dispatch('keydown', { key: 'Enter', isComposing: false });
|
||||
expect(h.controller.consumeTerminalData('確定')).toBe(true);
|
||||
h.textarea.dispatch('compositionend', { data: 'stale' });
|
||||
expect(h.controller.consumeTerminalData('stale')).toBe(false);
|
||||
});
|
||||
|
||||
test('capture keydown finalization precedes an earlier xterm bubble onData listener', () => {
|
||||
const consumed: boolean[] = [];
|
||||
const h = harness({}, (textarea, getController) => {
|
||||
textarea.addEventListener('keydown', () => {
|
||||
const controller = getController() as { consumeTerminalData(data: string): boolean };
|
||||
consumed.push(controller.consumeTerminalData('確定'));
|
||||
});
|
||||
});
|
||||
h.textarea.dispatch('compositionstart');
|
||||
h.textarea.dispatch('compositionupdate', { data: '確定' });
|
||||
h.textarea.dispatch('keydown', { key: 'Enter', isComposing: false });
|
||||
|
||||
expect(consumed).toEqual([true]);
|
||||
expect(h.controller.consumeTerminalData('確定')).toBe(false);
|
||||
expect(h.onCommit).toHaveBeenCalledOnce();
|
||||
|
||||
h.controller.destroy();
|
||||
h.textarea.dispatch('compositionstart');
|
||||
h.textarea.dispatch('compositionupdate', { data: 'later' });
|
||||
h.textarea.dispatch('keydown', { key: 'Enter', isComposing: false });
|
||||
expect(consumed).toEqual([true, false]);
|
||||
expect(h.onCommit).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test('generation fences stale frames and timers', () => {
|
||||
const h = harness();
|
||||
h.textarea.dispatch('compositionstart');
|
||||
h.textarea.dispatch('compositionupdate', { data: 'old' });
|
||||
const staleFrame = h.frames[0].callback;
|
||||
h.textarea.dispatch('compositionstart');
|
||||
staleFrame();
|
||||
expect(h.render).not.toHaveBeenCalled();
|
||||
|
||||
h.textarea.dispatch('compositionend', { data: 'first' });
|
||||
h.controller.consumeTerminalData('first');
|
||||
const staleTimer = h.timers[0].callback;
|
||||
h.textarea.dispatch('compositionstart');
|
||||
h.textarea.dispatch('compositionupdate', { data: 'new' });
|
||||
h.flushFrame();
|
||||
staleTimer();
|
||||
expect(h.render).toHaveBeenLastCalledWith({ text: 'new', phase: 'provisional' });
|
||||
});
|
||||
|
||||
test('predicted completion clears immediately while fallback waits for output or TTL', () => {
|
||||
const predicted = harness();
|
||||
predicted.textarea.dispatch('compositionstart');
|
||||
predicted.clear.mockClear();
|
||||
predicted.textarea.dispatch('compositionend', { data: 'one' });
|
||||
predicted.controller.consumeTerminalData('one');
|
||||
expect(predicted.frames).toHaveLength(1);
|
||||
predicted.controller.completeCommit({ predicted: true });
|
||||
expect(predicted.clear).toHaveBeenCalledOnce();
|
||||
expect(predicted.frames).toHaveLength(0);
|
||||
expect(predicted.timers).toHaveLength(0);
|
||||
expect(predicted.controller.state.latest).toBe('');
|
||||
expect(predicted.controller.state.committed).toBe(false);
|
||||
|
||||
const fallback = harness();
|
||||
fallback.textarea.dispatch('compositionstart');
|
||||
fallback.clear.mockClear();
|
||||
fallback.textarea.dispatch('compositionend', { data: 'two' });
|
||||
fallback.controller.consumeTerminalData('two');
|
||||
fallback.controller.completeCommit({ predicted: false });
|
||||
expect(fallback.clear).not.toHaveBeenCalled();
|
||||
fallback.controller.noteAuthoritativeOutput();
|
||||
expect(fallback.clear).toHaveBeenCalledOnce();
|
||||
expect(fallback.frames).toHaveLength(0);
|
||||
expect(fallback.timers).toHaveLength(0);
|
||||
expect(fallback.controller.state.latest).toBe('');
|
||||
expect(fallback.controller.state.committed).toBe(false);
|
||||
|
||||
const ttl = harness();
|
||||
ttl.textarea.dispatch('compositionstart');
|
||||
ttl.clear.mockClear();
|
||||
ttl.textarea.dispatch('compositionend', { data: 'three' });
|
||||
ttl.controller.consumeTerminalData('three');
|
||||
ttl.controller.completeCommit({ predicted: false });
|
||||
ttl.flushTimer();
|
||||
expect(ttl.clear).toHaveBeenCalledOnce();
|
||||
expect(ttl.frames).toHaveLength(0);
|
||||
expect(ttl.timers).toHaveLength(0);
|
||||
expect(ttl.controller.state.latest).toBe('');
|
||||
expect(ttl.controller.state.committed).toBe(false);
|
||||
});
|
||||
|
||||
test('contains re-entrant reset and destroy from commit callbacks without resurrecting work', () => {
|
||||
let resetController: { reset(): void };
|
||||
const reset = harness({ onCommit: () => resetController.reset() });
|
||||
resetController = reset.controller;
|
||||
reset.textarea.dispatch('compositionstart');
|
||||
reset.textarea.dispatch('compositionupdate', { data: 'draft' });
|
||||
reset.textarea.dispatch('compositionend');
|
||||
expect(reset.controller.consumeTerminalData('final')).toBe(true);
|
||||
expect(reset.frames).toHaveLength(0);
|
||||
expect(reset.timers).toHaveLength(0);
|
||||
expect(reset.controller.state.latest).toBe('');
|
||||
|
||||
let destroyController: { destroy(): void };
|
||||
const destroy = harness({ onCommit: () => destroyController.destroy() });
|
||||
destroyController = destroy.controller;
|
||||
destroy.textarea.dispatch('compositionstart');
|
||||
destroy.textarea.dispatch('compositionend');
|
||||
expect(destroy.controller.consumeTerminalData('final')).toBe(true);
|
||||
expect(destroy.textarea.listenerCount()).toBe(0);
|
||||
expect(destroy.frames).toHaveLength(0);
|
||||
expect(destroy.timers).toHaveLength(0);
|
||||
expect(destroy.controller.state.latest).toBe('');
|
||||
});
|
||||
|
||||
test('contains re-entrant render and clear callbacks', () => {
|
||||
let renderController: { reset(): void };
|
||||
const render = harness({ render: () => renderController.reset() });
|
||||
renderController = render.controller;
|
||||
render.textarea.dispatch('compositionstart');
|
||||
render.textarea.dispatch('compositionupdate', { data: 'draft' });
|
||||
expect(() => render.flushFrame()).not.toThrow();
|
||||
expect(render.frames).toHaveLength(0);
|
||||
expect(render.timers).toHaveLength(0);
|
||||
expect(render.controller.state.latest).toBe('');
|
||||
|
||||
let clearController: { destroy(): void };
|
||||
const clear = harness({ clear: () => clearController?.destroy() });
|
||||
clearController = clear.controller;
|
||||
expect(() => clear.textarea.dispatch('compositionstart')).not.toThrow();
|
||||
expect(clear.textarea.listenerCount()).toBe(0);
|
||||
expect(clear.frames).toHaveLength(0);
|
||||
expect(clear.timers).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('fails open when frame or timer schedulers throw', () => {
|
||||
const frame = harness({
|
||||
scheduleFrame: () => {
|
||||
throw new Error('frame scheduler');
|
||||
},
|
||||
});
|
||||
frame.textarea.dispatch('compositionstart');
|
||||
expect(() => frame.textarea.dispatch('compositionupdate', { data: 'safe' })).not.toThrow();
|
||||
expect(frame.controller.state.framePending).toBe(false);
|
||||
|
||||
let lateFrame: (() => void) | undefined;
|
||||
const timer = harness({
|
||||
scheduleFrame: (callback: () => void) => {
|
||||
lateFrame = callback;
|
||||
return 1;
|
||||
},
|
||||
cancelFrame: () => {},
|
||||
setTimer: () => {
|
||||
throw new Error('timer scheduler');
|
||||
},
|
||||
});
|
||||
timer.textarea.dispatch('compositionstart');
|
||||
timer.clear.mockClear();
|
||||
timer.textarea.dispatch('compositionend');
|
||||
expect(() => timer.controller.consumeTerminalData('safe')).not.toThrow();
|
||||
expect(timer.controller.state.committed).toBe(false);
|
||||
expect(timer.controller.state.latest).toBe('');
|
||||
expect(timer.controller.state.framePending).toBe(false);
|
||||
expect(timer.controller.state.timerPending).toBe(false);
|
||||
expect(timer.clear).toHaveBeenCalledOnce();
|
||||
lateFrame?.();
|
||||
expect(timer.render).not.toHaveBeenCalled();
|
||||
|
||||
const tokenlessTimer = harness({ setTimer: () => undefined });
|
||||
tokenlessTimer.textarea.dispatch('compositionstart');
|
||||
tokenlessTimer.clear.mockClear();
|
||||
tokenlessTimer.textarea.dispatch('compositionend');
|
||||
expect(tokenlessTimer.controller.consumeTerminalData('safe')).toBe(true);
|
||||
expect(tokenlessTimer.controller.state.committed).toBe(false);
|
||||
expect(tokenlessTimer.controller.state.latest).toBe('');
|
||||
expect(tokenlessTimer.controller.state.framePending).toBe(false);
|
||||
expect(tokenlessTimer.controller.state.timerPending).toBe(false);
|
||||
expect(tokenlessTimer.frames).toHaveLength(0);
|
||||
expect(tokenlessTimer.timers).toHaveLength(0);
|
||||
expect(tokenlessTimer.clear).toHaveBeenCalledOnce();
|
||||
|
||||
const cancellation = harness({
|
||||
cancelFrame: () => {
|
||||
throw new Error('frame cancellation');
|
||||
},
|
||||
clearTimer: () => {
|
||||
throw new Error('timer cancellation');
|
||||
},
|
||||
});
|
||||
cancellation.textarea.dispatch('compositionstart');
|
||||
cancellation.textarea.dispatch('compositionupdate', { data: 'draft' });
|
||||
cancellation.textarea.dispatch('compositionend');
|
||||
cancellation.controller.consumeTerminalData('final');
|
||||
expect(() => cancellation.controller.reset()).not.toThrow();
|
||||
expect(cancellation.controller.state.framePending).toBe(false);
|
||||
expect(cancellation.controller.state.timerPending).toBe(false);
|
||||
});
|
||||
|
||||
test('authoritative output never clears active provisional composition', () => {
|
||||
const h = harness();
|
||||
h.textarea.dispatch('compositionstart');
|
||||
h.clear.mockClear();
|
||||
h.textarea.dispatch('compositionupdate', { data: 'active' });
|
||||
h.flushFrame();
|
||||
h.controller.noteAuthoritativeOutput();
|
||||
expect(h.clear).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('blur and reset cancel scheduled work and clear visual state', () => {
|
||||
const h = harness();
|
||||
h.textarea.dispatch('compositionstart');
|
||||
h.clear.mockClear();
|
||||
h.textarea.dispatch('compositionupdate', { data: 'pending' });
|
||||
h.textarea.dispatch('blur');
|
||||
expect(h.frames).toHaveLength(0);
|
||||
expect(h.clear).toHaveBeenCalledOnce();
|
||||
|
||||
h.textarea.dispatch('compositionstart');
|
||||
h.clear.mockClear();
|
||||
h.textarea.dispatch('compositionupdate', { data: 'again' });
|
||||
h.controller.reset();
|
||||
expect(h.frames).toHaveLength(0);
|
||||
expect(h.clear).toHaveBeenCalledOnce();
|
||||
expect(h.controller.state.latest).toBe('');
|
||||
expect(h.controller.state.committed).toBe(false);
|
||||
});
|
||||
|
||||
test('destroy is idempotent and removes listeners and scheduled work', () => {
|
||||
const h = harness();
|
||||
h.textarea.dispatch('compositionstart');
|
||||
h.textarea.dispatch('compositionupdate', { data: 'pending' });
|
||||
expect(h.textarea.listenerCount()).toBe(6);
|
||||
h.controller.destroy();
|
||||
h.controller.destroy();
|
||||
expect(h.textarea.listenerCount()).toBe(0);
|
||||
expect(h.frames).toHaveLength(0);
|
||||
h.textarea.dispatch('compositionupdate', { data: 'ignored' });
|
||||
expect(h.scheduleFrame).toHaveBeenCalledOnce();
|
||||
expect(() => h.controller.reset()).not.toThrow();
|
||||
expect(h.controller.state.latest).toBe('');
|
||||
expect(h.controller.state.framePending).toBe(false);
|
||||
expect(h.controller.state.timerPending).toBe(false);
|
||||
});
|
||||
|
||||
test('contains render, clear, and commit callback exceptions', () => {
|
||||
const h = harness({
|
||||
render: vi.fn(() => {
|
||||
throw new Error('render');
|
||||
}),
|
||||
clear: vi.fn(() => {
|
||||
throw new Error('clear');
|
||||
}),
|
||||
onCommit: vi.fn(() => {
|
||||
throw new Error('commit');
|
||||
}),
|
||||
});
|
||||
h.textarea.dispatch('compositionstart');
|
||||
h.textarea.dispatch('compositionupdate', { data: 'safe' });
|
||||
expect(() => h.flushFrame()).not.toThrow();
|
||||
h.textarea.dispatch('compositionend', { data: 'safe' });
|
||||
expect(() => h.controller.consumeTerminalData('safe')).not.toThrow();
|
||||
expect(() => h.controller.reset()).not.toThrow();
|
||||
});
|
||||
|
||||
test.each([
|
||||
[{ userAgent: 'Mozilla/5.0 (iPhone) AppleWebKit/605.1.15', maxTouchPoints: 5 }, true],
|
||||
[{ userAgent: 'Mozilla/5.0 (iPad) AppleWebKit/605.1.15', maxTouchPoints: 5 }, true],
|
||||
[{ userAgent: 'Mozilla/5.0 (iPod) AppleWebKit/605.1.15', maxTouchPoints: 1 }, true],
|
||||
[{ userAgent: 'Mozilla/5.0 (Macintosh) AppleWebKit/605.1.15', platform: 'MacIntel', maxTouchPoints: 2 }, true],
|
||||
[{ userAgent: 'CriOS/120.0 (iPhone) AppleWebKit/605.1.15', maxTouchPoints: 5 }, true],
|
||||
[{ userAgent: 'Mozilla/5.0 (iPhone) Gecko/120', maxTouchPoints: 5 }, false],
|
||||
[{ userAgent: 'Mozilla/5.0 (iPhone) AppleWebKit/605.1.15', maxTouchPoints: 0 }, false],
|
||||
[{ userAgent: 'Mozilla/5.0 (Macintosh) AppleWebKit/605.1.15', platform: 'MacIntel', maxTouchPoints: 1 }, false],
|
||||
[{ userAgent: 'Mozilla/5.0 (Android) AppleWebKit/537.36', maxTouchPoints: 5 }, false],
|
||||
])('detects iOS WebKit touch eligibility for %j', (nav, expected) => {
|
||||
expect(harness().api.isIosWebKitTouch(nav)).toBe(expected);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user