diff --git a/src/renderer/src/components/terminal-pane/layout-serialization.test.ts b/src/renderer/src/components/terminal-pane/layout-serialization.test.ts index 95b7bb0920c..f4237e507f0 100644 --- a/src/renderer/src/components/terminal-pane/layout-serialization.test.ts +++ b/src/renderer/src/components/terminal-pane/layout-serialization.test.ts @@ -34,7 +34,12 @@ beforeAll(() => { import { buildFontFamily, + buildPostReplayLiveAgentReattachReset, + POST_REPLAY_LIVE_AGENT_REATTACH_RESET, POST_REPLAY_MODE_RESET, + replayPayloadEndsWithCursorHidden, + RESET_KITTY_KEYBOARD_PROTOCOL, + RESET_TERMINAL_CURSOR_STYLE, restoreScrollbackBuffers, serializePaneTree, serializeTerminalLayout, @@ -491,3 +496,33 @@ describe('collectLeafIdsInReplayCreationOrder', () => { expect(collectLeafIdsInReplayCreationOrder(layout)).toEqual(['A', 'B', 'C']) }) }) + +describe('replayPayloadEndsWithCursorHidden', () => { + it('is true when the last DECTCEM sequence hides the cursor', () => { + expect(replayPayloadEndsWithCursorHidden('\x1b[?25h frame \x1b[?25l')).toBe(true) + expect(replayPayloadEndsWithCursorHidden('\x1b[?1004h\x1b[?25lparked screen')).toBe(true) + }) + + it('is false when the cursor was re-shown or never touched', () => { + expect(replayPayloadEndsWithCursorHidden('\x1b[?25l frame \x1b[?25h')).toBe(false) + expect(replayPayloadEndsWithCursorHidden('plain shell output')).toBe(false) + expect(replayPayloadEndsWithCursorHidden('')).toBe(false) + }) +}) + +describe('buildPostReplayLiveAgentReattachReset', () => { + it('preserves an intentionally hidden cursor', () => { + expect(buildPostReplayLiveAgentReattachReset('agent frame\x1b[?25l')).toBe( + `${RESET_TERMINAL_CURSOR_STYLE}${RESET_KITTY_KEYBOARD_PROTOCOL}` + ) + }) + + it('re-shows the cursor when the payload left it visible', () => { + expect(buildPostReplayLiveAgentReattachReset('agent frame\x1b[?25l\x1b[?25h')).toBe( + POST_REPLAY_LIVE_AGENT_REATTACH_RESET + ) + expect(buildPostReplayLiveAgentReattachReset('no dectcem at all')).toBe( + POST_REPLAY_LIVE_AGENT_REATTACH_RESET + ) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/layout-serialization.ts b/src/renderer/src/components/terminal-pane/layout-serialization.ts index e33cd270bf9..5afdcc69572 100644 --- a/src/renderer/src/components/terminal-pane/layout-serialization.ts +++ b/src/renderer/src/components/terminal-pane/layout-serialization.ts @@ -79,12 +79,36 @@ export const POST_REPLAY_REATTACH_RESET = `${RESET_TERMINAL_CURSOR_STYLE}${RESET // Why: a live agent TUI legitimately owns focus reporting; resetting `?1004h` // would suppress the post-reattach focus-in the agent needs to move its real -// cursor back to the input caret (the IME anchor). Cursor visibility is still -// reset: agent detection can false-positive on a dead TUI's leftovers, and a -// permanently invisible shell cursor is far worse than the brief flash a live -// agent re-hides on its post-reattach SIGWINCH repaint. +// cursor back to the input caret (the IME anchor). export const POST_REPLAY_LIVE_AGENT_REATTACH_RESET = `${RESET_TERMINAL_CURSOR_STYLE}${RESET_KITTY_KEYBOARD_PROTOCOL}\x1b[?25h` +// Why: DECTCEM applies in emission order, so the payload's last ?25l/?25h is +// the state the TUI left the cursor in. +export function replayPayloadEndsWithCursorHidden(payload: string): boolean { + const hideIndex = payload.lastIndexOf('\x1b[?25l') + return hideIndex !== -1 && hideIndex > payload.lastIndexOf('\x1b[?25h') +} + +// Why: some live agents (Cursor Agent) intentionally hide the real cursor and +// draw their own caret; re-showing it paints a stray cursor on the parked row. +// Preserve the payload's own final cursor-visibility state instead of forcing +// ?25h. Agent detection can still false-positive on a dead TUI's leftovers — +// the post-replay viewport check in pty-connection re-shows the cursor once +// the parsed screen disproves a live parked agent, so a shell cannot inherit +// a permanently invisible cursor. +export function buildPostReplayLiveAgentReattachReset(payload: string): string { + return replayPayloadEndsWithCursorHidden(payload) + ? `${RESET_TERMINAL_CURSOR_STYLE}${RESET_KITTY_KEYBOARD_PROTOCOL}` + : POST_REPLAY_LIVE_AGENT_REATTACH_RESET +} + +// Why: hidden-output recovery replays into the same live session. When a live +// agent still owns the pane it also owns cursor visibility and focus +// reporting: forcing ?25h re-shows a parked cursor the agent hid on purpose, +// and ?1004l permanently silences the focus-in the agent needs to unpark +// (agents only enable ?1004h at startup, so nothing ever re-arms it). +export const POST_REPLAY_LIVE_AGENT_SNAPSHOT_RESET = RESET_TERMINAL_CURSOR_STYLE + // Cross-platform monospace fallback chain ensures the terminal always has a // usable font regardless of OS. macOS-only fonts like SF Mono and Menlo are // harmless on other platforms (the browser skips them), while Cascadia Mono / diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index 5b7af303159..ce885163fed 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -4,6 +4,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { Terminal } from '@xterm/headless' import { POST_REPLAY_LIVE_AGENT_REATTACH_RESET, + POST_REPLAY_LIVE_AGENT_SNAPSHOT_RESET, + POST_REPLAY_LIVE_SNAPSHOT_RESET, POST_REPLAY_MODE_RESET, POST_REPLAY_REATTACH_RESET, RESET_KITTY_KEYBOARD_PROTOCOL, @@ -5462,6 +5464,46 @@ describe('connectPanePty', () => { await flushAsyncTicks(20) expect(transport.sendInput).toHaveBeenCalledWith('\x1b[I') + // The snapshot ends with ?25l (Cursor Agent parks the real cursor and + // hides it); the reset must preserve that instead of forcing ?25h, or + // the parked cursor paints as a stray block below the prompt. + expect(pane.terminal.write).toHaveBeenCalledWith( + `${RESET_TERMINAL_CURSOR_STYLE}${RESET_KITTY_KEYBOARD_PROTOCOL}`, + expect.any(Function) + ) + const writes = (pane.terminal.write as ReturnType).mock.calls.map( + ([data]) => data as string + ) + expect(writes.some((data) => data.includes('\x1b[?25h'))).toBe(false) + }) + }) + + it('keeps ?25h in the live agent reattach reset when the snapshot leaves the cursor visible', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { + if (sessionId) { + // A mid-frame snapshot cut after the TUI re-showed its cursor. + return { id: sessionId, snapshot: '\x1b[?1004h\x1b[?25l\x1b[?25hrestored cursor snapshot' } + } + return null + }) + transportFactoryQueue.push(transport) + setReattachPaneTitle('Cursor Agent') + + const pane = createPane(1) + const textarea = {} as HTMLTextAreaElement + configureTerminalFocusMode(pane, textarea) + await withMockedDocumentActiveElement(textarea, async () => { + const manager = createManager(1) + const deps = createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } + }) + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(20) + expect(pane.terminal.write).toHaveBeenCalledWith( POST_REPLAY_LIVE_AGENT_REATTACH_RESET, expect.any(Function) @@ -5498,7 +5540,7 @@ describe('connectPanePty', () => { expect(transport.sendInput).not.toHaveBeenCalledWith('\x1b[I') expect(pane.terminal.write).toHaveBeenCalledWith( - POST_REPLAY_LIVE_AGENT_REATTACH_RESET, + `${RESET_TERMINAL_CURSOR_STYLE}${RESET_KITTY_KEYBOARD_PROTOCOL}`, expect.any(Function) ) }) @@ -8095,6 +8137,117 @@ describe('connectPanePty', () => { } }) + it('preserves live agent cursor and focus modes on hidden-to-visible snapshot restore', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + }) + transportFactoryQueue.push(transport) + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + getMainBufferSnapshot.mockResolvedValue({ + data: 'agent-frame\x1b[?25l', + cols: 100, + rows: 30 + }) + const paneKey = makePaneKey('tab-1', LEAF_1) + const now = Date.now() + mockStoreState.agentStatusByPaneKey[paneKey] = { + state: 'working', + prompt: '', + agentType: 'codex', + paneKey, + terminalTitle: 'codex', + updatedAt: now, + stateStartedAt: now, + stateHistory: [] + } + + const isVisibleRef = { current: false } + const pane = createPane(1) + const manager = createManager(1) + const binding = connectPanePty( + pane as never, + manager as never, + createDeps({ + isVisibleRef, + startup: { command: 'codex' } + }) as never + ) + try { + await flushAsyncTicks(6) + + capturedDataCallback.current?.('\x1b[6') + isVisibleRef.current = true + capturedDataCallback.current?.('n') + await flushAsyncTicks(20) + + // A live agent owns ?25l (parked cursor) and ?1004h (focus reporting); + // the ?1004l in the plain reset would silence focus events until the + // agent restarts, since agents only enable focus reporting at startup. + expect(pane.terminal.write).toHaveBeenCalledWith( + POST_REPLAY_LIVE_AGENT_SNAPSHOT_RESET, + expect.any(Function) + ) + expect(pane.terminal.write).not.toHaveBeenCalledWith( + POST_REPLAY_LIVE_SNAPSHOT_RESET, + expect.any(Function) + ) + } finally { + binding.dispose() + } + }) + + it('resets cursor and focus modes on hidden-to-visible snapshot restore without a live agent', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + }) + transportFactoryQueue.push(transport) + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + getMainBufferSnapshot.mockResolvedValue({ + data: 'shell-frame\x1b[?25l', + cols: 100, + rows: 30 + }) + + const isVisibleRef = { current: false } + const pane = createPane(1) + const manager = createManager(1) + const binding = connectPanePty( + pane as never, + manager as never, + createDeps({ + isVisibleRef, + startup: { command: 'codex' } + }) as never + ) + try { + await flushAsyncTicks(6) + + capturedDataCallback.current?.('\x1b[6') + isVisibleRef.current = true + capturedDataCallback.current?.('n') + await flushAsyncTicks(20) + + expect(pane.terminal.write).toHaveBeenCalledWith( + POST_REPLAY_LIVE_SNAPSHOT_RESET, + expect.any(Function) + ) + } finally { + binding.dispose() + } + }) + it('keeps all visible bytes after a pending hidden ESC becomes non-query output', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport('pty-id') @@ -10300,10 +10453,16 @@ describe('connectPanePty', () => { const connection = connectPanePty(pane as never, manager as never, deps as never) await flushAsyncTicks(6) - capturedReplayCallback.current?.(ANSI_POSITIONED_CURSOR_AGENT_REATTACH_SCREEN) + // A dead run's screen with the cursor left hidden — the live-agent reset + // preserves the ?25l, so the veto must re-show the cursor for the shell. + capturedReplayCallback.current?.(`${ANSI_POSITIONED_CURSOR_AGENT_REATTACH_SCREEN}\x1b[?25l`) await flushAsyncTicks(12) - expect(pane.terminal.write).toHaveBeenCalledWith('\x1b[?1004l', expect.any(Function)) + expect(pane.terminal.write).toHaveBeenCalledWith( + `${RESET_TERMINAL_CURSOR_STYLE}${RESET_KITTY_KEYBOARD_PROTOCOL}`, + expect.any(Function) + ) + expect(pane.terminal.write).toHaveBeenCalledWith('\x1b[?25h\x1b[?1004l', expect.any(Function)) expect(transport.sendInput).not.toHaveBeenCalledWith('\x1b[I') return connection }) diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 604b0f2b5fe..1ba85b67787 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -49,7 +49,8 @@ import { type PanePtyResizeHoldFlushDetail } from '@/lib/pane-manager/pane-pty-resize-hold' import { - POST_REPLAY_LIVE_AGENT_REATTACH_RESET, + buildPostReplayLiveAgentReattachReset, + POST_REPLAY_LIVE_AGENT_SNAPSHOT_RESET, POST_REPLAY_LIVE_SNAPSHOT_RESET, POST_REPLAY_MODE_RESET, POST_REPLAY_REATTACH_RESET, @@ -4018,9 +4019,9 @@ export function connectPanePty( return replayIntoTerminalAsync(pane, deps.replayingPanesRef, data) } - const reattachReplayResetSequence = (): string => { + const reattachReplayResetSequence = (payload: string): string => { return shouldPreserveAgentReattachModes() - ? POST_REPLAY_LIVE_AGENT_REATTACH_RESET + ? buildPostReplayLiveAgentReattachReset(payload) : POST_REPLAY_REATTACH_RESET } @@ -4064,7 +4065,9 @@ export function connectPanePty( ) { if (parsedViewportShowsParkedCursorAgentScreen(pane.terminal) === false) { reattachReplayPayloadHasCursorAgentSignal = false - writeReplayData(FOCUS_REPORTING_DISABLE_SEQUENCE) + // Why: the live-agent reset preserved the payload's ?25l; a plain + // shell never re-shows the cursor itself. + writeReplayData(`${CURSOR_SHOW_SEQUENCE}${FOCUS_REPORTING_DISABLE_SEQUENCE}`) return } } @@ -4108,7 +4111,7 @@ export function connectPanePty( } await writeReplayDataAsync(data) if (clearBeforeReplay || data.length > 0) { - await writeReplayDataAsync(reattachReplayResetSequence()) + await writeReplayDataAsync(reattachReplayResetSequence(data)) sendFocusedReattachFocusInAfterReplay() } // Why: the daemon could not serialize a PTY read that ended mid-escape, @@ -5054,7 +5057,14 @@ export function connectPanePty( writeReplayData('\x1b[?1049h\x1b[2J\x1b[H') } writeReplayData(snapshot.data) - writeReplayData(POST_REPLAY_LIVE_SNAPSHOT_RESET) + // Why: status/title-corroborated live agents own ?25l/?1004h (a forced + // ?1004l here would silence focus events until the agent restarts, since + // agents only enable focus reporting at startup). + writeReplayData( + hasLiveAgentReattachStatusOrTitleSignal() + ? POST_REPLAY_LIVE_AGENT_SNAPSHOT_RESET + : POST_REPLAY_LIVE_SNAPSHOT_RESET + ) if (snapshot.pendingEscapeTailAnsi) { // Why last: the snapshot was serialized while the emulator sat mid-escape; // re-arm the dangling sequence as the FINAL replay write (any later ESC — @@ -5491,7 +5501,7 @@ export function connectPanePty( // Snapshot reattach keeps a live session, so avoid the broader mode // reset. We only drop renderer-owned state that should not leak from // replay bytes into the restored renderer terminal. - writeReplayData(reattachReplayResetSequence()) + writeReplayData(reattachReplayResetSequence(connectResult.snapshot)) if (connectResult.pendingEscapeTailAnsi) { // Why last: re-arm the daemon's dangling mid-escape sequence AFTER the // reset (whose ESC would abort it) so the racing live continuation @@ -5514,7 +5524,7 @@ export function connectPanePty( // tearing down the still-running TUI's live modes. writeReplayData('\x1b[2J\x1b[3J\x1b[H') writeReplayData(connectResult.replay) - writeReplayData(reattachReplayResetSequence()) + writeReplayData(reattachReplayResetSequence(connectResult.replay)) sendFocusedReattachFocusInAfterReplay() if (connectResult.coldRestore) { if (!isRemoteRuntimePtyId(ptyId)) {