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.
This commit is contained in:
Neil
2026-05-30 17:26:13 -07:00
committed by GitHub
parent bcd830ffac
commit 437a8f3cfa
2 changed files with 87 additions and 0 deletions
@@ -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-
@@ -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<typeof setTimeout> | null = null
let pendingTerminalBellNotification = false
let reattachIdleAgentCursorResetTimer: ReturnType<typeof setTimeout> | 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?.()