Compare commits

...
Author SHA1 Message Date
netcatty-bot 9ff48cee66 fix(#3441): automated AI fix 2026-09-18 09:18:00 +00:00
3 changed files with 160 additions and 0 deletions

No files matched your search

@@ -122,6 +122,7 @@ import {
shouldFlushDeferredImeTextInputOnKeyUp,
shouldFlushStaleDeferredImeTextInput,
} from "./terminalImeTextInput";
import { keepImeCommittedTextThroughModifierKeyDowns } from "./imeModifierKeyDownSeenGuard";
import { formatSerialLocalEcho } from "./serialLocalEcho";
import { getLastChar, removeLastChar, isPrintableInput } from "../../../domain/serialCharMetrics";
import { mapTerminalBackspaceInput } from "./terminalBackspaceInput";
@@ -631,6 +632,10 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
},
});
installSearchDecorationTracker(term);
// Sogou on Windows commits pending preedit text when Shift toggles
// Chinese/English mode; a pure modifier keydown must not arm xterm's
// insertText dedupe guard or the committed text is dropped (#3441).
keepImeCommittedTextThroughModifierKeyDowns(term);
type MaybeRenderer = {
constructor?: { name?: string };
@@ -0,0 +1,99 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import {
isModifierOnlyKeyDownEvent,
keepImeCommittedTextThroughModifierKeyDowns,
} from "./imeModifierKeyDownSeenGuard";
const runtimeSource = readFileSync(
join(dirname(fileURLToPath(import.meta.url)), "createXTermRuntime.ts"),
"utf8",
);
test("isModifierOnlyKeyDownEvent accepts both DOM key and classic keyCode shapes", () => {
assert.equal(isModifierOnlyKeyDownEvent({ key: "Shift", keyCode: 16 }), true);
assert.equal(isModifierOnlyKeyDownEvent({ key: "Shift" }), true);
assert.equal(isModifierOnlyKeyDownEvent({ key: "Control", keyCode: 17 }), true);
assert.equal(isModifierOnlyKeyDownEvent({ key: "Alt", keyCode: 18 }), true);
assert.equal(isModifierOnlyKeyDownEvent({ key: "Meta", keyCode: 91 }), true);
assert.equal(isModifierOnlyKeyDownEvent({ key: "CapsLock" }), true);
});
test("isModifierOnlyKeyDownEvent rejects real keys and IME process events", () => {
assert.equal(isModifierOnlyKeyDownEvent({ key: "a", keyCode: 65 }), false);
assert.equal(isModifierOnlyKeyDownEvent({ key: "Process", keyCode: 229 }), false);
assert.equal(isModifierOnlyKeyDownEvent({ key: "Enter", keyCode: 13 }), false);
assert.equal(isModifierOnlyKeyDownEvent({ key: "Dead", keyCode: 221 }), false);
});
type FakeCore = {
_keyDown: (event: Pick<KeyboardEvent, "key" | "keyCode">) => boolean;
_keyDownSeen: boolean;
_keyUp: () => void;
};
function createFakeCore(initialSeen = false): {
core: FakeCore;
term: { _core: FakeCore };
} {
const core: FakeCore = {
_keyDownSeen: initialSeen,
_keyDown(_event) {
// Mirrors xterm: every keydown arms the insertText dedupe guard.
this._keyDownSeen = true;
return true;
},
_keyUp() {
this._keyDownSeen = false;
},
};
return { core, term: { _core: core } };
}
test("keepImeCommittedTextThroughModifierKeyDowns preserves seen state across Shift keydown", () => {
// Sogou Shift commit: a previous real keydown armed the guard, and the
// Shift keydown must not re-arm (or clear) it before the composed input.
const { core, term } = createFakeCore(false);
keepImeCommittedTextThroughModifierKeyDowns(term as never);
core._keyDown({ key: "Shift", keyCode: 16 } as KeyboardEvent);
assert.equal(core._keyDownSeen, false);
});
test("keepImeCommittedTextThroughModifierKeyDowns keeps guard armed for real keydowns", () => {
const { core, term } = createFakeCore(false);
keepImeCommittedTextThroughModifierKeyDowns(term as never);
core._keyDown({ key: "a", keyCode: 65 } as KeyboardEvent);
assert.equal(core._keyDownSeen, true);
});
test("keepImeCommittedTextThroughModifierKeyDowns preserves an already-armed guard", () => {
const { core, term } = createFakeCore(true);
keepImeCommittedTextThroughModifierKeyDowns(term as never);
core._keyDown({ key: "Shift" } as KeyboardEvent);
assert.equal(core._keyDownSeen, true);
});
test("keepImeCommittedTextThroughModifierKeyDowns leaves non-modifier behavior intact", () => {
const { core, term } = createFakeCore(false);
keepImeCommittedTextThroughModifierKeyDowns(term as never);
core._keyDown({ key: "Process", keyCode: 229 } as KeyboardEvent);
assert.equal(core._keyDownSeen, true);
core._keyDown({ key: "Control", keyCode: 17 } as KeyboardEvent);
assert.equal(core._keyDownSeen, true, "Control still restored to true (was true)");
});
test("guard tolerates a terminal without an introspectable core", () => {
assert.doesNotThrow(() => {
keepImeCommittedTextThroughModifierKeyDowns({} as never);
});
});
test("runtime installs the guard when the terminal is created", () => {
assert.match(
runtimeSource,
/keepImeCommittedTextThroughModifierKeyDowns\(term\)/,
);
});
@@ -0,0 +1,56 @@
/**
* xterm.js arms its `insertText` dedupe guard (`_keyDownSeen`) on every
* keydown and only clears it on keyup. A pure modifier keydown never emits
* text itself, but on Windows Sogou Pinyin commits the pending preedit text
* when Shift toggles Chinese/English mode — and the composed `input` event
* that carries the commit is delivered right after the Shift keydown. The
* armed guard then suppresses that event, so the committed text never
* reaches `onData` and the preedit text vanishes (#3441).
*
* Same root cause as upstream xtermjs/xterm.js#6054: a modifier keydown
* never produces text itself, so it must not arm the "a real keydown was
* already handled" heuristic. Preserve the pre-keydown guard state for
* modifier-only keydowns (both `key === "Shift"` and legacy keyCode 16/17/
* 18 shapes) so composed IME commits still arrive through `_inputEvent`.
*/
import type { Terminal as XTerm } from "@xterm/xterm";
import { isModifierOnlyKey } from "./terminalImeTextInput";
/** Classic keyCodes that stand for a modifier-only keydown. */
const MODIFIER_KEY_CODES = new Set([16, 17, 18, 91, 92, 93, 224]);
export function isModifierOnlyKeyDownEvent(event: {
key: string;
keyCode?: number;
}): boolean {
return (
isModifierOnlyKey(event.key)
|| (event.keyCode !== undefined && MODIFIER_KEY_CODES.has(event.keyCode))
);
}
type XTermInputCore = {
_keyDown?: (event: KeyboardEvent) => boolean | undefined;
_keyDownSeen?: boolean;
};
/**
* Wrap xterm's keydown entry so a pure modifier keydown cannot arm the
* `insertText` dedupe guard. Installed per-terminal instance; the prototype
* method stays untouched.
*/
export function keepImeCommittedTextThroughModifierKeyDowns(term: XTerm): void {
const core = (term as XTerm & { _core?: XTermInputCore })._core;
if (!core || typeof core._keyDown !== "function") return;
const originalKeyDown = core._keyDown.bind(core);
core._keyDown = (event: KeyboardEvent) => {
if (!isModifierOnlyKeyDownEvent(event)) return originalKeyDown(event);
const seenBefore = core._keyDownSeen;
try {
return originalKeyDown(event);
} finally {
core._keyDownSeen = seenBefore;
}
};
}