From 437a8f3cfa65b529436ea4b30c033b8a4b04ac04 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 30 May 2026 17:26:13 -0700 Subject: [PATCH] Fix idle agent cursor after reattach Reset the cursor after reattaching an already-idle Codex session so post-SIGWINCH repaint cannot leave the focused cursor as a steady bar. --- .../terminal-pane/pty-connection.test.ts | 47 +++++++++++++++++++ .../terminal-pane/pty-connection.ts | 40 ++++++++++++++++ 2 files changed, 87 insertions(+) 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 b48dd9ab7e6..24c99461511 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -2434,6 +2434,53 @@ describe('connectPanePty', () => { ) }) + it('resets an already-idle agent cursor again after reattach SIGWINCH repaint', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('tab-pty') + transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { + if (sessionId) { + return { id: sessionId, snapshot: 'restored idle codex snapshot' } + } + return null + }) + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-1', ptyId: 'tab-pty', title: 'Codex done' }] + }, + runtimePaneTitlesByTabId: { + 'tab-1': { 1: 'Codex done' } + }, + settings: { + ...mockStoreState.settings + } + } as StoreState + + const pane = createPane(1) + 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(window.api.pty.signal).toHaveBeenCalledWith('tab-pty', 'SIGWINCH') + expect(pane.terminal.write).not.toHaveBeenCalledWith( + RESET_TERMINAL_CURSOR_STYLE, + expect.any(Function) + ) + + await new Promise((resolve) => setTimeout(resolve, 300)) + + expect(pane.terminal.write).toHaveBeenCalledWith( + RESET_TERMINAL_CURSOR_STYLE, + expect.any(Function) + ) + }) + // Why: when a reattach result carries both snapshot and replay (the daemon // host serves the snapshot, the relay replay buffer covers the same tail), // painting both into xterm doubles the same lines. This is the duplicated- diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 2dafe7ec9dd..85e374b6b66 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -83,6 +83,7 @@ const HIDDEN_OUTPUT_RESTORE_PENDING_CHARS = 512 * 1024 const HIDDEN_STARTUP_RENDERER_QUERY_WINDOW_MS = 10_000 const STARTUP_COMMAND_EXTENSION_RE = /\.(?:exe|cmd|bat|ps1)$/i const TERMINAL_RENDERER_RISK_SCAN_TAIL_CHARS = 256 +const REATTACH_IDLE_AGENT_CURSOR_RESET_DELAY_MS = 250 // Why: this is only shown if renderer backlog overflowed and main-owned // terminal state is unavailable, so the user has an explicit loss signal. const HIDDEN_OUTPUT_RESTORE_UNAVAILABLE_WARNING = @@ -330,6 +331,7 @@ export function connectPanePty( let wasAgentTaskCompleteNotificationEnabled = isAgentTaskCompleteNotificationEnabled() let terminalBellNotificationTimer: ReturnType | null = null let pendingTerminalBellNotification = false + let reattachIdleAgentCursorResetTimer: ReturnType | null = null // Why: idle callbacks are registered before the deferred PTY output plumbing // exists. Start with the shared scheduler, then switch to the PTY writer // below so hidden-tab resets keep backlog-recovery callbacks and byte order. @@ -427,6 +429,41 @@ export function connectPanePty( } }, AGENT_INTERRUPT_SETTLE_MS) } + const clearReattachIdleAgentCursorResetTimer = (): void => { + if (reattachIdleAgentCursorResetTimer !== null) { + clearTimeout(reattachIdleAgentCursorResetTimer) + reattachIdleAgentCursorResetTimer = null + } + } + const getCurrentTerminalTitle = (): string | null => { + const state = useAppStore.getState() + const runtimeTitle = state.runtimePaneTitlesByTabId?.[deps.tabId]?.[pane.id] + const tabTitle = (state.tabsByWorktree[deps.worktreeId] ?? []).find( + (entry) => entry.id === deps.tabId + )?.title + return runtimeTitle ?? tabTitle ?? null + } + const scheduleReattachIdleAgentCursorReset = (): void => { + const status = detectAgentStatusFromTitle(getCurrentTerminalTitle() ?? '') + if (status !== 'idle' && status !== 'permission') { + return + } + clearReattachIdleAgentCursorResetTimer() + reattachIdleAgentCursorResetTimer = setTimeout(() => { + reattachIdleAgentCursorResetTimer = null + if (disposed) { + return + } + const latestStatus = detectAgentStatusFromTitle(getCurrentTerminalTitle() ?? '') + if (latestStatus !== 'idle' && latestStatus !== 'permission') { + return + } + // Why: restored idle agent TUIs can repaint after reattach SIGWINCH and + // reapply DECSCUSR steady-bar; the normal working→idle reset will not + // fire because the agent was already idle before Orca restarted. + queueAgentIdleCursorReset() + }, REATTACH_IDLE_AGENT_CURSOR_RESET_DELAY_MS) + } const interruptInference = createAgentInterruptInference({ paneKey: cacheKey, getStatusEntry: () => useAppStore.getState().agentStatusByPaneKey[cacheKey], @@ -1773,6 +1810,7 @@ export function connectPanePty( if (!isRemoteRuntimePtyId(currentPtyId)) { window.api.pty.signal(currentPtyId, 'SIGWINCH') } + scheduleReattachIdleAgentCursorReset() } restoreScrollStateAfterSnapshotReplay(scrollState) } @@ -2076,6 +2114,7 @@ export function connectPanePty( if (!isRemoteRuntimePtyId(ptyId)) { window.api.pty.signal(ptyId, 'SIGWINCH') } + scheduleReattachIdleAgentCursorReset() scheduleRuntimeGraphSync() } @@ -2567,6 +2606,7 @@ export function connectPanePty( clearPendingAgentTaskCompleteNotification() pendingTerminalBellNotification = false clearTerminalBellNotificationTimer() + clearReattachIdleAgentCursorResetTimer() unregisterBacklogRecovery?.() unregisterBacklogRecovery = null unregisterDocumentVisibilityRecovery?.()