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) } }