fix(tui): recognize standard DSR cursor position reports (supersedes #48762) (#67731)

* fix(tui): recognize standard DSR cursor position reports in input parser

The CURSOR_POSITION_RE regex only matched DECXCPR reports (CSI ? row;col R)
but not standard DSR reports (CSI row;col R without the ? marker). Terminals
that respond to CSI ? 6 n with the plain DSR form had their cursor position
reports fall through to parseKeypress, where they were inserted as literal
text — garbling the composer input with escape sequences like ESC[22;1R.

Fix: make the regex match both forms. For the standard form (no ?), only
treat it as a cursor position report when row > 1, since modified F3 keys
(Shift+F3 = CSI 1;2 R, etc.) always use row 1 and are genuinely ambiguous
with row-1 cursor reports.

* fix(tui): reject invalid row-zero DSR cursor position reports

Follow-up to the standard-DSR recognition fix. The row guard rejected
only row === 1, which let CSI 0;col R (row 0, no ? marker) through and
misclassified it as a cursorPosition report. Terminal coordinates are
1-indexed, so row 0 is an invalid DSR report and must remain
unclassified.

Change the guard to row <= 1 to match the stated 'row > 1' semantics,
and add a boundary test asserting CSI 0;col R is not emitted as a
response.

Supersedes #48762; incorporates review feedback from that PR.

---------

Co-authored-by: Alex Yates <43525405+yatesjalex@users.noreply.github.com>
This commit is contained in:
Austin Pickett 2026-07-19 19:35:20 -04:00 committed by GitHub
parent 54459e76ed
commit 2f6a4e099b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 87 additions and 6 deletions

View file

@ -131,3 +131,68 @@ describe('flush-boundary SGR mouse reassembly', () => {
expect(key).toMatchObject({ name: 'wheelup' })
})
})
describe('cursor position report parsing', () => {
it('parses DECXCPR cursor position report (CSI ? row;col R)', () => {
const [[key]] = parseMultipleKeypresses(INITIAL_STATE, '\x1b[?22;1R')
expect(key).toMatchObject({
kind: 'response',
response: { type: 'cursorPosition', row: 22, col: 1 }
})
})
it('parses standard DSR cursor position report (CSI row;col R) when row > 1', () => {
// Terminals that don't support DECXCPR may respond to CSI ? 6 n with
// the plain DSR form (no ?). These must be recognized as responses,
// not inserted as literal text.
const [[key]] = parseMultipleKeypresses(INITIAL_STATE, '\x1b[22;1R')
expect(key).toMatchObject({
kind: 'response',
response: { type: 'cursorPosition', row: 22, col: 1 }
})
})
it('parses standard DSR report with multi-digit row and col', () => {
const [[key]] = parseMultipleKeypresses(INITIAL_STATE, '\x1b[10;80R')
expect(key).toMatchObject({
kind: 'response',
response: { type: 'cursorPosition', row: 10, col: 80 }
})
})
it('does NOT treat CSI 1;2 R as a cursor position report (Shift+F3 ambiguity)', () => {
// CSI 1;2 R is Shift+F3 in xterm. Without the ? marker, row 1 is
// ambiguous with F3 modifiers — must fall through to parseKeypress,
// not be silently dropped as a terminal response.
const [[key]] = parseMultipleKeypresses(INITIAL_STATE, '\x1b[1;2R')
expect(key.kind).not.toBe('response')
})
it('does NOT treat CSI 1;5 R as a cursor position report (Ctrl+F3 ambiguity)', () => {
const [[key]] = parseMultipleKeypresses(INITIAL_STATE, '\x1b[1;5R')
expect(key.kind).not.toBe('response')
})
it('does NOT treat CSI 0;col R as a cursor position report (invalid row-zero DSR)', () => {
// Terminal coordinates are 1-indexed, so a plain DSR report with row 0 is
// invalid. Without the ? marker it must remain unclassified rather than be
// reported as a cursor position (guard is row <= 1, not row === 1).
const [[key]] = parseMultipleKeypresses(INITIAL_STATE, '\x1b[0;5R')
expect(key.kind).not.toBe('response')
})
it('treats DECXCPR at row 1 as a cursor position report (? disambiguates from F3)', () => {
const [[key]] = parseMultipleKeypresses(INITIAL_STATE, '\x1b[?1;2R')
expect(key).toMatchObject({
kind: 'response',
response: { type: 'cursorPosition', row: 1, col: 2 }
})
})
})

View file

@ -44,11 +44,14 @@ const DA2_RE = /^\x1b\[>([\d;]*)c$/
// (private ? marker distinguishes from CSI u key events)
// eslint-disable-next-line no-control-regex
const KITTY_FLAGS_RE = /^\x1b\[\?(\d+)u$/
// DECXCPR cursor position: CSI ? row ; col R
// The ? marker disambiguates from modified F3 keys (Shift+F3 = CSI 1;2 R,
// Ctrl+F3 = CSI 1;5 R, etc.) — plain CSI row;col R is genuinely ambiguous.
// Cursor position report: CSI [?] row ; col R
// DECXCPR (CSI ? row;col R) is unambiguous and always matched.
// Standard DSR (CSI row;col R, no ?) is also matched, but only when
// row > 1 — modified F3 keys use CSI 1;modifier R (Shift+F3 = CSI 1;2 R,
// Ctrl+F3 = CSI 1;5 R, etc.), and F3 always has row 1. Reports at row 1
// without the ? marker fall through to parseKeypress to preserve F3.
// eslint-disable-next-line no-control-regex
const CURSOR_POSITION_RE = /^\x1b\[\?(\d+);(\d+)R$/
const CURSOR_POSITION_RE = /^\x1b\[(\??)(\d+);(\d+)R$/
// OSC response: OSC code ; data (BEL|ST)
// eslint-disable-next-line no-control-regex
const OSC_RESPONSE_RE = /^\x1b\](\d+);(.*?)(?:\x07|\x1b\\)$/s
@ -145,10 +148,23 @@ function parseTerminalResponse(s: string): TerminalResponse | null {
}
if ((m = CURSOR_POSITION_RE.exec(s))) {
const hasPrivateMarker = m[1] === '?'
const row = parseInt(m[2]!, 10)
// Without the DEC-private '?' marker, CSI row;col R is ambiguous with
// modified F3 keys (Shift+F3 = CSI 1;2 R, etc.). F3 always uses row 1,
// so only treat the standard form as a cursor position report when
// row > 1. Row <= 1 reports without '?' fall through to parseKeypress:
// row 1 preserves F3, and row 0 is an invalid DSR report (terminal
// coordinates are 1-indexed) that should remain unclassified.
if (!hasPrivateMarker && row <= 1) {
return null
}
return {
type: 'cursorPosition',
row: parseInt(m[1]!, 10),
col: parseInt(m[2]!, 10)
row,
col: parseInt(m[3]!, 10)
}
}