fix(terminal): stop fcitx5 commits from being sent twice (#336)

With WebKitGTK and fcitx5, committed IME text reached the terminal twice: the IME commits without firing compositionstart, so xterm's composition bookkeeping never advanced and every commit was emitted both as the committed text and as a stale textarea diff. An IME commit gate now lets only the first emission of a commit through (with a 250 ms safety timeout) and empties the hidden textarea once the commit ends. It engages only for input that looks like a composition (keyCode 229 / composition input events) and stands down for IMEs that report compositionstart (macOS, IBus, Windows), leaving xterm's own handling unchanged there.

Co-authored-by: Hepengzhou <[email protected]>

未在 Linux(fcitx5)实机验证。
Цей коміт міститься в:
HePengZhou authored and GitHub committed 2026-10-30 13:22:12 +08:00
1 джерело 97b8928e4f
коміт 0a27d02f4d
3 files changed
+228 -1

No files matched your search

+99
Переглянути файл
@@ -0,0 +1,99 @@
import { describe, expect, it } from "vitest";
import { IME_COMMIT_TIMEOUT_MS, ImeCommitGate, type ImeScheduler } from "./imeCommit";
/** Runs queued callbacks in order, the way setTimeout(0) drains after the current event. */
function manualScheduler() {
let next = 1;
const tasks = new Map<number, () => void>();
const scheduler: ImeScheduler = {
after: (callback) => {
const id = next++;
tasks.set(id, callback);
return id;
},
cancel: (id) => {
tasks.delete(id);
},
};
const run = () => {
for (const [id, task] of [...tasks]) {
tasks.delete(id);
task();
}
};
return { scheduler, run, pending: () => tasks.size };
}
function gateWithRebase() {
const { scheduler, run, pending } = manualScheduler();
let rebased = 0;
const gate = new ImeCommitGate(() => rebased++, scheduler);
return { gate, run, pending, rebases: () => rebased };
}
describe("ImeCommitGate", () => {
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);
});
it("keeps the real text of the pi case and drops the stale suffix", () => {
// xterm sends the committed diff first, then a stale `value.substring(start)`.
const { gate, run } = gateWithRebase();
const sent: string[] = [];
const emit = (data: string) => {
if (gate.accept()) sent.push(data);
};
gate.begin();
emit("在吗");
gate.finish();
emit("好在吗");
run();
expect(sent).toEqual(["在吗"]);
});
it("accepts every emission once the commit is over", () => {
const { gate, run } = gateWithRebase();
gate.begin();
expect(gate.accept()).toBe(true);
expect(gate.accept()).toBe(false);
gate.finish();
run();
expect(gate.accept()).toBe(true);
expect(gate.accept()).toBe(true);
});
it("rebases the textarea when the commit closes, not before", () => {
const { gate, run, rebases } = gateWithRebase();
gate.begin();
gate.finish();
expect(rebases()).toBe(0);
run();
expect(rebases()).toBe(1);
});
it("stands down once a real compositionstart arrives", () => {
const { gate, rebases } = gateWithRebase();
gate.begin();
gate.compositionStart();
expect(gate.accept()).toBe(true);
expect(gate.accept()).toBe(true);
expect(rebases()).toBe(0);
// And a later commit is not gated either.
gate.begin();
expect(gate.accept()).toBe(true);
expect(gate.accept()).toBe(true);
});
it("closes a commit whose compositionend never arrives", () => {
const { gate, run, pending, rebases } = gateWithRebase();
gate.begin();
expect(pending()).toBe(1);
run();
expect(IME_COMMIT_TIMEOUT_MS).toBeGreaterThan(0);
expect(rebases()).toBe(1);
expect(gate.accept()).toBe(true);
});
});
+84
Переглянути файл
@@ -0,0 +1,84 @@
/**
* Collapses the repeated data emissions of one IME commit.
*
* WebKitGTK + fcitx5 commit text without firing `compositionstart`, so xterm's `CompositionHelper`
* never advances `_compositionPosition.start` and keeps appending the committed text to its hidden
* textarea. A commit then reaches `onData` twice — once with the committed text, once with a stale
* `value.substring(start)` — and the same text shows up in the terminal twice.
*
* One commit must send one chunk: take the first emission and drop the rest, then rebase the
* textarea so the next commit starts from an empty value. IMEs that do report `compositionstart`
* (macOS, IBus) keep xterm's own bookkeeping, so the gate stands down for them.
*/
/** How long a commit may stay open when its `compositionend` never arrives. */
export const IME_COMMIT_TIMEOUT_MS = 250;
export interface ImeScheduler {
after(callback: () => void, ms: number): number;
cancel(id: number): void;
}
const WINDOW_SCHEDULER: ImeScheduler = {
after: (callback, ms) => window.setTimeout(callback, ms),
cancel: (id) => window.clearTimeout(id),
};
export class ImeCommitGate {
private open = false;
private sent = false;
private timer: number | null = null;
private native = false;
private readonly rebase: () => void;
private readonly scheduler: ImeScheduler;
constructor(rebase: () => void, scheduler: ImeScheduler = WINDOW_SCHEDULER) {
this.rebase = rebase;
this.scheduler = scheduler;
}
/** A commit starts: the IME's own keystroke (code 229) or its composition input event. */
begin(): void {
if (this.native || this.open) return;
this.open = true;
this.sent = false;
this.timer = this.scheduler.after(() => this.close(), IME_COMMIT_TIMEOUT_MS);
}
/** The commit ended: let the emissions both of xterm's senders queued go out, then rebase. */
finish(): void {
if (!this.open) return;
this.cancelTimer();
this.timer = this.scheduler.after(() => this.close(), 0);
}
/** Whether this emission should reach the PTY. */
accept(): boolean {
if (!this.open) return true;
if (this.sent) return false;
this.sent = true;
return true;
}
/** The IME reports composition events, so xterm needs no help — stay out of its way. */
compositionStart(): void {
this.native = true;
this.cancelTimer();
this.open = false;
this.sent = false;
}
/** Ends the commit and rebases the textarea to an empty value. */
close(): void {
this.cancelTimer();
if (!this.open) return;
this.open = false;
this.rebase();
}
private cancelTimer(): void {
if (this.timer === null) return;
this.scheduler.cancel(this.timer);
this.timer = null;
}
}
+45 -1
Переглянути файл
@@ -11,6 +11,7 @@ import { Channel } from "@tauri-apps/api/core";
import { call, commands, isTauri, type TerminalId } from "@/platform/ipc";
import { editorService, notifications } from "@/workbench/contributions";
import { fitScheduler, settledResize } from "./fit";
import { ImeCommitGate } from "./imeCommit";
import { findTerminalLinks, resolvePath, type TerminalLink } from "./links";
import { splitCommandLine } from "@/platform/commandLine";
import { useProject } from "@/platform/project";
@@ -176,6 +177,10 @@ export class TerminalSession {
private lastSize = { cols: 0, rows: 0 };
/** Bumped on restart so late output from a previous PTY is ignored. */
private generation = 0;
/** WebKitGTK delivers one IME commit' worth of text more than once; see `ImeCommitGate`. */
private readonly ime = new ImeCommitGate(() => {
if (this.term.textarea) this.term.textarea.value = "";
});
constructor(opts: SessionOptions) {
this.opts = opts;
@@ -227,7 +232,7 @@ export class TerminalSession {
this.searchAddon.onDidChangeResults((r) => {
for (const l of this.searchListeners) l({ index: r.resultIndex, count: r.resultCount });
}),
this.term.onData((data) => this.input(data)),
this.term.onData((data) => this.fromTerminal(data)),
this.term.onBinary((data) => this.input(data)),
this.term.onTitleChange((title) => this.update({ appTitle: title.trim() || null })),
// OSC 7: `file://host/path` — shells report their cwd so relative links resolve correctly.
@@ -347,6 +352,7 @@ export class TerminalSession {
() => this.scheduleFit(),
);
this.term.textarea?.addEventListener("focus", () => this.opts.onFocus(this.key));
this.watchImeCommit();
}
this.resizeObserver?.disconnect();
this.resizeObserver = new ResizeObserver(() => this.scheduleFit());
@@ -364,6 +370,44 @@ export class TerminalSession {
this.fitter.schedule();
}
/**
* Routes a WebKitGTK / fcitx5 IME commit through `ImeCommitGate`: the platform reports the
* composition's own keystroke (code 229) and its input event but never `compositionstart`,
* which is what xterm's own bookkeeping is built on.
*/
private watchImeCommit() {
const textarea = this.term.textarea;
if (!textarea) return;
const onKeyDown = (e: KeyboardEvent) => {
if (e.keyCode === 229) this.ime.begin();
};
const onInput = (e: InputEvent) => {
if (e.inputType === "insertFromComposition" || e.inputType === "insertCompositionText") this.ime.begin();
};
const onCompositionStart = () => this.ime.compositionStart();
// Bubble phase, i.e. after xterm's own listener: the rebase must land behind the emissions
// that xterm's composition and textarea-diff paths already scheduled.
const onCompositionEnd = () => this.ime.finish();
this.host.addEventListener("keydown", onKeyDown, true);
textarea.addEventListener("input", onInput, true);
textarea.addEventListener("compositionstart", onCompositionStart, true);
textarea.addEventListener("compositionend", onCompositionEnd);
this.disposables.push({
dispose: () => {
this.ime.close();
this.host.removeEventListener("keydown", onKeyDown, true);
textarea.removeEventListener("input", onInput, true);
textarea.removeEventListener("compositionstart", onCompositionStart, true);
textarea.removeEventListener("compositionend", onCompositionEnd);
},
});
}
/** xterm's data event, with one IME commit's repeated emissions collapsed. */
private fromTerminal(data: string) {
if (this.ime.accept()) this.input(data);
}
private doFit() {
if (this.disposed || !this.host.isConnected) return;
if (this.host.offsetWidth === 0 || this.host.offsetHeight === 0) return;