diff --git a/components/terminal/runtime/createXTermRuntime.ts b/components/terminal/runtime/createXTermRuntime.ts index fdd84eb440..3386de1038 100644 --- a/components/terminal/runtime/createXTermRuntime.ts +++ b/components/terminal/runtime/createXTermRuntime.ts @@ -1662,6 +1662,13 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime return false; } + if (e.type === "keypress" && broadcastForwardedKeys.has(kittyKeyIdentity(e))) { + // Space and uppercase letters can emit xterm data from keypress, after + // keydown's zero-delay cleanup. Rejoin that physical broadcast instead + // of sending the same character a second time as unpaired raw text. + markBroadcastLegacyDataPending(kittyKeyIdentity(e)); + } + if (e.type !== "keydown") { return true; } @@ -2423,6 +2430,10 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime }); return; } + // insertText can follow an already-delivered keypress. Its fallback marker + // must not classify the next physical key as a second composition commit. + // Actual composition/input handlers clear the physical-key pairing first. + if (broadcastLegacyDataPending) kittyCompositionPending = false; if (kittyCompositionPending && !data.startsWith("\u001b")) { kittyCompositionPending = false; if (kittyCompositionClearTimer !== undefined) { diff --git a/components/terminal/runtime/terminalBroadcastKeypress.test.ts b/components/terminal/runtime/terminalBroadcastKeypress.test.ts new file mode 100644 index 0000000000..28f53f95e9 --- /dev/null +++ b/components/terminal/runtime/terminalBroadcastKeypress.test.ts @@ -0,0 +1,169 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import { runInNewContext } from "node:vm"; +import ts from "typescript"; +import { resolveKittyKeyboardBroadcastInput, type KittyKeyboardBroadcastInput } from "./kittyKeyboardBroadcast"; +import { createKittyKeyboardModeState, setKittyKeyboardModeFlags, shouldEncodeKittyCompositionText, shouldMarkKittyTextInputEvent, encodeKittyCompositionText } from "./kittyKeyboardProtocol"; +import { shouldBlockKeyPressForImeTextInput, shouldCommitDeferredImeTextInput } from "./terminalImeTextInput"; +import { sanitizeTerminalInput } from "./terminalInputSanitize"; + +// Execute the actual registered key/data callbacks, without constructing the +// renderer and its WebGL/addon stack. In particular, retain their real timer +// cleanup and raw-broadcast suppression rather than reproducing that logic here. +const source = readFileSync(new URL("./createXTermRuntime.ts", import.meta.url), "utf8"); +function section(start: string, end: string): string { + const from = source.indexOf(start); + const to = source.indexOf(end, from); + assert.ok(from >= 0 && to > from, `runtime section missing: ${start}`); + return source.slice(from, to); +} +const declarations = section(" let suppressNextTerminalDataBroadcast =", " const broadcastKittyInput ="); +const keyboardCallback = section(" term.attachCustomKeyEventHandler((e: KeyboardEvent) => {", " const handleMiddleClick ="); +const dataCallback = section(" term.onData((data) => {", " const handleKittyKeyboardBroadcast ="); +const compositionMarker = section(" const markKittyCompositionPending =", " const finishKittyComposition ="); +const textInputMarker = section(" const markKittyTextInput =", ' textarea?.addEventListener("compositionstart"'); +const suppression = section(" const suppressTerminalBroadcast =", " // skipBroadcast"); +const callbackCode = ts.transpileModule(` + ${declarations} + let win32InputModePendingEvent = null; + let kittyCompositionPending = false; + let kittyCompositionClearTimer; + const handleTerminalInputData = (data) => { + const inputSource = "terminal"; + ${suppression} + if (!suppressTerminalBroadcast) raw(data); + }; + const textarea = null; + ${compositionMarker} + ${textInputMarker} + ${keyboardCallback} + ${dataCallback} + globalThis.controls = { mark: markBroadcastLegacyDataPending, clear: clearBroadcastLegacyDataPending, input: markKittyTextInput }; +`, { compilerOptions: { target: ts.ScriptTarget.ES2022 } }).outputText; + +function setup(flags = 0) { + const writes: string[] = []; + const timers = new Map void>(); + let timerId = 0; + let receive!: (data: string) => void; + let keyboard!: (event: Partial) => boolean; + const forwarded = new Map(); + const mode = createKittyKeyboardModeState(); + setKittyKeyboardModeFlags(mode, flags); + const options = { + kittyProtocolEnabled: flags !== 0, kittyMode: mode, applicationCursorMode: false, + encodedKeys: new Set(), legacySuppressedKeys: new Set(), + }; + const normalized = (input: KittyKeyboardBroadcastInput) => { + const result = resolveKittyKeyboardBroadcastInput(input, options); + if (result) writes.push(result.data); + }; + const context = { + controls: undefined as unknown as { mark: (identity: string) => void; clear: () => void; input: (event: { data: string; inputType: string }) => void }, + window: { + setTimeout(fn: () => void) { const id = ++timerId; timers.set(id, fn); return id; }, + clearTimeout(id: number) { timers.delete(id); }, + }, + term: { + modes: { win32InputMode: false }, + onData(fn: typeof receive) { receive = fn; }, + attachCustomKeyEventHandler(fn: typeof keyboard) { keyboard = fn; }, + }, + ctx: { terminalSettingsRef: { current: {} }, isBroadcastEnabledRef: { current: true }, onBroadcastInputRef: { current: () => undefined } }, + imeTextInputDeferredKey: null, imeTextInputDeferredKittyEvent: null, + shouldBlockKeyPressForImeTextInput, shouldCommitDeferredImeTextInput, shouldMarkKittyTextInputEvent, shouldEncodeKittyCompositionText, encodeKittyCompositionText, + kittyKeyboardMode: createKittyKeyboardModeState(), shouldSplitImeTextInputForWire: () => false, + kittyKeyIdentity: (event: Partial) => event.code || event.key, + broadcastForwardedKeys: forwarded, + broadcastKittyInput: normalized, + sanitizeTerminalInput, + shouldSplitRawPasteInputForWire: () => false, + raw: (data: string) => writes.push(data), + }; + runInNewContext(callbackCode, context); + return { + writes, receive: (data: string) => receive(data), controls: context.controls, + keypress: (key: string, code: string) => keyboard({ type: "keypress", key, code }), + press(key: string, code: string) { + normalized({ kind: "key", event: { type: "keydown", key, code }, fallbackToLegacy: true }); + forwarded.set(code, { targetSessionIds: ["target"] }); + context.controls.mark(code); + }, + flushTimers() { for (const [id, fn] of timers) { timers.delete(id); fn(); } }, + release(key: string, code: string) { + normalized({ kind: "key", event: { type: "keyup", key, code }, fallbackToLegacy: true }); + forwarded.delete(code); + context.controls.clear(); + }, + }; +} + +for (const flags of [0, 8]) { + for (const [key, code] of [["A", "KeyA"], [" ", "Space"]]) { + test(`broadcast pairs delayed ${code} keypress once with target flags ${flags}`, () => { + const runtime = setup(flags); + runtime.press(key, code); + const firstWrite = runtime.writes.join(""); + assert.ok(firstWrite); + runtime.flushTimers(); + assert.equal(runtime.keypress(key, code), true); + runtime.receive(key); + assert.equal(runtime.writes.join(""), firstWrite, "the source's later text must not duplicate its physical key broadcast"); + }); + } +} + +test("repeated physical presses each reach the broadcast target once", () => { + const runtime = setup(); + for (let i = 0; i < 3; i++) { + runtime.press("A", "KeyA"); + runtime.flushTimers(); + runtime.keypress("A", "KeyA"); + runtime.receive("A"); + } + assert.equal(runtime.writes.join(""), "AAA"); +}); + +test("keypress without an earlier broadcast and text after release are not swallowed", () => { + const runtime = setup(); + runtime.keypress("B", "KeyB"); + runtime.receive("B"); + runtime.press("A", "KeyA"); + runtime.flushTimers(); + runtime.keypress("A", "KeyA"); + runtime.receive("A"); + runtime.release("A", "KeyA"); + runtime.receive("paste"); + assert.equal(runtime.writes.join(""), "BApaste"); +}); + +test("unmatched keypress cleanup still permits a later paste", () => { + const runtime = setup(); + runtime.press("A", "KeyA"); + runtime.flushTimers(); + runtime.keypress("A", "KeyA"); + runtime.flushTimers(); + runtime.receive("paste"); + assert.equal(runtime.writes.join(""), "Apaste"); +}); + +test("trailing insertText for Space does not turn the next physical key into duplicate composition text", () => { + const runtime = setup(); + for (const [key, code] of [[" ", "Space"], ["A", "KeyA"], ["B", "KeyB"], ["C", "KeyC"]]) { + runtime.press(key, code); + runtime.keypress(key, code); + runtime.receive(key); + runtime.controls.input({ data: key, inputType: "insertText" }); + runtime.release(key, code); + // Real trace: the next key can arrive before insertText's zero-delay timer. + } + assert.equal(runtime.writes.join(""), " ABC"); +}); + +test("keyless insertText is still broadcast as actual text", () => { + const runtime = setup(); + runtime.controls.input({ data: "中文", inputType: "insertText" }); + runtime.receive("中文"); + assert.equal(runtime.writes.join(""), "中文"); +});