Compare commits

...
Author SHA1 Message Date
bincxz 7b78a4a0c1 fix(terminal): preserve IME rewrite state across delayed commits 2026-09-29 22:47:44 +08:00
bincxz 96f1e37faf fix(terminal): remember a rewritten IME hypothesis in full
A 229 rewrite stored only the inserted tail, so a later
compositionend with no composition marker sent the revised
line again. Remember the whole revised textarea instead.
2026-09-29 22:30:05 +08:00
bincxz 7abc49d5d2 fix(terminal): send a new IME word that shares a finished prefix
A composition that starts at or after the snapshot is a new word.
Sharing the first character with text already on the PTY is not
enough to treat it as the same hypothesis.
2026-09-29 22:22:56 +08:00
bincxz e1aca17f57 fix(terminal): rewrite a revised IME hypothesis in place
A composition that replaces the snapshot, instead of extending it,
sends the cancelled textarea edit. Deletions are counted in graphemes
so a surrogate pair is one backspace.
2026-09-29 22:13:45 +08:00
bincxz ecaef75514 fix(terminal): retype rewritten IME tails as separate backspaces
A shared suffix has to be deleted and typed again because the cursor
stays at the end of the line. One-character hypotheses are real
prefixes, and each backspace is its own input event so Ctrl-H hosts
still erase.
2026-09-29 22:06:04 +08:00
bincxz 83ceaa51a0 fix(terminal): commit live IME transcription once
xterm sends a rewritten dictation hypothesis as the interim, the full
final string, and the tail. Keep the pending textarea diff from firing
again when composition starts, and send only the part that is not
already on the PTY.
2026-09-29 21:56:13 +08:00
4 changed files with 881 additions and 5 deletions

No files matched your search

@@ -129,6 +129,7 @@ import {
shouldFlushStaleDeferredImeTextInput,
} from "./terminalImeTextInput";
import { keepImeCommittedTextThroughModifierKeyDowns } from "./imeModifierKeyDownSeenGuard";
import { keepLiveImeTranscriptionSingle } from "./imeCompositionRewrite";
import { formatSerialLocalEcho } from "./serialLocalEcho";
import { getLastChar, removeLastChar, isPrintableInput } from "../../../domain/serialCharMetrics";
import { mapTerminalBackspaceInput } from "./terminalBackspaceInput";
@@ -705,6 +706,9 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
}
term.open(ctx.container);
// CompositionHelper is created in open(). Live transcription rewrites the
// hypothesis instead of appending; commit that utterance once (#3421).
keepLiveImeTranscriptionSingle(term);
// Inline raster images (Kitty graphics / SIXEL / iTerm IIP). Loaded right after
// term.open so the addon can patch IRenderService.setRenderer before the WebGL
@@ -2977,7 +2981,9 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
// 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")) {
// A rewritten IME hypothesis first emits standalone deletions. Route those
// as backspaces and keep the composition marker for the replacement text.
if (kittyCompositionPending && data !== "\x7f" && !data.startsWith("\u001b")) {
kittyCompositionPending = false;
if (kittyCompositionClearTimer !== undefined) {
window.clearTimeout(kittyCompositionClearTimer);
@@ -0,0 +1,516 @@
import assert from "node:assert/strict";
import test from "node:test";
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import {
commitTextareaChange,
continuedCompositionData,
continuedCompositionPrefix,
keepLiveImeTranscriptionSingle,
rememberTextareaCommit,
type ImeCompositionCommitTarget,
} from "./imeCompositionRewrite";
const BACKSPACE = "\x7f";
test("commitTextareaChange appends the new suffix", () => {
assert.equal(commitTextareaChange("\u6211\u662f\u771f\u7684", "\u6211\u662f\u771f\u7684\u725b\u903c"), "\u725b\u903c");
assert.equal(commitTextareaChange("", "\u6211\u662f\u771f\u7684"), "\u6211\u662f\u771f\u7684");
assert.equal(commitTextareaChange("ls ", "ls \uff0c"), "\uff0c");
});
test("commitTextareaChange counts a supplementary-plane character as one grapheme", () => {
const emoji = "\u{1F600}";
const other = "\u{1F601}";
assert.equal(commitTextareaChange(`a${emoji}`, "aX"), `${BACKSPACE}X`);
assert.equal(commitTextareaChange(emoji, other), `${BACKSPACE}${other}`);
assert.equal(commitTextareaChange(`a${emoji}`, "a"), BACKSPACE);
});
test("commitTextareaChange counts only characters forwarded to the terminal", () => {
assert.equal(commitTextareaChange("ab\u200bcd", "abXcd"), `${BACKSPACE}${BACKSPACE}Xcd`);
});
test("commitTextareaChange deletes the whole tail and retypes the new one", () => {
assert.equal(commitTextareaChange("abcd", "abXY"), `${BACKSPACE}${BACKSPACE}XY`);
assert.equal(commitTextareaChange("abcde", "abXde"), `${BACKSPACE}${BACKSPACE}${BACKSPACE}Xde`);
assert.equal(commitTextareaChange("abcd", "ab"), `${BACKSPACE}${BACKSPACE}`);
assert.equal(commitTextareaChange("hello", ""), BACKSPACE);
assert.equal(commitTextareaChange("same", "same"), "");
});
test("rememberTextareaCommit grows a hypothesis and replaces a rewrite", () => {
assert.equal(rememberTextareaCommit("", "", "\u6211\u662f\u771f\u7684"), "\u6211\u662f\u771f\u7684");
assert.equal(rememberTextareaCommit("\u6211\u662f\u771f\u7684", "\u6211\u662f\u771f\u7684", "\u725b\u903c"), "\u6211\u662f\u771f\u7684\u725b\u903c");
assert.equal(
rememberTextareaCommit("abcd", "abcd", `${BACKSPACE}${BACKSPACE}XY`),
"abXY",
);
assert.equal(
rememberTextareaCommit("abcde", "abcde", `${BACKSPACE}${BACKSPACE}${BACKSPACE}Xde`, "abXde"),
"abXde",
);
assert.equal(
rememberTextareaCommit("a\u{1F600}", "a\u{1F600}", `${BACKSPACE}X`),
"aX",
);
assert.equal(rememberTextareaCommit("hello", "hello", BACKSPACE, ""), "");
});
test("continuedCompositionPrefix keeps a new composition after a finished line", () => {
assert.equal(continuedCompositionPrefix(undefined, "\u4f60\u597d"), "");
assert.equal(
continuedCompositionPrefix({
continued: true,
pendingPrevious: "hello\uff0c",
alreadySent: "\uff0c",
textareaAtStart: "hello\uff0c",
}, "\u4f60\u597d"),
"",
);
assert.equal(
continuedCompositionPrefix({
continued: false,
pendingPrevious: "a",
alreadySent: "a",
textareaAtStart: "a",
}, "apple"),
"",
);
assert.equal(
continuedCompositionPrefix({
continued: true,
pendingPrevious: "\u6211",
alreadySent: "\u6211",
textareaAtStart: "\u6211\u771f",
}, "\u6211\u771f"),
"\u6211",
);
});
test("continuedCompositionData rewrites a revised hypothesis instead of appending it", () => {
const marker = {
continued: true,
pendingPrevious: "abcde",
alreadySent: "abcde",
textareaAtStart: "abXde",
};
assert.equal(
continuedCompositionData(marker, "abXde", "abXde", 0),
`${BACKSPACE}${BACKSPACE}${BACKSPACE}Xde`,
);
// The new character starts at the caret after "hello", so this is a new
// composition. The caller sends the slice unchanged.
assert.equal(
continuedCompositionData({
continued: true,
pendingPrevious: "hello",
alreadySent: "",
textareaAtStart: "hello",
}, "\u4f60", "hello\u4f60", 5),
null,
);
// In-place extension of a one-character hypothesis still strips the prefix.
assert.equal(
continuedCompositionData({
continued: true,
pendingPrevious: "\u6211",
alreadySent: "\u6211",
textareaAtStart: "\u6211\u771f",
}, "\u6211\u771f", "\u6211\u771f", 0),
"\u771f",
);
// A new word that merely starts with the finished character is not a prefix.
assert.equal(
continuedCompositionData({
continued: true,
pendingPrevious: "\u6211",
alreadySent: "\u6211",
textareaAtStart: "\u6211\u6211\u4eec",
}, "\u6211\u4eec", "\u6211\u6211\u4eec", 1),
null,
);
});
test("continuedCompositionPrefix returns the hypothesis already on the PTY", () => {
assert.equal(
continuedCompositionPrefix({
continued: true,
pendingPrevious: "\u6211\u662f\u771f\u7684",
alreadySent: "\u6211\u662f\u771f\u7684",
textareaAtStart: "\u6211\u662f\u771f\u7684\u725b\u903c",
}, "\u6211\u662f\u771f\u7684\u725b\u903c"),
"\u6211\u662f\u771f\u7684",
);
assert.equal(
continuedCompositionPrefix({
continued: true,
pendingPrevious: "ls \u6211\u662f\u771f\u7684",
alreadySent: "\u6211\u662f\u771f\u7684",
textareaAtStart: "ls \u6211\u662f\u771f\u7684\u725b\u903c",
}, "ls \u6211\u662f\u771f\u7684\u725b\u903c"),
"ls \u6211\u662f\u771f\u7684",
);
});
type Harness = ImeCompositionCommitTarget & {
compositionstart: () => void;
compositionend: () => void;
keydown: (event: { keyCode: number }) => boolean;
sent: string[];
};
function createHarness(): Harness {
const sent: string[] = [];
const helper: Harness = {
_textarea: { value: "" },
_compositionView: { classList: { remove() {} } },
_isComposing: false,
_isSendingComposition: false,
_compositionPosition: { start: 0, end: 0 },
_compositionSuffix: "",
_dataAlreadySent: "",
_coreService: {
triggerDataEvent(data: string) {
sent.push(data);
},
},
sent,
compositionstart() {
this._isComposing = true;
this._compositionPosition = { start: 0, end: this._textarea.value.length };
this._compositionSuffix = "";
this._dataAlreadySent = "";
this._compositionView.classList.remove("active");
},
compositionend() {
this._finalizeComposition?.(true);
},
keydown(event) {
if (this._isComposing || this._isSendingComposition) {
if (event.keyCode === 229 || event.keyCode === 16) return false;
this._finalizeComposition?.(false);
}
if (event.keyCode === 229) {
this._handleAnyTextareaChanges?.();
return false;
}
return true;
},
};
return helper;
}
function install(helper: Harness): void {
keepLiveImeTranscriptionSingle({ _core: { _compositionHelper: helper } });
}
async function flushTimers(): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, 0));
}
test("live transcription of one sentence is written once", async () => {
const helper = createHarness();
install(helper);
helper.keydown({ keyCode: 229 });
helper._textarea.value = "\u6211\u662f\u771f\u7684";
await flushTimers();
helper.keydown({ keyCode: 229 });
helper._textarea.value = "\u6211\u662f\u771f\u7684\u725b\u903c";
helper.compositionstart();
helper.compositionend();
await flushTimers();
await flushTimers();
assert.equal(helper.sent.join(""), "\u6211\u662f\u771f\u7684\u725b\u903c");
});
test("a composition range that contains only the new tail is not prefixed again", async () => {
const helper = createHarness();
install(helper);
helper.keydown({ keyCode: 229 });
helper._textarea.value = "ls \u6211\u662f\u771f\u7684";
await flushTimers();
helper.keydown({ keyCode: 229 });
helper._textarea.value = "ls \u6211\u662f\u771f\u7684\u725b\u903c";
helper.compositionstart();
helper._compositionPosition = { start: "ls \u6211\u662f\u771f\u7684".length, end: "ls \u6211\u662f\u771f\u7684\u725b\u903c".length };
helper.compositionend();
await flushTimers();
await flushTimers();
assert.equal(helper.sent.join(""), "ls \u6211\u662f\u771f\u7684\u725b\u903c");
});
test("a composition that only confirms the hypothesis does not append it again", async () => {
const helper = createHarness();
install(helper);
helper.keydown({ keyCode: 229 });
helper._textarea.value = "\u6211\u662f\u771f\u7684\u725b\u903c";
await flushTimers();
helper.keydown({ keyCode: 229 });
helper.compositionstart();
helper.compositionend();
await flushTimers();
await flushTimers();
assert.equal(helper.sent.join(""), "\u6211\u662f\u771f\u7684\u725b\u903c");
});
test("normal compositionend still sends the committed word once", async () => {
const helper = createHarness();
install(helper);
helper._textarea.value = "hello";
helper.keydown({ keyCode: 229 });
helper._textarea.value = "hello\u4f60";
helper.compositionstart();
helper._compositionPosition = { start: 5, end: 6 };
helper.compositionend();
await flushTimers();
assert.deepEqual(helper.sent, ["\u4f60"]);
});
test("punctuation entered through keyCode 229 sends only the new character", async () => {
const helper = createHarness();
install(helper);
helper._textarea.value = "ls ";
helper.keydown({ keyCode: 229 });
helper._textarea.value = "ls \uff0c";
await flushTimers();
assert.deepEqual(helper.sent, ["\uff0c"]);
});
test("keydown text already sent is not repeated on compositionend", async () => {
const helper = createHarness();
install(helper);
helper.keydown({ keyCode: 229 });
helper._textarea.value = "abc";
await flushTimers();
helper._compositionPosition = { start: 0, end: 4 };
helper._textarea.value = "abcd";
helper.compositionend();
await flushTimers();
assert.equal(helper.sent.join(""), "abcd");
});
test("a rewritten hypothesis is not sent again on markerless compositionend", async () => {
const helper = createHarness();
install(helper);
helper.keydown({ keyCode: 229 });
helper._textarea.value = "abcde";
await flushTimers();
helper.keydown({ keyCode: 229 });
helper._textarea.value = "abXde";
await flushTimers();
helper._compositionPosition = { start: 0, end: "abXde".length };
helper.compositionend();
await flushTimers();
assert.deepEqual(helper.sent, ["abcde", BACKSPACE, BACKSPACE, BACKSPACE, "Xde"]);
});
test("an equal-length rewrite does not resend the shared prefix", async () => {
const helper = createHarness();
install(helper);
helper.keydown({ keyCode: 229 });
helper._textarea.value = "abcd";
await flushTimers();
helper.sent.length = 0;
helper.keydown({ keyCode: 229 });
helper._textarea.value = "abXY";
await flushTimers();
assert.deepEqual(helper.sent, [BACKSPACE, BACKSPACE, "XY"]);
});
test("a revised composition replaces the hypothesis already on the PTY", async () => {
const helper = createHarness();
install(helper);
helper.keydown({ keyCode: 229 });
helper._textarea.value = "abcde";
await flushTimers();
helper.keydown({ keyCode: 229 });
helper._textarea.value = "abXde";
helper.compositionstart();
helper.compositionend();
await flushTimers();
await flushTimers();
assert.deepEqual(helper.sent, ["abcde", BACKSPACE, BACKSPACE, BACKSPACE, "Xde"]);
});
test("a one-character hypothesis is not repeated when composition extends it", async () => {
const helper = createHarness();
install(helper);
helper.keydown({ keyCode: 229 });
helper._textarea.value = "\u6211";
await flushTimers();
helper.keydown({ keyCode: 229 });
helper._textarea.value = "\u6211\u771f";
helper.compositionstart();
helper.compositionend();
await flushTimers();
await flushTimers();
assert.equal(helper.sent.join(""), "\u6211\u771f");
});
test("a new word that shares a prefix with a finished character is sent whole", async () => {
const helper = createHarness();
install(helper);
helper.keydown({ keyCode: 229 });
helper._textarea.value = "\u6211";
await flushTimers();
helper.keydown({ keyCode: 229 });
helper._textarea.value = "\u6211\u6211\u4eec";
helper.compositionstart();
helper._compositionPosition = { start: 1, end: "\u6211\u6211\u4eec".length };
helper.compositionend();
await flushTimers();
await flushTimers();
assert.equal(helper.sent.join(""), "\u6211\u6211\u4eec");
});
test("a paused live transcription still extends its previously sent hypothesis", async () => {
const helper = createHarness();
install(helper);
helper.keydown({ keyCode: 229 });
helper._textarea.value = "\u6211\u662f\u771f\u7684";
await flushTimers();
helper.compositionstart();
helper._textarea.value = "\u6211\u662f\u771f\u7684\u725b\u903c";
helper._compositionPosition = { start: 0, end: helper._textarea.value.length };
helper.compositionend();
await flushTimers();
assert.deepEqual(helper.sent, ["\u6211\u662f\u771f\u7684", "\u725b\u903c"]);
});
test("a later new word with the same prefix is sent whole", async () => {
const helper = createHarness();
install(helper);
helper.keydown({ keyCode: 229 });
helper._textarea.value = "\u6211";
await flushTimers();
helper.compositionstart();
helper._textarea.value = "\u6211\u6211\u4eec";
helper._compositionPosition = { start: 1, end: helper._textarea.value.length };
helper.compositionend();
await flushTimers();
assert.deepEqual(helper.sent, ["\u6211", "\u6211\u4eec"]);
});
test("a sent rewrite is not repeated when its composition later confirms", async () => {
const helper = createHarness();
install(helper);
helper.keydown({ keyCode: 229 });
helper._textarea.value = "ls abcde";
await flushTimers();
helper.keydown({ keyCode: 229 });
helper._textarea.value = "ls abXde";
await flushTimers();
helper.compositionstart();
helper._compositionPosition = { start: 3, end: helper._textarea.value.length };
helper.compositionend();
await flushTimers();
assert.deepEqual(helper.sent, ["ls abcde", BACKSPACE, BACKSPACE, BACKSPACE, "Xde"]);
});
test("an earlier rewrite is delivered when the next composition starts first", async () => {
const helper = createHarness();
install(helper);
helper.keydown({ keyCode: 229 });
helper._textarea.value = "abcde";
await flushTimers();
helper.compositionstart();
helper._compositionPosition = { start: 0, end: 5 };
helper._textarea.value = "abXde";
helper.compositionend();
helper.compositionstart();
helper._compositionPosition = { start: 5, end: 5 };
helper._textarea.value = "abXdeY";
helper._compositionPosition = { start: 5, end: 6 };
await flushTimers();
helper.compositionend();
await flushTimers();
assert.deepEqual(helper.sent, ["abcde", BACKSPACE, BACKSPACE, BACKSPACE, "Xde", "Y"]);
});
test("a punctuation commit does not make the next word repeat", async () => {
const helper = createHarness();
install(helper);
helper.keydown({ keyCode: 229 });
helper._textarea.value = "\uff0c";
await flushTimers();
helper.keydown({ keyCode: 229 });
helper._textarea.value = "\uff0c\u4f60";
await flushTimers();
helper._compositionPosition = { start: 1, end: 2 };
helper.compositionend();
await flushTimers();
assert.deepEqual(helper.sent, ["\uff0c", "\u4f60"]);
});
test("filtered formatting characters do not break final composition deduplication", async () => {
const helper = createHarness();
install(helper);
helper.keydown({ keyCode: 229 });
helper._textarea.value = "ab";
await flushTimers();
helper.keydown({ keyCode: 229 });
helper._textarea.value = "ab\u200b";
await flushTimers();
helper.keydown({ keyCode: 229 });
helper._textarea.value = "ab\u200bX";
await flushTimers();
helper._compositionPosition = { start: 0, end: helper._textarea.value.length };
helper.compositionend();
await flushTimers();
assert.deepEqual(helper.sent, ["ab", "X"]);
});
test("keepLiveImeTranscriptionSingle is a no-op without a composition helper", () => {
assert.doesNotThrow(() => keepLiveImeTranscriptionSingle({}));
const helper = createHarness();
install(helper);
install(helper);
assert.equal(helper.__ncImeCommitInstalled, true);
});
test("createXTermRuntime installs the guard after the composition helper exists", () => {
const source = readFileSync(
join(dirname(fileURLToPath(import.meta.url)), "createXTermRuntime.ts"),
"utf8",
);
const openAt = source.indexOf("term.open(ctx.container);");
const installAt = source.indexOf("keepLiveImeTranscriptionSingle(term);");
assert.ok(openAt >= 0);
assert.ok(installAt > openAt);
});
@@ -0,0 +1,342 @@
/**
* xterm.js commits an IME textarea change with `newValue.replace(oldValue, "")`
* and, on compositionend, skips `_dataAlreadySent.length` characters. Both
* steps assume the IME only appends. Live transcription rewrites the whole
* hypothesis, so the utterance is written as the
* interim, again as the full final string, and again as the tail (#3421).
*
* Append-only commits (punctuation, a normal compositionend) stay as they are.
* A composition that continues a multi-character hypothesis already sent to
* the PTY writes only the unsent suffix, and the in-flight textarea timer is
* dropped so it cannot append that suffix a second time.
*/
import { sanitizeTerminalInput } from "./terminalInputSanitize";
const BACKSPACE = "\x7f";
const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
function graphemes(value: string): string[] {
return Array.from(graphemeSegmenter.segment(value), (part) => part.segment);
}
export type ImeCompositionCommitTarget = {
_textarea: { value: string };
_compositionView: { classList: { remove(token: string): void } };
_isComposing: boolean;
_isSendingComposition: boolean;
_compositionPosition: { start: number; end: number };
_compositionSuffix: string;
_dataAlreadySent: string;
_textareaChangeTimer?: ReturnType<typeof setTimeout>;
__ncPendingPrevious?: string;
_coreService: { triggerDataEvent(data: string, wasUserInput?: boolean): void };
compositionstart: () => void;
_handleAnyTextareaChanges?: () => void;
_finalizeComposition?: (waitForPropagation: boolean) => void;
__ncImeCommitInstalled?: boolean;
__ncImeCommit?: ImeCompositionMarker;
};
export type ImeCompositionMarker = {
/** A keydown-229 textarea timer was still pending when composition started. */
continued: boolean;
/** Textarea value that timer snapshotted, already reflected on the PTY. */
pendingPrevious: string;
alreadySent: string;
textareaAtStart: string;
};
/**
* PTY edit for one textarea rewrite.
* A pure append sends the new suffix. A pure shrink sends one backspace per
* deleted character, except a cleared textarea, which stays a single
* backspace. xterm empties the textarea when a line is submitted, and that
* reset is not a dictation rewrite. Any other rewrite deletes only the
* diverging span and inserts its replacement, instead of `String.replace`
* or the whole new value.
*/
export function commitTextareaChange(previous: string, next: string): string {
previous = sanitizeTerminalInput(previous);
next = sanitizeTerminalInput(next);
if (previous === next) return "";
if (next.startsWith(previous)) return next.slice(previous.length);
if (previous.startsWith(next)) {
if (next.length === 0) return BACKSPACE;
return BACKSPACE.repeat(graphemes(previous.slice(next.length)).length);
}
return rewriteDivergingSpan(previous, next);
}
export function rememberTextareaCommit(
alreadySent: string,
previous: string,
sent: string,
next?: string,
): string {
if (!sent) return alreadySent;
let index = 0;
while (sent.charCodeAt(index) === 0x7f) index += 1;
const inserted = sent.slice(index);
if (index > 0) {
// Backspaces edit the PTY in place. A later markerless compositionend
// only skips `_dataAlreadySent` when it is a prefix of the final text,
// so the whole revised value has to be remembered, not just the tail.
// A cleared textarea is xterm's line-submit reset, not that value.
if (typeof next === "string") return sanitizeTerminalInput(next);
const parts = graphemes(previous);
const kept = parts.slice(0, Math.max(0, parts.length - index)).join("");
return kept + inserted;
}
if (sanitizeTerminalInput(previous).endsWith(alreadySent)) return alreadySent + inserted;
return sent;
}
/**
* Prefix of `compositionText` already on the PTY. Set only when composition
* started while a 229 textarea timer was still pending, so this composition
* finishes the update that timer snapshotted, not the next word.
*
* The timer's snapshot is the text already written, including a one-character
* hypothesis. A later composition that does not begin with that snapshot
* (normal pinyin after a finished line) keeps every character.
*/
export function continuedCompositionPrefix(
marker: ImeCompositionMarker | undefined,
compositionText: string,
): string {
if (!marker?.continued) return "";
if (
marker.pendingPrevious.length > 0
&& compositionText.startsWith(marker.pendingPrevious)
) {
return marker.pendingPrevious;
}
if (
marker.alreadySent.length > 0
&& compositionText.startsWith(marker.alreadySent)
&& marker.textareaAtStart.endsWith(marker.alreadySent)
) {
return marker.alreadySent;
}
return "";
}
function rewriteDivergingSpan(previous: string, next: string): string {
const previousParts = graphemes(previous);
const nextParts = graphemes(next);
let start = 0;
const shared = Math.min(previousParts.length, nextParts.length);
while (start < shared && previousParts[start] === nextParts[start]) start += 1;
// The cursor stays after the last cell. A shared suffix is still part of
// the tail that must be deleted and typed again. Count graphemes so an
// emoji is one deletion, not the two UTF-16 units of a surrogate pair.
return BACKSPACE.repeat(previousParts.length - start) + nextParts.slice(start).join("");
}
/**
* What a continued composition should write. Null means this composition is a
* new word and the caller sends the slice unchanged.
*
* An extension of the snapshot sends only the new suffix. A revision sends
* the same edit the cancelled textarea timer would have sent, so `abcde`
* replaced by `abXde` does not get appended after the old hypothesis.
* A composition that starts at or after the snapshot is a new word. A
* finished character followed by a new word that begins with that same
* character must be sent whole.
*/
export function continuedCompositionData(
marker: ImeCompositionMarker | undefined,
compositionText: string,
textareaNow: string,
compositionStart: number,
followingComposition = false,
): string | null {
if (!marker || !compositionText) return null;
// A live hypothesis can settle before the next composition starts. In that
// case the timer has fired, but its sent text is still the textarea suffix.
const previous = marker.continued ? marker.pendingPrevious : marker.textareaAtStart;
if (!marker.continued && (
!marker.alreadySent || !sanitizeTerminalInput(previous).endsWith(marker.alreadySent)
)) {
return null;
}
if (!previous) return null;
if (compositionStart >= previous.length) return null;
if (!marker.continued && compositionText === previous.slice(compositionStart)) return "";
if (compositionText.startsWith(previous)) return compositionText.slice(previous.length);
if (
marker.alreadySent.length > 0
&& sanitizeTerminalInput(compositionText).startsWith(marker.alreadySent)
&& (
sanitizeTerminalInput(textareaNow).endsWith(marker.alreadySent)
|| sanitizeTerminalInput(marker.textareaAtStart).endsWith(marker.alreadySent)
)
) {
return sanitizeTerminalInput(compositionText).slice(marker.alreadySent.length);
}
if (textareaNow === compositionText || marker.textareaAtStart === compositionText) {
return commitTextareaChange(previous, compositionText);
}
if (
followingComposition
&& textareaNow.startsWith(compositionText, compositionStart)
&& textareaNow.length > compositionStart + compositionText.length
) {
return commitTextareaChange(previous.slice(compositionStart), compositionText);
}
if (textareaNow.endsWith(compositionText) && textareaNow.length > compositionText.length) {
const headLength = textareaNow.length - compositionText.length;
if (
previous.length >= headLength
&& textareaNow.startsWith(previous.slice(0, headLength))
) {
return commitTextareaChange(previous.slice(headLength), compositionText);
}
}
return null;
}
function emitPtyData(helper: ImeCompositionCommitTarget, data: string): void {
// One event per backspace so mapTerminalBackspaceInput and the command
// buffer see a standalone DEL or Ctrl-H, then the replacement text.
let index = 0;
while (data.charCodeAt(index) === 0x7f) {
helper._coreService.triggerDataEvent(BACKSPACE, true);
index += 1;
}
const inserted = data.slice(index);
if (inserted.length > 0) helper._coreService.triggerDataEvent(inserted, true);
}
function compositionSlice(
helper: ImeCompositionCommitTarget,
rangeStart: number,
suffix: string,
composingNow: boolean,
newCompositionStart: number,
): string {
const value = helper._textarea.value;
if (composingNow) return value.substring(rangeStart, newCompositionStart);
const valueEnd = suffix.length > 0 && value.endsWith(suffix)
? value.length - suffix.length
: value.length;
return value.substring(rangeStart, Math.max(rangeStart, valueEnd));
}
function deliverComposition(
helper: ImeCompositionCommitTarget,
compositionText: string,
marker: ImeCompositionMarker | undefined,
compositionStart: number,
followingComposition = false,
): void {
const continued = continuedCompositionData(
marker,
compositionText,
helper._textarea.value,
compositionStart,
followingComposition,
);
if (continued !== null) {
if (continued.length > 0) emitPtyData(helper, continued);
if (!helper._isComposing) helper._dataAlreadySent = "";
return;
}
const cleanText = sanitizeTerminalInput(compositionText);
const live = helper._dataAlreadySent && cleanText.startsWith(helper._dataAlreadySent)
? helper._dataAlreadySent
: "";
const data = live
? cleanText.slice(live.length)
: helper._dataAlreadySent.endsWith(cleanText) ? "" : cleanText;
if (data.length > 0) emitPtyData(helper, data);
if (!helper._isComposing) helper._dataAlreadySent = "";
}
/**
* Installs the commit guard on one terminal. CompositionHelper is created in
* `terminal.open()`, so this runs after open. Missing helpers (tests, log
* view) are left untouched.
*/
export function keepLiveImeTranscriptionSingle(term: object): void {
const helper = (term as { _core?: { _compositionHelper?: ImeCompositionCommitTarget } })
._core?._compositionHelper;
if (!helper || helper.__ncImeCommitInstalled || typeof helper.compositionstart !== "function") {
return;
}
helper.__ncImeCommitInstalled = true;
const originalStart = helper.compositionstart.bind(helper);
helper.compositionstart = () => {
const alreadySent = helper._dataAlreadySent;
const pendingPrevious = helper.__ncPendingPrevious ?? "";
const continued = helper._textareaChangeTimer !== undefined;
if (helper._textareaChangeTimer !== undefined) {
clearTimeout(helper._textareaChangeTimer);
helper._textareaChangeTimer = undefined;
}
helper.__ncPendingPrevious = undefined;
originalStart();
helper.__ncImeCommit = {
continued,
pendingPrevious,
alreadySent,
textareaAtStart: helper._textarea.value,
};
};
helper._handleAnyTextareaChanges = () => {
if (helper._textareaChangeTimer !== undefined) return;
const previous = helper._textarea.value;
helper.__ncPendingPrevious = previous;
helper._textareaChangeTimer = setTimeout(() => {
helper._textareaChangeTimer = undefined;
helper.__ncPendingPrevious = undefined;
helper.__ncImeCommit = undefined;
if (helper._isComposing || helper._isSendingComposition) return;
const next = helper._textarea.value;
const data = commitTextareaChange(previous, next);
if (!data) return;
helper._dataAlreadySent = rememberTextareaCommit(helper._dataAlreadySent, previous, data, next);
emitPtyData(helper, data);
}, 0);
};
helper._finalizeComposition = (waitForPropagation: boolean) => {
const marker = helper.__ncImeCommit;
helper.__ncImeCommit = undefined;
helper._compositionView.classList.remove("active");
helper._isComposing = false;
if (!waitForPropagation) {
helper._isSendingComposition = false;
const compositionText = helper._textarea.value.substring(
helper._compositionPosition.start,
helper._compositionPosition.end,
);
deliverComposition(helper, compositionText, marker, helper._compositionPosition.start);
return;
}
const rangeStart = helper._compositionPosition.start;
const suffix = helper._compositionSuffix;
const alreadySent = helper._dataAlreadySent;
helper._isSendingComposition = true;
setTimeout(() => {
if (!helper._isSendingComposition) return;
helper._isSendingComposition = false;
const compositionText = compositionSlice(
helper,
rangeStart,
suffix,
helper._isComposing,
helper._compositionPosition.start,
);
// compositionstart may have cleared the live field. The no-composition
// path (#3191) still has the keydown commit in this snapshot.
if (!marker && alreadySent) helper._dataAlreadySent = alreadySent;
deliverComposition(helper, compositionText, marker, rangeStart, helper._isComposing && !suffix);
}, 0);
};
}
@@ -39,10 +39,10 @@ const callbackCode = ts.transpileModule(`
${textInputMarker}
${keyboardCallback}
${dataCallback}
globalThis.controls = { mark: markBroadcastLegacyDataPending, clear: clearBroadcastLegacyDataPending, input: markKittyTextInput };
globalThis.controls = { mark: markBroadcastLegacyDataPending, clear: clearBroadcastLegacyDataPending, input: markKittyTextInput, composition: markKittyCompositionPending };
`, { compilerOptions: { target: ts.ScriptTarget.ES2022 } }).outputText;
function setup(flags = 0) {
function setup(flags = 0, sourceFlags = 0) {
const writes: string[] = [];
const timers = new Map<number, () => void>();
let timerId = 0;
@@ -51,6 +51,8 @@ function setup(flags = 0) {
const forwarded = new Map<string, { targetSessionIds: string[] }>();
const mode = createKittyKeyboardModeState();
setKittyKeyboardModeFlags(mode, flags);
const sourceMode = createKittyKeyboardModeState();
setKittyKeyboardModeFlags(sourceMode, sourceFlags);
const options = {
kittyProtocolEnabled: flags !== 0, kittyMode: mode, applicationCursorMode: false,
encodedKeys: new Set<string>(), legacySuppressedKeys: new Set<string>(),
@@ -60,7 +62,7 @@ function setup(flags = 0) {
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 },
controls: undefined as unknown as { mark: (identity: string) => void; clear: () => void; input: (event: { data: string; inputType: string }) => void; composition: () => void },
window: {
setTimeout(fn: () => void) { const id = ++timerId; timers.set(id, fn); return id; },
clearTimeout(id: number) { timers.delete(id); },
@@ -73,7 +75,7 @@ function setup(flags = 0) {
ctx: { terminalSettingsRef: { current: {} }, isBroadcastEnabledRef: { current: true }, onBroadcastInputRef: { current: () => undefined } },
imeTextInputDeferredKey: null, imeTextInputDeferredKittyEvent: null,
shouldBlockKeyPressForImeTextInput, shouldCommitDeferredImeTextInput, shouldMarkKittyTextInputEvent, shouldEncodeKittyCompositionText, encodeKittyCompositionText,
kittyKeyboardMode: createKittyKeyboardModeState(), shouldSplitImeTextInputForWire: () => false,
kittyKeyboardMode: sourceMode, shouldSplitImeTextInputForWire: () => false,
kittyKeyIdentity: (event: Partial<KeyboardEvent>) => event.code || event.key,
broadcastForwardedKeys: forwarded,
broadcastKittyInput: normalized,
@@ -99,6 +101,16 @@ function setup(flags = 0) {
};
}
test("IME rewrite backspaces leave Kitty composition routing for replacement text", () => {
const runtime = setup(0, 24);
runtime.controls.composition();
runtime.receive("\x7f");
runtime.receive("Xde");
assert.equal(runtime.writes[0], "\x7f");
assert.ok(runtime.writes.includes("\x1b[0;;88:100:101u"));
assert.ok(!runtime.writes.includes("\x1b[0;;127u"));
});
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}`, () => {