From 43ed9878b7c2b48e1d16ec0030219cf283ff642f Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 29 Oct 2026 04:45:26 -0700 Subject: [PATCH] fix(terminal): judge live-agent reattach modes by the image's own title (#35920) After a serve restart where the daemon survives, a paired client could drop mouse and focus reporting for a still-live agent. The host publishes the pane with its tab-default title until it seeds the daemon's live title about 180 ms later. A reattach image replayed inside that window was judged against the default title, read as a plain normal-buffer program, and got the reset that disarms its modes, though the image itself carried them. The snapshot now carries the host's tracked title when the image was taken, as an additive `lastTitle` field in the snapshot info, gated on the image's seq. Every reset that judges a replayed image as a live agent's (the post-replay reset, the focus-in that follows it, the hidden-restore reset, and the model-snapshot reattach) uses that title when present, so the decision describes the same moment as the replayed bytes. Local Electron model snapshots already carried this title and now use it the same way. The reattach policy itself is unchanged: non-agent normal-buffer panes still shed modes a dead child may have left (#7329, #7893). Older hosts omit the field and clients fall back to the published title. Older clients ignore it. Refs #35559 --- .../terminal-multiplex-flow-control.ts | 1 + .../terminal-multiplex-initial-snapshot.ts | 1 + .../terminal-multiplex-slot-frames.ts | 1 + .../terminal/terminal-snapshot-payload.ts | 4 + .../terminal/terminal-snapshot-publication.ts | 3 + .../terminal-snapshot-scrollback-rows.test.ts | 11 + .../methods/terminal/terminal-stream-types.ts | 3 + .../terminal-subscribe-binary-snapshot.ts | 1 + ...ection-remote-snapshot-image-title.test.ts | 237 ++++++++++++++++++ .../pty-connection/apply-reattach-payload.ts | 8 +- .../deferred-cold-restore-and-snapshot.ts | 1 + .../fresh-spawn-follow-reset.ts | 5 +- .../hidden-output-restore-snapshot.ts | 3 +- .../pty-connection/replay-data-drain.ts | 13 +- .../pty-connection/shell-command-inference.ts | 24 +- .../terminal-pane/pty-output-processor.ts | 2 + .../terminal-pane/pty-transport-types.ts | 4 + .../remote-runtime-pty-transport.ts | 3 + ...emote-runtime-terminal-binary-snapshots.ts | 2 + ...mote-runtime-terminal-multiplexer-types.ts | 4 + .../remote-runtime-terminal-snapshot-state.ts | 2 + ...paired-serve-respawn-process-state.spec.ts | 9 +- 22 files changed, 321 insertions(+), 21 deletions(-) create mode 100644 src/renderer/src/components/terminal-pane/pty-connection-remote-snapshot-image-title.test.ts diff --git a/src/main/runtime/rpc/methods/terminal/terminal-multiplex-flow-control.ts b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-flow-control.ts index 42a59b65a3f..df1640a9de5 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-multiplex-flow-control.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-flow-control.ts @@ -67,6 +67,7 @@ export function installMultiplexFlowControl(state: TerminalMultiplexConnection): kittyKeyboardFlags: serialized.kittyKeyboardFlags, alternateScreen: serialized.alternateScreen, terminalOwner: serialized.terminalOwner, + lastTitle: serialized.lastTitle, truncatedByByteBudget: serialized.truncatedByByteBudget, scrollbackRows: serialized.scrollbackRows, data: serialized.data diff --git a/src/main/runtime/rpc/methods/terminal/terminal-multiplex-initial-snapshot.ts b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-initial-snapshot.ts index 91e2aefb9da..e20722c3c5b 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-multiplex-initial-snapshot.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-initial-snapshot.ts @@ -116,6 +116,7 @@ export async function publishMultiplexInitialSnapshot( kittyKeyboardFlags: serialized?.kittyKeyboardFlags, alternateScreen: serialized?.alternateScreen, terminalOwner: serialized?.terminalOwner, + lastTitle: serialized?.lastTitle, oscLinks: serialized?.oscLinks, pendingEscapeTailAnsi: serialized?.pendingEscapeTailAnsi, // E2E: a forced-unavailable host retains nothing, so it has no tail to fall back on either. diff --git a/src/main/runtime/rpc/methods/terminal/terminal-multiplex-slot-frames.ts b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-slot-frames.ts index 9c4debe5cb3..97d30fae24d 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-multiplex-slot-frames.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-slot-frames.ts @@ -214,6 +214,7 @@ export function installMultiplexSlotFrames(state: TerminalMultiplexConnection): kittyKeyboardFlags: serialized?.kittyKeyboardFlags, alternateScreen: serialized?.alternateScreen, terminalOwner: serialized?.terminalOwner, + lastTitle: serialized?.lastTitle, oscLinks: serialized?.oscLinks, pendingEscapeTailAnsi: serialized?.pendingEscapeTailAnsi, truncated: false, diff --git a/src/main/runtime/rpc/methods/terminal/terminal-snapshot-payload.ts b/src/main/runtime/rpc/methods/terminal/terminal-snapshot-payload.ts index e1a9304ab0b..215b7f4fdd9 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-snapshot-payload.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-snapshot-payload.ts @@ -23,6 +23,10 @@ export function buildSnapshotFrameMeta(options: SnapshotFrameOptions): Record type TerminalBufferSource = Pick @@ -117,6 +118,7 @@ export type MobileSnapshotByteBudget = { | 'kittyKeyboardFlags' | 'alternateScreen' | 'terminalOwner' + | 'lastTitle' | 'truncatedByByteBudget' > } @@ -157,6 +159,7 @@ function budgetedPublication( kittyKeyboardFlags: serialized.kittyKeyboardFlags, alternateScreen: serialized.alternateScreen, terminalOwner: serialized.terminalOwner, + lastTitle: serialized.lastTitle, data } } diff --git a/src/main/runtime/rpc/methods/terminal/terminal-snapshot-scrollback-rows.test.ts b/src/main/runtime/rpc/methods/terminal/terminal-snapshot-scrollback-rows.test.ts index e6210b47555..0305aa7cb2a 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-snapshot-scrollback-rows.test.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-snapshot-scrollback-rows.test.ts @@ -69,3 +69,14 @@ describe('SnapshotStart scrollbackRows', () => { expect('scrollbackRows' in buildSnapshotFrameMeta(base)).toBe(false) }) }) + +describe('snapshot frame title', () => { + const base = { kind: 'scrollback' as const, cols: 80, rows: 24, data: '' } + + it("carries the image's title only with the seq that orders it", () => { + expect(buildSnapshotFrameMeta({ ...base, seq: 7, lastTitle: 'Claude' }).lastTitle).toBe( + 'Claude' + ) + expect('lastTitle' in buildSnapshotFrameMeta({ ...base, lastTitle: 'Claude' })).toBe(false) + }) +}) diff --git a/src/main/runtime/rpc/methods/terminal/terminal-stream-types.ts b/src/main/runtime/rpc/methods/terminal/terminal-stream-types.ts index 3040ad9e444..3840acceeca 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-stream-types.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-stream-types.ts @@ -31,6 +31,8 @@ export type SnapshotFrameOptions = { kittyKeyboardFlags?: number alternateScreen?: boolean terminalOwner?: 'shell' + /** The host's tracked title when this image was taken. */ + lastTitle?: string } export type SerializedSnapshot = { @@ -48,6 +50,7 @@ export type SerializedSnapshot = { kittyKeyboardFlags?: number alternateScreen?: boolean terminalOwner?: 'shell' + lastTitle?: string } | null export type TerminalViewportClient = { diff --git a/src/main/runtime/rpc/methods/terminal/terminal-subscribe-binary-snapshot.ts b/src/main/runtime/rpc/methods/terminal/terminal-subscribe-binary-snapshot.ts index 5f5137c1a77..f97ed628f14 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-subscribe-binary-snapshot.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-subscribe-binary-snapshot.ts @@ -208,6 +208,7 @@ export async function publishTerminalSubscribeBinarySnapshot( oscLinks: serialized?.oscLinks, alternateScreen: serialized?.alternateScreen, terminalOwner: serialized?.terminalOwner, + lastTitle: serialized?.lastTitle, data: serialized?.data ?? '' }) console.log('[mobile-terminal-stream] snapshot', { diff --git a/src/renderer/src/components/terminal-pane/pty-connection-remote-snapshot-image-title.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-remote-snapshot-image-title.test.ts new file mode 100644 index 00000000000..1ba108a585c --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection-remote-snapshot-image-title.test.ts @@ -0,0 +1,237 @@ +// A live agent's reattach image must keep its modes even when the separately published pane title lags it. +import type * as React from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { flushAsyncTicks } from './pty-connection-test-async' +import { + terminalWriteText, + createMockTransport, + createPane, + createManager +} from './pty-connection-test-pane-fixtures' +import type { ConnectCallbacks, MockTransport } from './pty-connection-test-pane-fixtures' +import { buildPaneConnectionDeps } from './pty-connection-test-deps' +import { + createInitialStoreState, + buildActiveRuntimeEnvironmentState +} from './pty-connection-test-store-fixtures' +import type { StoreState } from './pty-connection-test-store-state' +import type { PtyReplayDataMeta } from './pty-transport-types' +import { mockPanePtyArgs } from './pty-connection-test-connect-mock-pane' +import { + installTerminalTestGlobals, + restoreTerminalTestGlobals +} from './pty-connection-test-environment' + +const { + resetAndRefreshAllTerminalWebglAtlases, + scheduleTerminalWebglAtlasRecovery, + scheduleRuntimeGraphSync, + shouldSeedCacheTimerOnInitialTitle, + toastInfo, + notifyCodexPaneBoundForStaleSweep +} = vi.hoisted(() => ({ + resetAndRefreshAllTerminalWebglAtlases: vi.fn(), + scheduleTerminalWebglAtlasRecovery: vi.fn(), + scheduleRuntimeGraphSync: vi.fn(), + shouldSeedCacheTimerOnInitialTitle: vi.fn(() => false), + toastInfo: vi.fn(), + notifyCodexPaneBoundForStaleSweep: vi.fn() +})) + +let mockStoreState: StoreState +let transportFactoryQueue: MockTransport[] = [] +let createdTransportOptions: Record[] = [] +let storeSubscribers: ((state: StoreState) => void)[] = [] + +vi.mock('@/runtime/sync-runtime-graph', () => ({ + scheduleRuntimeGraphSync +})) + +vi.mock('@/lib/pane-manager/pane-manager-registry', async (importOriginal) => ({ + ...(await importOriginal>()), + resetAndRefreshAllTerminalWebglAtlases +})) + +vi.mock('./terminal-webgl-atlas-recovery', () => ({ + scheduleTerminalWebglAtlasRecovery +})) + +vi.mock('@/store', () => ({ + useAppStore: { + getState: () => mockStoreState, + subscribe: (listener: (state: StoreState) => void) => { + storeSubscribers.push(listener) + return () => { + storeSubscribers = storeSubscribers.filter((candidate) => candidate !== listener) + } + } + } +})) + +vi.mock('@/lib/agent-status', async (importOriginal) => { + const { buildAgentStatusModuleMock } = await import('./pty-connection-test-environment') + return buildAgentStatusModuleMock(await importOriginal>()) +}) + +vi.mock('./cache-timer-seeding', () => ({ + shouldSeedCacheTimerOnInitialTitle +})) + +vi.mock('sonner', () => ({ + toast: { info: toastInfo } +})) + +vi.mock('@/lib/codex-stale-pane-sweep', () => ({ + notifyCodexPaneBoundForStaleSweep +})) + +vi.mock('react', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useCallback: unknown>(fn: T): T => fn + } +}) + +vi.mock('./pty-transport', () => ({ + createIpcPtyTransport: vi.fn((options: Record) => { + createdTransportOptions.push(options) + const nextTransport = transportFactoryQueue.shift() + if (!nextTransport) { + throw new Error('No mock transport queued') + } + return nextTransport + }) +})) + +vi.mock('./remote-runtime-pty-transport', () => ({ + createRemoteRuntimePtyTransport: vi.fn( + (_environmentId: string, options: Record) => { + createdTransportOptions.push(options) + const nextTransport = transportFactoryQueue.shift() + if (!nextTransport) { + throw new Error('No mock transport queued') + } + return nextTransport + } + ) +})) + +vi.mock('./pty-dispatcher', async (importOriginal) => { + const actual = await importOriginal>() + return { + ...actual, + getEagerPtyBufferHandle: vi.fn(() => undefined) + } +}) + +const PANE_COLS = 120 +const PANE_ROWS = 40 +// Normal-buffer image whose live process armed mouse and focus reporting. +const LIVE_AGENT_IMAGE = 'agent output\r\n\x1b[?1003h\x1b[?1006h\x1b[?1004h' +const MOUSE_DISARM = '\x1b[?1003l' + +function createDeps(overrides: Record = {}) { + return buildPaneConnectionDeps(() => mockStoreState, overrides) +} + +async function connectRemotePane(): Promise<{ + operations: { kind: 'resize' | 'write'; value: string }[] + pane: ReturnType + transport: MockTransport + replay: (data: string, meta?: PtyReplayDataMeta) => void + dispose: () => void +}> { + const { connectPanePty } = await import('./pty-connection') + mockStoreState = buildActiveRuntimeEnvironmentState(mockStoreState, 'env-1') + const transport = createMockTransport('remote:env-1@@terminal-1') + const captured: { current: ConnectCallbacks['onReplayData'] | null } = { current: null } + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + captured.current = callbacks.onReplayData ?? null + return { id: 'remote:env-1@@terminal-1', replay: '' } + }) + transportFactoryQueue.push(transport) + + const pane = createPane(1) + pane.terminal.cols = PANE_COLS + pane.terminal.rows = PANE_ROWS + const operations: { kind: 'resize' | 'write'; value: string }[] = [] + pane.terminal.write = vi.fn((data: string | Uint8Array, callback?: () => void) => { + data = terminalWriteText(data) + operations.push({ kind: 'write', value: data }) + callback?.() + }) + pane.terminal.resize = vi.fn((cols: number, rows: number) => { + operations.push({ kind: 'resize', value: `${cols}x${rows}` }) + pane.terminal.cols = cols + pane.terminal.rows = rows + }) + pane.fitAddon.proposeDimensions = vi.fn(() => ({ cols: PANE_COLS, rows: PANE_ROWS })) + pane.fitAddon.fit = vi.fn(() => { + pane.terminal.resize(PANE_COLS, PANE_ROWS) + }) + + const manager = createManager(1) + const disposable = connectPanePty(...mockPanePtyArgs(pane, manager, createDeps())) + await flushAsyncTicks(6) + transport.resize.mockClear() + + return { + operations, + pane, + transport, + replay: (data, meta) => captured.current?.(data, meta), + dispose: () => disposable.dispose() + } +} + +describe('reattach image title', () => { + beforeEach(async () => { + vi.resetModules() + vi.clearAllMocks() + transportFactoryQueue = [] + createdTransportOptions = [] + storeSubscribers = [] + mockStoreState = createInitialStoreState(() => mockStoreState) + await installTerminalTestGlobals() + }) + + afterEach(async () => { + await restoreTerminalTestGlobals() + }) + + async function epilogueAfterImage( + storeTitle: string, + imageTitle: string | undefined + ): Promise { + const session = await connectRemotePane() + mockStoreState.runtimePaneTitlesByTabId = { 'tab-1': { 1: storeTitle } } + session.replay(LIVE_AGENT_IMAGE, { + snapshotCols: PANE_COLS, + snapshotRows: PANE_ROWS, + ...(imageTitle !== undefined ? { lastTitle: imageTitle } : {}) + }) + await flushAsyncTicks(20) + const imageIndex = session.operations.findIndex( + (operation) => operation.kind === 'write' && operation.value.includes(LIVE_AGENT_IMAGE) + ) + expect(imageIndex).toBeGreaterThanOrEqual(0) + session.dispose() + return session.operations + .slice(imageIndex + 1) + .map((operation) => (operation.kind === 'write' ? operation.value : '')) + .join('') + } + + it("keeps a live agent's modes when the published title still shows the tab default", async () => { + expect(await epilogueAfterImage('Terminal 1', 'Claude working')).not.toContain(MOUSE_DISARM) + }) + + it('disarms a plain program the published title still names as an agent', async () => { + expect(await epilogueAfterImage('Claude working', 'vim')).toContain(MOUSE_DISARM) + }) + + it('falls back to the published title when an older host sends no image title', async () => { + expect(await epilogueAfterImage('Terminal 1', undefined)).toContain(MOUSE_DISARM) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/pty-connection/apply-reattach-payload.ts b/src/renderer/src/components/terminal-pane/pty-connection/apply-reattach-payload.ts index f3c1f11acd0..221b63ac8fa 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/apply-reattach-payload.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/apply-reattach-payload.ts @@ -129,7 +129,10 @@ export function createReattachPayloadHandlers( if (modelSnapshot) { // Scan/reset needs composed snapshot bytes; actual writes use alt-screen choreography to preserve scrollback. const modelData = `${modelSnapshot.scrollbackAnsi ?? ''}${modelSnapshot.data}` - session.rememberReattachPayloadAgentSignal(modelData, { fullScreenReplay: true }) + session.rememberReattachPayloadAgentSignal(modelData, { + fullScreenReplay: true, + imageTitle: modelSnapshot.lastTitle + }) const modelCols = modelSnapshot.cols const modelRows = modelSnapshot.rows if ( @@ -164,7 +167,8 @@ export function createReattachPayloadHandlers( modelData, Boolean(ctx.connectResult?.coldRestore), modelSnapshot.alternateScreen ?? ctx.connectResult?.isAlternateScreen, - modelSnapshot.terminalOwner + modelSnapshot.terminalOwner, + modelSnapshot.lastTitle ) ) if (modelSnapshot.pendingEscapeTailAnsi) { diff --git a/src/renderer/src/components/terminal-pane/pty-connection/deferred-cold-restore-and-snapshot.ts b/src/renderer/src/components/terminal-pane/pty-connection/deferred-cold-restore-and-snapshot.ts index cfff4d84bd6..4fe3ccf75f9 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/deferred-cold-restore-and-snapshot.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/deferred-cold-restore-and-snapshot.ts @@ -219,6 +219,7 @@ export function bindDeferredColdRestoreAndSnapshot(session: ConnectPanePtySessio } : {}), ...(meta.terminalOwner ? { terminalOwner: meta.terminalOwner } : {}), + ...(meta.lastTitle !== undefined ? { lastTitle: meta.lastTitle } : {}), ...(meta.alternateScreen !== undefined ? { alternateScreen: meta.alternateScreen } : {}), ...(meta.snapshotCols !== undefined && meta.snapshotRows !== undefined ? { snapshotCols: meta.snapshotCols, snapshotRows: meta.snapshotRows } diff --git a/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-follow-reset.ts b/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-follow-reset.ts index a374323d6b8..8e181607b0a 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-follow-reset.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-follow-reset.ts @@ -151,7 +151,8 @@ export function bindFreshSpawnFollowReset(session: ConnectPanePtySession): void payload: string, ownerProcessEnded = false, isAlternateScreen?: boolean, - terminalOwner?: 'shell' + terminalOwner?: 'shell', + imageTitle?: string ): string => { // Cold restore replaces the dead process despite persisted agent titles/status; clear stale mouse/focus/paste modes before the fresh shell (#12101). if (ownerProcessEnded) { @@ -162,7 +163,7 @@ export function bindFreshSpawnFollowReset(session: ConnectPanePtySession): void ? POST_REPLAY_DEAD_TUI_RESET : POST_REPLAY_REATTACH_RESET } - if (session.shouldPreserveAgentReattachModes()) { + if (session.shouldPreserveAgentReattachModes(imageTitle)) { return buildPostReplayLiveAgentReattachReset(payload) } // Alt-screen panes can be live unrecognized TUIs; preserve mouse modes the replay restored (#8291). diff --git a/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-snapshot.ts b/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-snapshot.ts index 1f548035f4f..162077f4871 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-snapshot.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-snapshot.ts @@ -82,6 +82,7 @@ export function bindHiddenOutputRestoreSnapshot(session: ConnectPanePtySession): pendingEscapeTailAnsi?: string kittyKeyboardFlags?: number terminalOwner?: 'shell' + lastTitle?: string }): Promise { const restorePtyId = session.transport.getPtyId() const restoreGeneration = session.hiddenOutputRestoreGeneration @@ -165,7 +166,7 @@ export function bindHiddenOutputRestoreSnapshot(session: ConnectPanePtySession): session.writeReplayData(replayChunk) } } - const hasLiveAgent = session.hasLiveAgentReattachStatusOrTitleSignal() + const hasLiveAgent = session.hasLiveAgentReattachStatusOrTitleSignal(snapshot.lastTitle) // Match reattach’s live-pane fallback when alternateScreen is absent; even same-buffer ?1049l runs cursor restore. const postReplayReset = snapshot.terminalOwner === 'shell' diff --git a/src/renderer/src/components/terminal-pane/pty-connection/replay-data-drain.ts b/src/renderer/src/components/terminal-pane/pty-connection/replay-data-drain.ts index 8356ae13c1c..1822e375a3b 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/replay-data-drain.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/replay-data-drain.ts @@ -26,6 +26,7 @@ export function bindReplayDataDrain(session: ConnectPanePtySession): void { expectedStreamGeneration = session.transportStreamGeneration ): void => { const scheduledGeneration = session.reattachReplayPayloadSignalGeneration + const imageTitle: string | undefined = session.reattachReplayImageTitle void waitForTerminalOutputParsed(session.pane.terminal).then(() => { const currentPtyId = session.transport.getPtyId() if ( @@ -41,7 +42,7 @@ export function bindReplayDataDrain(session: ConnectPanePtySession): void { } // Dead replay screens can remain above shell prompts; require parsed parked-cursor screen or corroborating status/title before retaining agent focus behavior. if ( - !session.hasLiveAgentReattachStatusOrTitleSignal() && + !session.hasLiveAgentReattachStatusOrTitleSignal(imageTitle) && session.reattachReplayPayloadHasCursorAgentSignal ) { if (parsedViewportShowsParkedCursorAgentScreen(session.pane.terminal) === false) { @@ -53,7 +54,7 @@ export function bindReplayDataDrain(session: ConnectPanePtySession): void { } // Reattach preserves DOM focus, so live TUIs may miss focus-in needed to unpark their cursor; inject only with ?1004h to spare bare shells. const sendFocusMode = terminalHasFocusReportingEnabled(session.pane.terminal) - if (!session.shouldSendFocusedAgentReattachFocusIn() || !sendFocusMode) { + if (!session.shouldSendFocusedAgentReattachFocusIn(imageTitle) || !sendFocusMode) { return } session.transport.sendInput(TERMINAL_FOCUS_IN_SEQUENCE, 'query-reply') @@ -91,6 +92,7 @@ export function bindReplayDataDrain(session: ConnectPanePtySession): void { pendingEscapeTailAnsi, alternateScreen, terminalOwner, + lastTitle, snapshotCols, snapshotRows, carriesNormalBuffer, @@ -148,7 +150,10 @@ export function bindReplayDataDrain(session: ConnectPanePtySession): void { } if (clearBeforeReplay || data.length > 0) { // Authoritative empty clearing frames must also retire prior agent-screen evidence. - session.rememberReattachPayloadAgentSignal(data, { fullScreenReplay: clearBeforeReplay }) + session.rememberReattachPayloadAgentSignal(data, { + fullScreenReplay: clearBeforeReplay, + imageTitle: lastTitle + }) } // Re-arm Kitty from application replay using set semantics so repeated windows cannot grow its stack. session.applySnapshotKittyKeyboardModes(data, payload) @@ -158,7 +163,7 @@ export function bindReplayDataDrain(session: ConnectPanePtySession): void { } if (clearBeforeReplay || data.length > 0) { await session.writeReplayEpilogue( - session.chooseReattachReplayReset(data, false, alternateScreen, terminalOwner), + session.chooseReattachReplayReset(data, false, alternateScreen, terminalOwner, lastTitle), session.writeReplayDataAsync ) if (!isCurrentPayload()) { diff --git a/src/renderer/src/components/terminal-pane/pty-connection/shell-command-inference.ts b/src/renderer/src/components/terminal-pane/pty-connection/shell-command-inference.ts index 68d705b7552..ca9c010d54d 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/shell-command-inference.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/shell-command-inference.ts @@ -197,9 +197,11 @@ export function installShellCommandInference(session: ConnectPanePtySession): vo session.reattachReplayPayloadSignalGeneration = 0 session.rememberReattachPayloadAgentSignal = ( data: string, - opts: { fullScreenReplay: boolean } + opts: { fullScreenReplay: boolean; imageTitle?: string } ): void => { session.reattachReplayPayloadSignalGeneration += 1 + // Post-parse checks for this replay must judge the same title its reset did. + session.reattachReplayImageTitle = opts.imageTitle // Agent names in history are not liveness; require restored Cursor screen shape. const signal = hasCursorAgentReattachPayloadScreenSignal(data) // Incremental frames may add evidence; only authoritative full-screen repaint may clear it. @@ -210,27 +212,31 @@ export function installShellCommandInference(session: ConnectPanePtySession): vo session.isCursorAgentNativeTitle = (title: string): boolean => { return title.trim().toLowerCase() === CURSOR_AGENT_REATTACH_HEADER.toLowerCase() } - session.hasLiveAgentReattachStatusOrTitleSignal = (): boolean => { + session.hasLiveAgentReattachStatusOrTitleSignal = (imageTitle?: string): boolean => { // Permanent tab launch metadata cannot prove liveness; require current status/title or restored screen shape. if (useAppStore.getState().agentStatusByPaneKey[session.cacheKey]) { return true } - const title = session.getCurrentTerminalTitle() ?? '' + // A replayed image's own title outranks the store's, which arrives on a separate channel. + const title = imageTitle ?? session.getCurrentTerminalTitle() ?? '' // Loose label matching can classify ssh user names; mode preservation and input injection need exact/status titles. return detectAgentStatusFromTitle(title) !== null || session.isCursorAgentNativeTitle(title) } - session.hasLiveAgentReattachSignal = (): boolean => { + session.hasLiveAgentReattachSignal = (imageTitle?: string): boolean => { return ( - session.hasLiveAgentReattachStatusOrTitleSignal() || + session.hasLiveAgentReattachStatusOrTitleSignal(imageTitle) || session.reattachReplayPayloadHasCursorAgentSignal ) } - session.shouldPreserveAgentReattachModes = (): boolean => { + session.shouldPreserveAgentReattachModes = (imageTitle?: string): boolean => { // Why: ordinary shells can inherit stale ?25l/?1004h from replay bytes. // Preserve those modes only when reattach still looks agent-owned. - return session.hasLiveAgentReattachSignal() + return session.hasLiveAgentReattachSignal(imageTitle) } - session.shouldSendFocusedAgentReattachFocusIn = (): boolean => { - return terminalOwnsDomFocus(session.pane.terminal) && session.shouldPreserveAgentReattachModes() + session.shouldSendFocusedAgentReattachFocusIn = (imageTitle?: string): boolean => { + return ( + terminalOwnsDomFocus(session.pane.terminal) && + session.shouldPreserveAgentReattachModes(imageTitle) + ) } } diff --git a/src/renderer/src/components/terminal-pane/pty-output-processor.ts b/src/renderer/src/components/terminal-pane/pty-output-processor.ts index 743c571fa84..151acaddf3d 100644 --- a/src/renderer/src/components/terminal-pane/pty-output-processor.ts +++ b/src/renderer/src/components/terminal-pane/pty-output-processor.ts @@ -43,6 +43,7 @@ export type ProcessPtyOutputOptions = { snapshotSeq?: number alternateScreen?: boolean terminalOwner?: 'shell' + lastTitle?: string snapshotCols?: number snapshotRows?: number carriesNormalBuffer?: boolean @@ -202,6 +203,7 @@ export function createPtyOutputProcessor({ ? { alternateScreen: options.alternateScreen } : {}), ...(options.terminalOwner ? { terminalOwner: options.terminalOwner } : {}), + ...(options.lastTitle !== undefined ? { lastTitle: options.lastTitle } : {}), ...(options.snapshotCols !== undefined && options.snapshotRows !== undefined ? { snapshotCols: options.snapshotCols, snapshotRows: options.snapshotRows } : {}), diff --git a/src/renderer/src/components/terminal-pane/pty-transport-types.ts b/src/renderer/src/components/terminal-pane/pty-transport-types.ts index 002f829b908..1c25be3089f 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport-types.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport-types.ts @@ -53,6 +53,8 @@ export type PtyBufferSnapshot = { * means unknown; never rewrite that silence into a known `0`. */ kittyKeyboardFlags?: number terminalOwner?: 'shell' + /** The host's tracked title when this image was taken; absent from older hosts. */ + lastTitle?: string } /** Metadata for one authoritative replay payload. */ @@ -66,6 +68,8 @@ export type PtyReplayDataMeta = { snapshotSeq?: number alternateScreen?: boolean terminalOwner?: 'shell' + /** The host's tracked title when this image was taken, so live-agent judgement cannot race a separate title publication. */ + lastTitle?: string /** Grid the payload was serialized at. Present only when the producer proved * it; the drain replays there and fits back to the pane afterwards. */ snapshotCols?: number diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts index 2bafcf893a2..9b845338985 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts @@ -2951,6 +2951,9 @@ export function createRemoteRuntimePtyTransport( ...(meta?.terminalOwner && meta.seq !== undefined ? { terminalOwner: meta.terminalOwner } : {}), + ...(meta?.lastTitle !== undefined && meta.seq !== undefined + ? { lastTitle: meta.lastTitle } + : {}), ...(meta?.alternateScreen !== undefined && meta.seq !== undefined ? { alternateScreen: meta.alternateScreen } : {}), diff --git a/src/renderer/src/runtime/remote-runtime-terminal-binary-snapshots.ts b/src/renderer/src/runtime/remote-runtime-terminal-binary-snapshots.ts index 78ef4bcd535..33324157117 100644 --- a/src/renderer/src/runtime/remote-runtime-terminal-binary-snapshots.ts +++ b/src/renderer/src/runtime/remote-runtime-terminal-binary-snapshots.ts @@ -92,6 +92,7 @@ export abstract class RemoteRuntimeTerminalBinarySnapshots extends RemoteRuntime kittyKeyboardFlags: info?.kittyKeyboardFlags, alternateScreen: info?.alternateScreen, terminalOwner: info?.terminalOwner, + lastTitle: info?.lastTitle, // Replay at host grid dimensions because wraps/cursor moves depend on it; pushes must carry the same dimensions as requests. cols: info?.cols, rows: info?.rows, @@ -123,6 +124,7 @@ export abstract class RemoteRuntimeTerminalBinarySnapshots extends RemoteRuntime kittyKeyboardFlags: info?.kittyKeyboardFlags, alternateScreen: info?.alternateScreen, terminalOwner: info?.terminalOwner, + lastTitle: info?.lastTitle, pendingEscapeTailAnsi: info?.pendingEscapeTailAnsi, // The host folds the normal buffer into `data` (see terminal-snapshot-publication.ts). carriesNormalBuffer: true diff --git a/src/renderer/src/runtime/remote-runtime-terminal-multiplexer-types.ts b/src/renderer/src/runtime/remote-runtime-terminal-multiplexer-types.ts index 86204f37f27..b31cb7e88d0 100644 --- a/src/renderer/src/runtime/remote-runtime-terminal-multiplexer-types.ts +++ b/src/renderer/src/runtime/remote-runtime-terminal-multiplexer-types.ts @@ -51,6 +51,8 @@ export type RemoteRuntimeMultiplexedTerminalCallbacks = { kittyKeyboardFlags?: number alternateScreen?: boolean terminalOwner?: 'shell' + /** The host's tracked title when this image was taken; absent from older hosts. */ + lastTitle?: string /** Grid the host serialized this image at. Absent from hosts that omit * it, which must read as unknown so replay keeps the pane's own grid. */ cols?: number @@ -101,6 +103,7 @@ export type RemoteRuntimeSnapshotImage = { kittyKeyboardFlags?: number alternateScreen?: boolean terminalOwner?: 'shell' + lastTitle?: string /** `data` starts on the normal buffer and enters alt itself. */ carriesNormalBuffer?: boolean } @@ -226,6 +229,7 @@ export type RemoteRuntimeSnapshotInfo = { kittyKeyboardFlags?: number alternateScreen?: boolean terminalOwner?: 'shell' + lastTitle?: string requestId?: number truncated?: boolean unavailable?: TerminalSnapshotUnavailableReason diff --git a/src/renderer/src/runtime/remote-runtime-terminal-snapshot-state.ts b/src/renderer/src/runtime/remote-runtime-terminal-snapshot-state.ts index 7760df92673..e43e9346930 100644 --- a/src/renderer/src/runtime/remote-runtime-terminal-snapshot-state.ts +++ b/src/renderer/src/runtime/remote-runtime-terminal-snapshot-state.ts @@ -108,6 +108,7 @@ export function decodeSnapshotInfo( kittyKeyboardFlags?: unknown alternateScreen?: unknown terminalOwner?: unknown + lastTitle?: unknown scrollbackRows?: unknown }>(payload) if (!raw) { @@ -125,6 +126,7 @@ export function decodeSnapshotInfo( ? raw.alternateScreen : undefined, terminalOwner: raw.terminalOwner === 'shell' ? raw.terminalOwner : undefined, + lastTitle: typeof raw.lastTitle === 'string' ? raw.lastTitle : undefined, requestId: typeof raw.requestId === 'number' ? raw.requestId : undefined, truncated: raw.truncated === true, unavailable: parseTerminalSnapshotUnavailableReason(raw.unavailable), diff --git a/tests/e2e/paired-serve-respawn-process-state.spec.ts b/tests/e2e/paired-serve-respawn-process-state.spec.ts index b72c4a7e9ae..e9295e08a3c 100644 --- a/tests/e2e/paired-serve-respawn-process-state.spec.ts +++ b/tests/e2e/paired-serve-respawn-process-state.spec.ts @@ -103,7 +103,7 @@ test('a serve restart that respawns the shell drops the dead agent title, status .toMatchObject({ mouse: 'any', focus: true, applicationCursor: true }) expect((await readPaneState(page, webTabId))?.runtimeTitle).toContain('Claude Code') - // Control: a serve restart that keeps the daemon reattaches the same process, so no process-boundary ground runs. + // A serve restart that keeps the daemon reattaches the same live agent, so its modes must survive. await host.restartServeProcess() await expect .poll( @@ -123,8 +123,11 @@ test('a serve restart that respawns the shell drops the dead agent title, status { timeout: 120_000, message: 'The paired pane never reattached after the serve restart' } ) .toBe(true) - // Only a process-boundary ground clears cursor-key mode; reattach epilogues leave it. - expect((await readPaneState(page, webTabId))?.applicationCursor).toBe(true) + expect(await readPaneState(page, webTabId)).toMatchObject({ + mouse: 'any', + focus: true, + applicationCursor: true + }) const before = await readPaneState(page, webTabId) // The host machine restarted: serve and its daemon both lose every process.