mirror of
https://github.com/stablyai/orca.git
synced 2026-10-17 00:03:08 +00:00
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:
@@ -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)) {
|
||||
|
||||
Reference in New Issue
Block a user