Commit 1501f2a

Dennis Torbichuk  ·  2026-09-25 15:42:25 -0400 EDT
parent ad8be3f
fix(attach): don't let terminal query replies claim leadership

Every attached client's terminal answers the queries an app broadcasts
(kitty keyboard flags, DA1/DA2, DECRPM, ...). isUserInput counted any
CSI ending in `u` or `~` as a keystroke, so the reply `CSI ? 5 u` from a
passive viewer made it leader and resized the session to its window;
the actual user's client then rendered output laid out for the wrong
width until they pressed a key.

Replies carry a private marker (`?`, `>`, `=`, `<`) that keyboard
encodings never use. Skip those sequences and keep scanning, so a real
keystroke in the same read still counts.
1 files changed,  +39, -0
+39, -0
......@@ -683,8 +683,23 @@ fn parseDecimal(buf: []const u8, pos: *usize) ?u32 {
683683 return value;
684684 }
685685
686+/// True when a CSI carries a private marker (`?`, `>`, `=`, `<`). Keyboard
687+/// encodings never use one, but the replies a terminal sends on its own do:
688+/// kitty keyboard flags (`CSI ? flags u`), DA1 (`CSI ? 62;22 c`), DA2
689+/// (`CSI > 1;10;0 c`), DECRPM (`CSI ? 2026;2 $ y`), color scheme (`CSI ? 997;1 n`).
690+fn hasPrivateMarker(csi: ghostty_vt.Parser.Action.CSI) bool {
691+ for (csi.intermediates) |b| {
692+ if (b == '?' or b == '>' or b == '=' or b == '<') return true;
693+ }
694+ return false;
695+}
696+
686697 /// Detect if the payload contains user input that should be printed to the screen or
687698 /// is a key combination like up-arrow, backspace, enter, ctrl+f, etc.
699+///
700+/// Terminal-generated replies must not count: every attached client's terminal
701+/// answers the queries the app broadcasts, so a reply from a passive viewer
702+/// would otherwise make it leader and resize the session to its window.
688703 pub fn isUserInput(payload: []const u8) bool {
689704 var parser = ghostty_vt.Parser.init();
690705 var i: usize = 0;
......@@ -703,6 +718,9 @@ pub fn isUserInput(payload: []const u8) bool {
703718 switch (action) {
704719 .print => return true, // printable characters
705720 .csi_dispatch => |csi| {
721+ // A reply, not a key: skip it and keep scanning, since a
722+ // real keystroke can arrive in the same read.
723+ if (hasPrivateMarker(csi)) continue;
706724 // kitty keyboard: CSI ... u or CSI ... ~
707725 // legacy modified keys: CSI 27 ; ... ~
708726 // arrow/function keys with modifiers: CSI 1 ; <mod> A-D
......@@ -2116,6 +2134,27 @@ test "isUserInput: focus events excluded" {
21162134 try testing.expect(!isUserInput("\x1b[O")); // focus out
21172135 }
21182136
2137+test "isUserInput: terminal query replies excluded" {
2138+ // Replies every attached terminal sends to queries the app broadcasts.
2139+ try testing.expect(!isUserInput("\x1b[?5u")); // kitty keyboard flags
2140+ try testing.expect(!isUserInput("\x1b[?0u"));
2141+ try testing.expect(!isUserInput("\x1b[?62;22;52c")); // DA1
2142+ try testing.expect(!isUserInput("\x1b[>1;10;0c")); // DA2
2143+ try testing.expect(!isUserInput("\x1b[?2026;2$y")); // DECRPM
2144+ try testing.expect(!isUserInput("\x1b[?997;1n")); // color scheme
2145+ try testing.expect(!isUserInput("\x1b[12;40R")); // cursor position
2146+ try testing.expect(!isUserInput("\x1b[6;32;14t")); // cell size
2147+ // XTVERSION + kitty flags + DA1 in one read, as a passive Ghostty
2148+ // client sent it in the wild (it made that viewer the leader).
2149+ try testing.expect(!isUserInput("\x1bP>|ghostty 1.3.2-main+64f5cc175\x1b\\\x1b[?5u\x1b[?62;22;52c"));
2150+}
2151+
2152+test "isUserInput: key after a reply in the same read still counts" {
2153+ try testing.expect(isUserInput("\x1b[?5ux"));
2154+ try testing.expect(isUserInput("\x1b[?62;22c\x1b[99;5u")); // reply then kitty ctrl+c
2155+ try testing.expect(isUserInput("\x1b[?5u\x1b[3~")); // reply then delete
2156+}
2157+
21192158 test "isUserInput: bracketed paste included" {
21202159 // Bracketed paste start/end are user-initiated paste operations
21212160 try testing.expect(isUserInput("\x1b[200~")); // paste start