From 5c911d91bf46fe2c88f52f6d167475557390b87f Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 10 Jul 2026 21:52:26 -0700 Subject: [PATCH] fix(terminal): scope mode 2031 retries to subscription --- .../terminal-mode-2031-replies.test.ts | 24 ++++++++++++++++++- .../terminal-mode-2031-replies.ts | 3 ++- .../use-terminal-pane-lifecycle.ts | 7 ++++++ 3 files changed, 32 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/components/terminal-pane/terminal-mode-2031-replies.test.ts b/src/renderer/src/components/terminal-pane/terminal-mode-2031-replies.test.ts index ca88cb7fe8d..f26cd149894 100644 --- a/src/renderer/src/components/terminal-pane/terminal-mode-2031-replies.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-mode-2031-replies.test.ts @@ -17,6 +17,7 @@ describe('pushMode2031SeedReply', () => { const sendInputImmediate = vi.fn<(data: string) => boolean>(() => true) const scheduled: (() => void)[] = [] const recordMode = vi.fn() + const currentAttempt = { current: Symbol() } const transport = { isConnected: () => connectedState.current, sendInput, @@ -29,15 +30,19 @@ describe('pushMode2031SeedReply', () => { sendInputImmediate, scheduled, recordMode, - push: () => + push: () => { + const attempt = Symbol() + currentAttempt.current = attempt pushMode2031SeedReply(1, { hasPane: () => true, isSubscribed: () => subscribed.current, + isCurrentAttempt: () => currentAttempt.current === attempt, getTransport: () => transport, getMode: () => 'dark', recordMode, schedule: (callback) => scheduled.push(callback) }) + } } } @@ -65,4 +70,21 @@ describe('pushMode2031SeedReply', () => { expect(harness.sendInput).not.toHaveBeenCalled() expect(harness.scheduled).toHaveLength(0) }) + + it('does not let an old retry answer a later subscription', () => { + const harness = createHarness(false) + + harness.push() + harness.subscribed.current = false + harness.subscribed.current = true + harness.push() + expect(harness.scheduled).toHaveLength(2) + + harness.connected.current = true + harness.scheduled.shift()?.() + harness.scheduled.shift()?.() + + expect(harness.sendInputImmediate).toHaveBeenCalledTimes(1) + expect(harness.recordMode).toHaveBeenCalledTimes(1) + }) }) diff --git a/src/renderer/src/components/terminal-pane/terminal-mode-2031-replies.ts b/src/renderer/src/components/terminal-pane/terminal-mode-2031-replies.ts index e30961692f8..66df12517b1 100644 --- a/src/renderer/src/components/terminal-pane/terminal-mode-2031-replies.ts +++ b/src/renderer/src/components/terminal-pane/terminal-mode-2031-replies.ts @@ -10,6 +10,7 @@ type Mode2031ReplyTransport = Pick boolean isSubscribed: (paneId: number) => boolean + isCurrentAttempt: (paneId: number) => boolean getTransport: (paneId: number) => Mode2031ReplyTransport | undefined getMode: () => TerminalColorSchemeMode | null recordMode: (paneId: number, mode: TerminalColorSchemeMode) => void @@ -30,7 +31,7 @@ export function pushMode2031SeedReply(paneId: number, deps: Mode2031SeedReplyDep const send = (): void => { // Why: a TUI can unsubscribe while the PTY is connecting; every delayed // attempt must revalidate intent or its color reply can land at a shell prompt. - if (!deps.hasPane(paneId) || !deps.isSubscribed(paneId)) { + if (!deps.hasPane(paneId) || !deps.isSubscribed(paneId) || !deps.isCurrentAttempt(paneId)) { return } const transport = deps.getTransport(paneId) diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts index c39d97453e3..ccb21fac182 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts @@ -575,6 +575,7 @@ export function useTerminalPaneLifecycle({ const selectionDisposablesRef = useRef(new Map()) const selectionCaptureTimersRef = useRef(new Map()) const mode2031DisposablesRef = useRef(new Map()) + const mode2031SeedAttemptTokensRef = useRef(new Map()) const osc52DisposablesRef = useRef(new Map()) const osc7DisposablesRef = useRef(new Map()) const mouseHideDisposablesRef = useRef(new Map()) @@ -601,10 +602,15 @@ export function useTerminalPaneLifecycle({ } const pushMode2031ForPane = (paneId: number): void => { + const attemptToken = Symbol() + mode2031SeedAttemptTokensRef.current.set(paneId, attemptToken) pushMode2031SeedReply(paneId, { hasPane: (candidateId) => managerRef.current?.getPanes().some((pane) => pane.id === candidateId) === true, isSubscribed: (candidateId) => paneMode2031Ref.current.get(candidateId) === true, + // Why: an older connect retry must not answer a later resubscription. + isCurrentAttempt: (candidateId) => + mode2031SeedAttemptTokensRef.current.get(candidateId) === attemptToken, getTransport: (candidateId) => paneTransportsRef.current.get(candidateId), getMode: () => { const currentSettings = settingsRef.current @@ -1222,6 +1228,7 @@ export function useTerminalPaneLifecycle({ mode2031DisposablesRef.current.delete(paneId) } paneMode2031Ref.current.delete(paneId) + mode2031SeedAttemptTokensRef.current.delete(paneId) paneKittyKeyboardModesRef.current.delete(paneId) paneLastThemeModeRef.current.delete(paneId) const osc52Disposable = osc52DisposablesRef.current.get(paneId)