Fix terminal capability query replies (#5555)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo Hong 2026-06-16 20:54:17 -07:00 committed by GitHub
parent 5903b4a742
commit 5dffcd1562
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 312 additions and 118 deletions

View File

@ -308,6 +308,7 @@ function createPane(paneId: number) {
onTitleChange: vi.fn(() => ({ dispose: vi.fn() })),
hasSelection: vi.fn(() => false),
parser: {
registerCsiHandler: vi.fn(() => ({ dispose: vi.fn() })),
registerOscHandler: vi.fn(() => ({ dispose: vi.fn() }))
}
},
@ -3913,6 +3914,38 @@ describe('connectPanePty', () => {
binding.dispose()
})
it('keeps a Yazi-style hidden capability-query burst on the live xterm path', 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 pane = createPane(1)
const manager = createManager(1)
const binding = connectPanePty(
pane as never,
manager as never,
createDeps({
isVisibleRef: { current: false },
startup: { command: 'codex' }
}) as never
)
await flushAsyncTicks(6)
const queryBurst = '\x1b[c\x1b]11;?\x1b\\\x1b[>q\x1b[14t\x1b[16t'
const coalescedChunk = `${queryBurst}\x1b[?2026h${'codex redraw '.repeat(8_000)}`
capturedDataCallback.current?.(coalescedChunk)
expect(pane.terminal.write).toHaveBeenCalledWith(queryBurst, expect.any(Function))
expect(pane.terminal.write).not.toHaveBeenCalledWith(coalescedChunk, expect.any(Function))
binding.dispose()
})
it('keeps split hidden Codex terminal queries on the live xterm path', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport('pty-id')

View File

@ -78,7 +78,11 @@ import { createCommandCodeOutputStatusDetector } from './command-code-output-sta
import type { PtyDataMeta } from './pty-dispatcher'
import { getEagerPtyBufferHandle } from './pty-dispatcher'
import { createTerminalGitHubPRLinkDetector } from '@/lib/terminal-github-pr-link-detector'
import { installConptyDeviceAttributesHandler } from './terminal-conpty-device-attributes'
import {
CONPTY_DA1_RESPONSE,
createTerminalPixelSizeQueryResponder,
installTerminalCapabilityReplyHandlers
} from './terminal-capability-replies'
import {
cancelScheduledHiddenOutputRestore,
scheduleHiddenOutputRestore
@ -460,7 +464,12 @@ function isStatelessRendererReplyCsiQuery(sequence: string): boolean {
if (sequence.endsWith('c')) {
return true
}
return sequence === '\x1b[5n'
return (
sequence === '\x1b[5n' ||
sequence === '\x1b[>q' ||
sequence === '\x1b[14t' ||
sequence === '\x1b[16t'
)
}
function isStatefulRendererReplyCsiQuery(sequence: string): boolean {
@ -1695,13 +1704,17 @@ export function connectPanePty(
? createRemoteRuntimePtyTransport(runtimeEnvironmentId, transportOptions)
: createIpcPtyTransport(transportOptions)
deps.paneTransportsRef.current.set(pane.id, transport)
const conptyDeviceAttributesDisposable = isNativeWindowsConpty
? installConptyDeviceAttributesHandler({
parser: pane.terminal.parser,
sendInput: (data) => transport.sendInput(data),
isReplaying: () => isPaneReplaying(deps.replayingPanesRef, pane.id)
})
: null
const terminalCapabilityRepliesDisposable = installTerminalCapabilityReplyHandlers({
terminal: pane.terminal,
parser: pane.terminal.parser,
sendInput: (data) => transport.sendInput(data),
isReplaying: () => isPaneReplaying(deps.replayingPanesRef, pane.id),
...(isNativeWindowsConpty ? { da1Response: CONPTY_DA1_RESPONSE } : {})
})
const respondToTerminalPixelSizeQueries = createTerminalPixelSizeQueryResponder(
pane.terminal,
(data) => transport.sendInput(data)
)
const onDataDisposable = pane.terminal.onData((data) => {
// Why: xterm auto-replies to embedded query sequences (DA1, DECRQM,
@ -2955,6 +2968,7 @@ export function connectPanePty(
recordAgentHibernationPaneOutput(cacheKey)
}
resetHiddenOutputRestoreIfPtyChanged()
respondToTerminalPixelSizeQueries(data)
observeTerminalBracketedPasteModeOutput(pane.terminal, data)
for (const link of observeTerminalGitHubPRLink(data)) {
useAppStore.getState().observeTerminalGitHubPullRequestLink(deps.worktreeId, link)
@ -3701,7 +3715,7 @@ export function connectPanePty(
connectFrame = null
}
onDataDisposable.dispose()
conptyDeviceAttributesDisposable?.dispose()
terminalCapabilityRepliesDisposable.dispose()
onResizeDisposable.dispose()
pane.container.removeEventListener(PANE_PTY_RESIZE_HOLD_FLUSH_EVENT, onHeldPtyResizeFlush)
geometryReportObserver?.disconnect()

View File

@ -0,0 +1,147 @@
import { describe, expect, it, vi } from 'vitest'
import { Terminal } from '@xterm/headless'
import {
CONPTY_DA1_RESPONSE,
DEFAULT_DA1_RESPONSE,
createTerminalPixelSizeQueryResponder,
installTerminalCapabilityReplyHandlers
} from './terminal-capability-replies'
function writeTerminal(term: Terminal, data: string): Promise<void> {
return new Promise((resolve) => term.write(data, resolve))
}
function createElement(width: number, height: number): HTMLElement {
return {
querySelector: () => ({
getBoundingClientRect: () => ({ width, height })
})
} as unknown as HTMLElement
}
describe('installTerminalCapabilityReplyHandlers', () => {
it('answers primary DA1 with the default xterm-compatible response', async () => {
const term = new Terminal({ cols: 80, rows: 24, allowProposedApi: true })
const sendInput = vi.fn<(data: string) => boolean>(() => true)
const disposable = installTerminalCapabilityReplyHandlers({
terminal: term as never,
parser: term.parser,
sendInput,
isReplaying: () => false
})
try {
await writeTerminal(term, '\x1b[c')
expect(sendInput).toHaveBeenCalledTimes(1)
expect(sendInput).toHaveBeenCalledWith(DEFAULT_DA1_RESPONSE)
} finally {
disposable.dispose()
term.dispose()
}
})
it('keeps the ConPTY basic conformance response override', async () => {
const term = new Terminal({ cols: 80, rows: 24, allowProposedApi: true })
const sendInput = vi.fn<(data: string) => boolean>(() => true)
const disposable = installTerminalCapabilityReplyHandlers({
terminal: term as never,
parser: term.parser,
sendInput,
isReplaying: () => false,
da1Response: CONPTY_DA1_RESPONSE
})
try {
await writeTerminal(term, '\x1b[c')
expect(sendInput).toHaveBeenCalledWith(CONPTY_DA1_RESPONSE)
} finally {
disposable.dispose()
term.dispose()
}
})
it('answers window and cell pixel-size reports from renderer geometry', () => {
const sendInput = vi.fn<(data: string) => boolean>(() => true)
const observe = createTerminalPixelSizeQueryResponder(
{
cols: 100,
rows: 40,
element: createElement(900, 720)
},
sendInput
)
observe('\x1b[14t\x1b[16t')
expect(sendInput).toHaveBeenCalledWith('\x1b[4;720;900t')
expect(sendInput).toHaveBeenCalledWith('\x1b[6;18;9t')
})
it('answers split pixel-size reports', () => {
const sendInput = vi.fn<(data: string) => boolean>(() => true)
const observe = createTerminalPixelSizeQueryResponder(
{
cols: 100,
rows: 40,
element: createElement(900, 720)
},
sendInput
)
observe('\x1b[')
observe('16t')
expect(sendInput).toHaveBeenCalledWith('\x1b[6;18;9t')
})
it('consumes replayed capability queries without sending input to the shell', async () => {
const term = new Terminal({ cols: 80, rows: 24, allowProposedApi: true })
const sendInput = vi.fn<(data: string) => boolean>(() => true)
const disposable = installTerminalCapabilityReplyHandlers({
terminal: { ...term, element: createElement(800, 480) } as never,
parser: term.parser,
sendInput,
isReplaying: () => true
})
try {
await writeTerminal(term, '\x1b[0c')
expect(sendInput).not.toHaveBeenCalled()
} finally {
disposable.dispose()
term.dispose()
}
})
it('leaves non-primary DA queries to other handlers', async () => {
const term = new Terminal({ cols: 80, rows: 24, allowProposedApi: true })
const sendInput = vi.fn<(data: string) => boolean>(() => true)
const returnValues: boolean[] = []
const disposable = installTerminalCapabilityReplyHandlers({
terminal: term as never,
parser: {
registerCsiHandler: (id, cb) =>
term.parser.registerCsiHandler(id, (params) => {
const value = cb(params) as boolean
returnValues.push(value)
return value
})
},
sendInput,
isReplaying: () => false
})
try {
await writeTerminal(term, '\x1b[1c')
expect(sendInput).not.toHaveBeenCalled()
expect(returnValues).toEqual([false])
} finally {
disposable.dispose()
term.dispose()
}
})
})

View File

@ -0,0 +1,108 @@
import type { IDisposable, IParser, Terminal } from '@xterm/xterm'
export const DEFAULT_DA1_RESPONSE = '\x1b[?1;2c'
export const CONPTY_DA1_RESPONSE = '\x1b[?61;4c'
type TerminalCapabilityRepliesDeps = {
terminal: Pick<Terminal, 'cols' | 'rows' | 'element'>
parser: Pick<IParser, 'registerCsiHandler'>
sendInput: (data: string) => boolean | void
isReplaying: () => boolean
da1Response?: string
}
function isPrimaryDeviceAttributesQuery(params: (number | number[])[]): boolean {
return params.length === 0 || (params.length === 1 && params[0] === 0)
}
function getTerminalScreenElement(
terminal: Pick<Terminal, 'element'>
): Pick<HTMLElement, 'getBoundingClientRect'> | null {
if (typeof terminal.element?.querySelector !== 'function') {
return null
}
return terminal.element.querySelector('.xterm-screen') ?? null
}
function measureCellPixels(
terminal: Pick<Terminal, 'cols' | 'rows' | 'element'>
): { width: number; height: number } | null {
if (terminal.cols <= 0 || terminal.rows <= 0) {
return null
}
const rect = getTerminalScreenElement(terminal)?.getBoundingClientRect()
if (!rect || !(rect.width > 0) || !(rect.height > 0)) {
return null
}
return {
width: Math.max(1, Math.round(rect.width / terminal.cols)),
height: Math.max(1, Math.round(rect.height / terminal.rows))
}
}
function disposeAll(disposables: IDisposable[]): void {
for (const disposable of disposables) {
disposable.dispose()
}
}
export function createTerminalPixelSizeQueryResponder(
terminal: Pick<Terminal, 'cols' | 'rows' | 'element'>,
sendInput: (data: string) => boolean | void
): (data: string) => void {
let pending = ''
const respond = (reportsWindowPixels: boolean): void => {
const cell = measureCellPixels(terminal)
if (!cell) {
return
}
const width = cell.width * (reportsWindowPixels ? terminal.cols : 1)
const height = cell.height * (reportsWindowPixels ? terminal.rows : 1)
sendInput(`\x1b[${reportsWindowPixels ? 4 : 6};${height};${width}t`)
}
return (data) => {
const input = pending + data
pending = input.endsWith('\x1b') || input.endsWith('\x1b[') ? input.slice(-2) : ''
let offset = 0
while (offset < input.length) {
const queryIndex = input.indexOf('\x1b[', offset)
if (queryIndex === -1) {
break
}
const query = input.slice(queryIndex, queryIndex + 5)
if (query === '\x1b[14t') {
respond(true)
offset = queryIndex + 5
continue
}
if (query === '\x1b[16t') {
respond(false)
offset = queryIndex + 5
continue
}
offset = queryIndex + 2
}
}
}
export function installTerminalCapabilityReplyHandlers(
deps: TerminalCapabilityRepliesDeps
): IDisposable {
const disposables = [
deps.parser.registerCsiHandler({ final: 'c' }, (params) => {
if (!isPrimaryDeviceAttributesQuery(params)) {
return false
}
// Why: restored scrollback may contain old DA1 queries; answering those
// into the fresh shell recreates the stray-input leak this handler fixes.
if (!deps.isReplaying()) {
deps.sendInput(deps.da1Response ?? DEFAULT_DA1_RESPONSE)
}
return true
})
]
return {
dispose: () => disposeAll(disposables)
}
}

View File

@ -1,79 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import { Terminal } from '@xterm/headless'
import {
CONPTY_DA1_RESPONSE,
installConptyDeviceAttributesHandler
} from './terminal-conpty-device-attributes'
function writeTerminal(term: Terminal, data: string): Promise<void> {
return new Promise((resolve) => term.write(data, resolve))
}
describe('installConptyDeviceAttributesHandler', () => {
it('answers primary DA1 with the ConPTY basic conformance response', async () => {
const term = new Terminal({ cols: 80, rows: 24, allowProposedApi: true })
const sendInput = vi.fn<(data: string) => boolean>(() => true)
const disposable = installConptyDeviceAttributesHandler({
parser: term.parser,
sendInput,
isReplaying: () => false
})
try {
await writeTerminal(term, '\x1b[c')
expect(sendInput).toHaveBeenCalledTimes(1)
expect(sendInput).toHaveBeenCalledWith(CONPTY_DA1_RESPONSE)
} finally {
disposable.dispose()
term.dispose()
}
})
it('consumes replayed primary DA1 without sending input to the shell', async () => {
const term = new Terminal({ cols: 80, rows: 24, allowProposedApi: true })
const sendInput = vi.fn<(data: string) => boolean>(() => true)
const disposable = installConptyDeviceAttributesHandler({
parser: term.parser,
sendInput,
isReplaying: () => true
})
try {
await writeTerminal(term, '\x1b[0c')
expect(sendInput).not.toHaveBeenCalled()
} finally {
disposable.dispose()
term.dispose()
}
})
it('leaves non-primary DA queries to other handlers', async () => {
const term = new Terminal({ cols: 80, rows: 24, allowProposedApi: true })
const sendInput = vi.fn<(data: string) => boolean>(() => true)
const returnValues: boolean[] = []
const disposable = installConptyDeviceAttributesHandler({
parser: {
registerCsiHandler: (id, cb) =>
term.parser.registerCsiHandler(id, (params) => {
const value = cb(params) as boolean
returnValues.push(value)
return value
})
},
sendInput,
isReplaying: () => false
})
try {
await writeTerminal(term, '\x1b[1c')
expect(sendInput).not.toHaveBeenCalled()
expect(returnValues).toEqual([false])
} finally {
disposable.dispose()
term.dispose()
}
})
})

View File

@ -1,29 +0,0 @@
import type { IDisposable, IParser } from '@xterm/xterm'
export const CONPTY_DA1_RESPONSE = '\x1b[?61;4c'
type ConptyDeviceAttributesDeps = {
parser: Pick<IParser, 'registerCsiHandler'>
sendInput: (data: string) => boolean | void
isReplaying: () => boolean
}
function isPrimaryDeviceAttributesQuery(params: (number | number[])[]): boolean {
return params.length === 0 || (params.length === 1 && params[0] === 0)
}
export function installConptyDeviceAttributesHandler(
deps: ConptyDeviceAttributesDeps
): IDisposable {
return deps.parser.registerCsiHandler({ final: 'c' }, (params) => {
if (!isPrimaryDeviceAttributesQuery(params)) {
return false
}
// Why: ConPTY 1.22+ waits for a DA1 reply; replayed scrollback must not
// answer old queries into the live shell.
if (!deps.isReplaying()) {
deps.sendInput(CONPTY_DA1_RESPONSE)
}
return true
})
}