From 2f6a4e099ba7461a852635f6ee852e32b8e4cbf9 Mon Sep 17 00:00:00 2001 From: Austin Pickett Date: Sun, 19 Jul 2026 19:35:20 -0400 Subject: [PATCH] fix(tui): recognize standard DSR cursor position reports (supersedes #48762) (#67731) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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> --- .../hermes-ink/src/ink/parse-keypress.test.ts | 65 +++++++++++++++++++ .../hermes-ink/src/ink/parse-keypress.ts | 28 ++++++-- 2 files changed, 87 insertions(+), 6 deletions(-) diff --git a/ui-tui/packages/hermes-ink/src/ink/parse-keypress.test.ts b/ui-tui/packages/hermes-ink/src/ink/parse-keypress.test.ts index c84982d681aa..fcd7090b8b0b 100644 --- a/ui-tui/packages/hermes-ink/src/ink/parse-keypress.test.ts +++ b/ui-tui/packages/hermes-ink/src/ink/parse-keypress.test.ts @@ -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 } + }) + }) +}) diff --git a/ui-tui/packages/hermes-ink/src/ink/parse-keypress.ts b/ui-tui/packages/hermes-ink/src/ink/parse-keypress.ts index 966e32bac74d..59981f543fbd 100644 --- a/ui-tui/packages/hermes-ink/src/ink/parse-keypress.ts +++ b/ui-tui/packages/hermes-ink/src/ink/parse-keypress.ts @@ -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) } }