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.