fix(terminal): preserve protocol replies during IME commits (#2312)

The IME duplicate-commit gate dropped every emission after the first one in a commit, including xterm's device/cursor replies and mouse reports, so TUIs stopped getting answers after typing through an IME. The gate now only collapses text; ESC-prefixed output always reaches the PTY. Refs #2160.
Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-11-09 23:38:34 +08:00
1 джерело ff17c3753f
коміт e839e5dfb7
3 files changed
+68 -24

No files matched your search

+61 -18
Переглянути файл
@@ -1,4 +1,5 @@
import { describe, expect, it } from "vitest";
import { Terminal } from "@xterm/xterm";
import { IME_COMMIT_TIMEOUT_MS, ImeCommitGate, type ImeScheduler } from "./imeCommit";
/** Runs queued callbacks in order, the way setTimeout(0) drains after the current event. */
@@ -34,15 +35,57 @@ function gateWithRebase() {
/** Collects the emissions that reach the PTY, like `Session.fromTerminal`. */
function emitter(gate: ImeCommitGate) {
const sent: string[] = [];
return { sent, emit: (data: string) => gate.accept() && sent.push(data) };
return { sent, emit: (data: string) => gate.accept(data) && sent.push(data) };
}
describe("ImeCommitGate", () => {
it.each(["\x1b[<65;3;2M", "\x1b[A", "\x1b[200~paste\x1b[201~"])(
"keeps control input out of the commit's text quota: %j",
(control) => {
const { gate } = gateWithRebase();
const { sent, emit } = emitter(gate);
gate.begin();
emit(control);
emit(",");
emit(control);
emit("好,");
expect(sent).toEqual([control, ",", control]);
},
);
it("still drops a stale IME deletion but lets a real Backspace through", () => {
const { gate } = gateWithRebase();
const { sent, emit } = emitter(gate);
gate.begin();
emit(",");
emit("\x7f");
gate.keystroke();
emit("\x7f");
expect(sent).toEqual([",", "\x7f"]);
});
it.each([true, false])("preserves terminal replies during an IME commit (text first: %s)", async (textFirst) => {
const { gate } = gateWithRebase();
const { sent, emit } = emitter(gate);
const term = new Terminal();
term.onData(emit);
try {
gate.begin();
if (textFirst) term.input(",");
await new Promise<void>((done) => term.write("\x1b[6n\x1b[c", done));
if (!textFirst) term.input(",");
term.input("好,");
expect(sent).toEqual(textFirst ? [",", "\x1b[1;1R", "\x1b[?1;2c"] : ["\x1b[1;1R", "\x1b[?1;2c", ","]);
} finally {
term.dispose();
}
});
it("takes the first emission of a commit and drops the repeated one", () => {
const { gate } = gateWithRebase();
gate.begin();
expect(gate.accept()).toBe(true);
expect(gate.accept()).toBe(false);
expect(gate.accept("text")).toBe(true);
expect(gate.accept("text")).toBe(false);
});
it("keeps the real text of the pi case and drops the stale suffix", () => {
@@ -79,10 +122,10 @@ describe("ImeCommitGate", () => {
// commit's still-running timeout.
const { gate } = gateWithRebase();
gate.begin();
expect(gate.accept()).toBe(true);
expect(gate.accept("text")).toBe(true);
gate.begin();
expect(gate.accept()).toBe(true);
expect(gate.accept()).toBe(false);
expect(gate.accept("text")).toBe(true);
expect(gate.accept("text")).toBe(false);
});
it("drops the stale suffix of a WebKitGTK punctuation commit without compositionstart", () => {
@@ -106,23 +149,23 @@ describe("ImeCommitGate", () => {
const { gate, run, rebases } = gateWithRebase();
gate.begin();
gate.compositionStart();
expect(gate.accept()).toBe(true);
expect(gate.accept()).toBe(true);
expect(gate.accept("text")).toBe(true);
expect(gate.accept("text")).toBe(true);
gate.finish();
run();
expect(rebases()).toBe(1);
gate.begin();
expect(gate.accept()).toBe(true);
expect(gate.accept()).toBe(false);
expect(gate.accept("text")).toBe(true);
expect(gate.accept("text")).toBe(false);
});
it("accepts every emission while composing", () => {
const { gate } = gateWithRebase();
gate.compositionStart();
expect(gate.accept()).toBe(true);
expect(gate.accept()).toBe(true);
expect(gate.accept()).toBe(true);
expect(gate.accept("text")).toBe(true);
expect(gate.accept("text")).toBe(true);
expect(gate.accept("text")).toBe(true);
});
it("rebases after a composition that xterm sent by itself", () => {
@@ -137,7 +180,7 @@ describe("ImeCommitGate", () => {
it("rebases after a commit whose compositionend arrives, not before", () => {
const { gate, run, rebases } = gateWithRebase();
gate.begin();
expect(gate.accept()).toBe(true);
expect(gate.accept("text")).toBe(true);
gate.finish();
expect(rebases()).toBe(0);
run();
@@ -147,11 +190,11 @@ describe("ImeCommitGate", () => {
it("opens a commit from a composition input when no keydown preceded it", () => {
const { gate } = gateWithRebase();
gate.input();
expect(gate.accept()).toBe(true);
expect(gate.accept()).toBe(false);
expect(gate.accept("text")).toBe(true);
expect(gate.accept("text")).toBe(false);
// A second input event belongs to the same commit and must not reopen it.
gate.input();
expect(gate.accept()).toBe(false);
expect(gate.accept("text")).toBe(false);
});
it("closes a commit whose compositionend never arrives", () => {
@@ -161,7 +204,7 @@ describe("ImeCommitGate", () => {
run();
expect(IME_COMMIT_TIMEOUT_MS).toBeGreaterThan(0);
expect(rebases()).toBe(1);
expect(gate.accept()).toBe(true);
expect(gate.accept("text")).toBe(true);
});
it("does not rebase into a composition that started before the close ran", () => {
+6 -5
Переглянути файл
@@ -16,8 +16,9 @@
* composition events, which xterm sends through its keydown path; the stale emission there
* carries the previous commit's text instead.
*
* One commit must send one chunk, so the gate lets the first emission through and drops the
* rest until the commit closes. A commit is delimited by the IME's own keystroke (code 229)
* One commit must send one text chunk, so the gate lets the first emission through and drops
* the rest until the commit closes. Protocol replies and mouse reports bypass the gate.
* A commit is delimited by the IME's own keystroke (code 229)
* outside a composition, or by a composition input when the platform sends no keydown.
* Commits that really compose are left to xterm: `compositionstart` stands the gate down for
* that commit only — it must not disable the gate for the session, because the very next
@@ -109,9 +110,9 @@ export class ImeCommitGate {
if (this.open && !this.composing) this.close();
}
/** Whether this emission should reach the PTY. */
accept(): boolean {
if (!this.open) return true;
/** Only text can be a duplicate commit; terminal replies and mouse reports must always reach the PTY. */
accept(data: string): boolean {
if (!this.open || data.startsWith("\x1b")) return true;
if (this.sent) return false;
this.sent = true;
return true;
+1 -1
Переглянути файл
@@ -521,7 +521,7 @@ export class TerminalSession {
/** xterm's data event, with one IME commit's repeated emissions collapsed. */
private fromTerminal(data: string) {
if (this.ime.accept()) this.input(data);
if (this.ime.accept(data)) this.input(data);
}
private doFit() {