From 0d40955fd1f818daa716a2459ed1ff0af83ee2ae Mon Sep 17 00:00:00 2001 From: Denis H <79399355+dplush@users.noreply.github.com> Date: Sat, 8 Aug 2026 03:56:23 +0200 Subject: [PATCH] fix(dashboard): handle chunked IME composition input --- web/src/lib/pty-composition.test.ts | 41 +++++++++++++++++++++++++++++ web/src/lib/pty-composition.ts | 19 +++++++++++-- 2 files changed, 58 insertions(+), 2 deletions(-) diff --git a/web/src/lib/pty-composition.test.ts b/web/src/lib/pty-composition.test.ts index 5b533112c6..82b3bd636d 100644 --- a/web/src/lib/pty-composition.test.ts +++ b/web/src/lib/pty-composition.test.ts @@ -40,6 +40,47 @@ describe("createPtyCompositionForwarder", () => { 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(); diff --git a/web/src/lib/pty-composition.ts b/web/src/lib/pty-composition.ts index f5074826ed..82876c5880 100644 --- a/web/src/lib/pty-composition.ts +++ b/web/src/lib/pty-composition.ts @@ -7,9 +7,13 @@ export function createPtyCompositionForwarder(send: (data: string) => void) { let pending: string | null = null; let timer: ReturnType | null = null; + let matchedTerminalPrefix = ""; + let sawUnrelatedTerminalData = false; const clearPending = () => { pending = null; + matchedTerminalPrefix = ""; + sawUnrelatedTerminalData = false; if (timer) { clearTimeout(timer); timer = null; @@ -31,8 +35,19 @@ export function createPtyCompositionForwarder(send: (data: string) => void) { }, 16); }, noteTerminalData(data: string) { - // xterm delivers the committed text before any following terminal input. - if (pending && !data.startsWith("\x1b") && data.startsWith(pending)) clearPending(); + 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, };