fix(renderer): preserve live agent cursor visibility across replays

Cursor Agent parks the real cursor on a blank row and hides it (?25l),
drawing its own caret. Orca's post-replay resets forced ?25h, painting a
stray cursor block below the agent's prompt after reattach and after
hidden-to-visible snapshot restores.

Root cause fix: stop re-showing a cursor the replayed bytes intentionally
hid, instead of compensating afterwards.

- Live-agent reattach reset now preserves the payload's final DECTCEM
  state (last ?25l/?25h wins); the post-parse viewport veto re-shows the
  cursor when the screen disproves a live parked agent, so a shell can
  never inherit a permanently hidden cursor from a dead TUI's leftovers.
- Hidden-output restore now uses a live-agent variant of the snapshot
  reset when status/title corroborates a live agent: forcing ?25h
  re-showed the parked cursor, and ?1004l permanently silenced the
  focus-in the agent needs to unpark (agents only enable focus reporting
  at startup, so nothing ever re-armed it).

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinjing
2026-07-08 18:10:36 -07:00
co-authored by Orca
parent 60037d60ab
commit 65b488c809
4 changed files with 243 additions and 15 deletions
@@ -34,7 +34,12 @@ beforeAll(() => {
import {
buildFontFamily,
buildPostReplayLiveAgentReattachReset,
POST_REPLAY_LIVE_AGENT_REATTACH_RESET,
POST_REPLAY_MODE_RESET,
replayPayloadEndsWithCursorHidden,
RESET_KITTY_KEYBOARD_PROTOCOL,
RESET_TERMINAL_CURSOR_STYLE,
restoreScrollbackBuffers,
serializePaneTree,
serializeTerminalLayout,
@@ -491,3 +496,33 @@ describe('collectLeafIdsInReplayCreationOrder', () => {
expect(collectLeafIdsInReplayCreationOrder(layout)).toEqual(['A', 'B', 'C'])
})
})
describe('replayPayloadEndsWithCursorHidden', () => {
it('is true when the last DECTCEM sequence hides the cursor', () => {
expect(replayPayloadEndsWithCursorHidden('\x1b[?25h frame \x1b[?25l')).toBe(true)
expect(replayPayloadEndsWithCursorHidden('\x1b[?1004h\x1b[?25lparked screen')).toBe(true)
})
it('is false when the cursor was re-shown or never touched', () => {
expect(replayPayloadEndsWithCursorHidden('\x1b[?25l frame \x1b[?25h')).toBe(false)
expect(replayPayloadEndsWithCursorHidden('plain shell output')).toBe(false)
expect(replayPayloadEndsWithCursorHidden('')).toBe(false)
})
})
describe('buildPostReplayLiveAgentReattachReset', () => {
it('preserves an intentionally hidden cursor', () => {
expect(buildPostReplayLiveAgentReattachReset('agent frame\x1b[?25l')).toBe(
`${RESET_TERMINAL_CURSOR_STYLE}${RESET_KITTY_KEYBOARD_PROTOCOL}`
)
})
it('re-shows the cursor when the payload left it visible', () => {
expect(buildPostReplayLiveAgentReattachReset('agent frame\x1b[?25l\x1b[?25h')).toBe(
POST_REPLAY_LIVE_AGENT_REATTACH_RESET
)
expect(buildPostReplayLiveAgentReattachReset('no dectcem at all')).toBe(
POST_REPLAY_LIVE_AGENT_REATTACH_RESET
)
})
})
@@ -79,12 +79,36 @@ export const POST_REPLAY_REATTACH_RESET = `${RESET_TERMINAL_CURSOR_STYLE}${RESET
// Why: a live agent TUI legitimately owns focus reporting; resetting `?1004h`
// would suppress the post-reattach focus-in the agent needs to move its real
// cursor back to the input caret (the IME anchor). Cursor visibility is still
// reset: agent detection can false-positive on a dead TUI's leftovers, and a
// permanently invisible shell cursor is far worse than the brief flash a live
// agent re-hides on its post-reattach SIGWINCH repaint.
// cursor back to the input caret (the IME anchor).
export const POST_REPLAY_LIVE_AGENT_REATTACH_RESET = `${RESET_TERMINAL_CURSOR_STYLE}${RESET_KITTY_KEYBOARD_PROTOCOL}\x1b[?25h`
// Why: DECTCEM applies in emission order, so the payload's last ?25l/?25h is
// the state the TUI left the cursor in.
export function replayPayloadEndsWithCursorHidden(payload: string): boolean {
const hideIndex = payload.lastIndexOf('\x1b[?25l')
return hideIndex !== -1 && hideIndex > payload.lastIndexOf('\x1b[?25h')
}
// Why: some live agents (Cursor Agent) intentionally hide the real cursor and
// draw their own caret; re-showing it paints a stray cursor on the parked row.
// Preserve the payload's own final cursor-visibility state instead of forcing
// ?25h. Agent detection can still false-positive on a dead TUI's leftovers —
// the post-replay viewport check in pty-connection re-shows the cursor once
// the parsed screen disproves a live parked agent, so a shell cannot inherit
// a permanently invisible cursor.
export function buildPostReplayLiveAgentReattachReset(payload: string): string {
return replayPayloadEndsWithCursorHidden(payload)
? `${RESET_TERMINAL_CURSOR_STYLE}${RESET_KITTY_KEYBOARD_PROTOCOL}`
: POST_REPLAY_LIVE_AGENT_REATTACH_RESET
}
// Why: hidden-output recovery replays into the same live session. When a live
// agent still owns the pane it also owns cursor visibility and focus
// reporting: forcing ?25h re-shows a parked cursor the agent hid on purpose,
// and ?1004l permanently silences the focus-in the agent needs to unpark
// (agents only enable ?1004h at startup, so nothing ever re-arms it).
export const POST_REPLAY_LIVE_AGENT_SNAPSHOT_RESET = RESET_TERMINAL_CURSOR_STYLE
// 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
// harmless on other platforms (the browser skips them), while Cascadia Mono /
@@ -4,6 +4,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { Terminal } from '@xterm/headless'
import {
POST_REPLAY_LIVE_AGENT_REATTACH_RESET,
POST_REPLAY_LIVE_AGENT_SNAPSHOT_RESET,
POST_REPLAY_LIVE_SNAPSHOT_RESET,
POST_REPLAY_MODE_RESET,
POST_REPLAY_REATTACH_RESET,
RESET_KITTY_KEYBOARD_PROTOCOL,
@@ -5462,6 +5464,46 @@ describe('connectPanePty', () => {
await flushAsyncTicks(20)
expect(transport.sendInput).toHaveBeenCalledWith('\x1b[I')
// The snapshot ends with ?25l (Cursor Agent parks the real cursor and
// hides it); the reset must preserve that instead of forcing ?25h, or
// the parked cursor paints as a stray block below the prompt.
expect(pane.terminal.write).toHaveBeenCalledWith(
`${RESET_TERMINAL_CURSOR_STYLE}${RESET_KITTY_KEYBOARD_PROTOCOL}`,
expect.any(Function)
)
const writes = (pane.terminal.write as ReturnType<typeof vi.fn>).mock.calls.map(
([data]) => data as string
)
expect(writes.some((data) => data.includes('\x1b[?25h'))).toBe(false)
})
})
it('keeps ?25h in the live agent reattach reset when the snapshot leaves the cursor visible', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport()
transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => {
if (sessionId) {
// A mid-frame snapshot cut after the TUI re-showed its cursor.
return { id: sessionId, snapshot: '\x1b[?1004h\x1b[?25l\x1b[?25hrestored cursor snapshot' }
}
return null
})
transportFactoryQueue.push(transport)
setReattachPaneTitle('Cursor Agent')
const pane = createPane(1)
const textarea = {} as HTMLTextAreaElement
configureTerminalFocusMode(pane, textarea)
await withMockedDocumentActiveElement(textarea, async () => {
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(pane.terminal.write).toHaveBeenCalledWith(
POST_REPLAY_LIVE_AGENT_REATTACH_RESET,
expect.any(Function)
@@ -5498,7 +5540,7 @@ describe('connectPanePty', () => {
expect(transport.sendInput).not.toHaveBeenCalledWith('\x1b[I')
expect(pane.terminal.write).toHaveBeenCalledWith(
POST_REPLAY_LIVE_AGENT_REATTACH_RESET,
`${RESET_TERMINAL_CURSOR_STYLE}${RESET_KITTY_KEYBOARD_PROTOCOL}`,
expect.any(Function)
)
})
@@ -8095,6 +8137,117 @@ describe('connectPanePty', () => {
}
})
it('preserves live agent cursor and focus modes on hidden-to-visible snapshot restore', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport('pty-id')
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 getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType<
typeof vi.fn
>
getMainBufferSnapshot.mockResolvedValue({
data: 'agent-frame\x1b[?25l',
cols: 100,
rows: 30
})
const paneKey = makePaneKey('tab-1', LEAF_1)
const now = Date.now()
mockStoreState.agentStatusByPaneKey[paneKey] = {
state: 'working',
prompt: '',
agentType: 'codex',
paneKey,
terminalTitle: 'codex',
updatedAt: now,
stateStartedAt: now,
stateHistory: []
}
const isVisibleRef = { current: false }
const pane = createPane(1)
const manager = createManager(1)
const binding = connectPanePty(
pane as never,
manager as never,
createDeps({
isVisibleRef,
startup: { command: 'codex' }
}) as never
)
try {
await flushAsyncTicks(6)
capturedDataCallback.current?.('\x1b[6')
isVisibleRef.current = true
capturedDataCallback.current?.('n')
await flushAsyncTicks(20)
// A live agent owns ?25l (parked cursor) and ?1004h (focus reporting);
// the ?1004l in the plain reset would silence focus events until the
// agent restarts, since agents only enable focus reporting at startup.
expect(pane.terminal.write).toHaveBeenCalledWith(
POST_REPLAY_LIVE_AGENT_SNAPSHOT_RESET,
expect.any(Function)
)
expect(pane.terminal.write).not.toHaveBeenCalledWith(
POST_REPLAY_LIVE_SNAPSHOT_RESET,
expect.any(Function)
)
} finally {
binding.dispose()
}
})
it('resets cursor and focus modes on hidden-to-visible snapshot restore without a live agent', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport('pty-id')
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 getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType<
typeof vi.fn
>
getMainBufferSnapshot.mockResolvedValue({
data: 'shell-frame\x1b[?25l',
cols: 100,
rows: 30
})
const isVisibleRef = { current: false }
const pane = createPane(1)
const manager = createManager(1)
const binding = connectPanePty(
pane as never,
manager as never,
createDeps({
isVisibleRef,
startup: { command: 'codex' }
}) as never
)
try {
await flushAsyncTicks(6)
capturedDataCallback.current?.('\x1b[6')
isVisibleRef.current = true
capturedDataCallback.current?.('n')
await flushAsyncTicks(20)
expect(pane.terminal.write).toHaveBeenCalledWith(
POST_REPLAY_LIVE_SNAPSHOT_RESET,
expect.any(Function)
)
} finally {
binding.dispose()
}
})
it('keeps all visible bytes after a pending hidden ESC becomes non-query output', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport('pty-id')
@@ -10300,10 +10453,16 @@ describe('connectPanePty', () => {
const connection = connectPanePty(pane as never, manager as never, deps as never)
await flushAsyncTicks(6)
capturedReplayCallback.current?.(ANSI_POSITIONED_CURSOR_AGENT_REATTACH_SCREEN)
// A dead run's screen with the cursor left hidden — the live-agent reset
// preserves the ?25l, so the veto must re-show the cursor for the shell.
capturedReplayCallback.current?.(`${ANSI_POSITIONED_CURSOR_AGENT_REATTACH_SCREEN}\x1b[?25l`)
await flushAsyncTicks(12)
expect(pane.terminal.write).toHaveBeenCalledWith('\x1b[?1004l', expect.any(Function))
expect(pane.terminal.write).toHaveBeenCalledWith(
`${RESET_TERMINAL_CURSOR_STYLE}${RESET_KITTY_KEYBOARD_PROTOCOL}`,
expect.any(Function)
)
expect(pane.terminal.write).toHaveBeenCalledWith('\x1b[?25h\x1b[?1004l', expect.any(Function))
expect(transport.sendInput).not.toHaveBeenCalledWith('\x1b[I')
return connection
})
@@ -49,7 +49,8 @@ import {
type PanePtyResizeHoldFlushDetail
} from '@/lib/pane-manager/pane-pty-resize-hold'
import {
POST_REPLAY_LIVE_AGENT_REATTACH_RESET,
buildPostReplayLiveAgentReattachReset,
POST_REPLAY_LIVE_AGENT_SNAPSHOT_RESET,
POST_REPLAY_LIVE_SNAPSHOT_RESET,
POST_REPLAY_MODE_RESET,
POST_REPLAY_REATTACH_RESET,
@@ -4018,9 +4019,9 @@ export function connectPanePty(
return replayIntoTerminalAsync(pane, deps.replayingPanesRef, data)
}
const reattachReplayResetSequence = (): string => {
const reattachReplayResetSequence = (payload: string): string => {
return shouldPreserveAgentReattachModes()
? POST_REPLAY_LIVE_AGENT_REATTACH_RESET
? buildPostReplayLiveAgentReattachReset(payload)
: POST_REPLAY_REATTACH_RESET
}
@@ -4064,7 +4065,9 @@ export function connectPanePty(
) {
if (parsedViewportShowsParkedCursorAgentScreen(pane.terminal) === false) {
reattachReplayPayloadHasCursorAgentSignal = false
writeReplayData(FOCUS_REPORTING_DISABLE_SEQUENCE)
// Why: the live-agent reset preserved the payload's ?25l; a plain
// shell never re-shows the cursor itself.
writeReplayData(`${CURSOR_SHOW_SEQUENCE}${FOCUS_REPORTING_DISABLE_SEQUENCE}`)
return
}
}
@@ -4108,7 +4111,7 @@ export function connectPanePty(
}
await writeReplayDataAsync(data)
if (clearBeforeReplay || data.length > 0) {
await writeReplayDataAsync(reattachReplayResetSequence())
await writeReplayDataAsync(reattachReplayResetSequence(data))
sendFocusedReattachFocusInAfterReplay()
}
// Why: the daemon could not serialize a PTY read that ended mid-escape,
@@ -5054,7 +5057,14 @@ export function connectPanePty(
writeReplayData('\x1b[?1049h\x1b[2J\x1b[H')
}
writeReplayData(snapshot.data)
writeReplayData(POST_REPLAY_LIVE_SNAPSHOT_RESET)
// Why: status/title-corroborated live agents own ?25l/?1004h (a forced
// ?1004l here would silence focus events until the agent restarts, since
// agents only enable focus reporting at startup).
writeReplayData(
hasLiveAgentReattachStatusOrTitleSignal()
? POST_REPLAY_LIVE_AGENT_SNAPSHOT_RESET
: POST_REPLAY_LIVE_SNAPSHOT_RESET
)
if (snapshot.pendingEscapeTailAnsi) {
// Why last: the snapshot was serialized while the emulator sat mid-escape;
// re-arm the dangling sequence as the FINAL replay write (any later ESC —
@@ -5491,7 +5501,7 @@ export function connectPanePty(
// Snapshot reattach keeps a live session, so avoid the broader mode
// reset. We only drop renderer-owned state that should not leak from
// replay bytes into the restored renderer terminal.
writeReplayData(reattachReplayResetSequence())
writeReplayData(reattachReplayResetSequence(connectResult.snapshot))
if (connectResult.pendingEscapeTailAnsi) {
// Why last: re-arm the daemon's dangling mid-escape sequence AFTER the
// reset (whose ESC would abort it) so the racing live continuation
@@ -5514,7 +5524,7 @@ export function connectPanePty(
// tearing down the still-running TUI's live modes.
writeReplayData('\x1b[2J\x1b[3J\x1b[H')
writeReplayData(connectResult.replay)
writeReplayData(reattachReplayResetSequence())
writeReplayData(reattachReplayResetSequence(connectResult.replay))
sendFocusedReattachFocusInAfterReplay()
if (connectResult.coldRestore) {
if (!isRemoteRuntimePtyId(ptyId)) {