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:
parent
730aa7fba6
commit
c13441123a
|
|
@ -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)'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue