Skip to content
Closed
135 changes: 135 additions & 0 deletions web/src/lib/pty-composition.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import { afterEach, describe, expect, it, vi } from "vitest";

import { createPtyCompositionForwarder } from "./pty-composition";

describe("createPtyCompositionForwarder", () => {
afterEach(() => vi.useRealTimers());

it("forwards committed dead-key text when xterm emits no onData", () => {
vi.useFakeTimers();
const send = vi.fn();
const forwarder = createPtyCompositionForwarder(send);

forwarder.onCompositionEnd("ä");
vi.runAllTimers();

expect(send).toHaveBeenCalledExactlyOnceWith("ä");
});

it("leaves xterm's committed input alone when it arrives before the fallback", () => {
vi.useFakeTimers();
const send = vi.fn();
const forwarder = createPtyCompositionForwarder(send);

forwarder.onCompositionEnd("ä");
forwarder.noteTerminalData("äx");
vi.runAllTimers();

expect(send).not.toHaveBeenCalled();
});

it("forwards a pending composition after unrelated terminal data", () => {
vi.useFakeTimers();
const send = vi.fn();
const forwarder = createPtyCompositionForwarder(send);

forwarder.onCompositionEnd("ä");
forwarder.noteTerminalData("x");
vi.advanceTimersByTime(15);
expect(send).not.toHaveBeenCalled();
vi.advanceTimersByTime(1);

expect(send).toHaveBeenCalledExactlyOnceWith("ä");
});

it("forwards a pending composition when unrelated data precedes matching chunks", () => {
vi.useFakeTimers();
const send = vi.fn();
const forwarder = createPtyCompositionForwarder(send);

forwarder.onCompositionEnd("ab");
forwarder.noteTerminalData("x");
forwarder.noteTerminalData("a");
forwarder.noteTerminalData("b");
vi.runAllTimers();

expect(send).toHaveBeenCalledExactlyOnceWith("ab");
});

it("cancels a pending composition when matching text arrives in clean chunks", () => {
vi.useFakeTimers();
const send = vi.fn();
const forwarder = createPtyCompositionForwarder(send);

forwarder.onCompositionEnd("ab");
forwarder.noteTerminalData("a");
forwarder.noteTerminalData("b");
vi.runAllTimers();

expect(send).not.toHaveBeenCalled();
});

it("ignores ESC/SGR data while matching composition chunks", () => {
vi.useFakeTimers();
const send = vi.fn();
const forwarder = createPtyCompositionForwarder(send);

forwarder.onCompositionEnd("ab");
forwarder.noteTerminalData("a");
forwarder.noteTerminalData("\x1b[<0;10;10M");
forwarder.noteTerminalData("b");
vi.runAllTimers();

expect(send).not.toHaveBeenCalled();
});

it("forwards a second composition after the first fallback completes", () => {
vi.useFakeTimers();
const send = vi.fn();
const forwarder = createPtyCompositionForwarder(send);

forwarder.onCompositionEnd("ä");
vi.runAllTimers();
forwarder.onCompositionEnd("ö");
vi.runAllTimers();

expect(send).toHaveBeenNthCalledWith(1, "ä");
expect(send).toHaveBeenNthCalledWith(2, "ö");
});

it("preserves an earlier rapid composition before scheduling the next", () => {
vi.useFakeTimers();
const send = vi.fn();
const forwarder = createPtyCompositionForwarder(send);

forwarder.onCompositionEnd("a");
forwarder.onCompositionEnd("ä");
vi.runAllTimers();

expect(send).toHaveBeenNthCalledWith(1, "a");
expect(send).toHaveBeenNthCalledWith(2, "ä");
});

it("cancels a pending composition on disposal", () => {
vi.useFakeTimers();
const send = vi.fn();
const forwarder = createPtyCompositionForwarder(send);

forwarder.onCompositionEnd("ä");
forwarder.dispose();
vi.runAllTimers();

expect(send).not.toHaveBeenCalled();
});

it("does not send an empty cancelled composition", () => {
vi.useFakeTimers();
const send = vi.fn();
const forwarder = createPtyCompositionForwarder(send);

forwarder.onCompositionEnd("");
vi.runAllTimers();

expect(send).not.toHaveBeenCalled();
});
});
54 changes: 54 additions & 0 deletions web/src/lib/pty-composition.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/**
* Delays an IME/dead-key commit just long enough for xterm to emit onData.
*
* xterm is authoritative when it emits the commit. Browsers/layouts where it
* does not emit onData still forward the compositionend text on the next turn.
*/
export function createPtyCompositionForwarder(send: (data: string) => void) {
let pending: string | null = null;
let timer: ReturnType<typeof setTimeout> | null = null;
let matchedTerminalPrefix = "";
let sawUnrelatedTerminalData = false;

const clearPending = () => {
pending = null;
matchedTerminalPrefix = "";
sawUnrelatedTerminalData = false;
if (timer) {
clearTimeout(timer);
timer = null;
}
};

return {
onCompositionEnd(data: string | null) {
if (!data) return;
// Preserve rapid consecutive commits instead of discarding the first.
const previous = pending;
clearPending();
if (previous) send(previous);
pending = data;
timer = setTimeout(() => {
const committed = pending;
clearPending();
if (committed) send(committed);
}, 16);
},
noteTerminalData(data: string) {
if (!pending || data.startsWith("\x1b") || sawUnrelatedTerminalData) return;

// xterm may split committed text across callbacks, but only a clean,
// leading match is authoritative. Once unrelated data arrives, retain
// the fallback even if later callbacks happen to spell the composition.
const observed = matchedTerminalPrefix + data;
if (observed.startsWith(pending)) {
clearPending();
} else if (pending.startsWith(observed)) {
matchedTerminalPrefix = observed;
} else {
sawUnrelatedTerminalData = true;
}
},
dispose: clearPending,
};
}
25 changes: 22 additions & 3 deletions web/src/pages/ChatPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import { useI18n } from "@/i18n";
import { api } from "@/lib/api";
import { latchChatActivation } from "@/lib/chat-activation";
import { normalizeSessionTitle } from "@/lib/chat-title";
import { createPtyCompositionForwarder } from "@/lib/pty-composition";
import { PtyResumeSanitizer } from "@/lib/pty-resume-sanitizer";
import {
PTY_CONNECTING_TIMEOUT_MS,
Expand Down Expand Up @@ -739,6 +740,12 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
term.loadAddon(new WebLinksAddon());

let mobileInputCleanup: (() => void) | null = null;
// xterm occasionally drops committed dead-key/IME text instead of emitting
// onData. The compositionend event supplies the authoritative text.
let sendComposedText: (data: string) => void = () => undefined;
const compositionForwarder = createPtyCompositionForwarder((data) => {
sendComposedText(data);
});
term.open(host);

const textarea = term.textarea;
Expand All @@ -763,8 +770,9 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
mobileReplacementInputUntilRef.current = Date.now() + MOBILE_REPLACEMENT_WINDOW_MS;
}
};
const markCompositionEnd = () => {
const markCompositionEnd = (ev: CompositionEvent) => {
mobileReplacementInputUntilRef.current = Date.now() + MOBILE_REPLACEMENT_WINDOW_MS;
compositionForwarder.onCompositionEnd(ev.data);
};

textarea.addEventListener("beforeinput", markReplacementInput, true);
Expand Down Expand Up @@ -1179,7 +1187,7 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
// behave normally.
// eslint-disable-next-line no-control-regex -- intentional ESC byte in xterm SGR mouse report parser
const SGR_MOUSE_RE = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])$/;
onDataDisposable = term.onData((data) => {
const forwardPtyData = (data: string, useMobileReplacement = true) => {
// Mouse reports (scroll wheel etc.) are not typed input — swallow
// them before the blocked-input check so scrolling a disconnected
// terminal doesn't trip the "reconnecting" notice.
Expand All @@ -1203,13 +1211,23 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
const normalized = normalizePtyMobileInput(
data,
ptyInputLineRef.current,
Date.now() <= mobileReplacementInputUntilRef.current,
useMobileReplacement && Date.now() <= mobileReplacementInputUntilRef.current,
);
ptyInputLineRef.current = normalized.nextLine;
if (normalized.normalized) {
mobileReplacementInputUntilRef.current = 0;
}
ws.send(normalized.data);
};
// The deferred composition fallback is already committed text, so it
// must not consume the mobile replacement window intended for xterm's
// normal onData path.
sendComposedText = (data) => forwardPtyData(data, false);
onDataDisposable = term.onData((data) => {
if (!SGR_MOUSE_RE.test(data)) {
compositionForwarder.noteTerminalData(data);
}
forwardPtyData(data);
});

onResizeDisposable = term.onResize(({ cols, rows }) => {
Expand All @@ -1231,6 +1249,7 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
onDataDisposable?.dispose();
onResizeDisposable?.dispose();
mobileInputCleanup?.();
compositionForwarder.dispose();
host.removeEventListener("paste", handleBrowserPaste, true);
host.removeEventListener("dragover", handleBrowserDragOver, true);
host.removeEventListener("drop", handleBrowserDrop, true);
Expand Down