From aba3ee6e0a25ba0f88f643d4569f4c82dd85331e Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 28 May 2026 01:26:27 -0700 Subject: [PATCH] fix: reset agent terminal cursor on idle --- .../terminal-pane/layout-serialization.ts | 7 +- .../terminal-pane/pty-connection.test.ts | 67 ++++++++++++++++++- .../terminal-pane/pty-connection.ts | 30 ++++++++- 3 files changed, 99 insertions(+), 5 deletions(-) diff --git a/src/renderer/src/components/terminal-pane/layout-serialization.ts b/src/renderer/src/components/terminal-pane/layout-serialization.ts index 93c272a5b0f..e5b653a57e3 100644 --- a/src/renderer/src/components/terminal-pane/layout-serialization.ts +++ b/src/renderer/src/components/terminal-pane/layout-serialization.ts @@ -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 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 33825de605c..4fa42b4258a 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -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( diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 5d2f61b1787..57292be3f2c 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -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 | 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)) {