Compare commits

...
Author SHA1 Message Date
Cursor Agentand陈大猫 0b076b4199 fix: track Kitty releases for deferred literal punctuation
When IME deferral commits unchanged ASCII via the text path (event-type
mode without report-all), still upsert kittyForwardedKeys so keyup can
emit the paired :3 release, matching the ordinary keydown handler.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-09 13:26:12 +00:00
netcatty-bot 7ae41fdd5c fix: address Codex review on PR #2834 2026-08-08 22:06:31 +00:00
netcatty-bot 51e510f029 fix: address Codex review on PR #2834 2026-08-08 21:51:03 +00:00
netcatty-bot 48fde96f42 fix: address Codex review on PR #2834 2026-08-08 21:15:28 +00:00
netcatty-bot 867452d860 fix: address Codex review on PR #2834 2026-08-08 09:19:01 +00:00
netcatty-bot f770a59847 fix(#2833): automated Cursor CLI fix 2026-08-08 08:00:31 +00:00
6 changed files with 422 additions and 5 deletions

No files matched your search

@@ -95,6 +95,12 @@ import {
resolveShiftEnterText,
shouldSendShiftEnterText,
} from "./shiftEnterText";
import {
isUnchangedDeferredImeTextInput,
shouldBlockKeyPressForImeTextInput,
shouldCommitDeferredImeTextInput,
shouldDeferKeyDownForImeTextInput,
} from "./terminalImeTextInput";
import { formatSerialLocalEcho } from "./serialLocalEcho";
import { mapTerminalBackspaceInput } from "./terminalBackspaceInput";
import { formatTelnetLocalEcho } from "./telnetLocalEcho";
@@ -1142,6 +1148,8 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
let kittyCompositionPending = false;
let kittyCompositionClearTimer: number | undefined;
let imeTextInputDeferredKey: string | null = null;
let imeTextInputDeferredKittyEvent: KittyKeyboardEvent | null = null;
const kittyForwardedKeys = new Map<string, KittyKeyboardForwardedPress>();
const broadcastForwardedKeys = new Map<string, KittyKeyboardForwardedPress>();
const broadcastEncodedKeys = new Set<string>();
@@ -1151,6 +1159,10 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
let suppressNextTerminalDataBroadcast = false;
let broadcastLegacyDataPending: string | null = null;
let broadcastLegacyDataClearTimer: number | undefined;
const clearImeTextInputDeferral = () => {
imeTextInputDeferredKey = null;
imeTextInputDeferredKittyEvent = null;
};
const clearBroadcastLegacyDataPending = () => {
broadcastLegacyDataPending = null;
suppressNextTerminalDataBroadcast = false;
@@ -1177,6 +1189,137 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
getDispatcher: () => ctx.onBroadcastInputRef.current,
});
const commitImeTextInput = (text: string) => {
const deferredKey = imeTextInputDeferredKey;
const deferredKittyEvent = imeTextInputDeferredKittyEvent;
clearImeTextInputDeferral();
clearBroadcastLegacyDataPending();
// Deferred punctuation reaches the PTY via this manual commit instead of
// xterm's keydown → onUserInput pipeline, so SelectionService never clears.
// Match ordinary typed characters unless preserveSelectionOnInput is on.
if (
!ctx.terminalSettingsRef.current?.preserveSelectionOnInput &&
term.hasSelection()
) {
term.clearSelection();
}
// Unchanged ASCII must keep the Kitty press/release path. Composition
// encoding under report-all emits unidentified CSI 0 u (or associated
// text without a physical key), so ordinary punctuation breaks in TUIs.
if (
isUnchangedDeferredImeTextInput(deferredKey, text) &&
deferredKittyEvent &&
kittyKeyboardProtocolEnabled
) {
const pressEvent: KittyKeyboardEvent = {
...deferredKittyEvent,
type: "keydown",
};
const sequence = encodeKittyKeyEvent(kittyKeyboardMode, pressEvent);
if (sequence) {
const identity = pressEvent.code || pressEvent.key;
upsertKittyKeyboardForwardedPress(
kittyForwardedKeys,
identity,
pressEvent,
[],
);
handleTerminalInputData(sequence, { source: "kitty" });
const forwarded = broadcastKittyInput({
kind: "key",
event: pressEvent,
fallbackToLegacy: true,
});
if (forwarded) {
upsertKittyKeyboardForwardedPress(
broadcastForwardedKeys,
identity,
pressEvent,
forwarded.targetSessionIds,
);
}
return;
}
}
if (isUnchangedDeferredImeTextInput(deferredKey, text)) {
// Source may write the literal glyph (Kitty off / no sequence), but
// broadcast peers still need the physical key + paired release so
// report-all/event-type targets do not receive composition text.
if (ctx.isBroadcastEnabledRef.current && ctx.onBroadcastInputRef.current) {
suppressNextTerminalDataBroadcast = true;
}
handleTerminalInputData(text);
if (deferredKittyEvent) {
const pressEvent: KittyKeyboardEvent = {
...deferredKittyEvent,
type: "keydown",
};
const identity = pressEvent.code || pressEvent.key;
// Event-type mode (flag 2) without report-all leaves printable presses
// on the text path, but still emits a paired :3 release on keyup.
// Mirror the ordinary keydown handler so that release is not dropped.
if (
kittyKeyboardProtocolEnabled &&
shouldTrackKittyKeyRelease(kittyKeyboardMode, pressEvent)
) {
upsertKittyKeyboardForwardedPress(
kittyForwardedKeys,
identity,
pressEvent,
[],
);
}
const forwarded = broadcastKittyInput({
kind: "key",
event: pressEvent,
fallbackToLegacy: true,
});
if (forwarded) {
upsertKittyKeyboardForwardedPress(
broadcastForwardedKeys,
identity,
pressEvent,
forwarded.targetSessionIds,
);
}
} else {
broadcastKittyInput({ kind: "text", text });
}
return;
}
// Actual IME remap — Kitty associated-text composition when negotiated;
// otherwise send the committed glyph (report-all alone cannot carry it).
const encoded = encodeKittyCompositionText(kittyKeyboardMode, text);
if (encoded) {
handleTerminalInputData(encoded, { source: "kitty" });
} else {
if (ctx.isBroadcastEnabledRef.current && ctx.onBroadcastInputRef.current) {
suppressNextTerminalDataBroadcast = true;
}
handleTerminalInputData(text);
}
broadcastKittyInput({ kind: "text", text });
};
const flushImeTextInputDeferral = () => {
const fallback = imeTextInputDeferredKey;
if (!fallback) return;
commitImeTextInput(fallback);
};
const armImeTextInputDeferral = (event: KeyboardEvent) => {
clearImeTextInputDeferral();
clearBroadcastLegacyDataPending();
// Wait for insertText (full-width) before keyup; keyup/blur flushes ASCII
// when the IME did not remap the key (English punctuation mode).
imeTextInputDeferredKey = event.key;
// Keep the physical key even when the source is not in Kitty mode so
// broadcast targets can still receive paired key events.
imeTextInputDeferredKittyEvent = toKittyKeyboardEvent(event);
};
term.attachCustomKeyEventHandler((e: KeyboardEvent) => {
// Preserve mouse selection across keystrokes when enabled. xterm.js
// unconditionally clears the selection on user input
@@ -1221,6 +1364,11 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
}
if (e.type === "keyup") {
// insertText for this keystroke has already run when present; flush the
// deferred ASCII key only when the IME did not remap it.
if (imeTextInputDeferredKey !== null && e.key === imeTextInputDeferredKey) {
flushImeTextInputDeferral();
}
const identity = kittyKeyIdentity(e);
if (broadcastLegacyDataPending === identity) clearBroadcastLegacyDataPending();
const forwardedPress = broadcastForwardedKeys.get(identity);
@@ -1246,6 +1394,12 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
return true;
}
// Block keypress so xterm cannot re-emit the half-width ASCII char after we
// deferred the matching keydown for IME insertText (#2833).
if (shouldBlockKeyPressForImeTextInput(imeTextInputDeferredKey, e)) {
return false;
}
if (e.type !== "keydown") {
return true;
}
@@ -1557,6 +1711,15 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
}
}
// Sogou/macOS CJK punctuation: keydown still reports ASCII "," while the
// full-width "," arrives on insertText. Returning false (without
// preventDefault) stops xterm/Kitty from sending the half-width key so the
// input listener can commit the real glyph (#2833).
if (shouldDeferKeyDownForImeTextInput(e)) {
armImeTextInputDeferral(e);
return false;
}
if (kittySequenceForKeyDown) {
e.preventDefault();
e.stopPropagation();
@@ -1723,6 +1886,9 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
}, 0);
};
const clearKittyConnectionInputState = () => {
// Drop deferred IME punctuation on session reset; do not write into the
// next connection. Focus loss uses clearKittyTransientInputState instead.
clearImeTextInputDeferral();
kittyCompositionPending = false;
kittyForwardedKeys.clear();
clearKittyKeyboardBroadcastPairingState(
@@ -1736,6 +1902,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
}
};
const clearKittyTransientInputState = () => {
flushImeTextInputDeferral();
flushKittyKeyboardBroadcastReleases(
kittyForwardedKeys,
(input) => {
@@ -1755,6 +1922,17 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
const textarea = term.textarea;
const startKittyComposition = () => markKittyCompositionPending();
const markKittyTextInput = (event: InputEvent) => {
if (shouldCommitDeferredImeTextInput(imeTextInputDeferredKey, event)) {
commitImeTextInput(event.data);
// Keep the helper textarea empty so the next composition does not treat
// the deferred punctuation as pre-existing suffix text.
try {
if (textarea) textarea.value = "";
} catch {
// ignore
}
return;
}
if (shouldMarkKittyTextInputEvent(event)) markKittyCompositionPending(true);
};
textarea?.addEventListener("compositionstart", startKittyComposition);
@@ -456,8 +456,8 @@ test("broadcast composition text follows each target's negotiated mode", () => {
urgentInterrupt: false,
});
assert.deepEqual(resolve(reportAll), {
data: "\u001b[0u",
kittyEncoded: true,
data: "你a",
kittyEncoded: false,
urgentInterrupt: false,
});
assert.deepEqual(resolve(associatedText), {
@@ -688,8 +688,9 @@ test("associated text supports multiple code points and pure composition text",
assert.equal(encodeKittyCompositionText(state, "\n\u0085"), null);
});
test("report-all without associated text emits an unidentified CSI-u composition event", () => {
assert.equal(encodeKittyCompositionText(withFlags(8), "你"), "\u001b[0u");
test("report-all without associated text leaves composition text for literal fallback", () => {
assert.equal(encodeKittyCompositionText(withFlags(8), "你"), null);
assert.equal(encodeKittyCompositionText(withFlags(8), ","), null);
});
test("encodes the complete functional, keypad, media, and modifier ranges", () => {
@@ -766,7 +766,9 @@ export const encodeKittyCompositionText = (
): string | null => {
const flags = getKittyKeyboardModeFlags(state);
if (!(flags & KITTY_KEYBOARD_REPORT_ALL_KEYS_AS_ESC_CODES)) return null;
if (!(flags & KITTY_KEYBOARD_REPORT_ASSOCIATED_TEXT)) return "\u001b[0u";
// Without associated text, CSI 0 u is unidentified and drops the glyph.
// Callers fall back to the literal committed text instead.
if (!(flags & KITTY_KEYBOARD_REPORT_ASSOCIATED_TEXT)) return null;
const points = textCodePoints(text);
return points.length > 0 ? `\u001b[0;;${points.join(":")}u` : null;
};
@@ -0,0 +1,166 @@
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 {
isAsciiPunctuationKey,
isUnchangedDeferredImeTextInput,
shouldBlockKeyPressForImeTextInput,
shouldCommitDeferredImeTextInput,
shouldDeferKeyDownForImeTextInput,
} from "./terminalImeTextInput";
const runtimeSource = readFileSync(
join(dirname(fileURLToPath(import.meta.url)), "createXTermRuntime.ts"),
"utf8",
);
test("isAsciiPunctuationKey accepts common remappable punctuation", () => {
for (const key of [",", ".", "/", ";", "'", "[", "]", "\\", "-", "=", "`", "?", "!", ":", '"', "<", ">", "{", "}", "|", "_", "+", "~", "@", "#", "$", "%", "^", "&", "*", "(", ")"]) {
assert.equal(isAsciiPunctuationKey(key), true, key);
}
});
test("isAsciiPunctuationKey rejects letters, digits, space, and CJK", () => {
for (const key of ["a", "Z", "0", " ", ",", "、", "?", "Enter", "ArrowLeft"]) {
assert.equal(isAsciiPunctuationKey(key), false, key);
}
});
test("shouldDeferKeyDownForImeTextInput defers bare ASCII punctuation keydowns", () => {
assert.equal(
shouldDeferKeyDownForImeTextInput({ type: "keydown", key: ",", keyCode: 188 }),
true,
);
assert.equal(
shouldDeferKeyDownForImeTextInput({ type: "keydown", key: "?", keyCode: 191 }),
true,
);
});
test("shouldDeferKeyDownForImeTextInput leaves composition and modified keys alone", () => {
assert.equal(
shouldDeferKeyDownForImeTextInput({ type: "keydown", key: ",", keyCode: 229 }),
false,
);
assert.equal(
shouldDeferKeyDownForImeTextInput({ type: "keydown", key: ",", isComposing: true }),
false,
);
assert.equal(
shouldDeferKeyDownForImeTextInput({ type: "keydown", key: ",", ctrlKey: true }),
false,
);
assert.equal(
shouldDeferKeyDownForImeTextInput({ type: "keydown", key: "a", keyCode: 65 }),
false,
);
assert.equal(
shouldDeferKeyDownForImeTextInput({ type: "keypress", key: "," }),
false,
);
});
test("shouldBlockKeyPressForImeTextInput only while a deferral is armed", () => {
assert.equal(shouldBlockKeyPressForImeTextInput(",", { type: "keypress" }), true);
assert.equal(shouldBlockKeyPressForImeTextInput(null, { type: "keypress" }), false);
assert.equal(shouldBlockKeyPressForImeTextInput(",", { type: "keydown" }), false);
});
test("shouldCommitDeferredImeTextInput accepts insertText payloads while deferred", () => {
assert.equal(
shouldCommitDeferredImeTextInput(",", { inputType: "insertText", data: "," }),
true,
);
assert.equal(
shouldCommitDeferredImeTextInput(",", { inputType: "insertText", data: "," }),
true,
);
assert.equal(
shouldCommitDeferredImeTextInput(null, { inputType: "insertText", data: "," }),
false,
);
assert.equal(
shouldCommitDeferredImeTextInput(",", { inputType: "insertFromPaste", data: "," }),
false,
);
assert.equal(
shouldCommitDeferredImeTextInput(",", { inputType: "insertText", data: null }),
false,
);
});
test("isUnchangedDeferredImeTextInput detects English punctuation flush", () => {
assert.equal(isUnchangedDeferredImeTextInput(",", ","), true);
assert.equal(isUnchangedDeferredImeTextInput(",", ","), false);
assert.equal(isUnchangedDeferredImeTextInput(null, ","), false);
});
test("createXTermRuntime defers ASCII punctuation keydowns to insertText", () => {
assert.match(runtimeSource, /shouldDeferKeyDownForImeTextInput\(e\)/);
assert.match(runtimeSource, /armImeTextInputDeferral\(e\)/);
assert.match(runtimeSource, /shouldBlockKeyPressForImeTextInput\(imeTextInputDeferredKey, e\)/);
assert.match(
runtimeSource,
/shouldCommitDeferredImeTextInput\(imeTextInputDeferredKey, event\)/,
);
assert.match(runtimeSource, /commitImeTextInput\(event\.data\)/);
assert.match(runtimeSource, /isUnchangedDeferredImeTextInput\(deferredKey, text\)/);
assert.match(runtimeSource, /imeTextInputDeferredKittyEvent/);
// Manual commit bypasses xterm onUserInput; clear selection unless preserved.
assert.match(
runtimeSource,
/!ctx\.terminalSettingsRef\.current\?\.preserveSelectionOnInput/,
);
assert.match(runtimeSource, /term\.clearSelection\(\)/);
const clearSelIdx = runtimeSource.indexOf("term.clearSelection()");
const commitIdx = runtimeSource.indexOf("const commitImeTextInput = (text: string)");
const firstHandleIdx = runtimeSource.indexOf(
"handleTerminalInputData",
commitIdx,
);
assert.ok(
commitIdx >= 0 &&
clearSelIdx > commitIdx &&
firstHandleIdx > clearSelIdx,
);
// Must run before Kitty/xterm send the half-width key from keydown.
const deferIdx = runtimeSource.indexOf("shouldDeferKeyDownForImeTextInput(e)");
const kittySendIdx = runtimeSource.indexOf("if (kittySequenceForKeyDown)");
assert.ok(deferIdx >= 0 && kittySendIdx > deferIdx);
// Unchanged ASCII must encode via Kitty key events, not composition text.
const unchangedIdx = runtimeSource.indexOf("isUnchangedDeferredImeTextInput(deferredKey, text)");
const compositionIdx = runtimeSource.indexOf(
"encodeKittyCompositionText(kittyKeyboardMode, text)",
);
assert.ok(unchangedIdx >= 0 && compositionIdx > unchangedIdx);
// Even when the source writes the literal glyph, broadcast peers still get
// the deferred physical key (with legacy fallback), not composition text.
const armIdx = runtimeSource.indexOf("const armImeTextInputDeferral");
assert.ok(armIdx >= 0);
const armSlice = runtimeSource.slice(armIdx, armIdx + 700);
assert.match(
armSlice,
/imeTextInputDeferredKittyEvent = toKittyKeyboardEvent\(event\)/,
);
assert.doesNotMatch(
armSlice,
/imeTextInputDeferredKittyEvent = kittyKeyboardProtocolEnabled/,
);
const unchangedFallbackIdx = runtimeSource.indexOf(
"isUnchangedDeferredImeTextInput(deferredKey, text))",
unchangedIdx + 1,
);
assert.ok(unchangedFallbackIdx > unchangedIdx);
assert.match(
runtimeSource.slice(unchangedFallbackIdx, unchangedFallbackIdx + 1800),
/handleTerminalInputData\(text\);[\s\S]*shouldTrackKittyKeyRelease\(kittyKeyboardMode, pressEvent\)[\s\S]*upsertKittyKeyboardForwardedPress\(\s*kittyForwardedKeys,[\s\S]*broadcastKittyInput\(\{[\s\S]*kind: "key",[\s\S]*fallbackToLegacy: true,/,
);
// Remap path must fall back to literal text when composition encoding is null
// (report-all without associated text).
assert.match(
runtimeSource.slice(compositionIdx, compositionIdx + 420),
/if \(encoded\) \{[\s\S]*handleTerminalInputData\(encoded, \{ source: "kitty" \}\);[\s\S]*\} else \{[\s\S]*handleTerminalInputData\(text\);/,
);
});
@@ -0,0 +1,70 @@
/**
* 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.
*/
export type ImeTextInputKeyEvent = {
type?: string;
key: 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);
}
export function shouldBlockKeyPressForImeTextInput(
deferredKey: string | null | undefined,
event: Pick<ImeTextInputKeyEvent, "type">,
): boolean {
return Boolean(deferredKey) && event.type === "keypress";
}
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;
}