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 { | |
| 683 | 683 | return value; | |
| 684 | 684 | } | |
| 685 | 685 | ||
| 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 | + | ||
| 686 | 697 | /// Detect if the payload contains user input that should be printed to the screen or | |
| 687 | 698 | /// 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. | |
| 688 | 703 | pub fn isUserInput(payload: []const u8) bool { | |
| 689 | 704 | var parser = ghostty_vt.Parser.init(); | |
| 690 | 705 | var i: usize = 0; |
| ... | ... | @@ -703,6 +718,9 @@ pub fn isUserInput(payload: []const u8) bool { | |
| 703 | 718 | switch (action) { | |
| 704 | 719 | .print => return true, // printable characters | |
| 705 | 720 | .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; | |
| 706 | 724 | // kitty keyboard: CSI ... u or CSI ... ~ | |
| 707 | 725 | // legacy modified keys: CSI 27 ; ... ~ | |
| 708 | 726 | // arrow/function keys with modifiers: CSI 1 ; <mod> A-D |
| ... | ... | @@ -2116,6 +2134,27 @@ test "isUserInput: focus events excluded" { | |
| 2116 | 2134 | try testing.expect(!isUserInput("\x1b[O")); // focus out | |
| 2117 | 2135 | } | |
| 2118 | 2136 | ||
| 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 | + | ||
| 2119 | 2158 | test "isUserInput: bracketed paste included" { | |
| 2120 | 2159 | // Bracketed paste start/end are user-initiated paste operations | |
| 2121 | 2160 | try testing.expect(isUserInput("\x1b[200~")); // paste start |