Fix terminal capability query replies (#5555)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
5903b4a742
commit
5dffcd1562
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
})
|
||||
}
|
||||
Loading…
Reference in New Issue