fix: reset agent terminal cursor on idle

This commit is contained in:
Neil
2026-05-28 01:26:27 -07:00
committed by GitHub
parent b1d87f310d
commit aba3ee6e0a
3 changed files with 99 additions and 5 deletions
@@ -42,8 +42,9 @@ export const EMPTY_LAYOUT: TerminalLayoutSnapshot = {
// 1000/1002/1003/1006 — mouse reporting variants
// 1004 — focus event reporting (the actual bug source)
// 2004 — bracketed paste
export const POST_REPLAY_MODE_RESET =
'\x1b[0 q\x1b[?25h\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1004l\x1b[?1006l\x1b[?2004l'
export const RESET_TERMINAL_CURSOR_STYLE = '\x1b[0 q'
export const POST_REPLAY_MODE_RESET = `${RESET_TERMINAL_CURSOR_STYLE}\x1b[?25h\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1004l\x1b[?1006l\x1b[?2004l`
// Why: daemon snapshot restore reattaches to a live session, so we avoid the
// full POST_REPLAY_MODE_RESET bundle there — a still-running TUI may still
@@ -63,7 +64,7 @@ export const POST_REPLAY_MODE_RESET =
// 1004 — focus event reporting: preserving `?1004h` makes restored shells
// ring BEL on pane focus/blur (shells like zsh treat `\e[I`/`\e[O`
// as unbound key input).
export const POST_REPLAY_REATTACH_RESET = '\x1b[0 q\x1b[?25h\x1b[?1004l'
export const POST_REPLAY_REATTACH_RESET = `${RESET_TERMINAL_CURSOR_STYLE}\x1b[?25h\x1b[?1004l`
// 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
@@ -1,7 +1,11 @@
/* oxlint-disable max-lines */
import type * as React from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { POST_REPLAY_MODE_RESET, POST_REPLAY_REATTACH_RESET } from './layout-serialization'
import {
POST_REPLAY_MODE_RESET,
POST_REPLAY_REATTACH_RESET,
RESET_TERMINAL_CURSOR_STYLE
} from './layout-serialization'
import type * as UseNotificationDispatchModule from './use-notification-dispatch'
import { makePaneKey } from '../../../../shared/stable-pane-id'
@@ -4241,6 +4245,67 @@ describe('connectPanePty', () => {
)
})
it('resets renderer cursor style when an agent becomes idle', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport()
transportFactoryQueue.push(transport)
const pane = createPane(1)
const manager = createManager(1)
const deps = createDeps()
connectPanePty(pane as never, manager as never, deps as never)
const idleHandler = createdTransportOptions[0]?.onAgentBecameIdle as
| ((title: string) => void)
| undefined
if (!idleHandler) {
throw new Error('Expected onAgentBecameIdle to be registered')
}
idleHandler('* Codex done')
expect(pane.terminal.write).toHaveBeenCalledWith(
RESET_TERMINAL_CURSOR_STYLE,
expect.any(Function)
)
})
it('queues the idle cursor reset behind hidden agent output', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport()
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 pane = createPane(1)
const manager = createManager(1)
const deps = createDeps({
isVisibleRef: { current: false }
})
connectPanePty(pane as never, manager as never, deps as never)
await flushAsyncTicks(6)
vi.useFakeTimers()
const idleHandler = createdTransportOptions[0]?.onAgentBecameIdle as
| ((title: string) => void)
| undefined
if (!capturedDataCallback.current || !idleHandler) {
throw new Error('Expected PTY data and idle handlers to be registered')
}
capturedDataCallback.current('\x1b[6 q')
idleHandler('* Codex done')
expect(pane.terminal.write).not.toHaveBeenCalled()
vi.advanceTimersByTime(50)
expect(pane.terminal.write).toHaveBeenCalledWith(`\x1b[6 q${RESET_TERMINAL_CURSOR_STYLE}`)
})
it('waits briefly for delayed agent status before dispatching task-complete', async () => {
const { connectPanePty } = await import('./pty-connection')
const { useNotificationDispatch } = await vi.importActual<typeof UseNotificationDispatchModule>(
@@ -18,7 +18,11 @@ import { getFitOverrideForPty, bindPanePtyId } from '@/lib/pane-manager/mobile-f
import { isPtyLocked } from '@/lib/pane-manager/mobile-driver-state'
import { isPaneReplaying, replayIntoTerminal } from './replay-guard'
import { terminalOutputPrefersDomRenderer } from '@/lib/pane-manager/terminal-complex-script'
import { POST_REPLAY_MODE_RESET, POST_REPLAY_REATTACH_RESET } from './layout-serialization'
import {
POST_REPLAY_MODE_RESET,
POST_REPLAY_REATTACH_RESET,
RESET_TERMINAL_CURSOR_STYLE
} from './layout-serialization'
import { warnTerminalLifecycleAnomaly } from './terminal-lifecycle-diagnostics'
import { registerPtySerializer, registerPtyTitleSource } from './pty-buffer-serializer'
import { getRemoteRuntimePtyEnvironmentId } from '@/runtime/runtime-terminal-stream'
@@ -242,6 +246,17 @@ export function connectPanePty(
let wasAgentTaskCompleteNotificationEnabled = isAgentTaskCompleteNotificationEnabled()
let terminalBellNotificationTimer: ReturnType<typeof setTimeout> | null = null
let pendingTerminalBellNotification = false
// 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.
let queueAgentIdleCursorReset = (): void => {
if (disposed) {
return
}
writeTerminalOutput(pane.terminal, RESET_TERMINAL_CURSOR_STYLE, {
foreground: shouldWritePtyOutputForeground(deps.isVisibleRef.current)
})
}
// Why: passphrase-gate waits register a teardown here so dispose() can
// actively unsubscribe + resolve them. Without this, a pane disposed
// mid-wait leaks its zustand subscriber and the surrounding async IIFE
@@ -877,6 +892,9 @@ export function connectPanePty(
if (syncAgentTaskCompleteNotificationEnabled()) {
agentCompletionCoordinator.observeClassifiedTitleCompletion(title)
}
// Why: some agent TUIs leave xterm in DECSCUSR steady-cursor mode when
// they become idle. Reset to Orca's configured cursor once the turn ends.
queueAgentIdleCursorReset()
}
const onAgentBecameWorking = (): void => {
if (syncAgentTaskCompleteNotificationEnabled()) {
@@ -1343,6 +1361,16 @@ export function connectPanePty(
})
}
queueAgentIdleCursorReset = (): void => {
if (disposed) {
return
}
writePtyOutputToXterm(
RESET_TERMINAL_CURSOR_STYLE,
shouldWritePtyOutputForeground(deps.isVisibleRef.current)
)
}
function markHiddenOutputRestoreNeeded(): void {
const ptyId = transport.getPtyId()
if (!canUseMainBufferSnapshot(ptyId)) {