diff options
| author | user <user@clank> | 2026-09-09 20:14:03 +0200 |
|---|---|---|
| committer | user <user@clank> | 2026-09-09 20:14:03 +0200 |
| commit | a1e2c1817ac8d967519cd978742f6a59591ff959 (patch) | |
| tree | eca5b858d7c60a18d261d5c64fbc3a45ffcce255 | |
| parent | search: a scrollback search bar on /, matches highlighted (diff) | |
| download | slosh-a1e2c1817ac8d967519cd978742f6a59591ff959.tar.gz slosh-a1e2c1817ac8d967519cd978742f6a59591ff959.tar.xz slosh-a1e2c1817ac8d967519cd978742f6a59591ff959.zip | |
pane: shift consumed by the character is consumed, tell the encoder
935fb7e9 taught the legacy decoder to read the shift out of shifted
punctuation, so / and ? could be two keys. But pane_send_key never set
the consumed mods on the re-encoded event, and libghostty's kitty path
only passes text through as text when no unconsumed modifier remains:
a kitty-protocol pane (nvim) received : as CSI 59;2u -- shift+semicolon,
no text -- and the command line never opened. :wq! was unreachable.
Mark shift consumed when the event carries text, shift is held, and the
text is not the unshifted character -- the same call a real terminal
makes. Text the shift produced goes through as text; chords whose shift
is real (shift+up, ctrl+shift+a, back-tab) keep their escapes, and a
legacy pane sees no change.
| -rw-r--r-- | src/pane.c | 23 | ||||
| -rw-r--r-- | tests/test_screen.py | 25 |
2 files changed, 48 insertions, 0 deletions
@@ -794,6 +794,29 @@ void pane_send_key(pane_t *p, const input_event_t *ev) { ev->text_len); ghostty_key_event_set_unshifted_codepoint(p->kev, ev->unshifted); + /* Shift that produced the character is consumed, and the encoder must be + * told so: its kitty path only passes text through as text when no + * unconsumed modifier remains. Without this, `:` (shift+; in the decoder's + * eyes) reaches a kitty-protocol pane as CSI 59;2u -- shift+semicolon, no + * text -- and nvim's command line never opens. A real terminal marks the + * shift consumed for exactly this case; so do we: text present, shift + * held, and the text is not the unshifted character. */ + GhosttyMods consumed = 0; + if (ev->text_len && (ev->mods & MOD_SHIFT)) { + const uint8_t *t = (const uint8_t *)ev->text; + uint32_t cp = t[0]; + if ((t[0] & 0xe0) == 0xc0 && ev->text_len >= 2) + cp = (uint32_t)(t[0] & 0x1f) << 6 | (t[1] & 0x3f); + else if ((t[0] & 0xf0) == 0xe0 && ev->text_len >= 3) + cp = (uint32_t)(t[0] & 0x0f) << 12 | (uint32_t)(t[1] & 0x3f) << 6 | + (t[2] & 0x3f); + else if ((t[0] & 0xf8) == 0xf0 && ev->text_len >= 4) + cp = (uint32_t)(t[0] & 0x07) << 18 | (uint32_t)(t[1] & 0x3f) << 12 | + (uint32_t)(t[2] & 0x3f) << 6 | (t[3] & 0x3f); + if (cp != ev->unshifted) consumed |= GHOSTTY_MODS_SHIFT; + } + ghostty_key_event_set_consumed_mods(p->kev, consumed); + char out[128]; size_t n = 0; if (ghostty_key_encoder_encode(p->kenc, p->kev, out, sizeof out, &n) == diff --git a/tests/test_screen.py b/tests/test_screen.py index c302785..52dfc96 100644 --- a/tests/test_screen.py +++ b/tests/test_screen.py @@ -180,6 +180,31 @@ def test_input_round_trip(): s.snapshot().screen(), ) + # Shift consumed by the character stays consumed. The decoder reads the + # shift out of `:` (shift+; on the layout the legacy protocol assumes), + # and the encoder must not let that inferred shift force a CSI u escape: + # nvim receives \e[59;2u as shift+semicolon and the command line never + # opens. Text the shift produced goes through as text; a chord whose + # shift is real (shift+up, ctrl+shift+a) keeps its escape. + kitty_dis = [ + "/bin/sh", + "-c", + 'stty raw -echo; printf "\\033[>1u"; cat -v', + ] + with Session(kitty_dis, cols=50, rows=8) as s: + s.settle() + s.send(":A") # legacy shifted punctuation + capital + s.send(r"\e[59:58;2u") # the same colon from a kitty outer + s.send(r"\e[1;2A") # shift+up: not text, keeps the modifier + s.settle() + screen = s.snapshot().screen() + check( + "shifted text reaches a kitty pane as text", + ":A:" in screen and "59" not in screen, + screen, + ) + check("real shift chords keep their escape", "^[[1;2A" in screen, screen) + def test_terminal_replies(): """WRITE_PTY: a query from the app must be answered on its own stdin. |
