fix(terminal): filter stale Windows Codex focus reports after turns (#8842)

* fix(terminal): reset Windows focus reporting after agent turns

* fix(terminal): preserve focus mode across Windows Codex turns
This commit is contained in:
OrcaWin 2026-07-15 01:28:25 -07:00 committed by GitHub
parent 730aa7fba6
commit c13441123a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 220 additions and 8 deletions

View File

@ -16544,6 +16544,12 @@ describe('connectPanePty', () => {
`${RESET_TERMINAL_CURSOR_STYLE}${RESET_KITTY_KEYBOARD_PROTOCOL}`,
expect.any(Function)
)
transport.sendInput.mockClear()
sendTerminalInputThroughPane(pane, '\x1b[I')
sendTerminalInputThroughPane(pane, '\x7f')
expect(transport.sendInput).toHaveBeenCalledTimes(2)
expect(transport.sendInput).toHaveBeenNthCalledWith(1, '\x1b[I')
expect(transport.sendInput).toHaveBeenLastCalledWith('\x7f')
} finally {
restoreUserAgent()
}
@ -16745,7 +16751,7 @@ describe('connectPanePty', () => {
)
})
it('resets stale Kitty keyboard state when a native Windows agent becomes idle', async () => {
it('resets stale keyboard state when a native Windows agent becomes idle', async () => {
const restoreUserAgent = temporarilySetNavigatorUserAgent(
'Mozilla/5.0 (Windows NT 10.0; Win64; x64)'
)
@ -16815,12 +16821,73 @@ describe('connectPanePty', () => {
`${RESET_TERMINAL_CURSOR_STYLE}${RESET_KITTY_KEYBOARD_PROTOCOL}`,
expect.any(Function)
)
transport.sendInput.mockClear()
sendTerminalInputThroughPane(pane, '\x1b[I')
expect(transport.sendInput).toHaveBeenCalledWith('\x1b[I')
} finally {
restoreUserAgent()
}
})
it('resets stale Kitty keyboard state when native Windows hook status reaches done', async () => {
it.each([
{
name: 'WSL',
configure: (): void => {
mockStoreState.tabsByWorktree = {
'wt-1': [{ id: 'tab-1', ptyId: null, shellOverride: 'wsl.exe' }]
}
}
},
{
name: 'remote runtime',
configure: (): void => {
mockStoreState.repos = [
{
id: 'repo1',
connectionId: null,
displayName: 'orca',
executionHostId: 'runtime:owner-runtime'
}
]
}
}
])(
'keeps $name focus reports and idle reset remote-safe on Windows clients',
async ({ configure }) => {
const restoreUserAgent = temporarilySetNavigatorUserAgent(
'Mozilla/5.0 (Windows NT 10.0; Win64; x64)'
)
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport()
transportFactoryQueue.push(transport)
try {
configure()
const pane = createPane(1)
connectPanePty(pane as never, createManager(1) as never, createDeps() 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)
)
transport.sendInput.mockClear()
sendTerminalInputThroughPane(pane, '\x1b[I')
expect(transport.sendInput).toHaveBeenCalledWith('\x1b[I')
} finally {
restoreUserAgent()
}
}
)
it('resets stale keyboard state when native Windows hook status reaches done', async () => {
const restoreUserAgent = temporarilySetNavigatorUserAgent(
'Mozilla/5.0 (Windows NT 10.0; Win64; x64)'
)
@ -16848,7 +16915,7 @@ describe('connectPanePty', () => {
notifyStoreSubscribers()
expect(pane.terminal.write).not.toHaveBeenCalled()
mockStoreState.agentStatusByPaneKey[paneKey] = {
const doneStatus = {
state: 'done',
prompt: 'ship it',
updatedAt: Date.now(),
@ -16857,6 +16924,7 @@ describe('connectPanePty', () => {
paneKey,
stateHistory: []
}
mockStoreState.agentStatusByPaneKey[paneKey] = doneStatus
notifyStoreSubscribers()
expect(pane.terminal.write).toHaveBeenCalledWith(
@ -16868,6 +16936,110 @@ describe('connectPanePty', () => {
}
})
it('drops only focus reports while native Windows Codex is done and resumes them when working', async () => {
const restoreUserAgent = temporarilySetNavigatorUserAgent(
'Mozilla/5.0 (Windows NT 10.0; Win64; x64)'
)
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport()
transportFactoryQueue.push(transport)
try {
const paneKey = makePaneKey('tab-1', LEAF_1)
const pane = createPane(1)
pane.terminal.modes.sendFocusMode = true
const doneStatus = {
state: 'done',
prompt: 'ship it',
updatedAt: Date.now(),
stateStartedAt: Date.now(),
agentType: 'codex',
paneKey,
stateHistory: []
}
connectPanePty(pane as never, createManager(1) as never, createDeps() as never)
mockStoreState.agentStatusByPaneKey[paneKey] = doneStatus
notifyStoreSubscribers()
const idleHandler = createdTransportOptions[0]?.onAgentBecameIdle as
| ((title: string) => void)
| undefined
idleHandler?.('* Codex done')
transport.sendInput.mockClear()
sendTerminalInputThroughPane(pane, '\x1b[O')
sendTerminalInputThroughPane(pane, '\x1b[I')
expect(transport.sendInput).not.toHaveBeenCalled()
sendTerminalInputThroughPane(pane, '\x7f')
sendTerminalInputThroughPane(pane, 'x')
expect(transport.sendInput).toHaveBeenNthCalledWith(1, '\x7f')
expect(transport.sendInput).toHaveBeenNthCalledWith(2, 'x')
expect(pane.terminal.modes.sendFocusMode).toBe(true)
mockStoreState.agentStatusByPaneKey[paneKey] = {
...doneStatus,
state: 'working'
}
notifyStoreSubscribers()
transport.sendInput.mockClear()
sendTerminalInputThroughPane(pane, '\x1b[I')
expect(transport.sendInput).toHaveBeenCalledWith('\x1b[I')
expect(pane.terminal.modes.sendFocusMode).toBe(true)
mockStoreState.agentStatusByPaneKey[paneKey] = {
...doneStatus,
state: 'waiting'
}
notifyStoreSubscribers()
transport.sendInput.mockClear()
sendTerminalInputThroughPane(pane, '\x1b[O')
expect(transport.sendInput).toHaveBeenCalledWith('\x1b[O')
} finally {
restoreUserAgent()
}
})
it('keeps focus reports enabled for native Windows Cursor completion', async () => {
const restoreUserAgent = temporarilySetNavigatorUserAgent(
'Mozilla/5.0 (Windows NT 10.0; Win64; x64)'
)
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport()
transportFactoryQueue.push(transport)
try {
const paneKey = makePaneKey('tab-1', LEAF_1)
const pane = createPane(1)
pane.terminal.modes.sendFocusMode = true
connectPanePty(pane as never, createManager(1) as never, createDeps() as never)
mockStoreState.agentStatusByPaneKey[paneKey] = {
state: 'done',
prompt: 'ship it',
updatedAt: Date.now(),
stateStartedAt: Date.now(),
agentType: 'cursor',
paneKey,
stateHistory: []
}
notifyStoreSubscribers()
transport.sendInput.mockClear()
sendTerminalInputThroughPane(pane, '\x1b[O')
sendTerminalInputThroughPane(pane, '\x1b[I')
expect(transport.sendInput).toHaveBeenNthCalledWith(1, '\x1b[O')
expect(transport.sendInput).toHaveBeenNthCalledWith(2, '\x1b[I')
expect(pane.terminal.modes.sendFocusMode).toBe(true)
} finally {
restoreUserAgent()
}
})
it('unsubscribes the native Windows done reset watcher on pane dispose', async () => {
const restoreUserAgent = temporarilySetNavigatorUserAgent(
'Mozilla/5.0 (Windows NT 10.0; Win64; x64)'

View File

@ -283,6 +283,7 @@ const SYNCHRONIZED_OUTPUT_MARKER_TAIL_CHARS = SYNCHRONIZED_OUTPUT_START_SEQUENCE
const CURSOR_SHOW_SEQUENCE = '\x1b[?25h'
const CURSOR_HIDE_SEQUENCE = '\x1b[?25l'
const TERMINAL_FOCUS_IN_SEQUENCE = '\x1b[I'
const TERMINAL_FOCUS_OUT_SEQUENCE = '\x1b[O'
const FOCUS_REPORTING_DISABLE_SEQUENCE = '\x1b[?1004l'
const REATTACH_IDLE_AGENT_CURSOR_RESET_DELAY_MS = 250
const SHIFT_ENTER_RECONFIRM_IDLE_MS = 350
@ -1002,6 +1003,15 @@ export function connectPanePty(
// 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 idleAgentTerminalModeReset = RESET_TERMINAL_CURSOR_STYLE
let suppressNativeWindowsIdleCodexFocusReports = false
const setFocusReportSuppressionForAgentCompletion = (
title: string | undefined,
agentType: AgentType | undefined
): void => {
const titleAgentType = resolveCommittedTitleAgentType(title ?? '')
suppressNativeWindowsIdleCodexFocusReports =
agentType && agentType !== 'unknown' ? agentType === 'codex' : titleAgentType === 'codex'
}
let queueAgentIdleTerminalModeReset = (): void => {
if (disposed) {
return
@ -1399,6 +1409,7 @@ export function connectPanePty(
) {
deps.setCacheTimerStartedAt(cacheKey, Date.now())
}
setFocusReportSuppressionForAgentCompletion(title, agentType)
queueAgentIdleTerminalModeReset()
}
const preserveSuppressedTitleSideEffects = (
@ -1410,6 +1421,7 @@ export function connectPanePty(
agentType: activeHookStatus.agentType
}
if (activeHookStatus.state === 'waiting' || activeHookStatus.state === 'blocked') {
suppressNativeWindowsIdleCodexFocusReports = false
queueAgentIdleTerminalModeReset()
}
}
@ -1438,6 +1450,7 @@ export function connectPanePty(
return
}
if (payload.state === 'waiting' || payload.state === 'blocked') {
suppressNativeWindowsIdleCodexFocusReports = false
queueAgentIdleTerminalModeReset()
}
}
@ -2204,6 +2217,10 @@ export function connectPanePty(
if (meta?.terminalIdleConfirmed === true) {
// Why: an agent can crash before its done hook; confirmed process death
// must still restore cursor and native Windows Kitty keyboard modes.
const currentAgentStatus = useAppStore.getState().agentStatusByPaneKey[cacheKey]
if (!isFreshNonDoneAgentStatus(currentAgentStatus)) {
setFocusReportSuppressionForAgentCompletion(title, meta.agentStatus?.agentType)
}
queueAgentIdleTerminalModeReset()
}
scheduleAgentTaskCompleteNotification(title, {
@ -2971,6 +2988,9 @@ export function connectPanePty(
if (isClaudeAgent(title) && (settings === null || settings.promptCacheTimerEnabled)) {
deps.setCacheTimerStartedAt(cacheKey, Date.now())
}
if (detectAgentStatusFromTitle(title) === 'idle') {
setFocusReportSuppressionForAgentCompletion(title, activeHookStatus?.agentType)
}
if (syncAgentTaskCompleteTrackingEnabled()) {
agentCompletionCoordinator.observeClassifiedTitleCompletion(title)
}
@ -2979,6 +2999,7 @@ export function connectPanePty(
queueAgentIdleTerminalModeReset()
}
const onAgentBecameWorking = (): void => {
suppressNativeWindowsIdleCodexFocusReports = false
clearSuppressedTitleSideEffects()
if (syncAgentTaskCompleteTrackingEnabled()) {
requiresFreshWorkingForAgentTaskCompleteNotification = false
@ -3056,8 +3077,8 @@ export function connectPanePty(
executionHostId
})
if (isNativeWindowsConpty) {
// Why: completed Windows ConPTY agent turns can leave xterm's renderer-side
// Kitty encoder enabled; clearing it restores plain Backspace/Enter input.
// Why: Windows ConPTY agent turns can leave renderer keyboard modes armed
// after completion, corrupting plain input with encoded bytes.
idleAgentTerminalModeReset = `${RESET_TERMINAL_CURSOR_STYLE}${RESET_KITTY_KEYBOARD_PROTOCOL}`
}
const shouldApplyNativeWindowsRewriteRefresh = isNativeWindowsConpty
@ -3066,10 +3087,20 @@ export function connectPanePty(
let lastAgentStatusState = state.agentStatusByPaneKey[cacheKey]?.state
let unsubscribeWindowsDoneTerminalModeReset: (() => void) | null = null
if (isNativeWindowsConpty) {
const initialAgentStatus = state.agentStatusByPaneKey[cacheKey]
if (initialAgentStatus?.state === 'done') {
setFocusReportSuppressionForAgentCompletion(undefined, initialAgentStatus.agentType)
}
unsubscribeWindowsDoneTerminalModeReset = useAppStore.subscribe((nextState) => {
const nextAgentStatusState = nextState.agentStatusByPaneKey[cacheKey]?.state
if (lastAgentStatusState !== 'done' && nextAgentStatusState === 'done') {
queueAgentIdleTerminalModeReset()
const nextAgentStatus = nextState.agentStatusByPaneKey[cacheKey]
const nextAgentStatusState = nextAgentStatus?.state
if (nextAgentStatusState === 'done') {
setFocusReportSuppressionForAgentCompletion(undefined, nextAgentStatus.agentType)
if (lastAgentStatusState !== 'done') {
queueAgentIdleTerminalModeReset()
}
} else if (nextAgentStatusState) {
suppressNativeWindowsIdleCodexFocusReports = false
}
lastAgentStatusState = nextAgentStatusState
})
@ -3445,6 +3476,15 @@ export function connectPanePty(
clearPendingTerminalInputIntent()
return
}
if (
isNativeWindowsConpty &&
suppressNativeWindowsIdleCodexFocusReports &&
(data === TERMINAL_FOCUS_IN_SEQUENCE || data === TERMINAL_FOCUS_OUT_SEQUENCE)
) {
// Why: Codex can leave focus reporting armed after a Windows turn, but
// disabling the mode would permanently silence focus events on resume.
return
}
// Why: xterm answers CPR/DSR/DA queries natively through this same onData
// stream (mixed with keystrokes). Those replies are latency-critical — a
// querying program reads them in raw mode with a short timeout — so send