From 7cba1079f89df67ee2984b0896814e0c2951757f Mon Sep 17 00:00:00 2001 From: shayann07 Date: Tue, 13 Oct 2026 05:41:16 +0500 Subject: [PATCH] Consume extended CSI sequences atomically in PosixEventParser --- .../terminalinterface/PosixEventParser.kt | 65 ++++++++++++------- .../terminalinterface/PosixEventParserTest.kt | 33 ++++++++++ 2 files changed, 73 insertions(+), 25 deletions(-) diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/terminal/terminalinterface/PosixEventParser.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/terminal/terminalinterface/PosixEventParser.kt index 732455b3..6b98ffa2 100644 --- a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/terminal/terminalinterface/PosixEventParser.kt +++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/terminal/terminalinterface/PosixEventParser.kt @@ -100,39 +100,42 @@ internal class PosixEventParser( val cmdStart = s.length - 1 - // leading digits - repeat(3) { - if (ch in '0'..'9') { - read() ?: return null - } - } - - // modifier - if (ch == ';') { + // Consume all parameter, intermediate, and final bytes of the CSI sequence. + // Final bytes are in the range 0x40..0x7E ('@' to '~'), plus '$' for rxvt. + while (ch.code !in 0x40..0x7E && ch != '$') { + if (ch.code < 0x20) break read() ?: return null - - if (ch in '0'..'9') { - read() ?: return null - } } // We buffered enough data, now extract code and modifier from it val cmd = s.substring(cmdStart) - var match = Regex("""(\d\d?)(?:;(\d))?([~^$])|(\d{3}~)""").matchEntire(cmd) + var match = Regex("""27;(\d+);(\d+)~""").matchEntire(cmd) if (match != null) { - if (match.groupValues[4].isNotEmpty()) { - code.append(match.groupValues[4]) - } else { - code.append(match.groupValues[1] + match.groupValues[3]) - modifier = (match.groupValues[2].toIntOrNull() ?: 1) - 1 - } + modifier = (match.groupValues[1].toIntOrNull() ?: 1) - 1 + name = keycodeToName(match.groupValues[2].toIntOrNull() ?: 0) } else { - match = Regex("""((\d;)?(\d))?([A-Za-z])""").matchEntire(cmd) + match = Regex("""(\d+)(?:;(\d+))?u""").matchEntire(cmd) if (match != null) { - code.append(match.groupValues[4]) - modifier = (match.groupValues[3].toIntOrNull() ?: 1) - 1 + modifier = (match.groupValues[2].toIntOrNull() ?: 1) - 1 + name = keycodeToName(match.groupValues[1].toIntOrNull() ?: 0) } else { - code.append(cmd) + match = Regex("""(\d\d?)(?:;(\d+))?([~^$])|(\d{3}~)""").matchEntire(cmd) + if (match != null) { + if (match.groupValues[4].isNotEmpty()) { + code.append(match.groupValues[4]) + } else { + code.append(match.groupValues[1] + match.groupValues[3]) + modifier = (match.groupValues[2].toIntOrNull() ?: 1) - 1 + } + } else { + match = Regex("""((\d+;)?(\d+))?([A-Za-z])""").matchEntire(cmd) + if (match != null) { + code.append(match.groupValues[4]) + modifier = (match.groupValues[3].toIntOrNull() ?: 1) - 1 + } else { + code.append(cmd) + } + } } } } @@ -142,7 +145,7 @@ internal class PosixEventParser( alt = (modifier and 10) != 0 shift = (modifier and 1) != 0 - when (code.toString()) { + if (name == null) when (code.toString()) { "[P" -> name = "F1" "[Q" -> name = "F2" "[R" -> name = "F3" @@ -430,3 +433,15 @@ internal class PosixEventParser( return readBytesAsUtf8 { readRawByte(timeout) } } } + +private fun keycodeToName(keycode: Int): String { + return when (keycode) { + 13 -> "Enter" + 9 -> "Tab" + 8, 127 -> "Backspace" + 27 -> "Escape" + 32 -> " " + in 33..126 -> keycode.toChar().toString() + else -> "Unidentified" + } +} diff --git a/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/terminal/terminalinterface/PosixEventParserTest.kt b/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/terminal/terminalinterface/PosixEventParserTest.kt index 7e10da0f..a0bbc993 100644 --- a/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/terminal/terminalinterface/PosixEventParserTest.kt +++ b/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/terminal/terminalinterface/PosixEventParserTest.kt @@ -79,6 +79,39 @@ class PosixEventParserTest { readEvent(parser) shouldBe KeyboardEvent("ArrowUp") } + @[Test JsName("ghostty_shift_enter")] + fun `ghostty shift enter`() { + parse("$esc[27;2;13~") shouldBe KeyboardEvent("Enter", shift = true) + } + + @[Test JsName("kitty_shift_enter")] + fun `kitty shift enter`() { + parse("$esc[13;2u") shouldBe KeyboardEvent("Enter", shift = true) + } + + @[Test JsName("sequential_extended_csi_without_byte_leak")] + fun `sequential extended csi without byte leak`() { + val parser = parserFor("$esc[27;2;13~a") + readEvent(parser) shouldBe KeyboardEvent("Enter", shift = true) + readEvent(parser) shouldBe KeyboardEvent("a") + } + + @[Test JsName("unsupported_csi_sequence_consumed_atomically")] + fun `unsupported csi sequence consumed atomically`() { + val parser = parserFor("$esc[?2004ha") + readEvent(parser) shouldBe KeyboardEvent("Unidentified") + readEvent(parser) shouldBe KeyboardEvent("a") + } + + @[Test JsName("modify_other_keys_modifiers")] + fun `modify other keys modifiers`() = forAll( + row("ctrl+enter", "27;5;13~", KeyboardEvent("Enter", ctrl = true)), + row("alt+enter", "27;3;13~", KeyboardEvent("Enter", alt = true)), + row("shift+tab", "27;2;9~", KeyboardEvent("Tab", shift = true)), + ) { _, seq, expected -> + parse("$esc[$seq") shouldBe expected + } + private fun parse(input: String): Any? = readEvent(parserFor(input)) private fun parserFor(input: String): PosixEventParser {