mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 16:02:32 +00:00
fix: reset agent terminal cursor on idle
This commit is contained in:
@@ -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)) {
|
||||
|
||||
Reference in New Issue
Block a user