Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
191 lines
7.5 KiB
TypeScript
191 lines
7.5 KiB
TypeScript
/**
|
||
* CJK IMEs (notably Sogou on macOS) often emit a keydown whose `event.key` is
|
||
* still the half-width ASCII punctuation, then commit the full-width glyph via
|
||
* an `input`/`insertText` event. xterm.js sends the keydown character and then
|
||
* drops the input event because `_keyDownSeen` is set — so the PTY receives
|
||
* "," instead of ",".
|
||
*
|
||
* Defer those keydowns to the following insertText. If no remap arrives before
|
||
* keyup/blur, the original ASCII key is flushed. Composition (keyCode 229 /
|
||
* isComposing) stays on xterm's CompositionHelper path.
|
||
*
|
||
* The deferral must never outlive the keystroke it was armed for: Windows IMEs
|
||
* report `Process` / keyCode 229 as the keyup of a key they consumed (or drop
|
||
* the keyup), so a deferral flushed only on an exact key match stayed armed and
|
||
* blocked typed input from then on (#3103). Any real key release, and any later
|
||
* unrelated keydown, now ends the deferral.
|
||
*/
|
||
|
||
export type ImeTextInputKeyEvent = {
|
||
type?: string;
|
||
key: string;
|
||
code?: string;
|
||
keyCode?: number;
|
||
altKey?: boolean;
|
||
ctrlKey?: boolean;
|
||
metaKey?: boolean;
|
||
isComposing?: boolean;
|
||
};
|
||
|
||
export type ImeTextInputEvent = Pick<InputEvent, "data" | "inputType">;
|
||
|
||
/** Printable ASCII punctuation IMEs commonly remap to full-width forms. */
|
||
const ASCII_PUNCTUATION_RE = /^[\x21-\x2f\x3a-\x40\x5b-\x60\x7b-\x7e]$/;
|
||
|
||
export function isAsciiPunctuationKey(key: string): boolean {
|
||
return ASCII_PUNCTUATION_RE.test(key);
|
||
}
|
||
|
||
export function shouldDeferKeyDownForImeTextInput(
|
||
event: ImeTextInputKeyEvent,
|
||
): boolean {
|
||
if (event.type !== undefined && event.type !== "keydown") return false;
|
||
if (event.isComposing === true || event.keyCode === 229) return false;
|
||
if (event.altKey || event.ctrlKey || event.metaKey) return false;
|
||
return isAsciiPunctuationKey(event.key);
|
||
}
|
||
|
||
/** Key releases that carry no keystroke of their own. */
|
||
const MODIFIER_ONLY_KEY_RE =
|
||
/^(Shift|Control|Alt|Meta|CapsLock|NumLock|ScrollLock|Hyper|Super|Fn|FnLock|Symbol|SymbolLock)$/;
|
||
|
||
export function isModifierOnlyKey(key: string): boolean {
|
||
return MODIFIER_ONLY_KEY_RE.test(key);
|
||
}
|
||
|
||
/**
|
||
* DOM keys that stand in for a key the IME consumed, mirroring the non-text
|
||
* DOM keys the Kitty encoder already refuses to send as text.
|
||
*/
|
||
const IME_SENTINEL_KEYS = new Set(["Dead", "Process", "Unidentified", "Compose"]);
|
||
|
||
export function isImeSentinelKeyUp(event: ImeTextInputKeyEvent): boolean {
|
||
if (event.type !== undefined && event.type !== "keyup") return false;
|
||
return event.keyCode === 229 || IME_SENTINEL_KEYS.has(event.key);
|
||
}
|
||
|
||
/**
|
||
* A deferred punctuation keystroke is over once any real key release arrives.
|
||
* The IME remap (insertText) is dispatched before keyup, so a release means the
|
||
* IME did not remap the key and the ASCII character must be flushed.
|
||
*
|
||
* The release key cannot be matched exactly: Windows IMEs report `Process` /
|
||
* keyCode 229 as the release of a key they consumed, and some drop the release
|
||
* entirely. Requiring an exact key match left the deferral armed, and the
|
||
* armed deferral then blocked input (#3103). Composing releases are excluded —
|
||
* an active composition still owns the keystroke and resolves it via
|
||
* insertText.
|
||
*/
|
||
export function shouldFlushDeferredImeTextInputOnKeyUp(
|
||
deferredKey: string | null | undefined,
|
||
event: ImeTextInputKeyEvent,
|
||
): boolean {
|
||
if (!deferredKey) return false;
|
||
if (event.type !== undefined && event.type !== "keyup") return false;
|
||
if (event.isComposing === true) return false;
|
||
if (event.altKey || event.ctrlKey || event.metaKey) return false;
|
||
return !isModifierOnlyKey(event.key);
|
||
}
|
||
|
||
/**
|
||
* A deferral that outlived its own keystroke is stale — the IME swallowed the
|
||
* release. Flush it when a new, unmodified, non-composing keydown for a
|
||
* different key arrives so the pending ASCII character still reaches the PTY.
|
||
* A same-key keydown is auto-repeat (or a second IME attempt) and keeps
|
||
* re-arming instead.
|
||
*/
|
||
export function shouldFlushStaleDeferredImeTextInput(
|
||
deferredKey: string | null | undefined,
|
||
event: ImeTextInputKeyEvent,
|
||
): boolean {
|
||
if (!deferredKey) return false;
|
||
if (event.type !== undefined && event.type !== "keydown") return false;
|
||
if (event.isComposing === true || event.keyCode === 229) return false;
|
||
if (event.altKey || event.ctrlKey || event.metaKey) return false;
|
||
if (isModifierOnlyKey(event.key)) return false;
|
||
return event.key !== deferredKey;
|
||
}
|
||
|
||
/**
|
||
* A modified keydown is a command (Ctrl+C, Alt+…), not the continuation of the
|
||
* lost punctuation keystroke: flushing there would inject the ASCII character
|
||
* in front of the interrupt or shortcut. Drop the stale deferral instead — the
|
||
* keystroke was already lost to the IME.
|
||
*/
|
||
export function shouldDiscardStaleDeferredImeTextInput(
|
||
deferredKey: string | null | undefined,
|
||
event: ImeTextInputKeyEvent,
|
||
): boolean {
|
||
if (!deferredKey) return false;
|
||
if (event.type !== undefined && event.type !== "keydown") return false;
|
||
if (event.isComposing === true || event.keyCode === 229) return false;
|
||
return Boolean(event.altKey || event.ctrlKey || event.metaKey);
|
||
}
|
||
|
||
/**
|
||
* How the keyup that ends a deferral relates to the deferred key.
|
||
* - `deferred`: an IME sentinel stood in for the deferred key, so the paired
|
||
* release must be encoded from the deferred physical key.
|
||
* - `own`: the release belongs to the deferred key itself (matched key or
|
||
* physical code), so it already pairs the press the flush emitted.
|
||
* - `unrelated`: another held key was released; it keeps its own identity and
|
||
* the deferred press needs a separate synthesized release.
|
||
*/
|
||
export type DeferredKeyupReleaseMode = "deferred" | "own" | "unrelated";
|
||
|
||
export function resolveDeferredKeyupRelease(
|
||
deferredKey: string | null | undefined,
|
||
deferredCode: string | null | undefined,
|
||
event: ImeTextInputKeyEvent,
|
||
): DeferredKeyupReleaseMode {
|
||
if (!deferredKey) return "own";
|
||
if (event.type !== undefined && event.type !== "keyup") return "own";
|
||
if (event.altKey || event.ctrlKey || event.metaKey) return "own";
|
||
if (isModifierOnlyKey(event.key)) return "own";
|
||
if (event.key === deferredKey) return "own";
|
||
if (event.code && deferredCode && event.code === deferredCode) return "own";
|
||
if (isImeSentinelKeyUp(event)) return "deferred";
|
||
return "unrelated";
|
||
}
|
||
|
||
export function shouldBlockKeyPressForImeTextInput(
|
||
deferredKey: string | null | undefined,
|
||
event: ImeTextInputKeyEvent,
|
||
deferredKeyCode?: number | null,
|
||
): boolean {
|
||
if (!deferredKey || event.type !== "keypress") return false;
|
||
// Composition keypresses carry no committable character on this path.
|
||
if (event.isComposing === true || event.keyCode === 229) return true;
|
||
// Only the deferred keystroke itself is suppressed. Blocking every keypress
|
||
// while armed turned one stale deferral into a terminal that ignored all
|
||
// typed input (#3103).
|
||
return (
|
||
event.key === deferredKey ||
|
||
(deferredKeyCode != null && event.keyCode === deferredKeyCode)
|
||
);
|
||
}
|
||
|
||
export function shouldCommitDeferredImeTextInput(
|
||
deferredKey: string | null | undefined,
|
||
event: ImeTextInputEvent,
|
||
): event is ImeTextInputEvent & { data: string } {
|
||
return (
|
||
Boolean(deferredKey) &&
|
||
event.inputType === "insertText" &&
|
||
typeof event.data === "string" &&
|
||
event.data.length > 0
|
||
);
|
||
}
|
||
|
||
/**
|
||
* True when insertText/flush kept the deferred ASCII key (no CJK remap).
|
||
* Those commits must not use Kitty composition encoding — under report-all
|
||
* that emits unidentified CSI 0 u and drops press/release.
|
||
*/
|
||
export function isUnchangedDeferredImeTextInput(
|
||
deferredKey: string | null | undefined,
|
||
text: string,
|
||
): boolean {
|
||
return deferredKey != null && text === deferredKey;
|
||
}
|