aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authoruser <user@clank>2026-09-09 20:14:03 +0200
committeruser <user@clank>2026-09-09 20:14:03 +0200
commita1e2c1817ac8d967519cd978742f6a59591ff959 (patch)
treeeca5b858d7c60a18d261d5c64fbc3a45ffcce255
parentsearch: a scrollback search bar on /, matches highlighted (diff)
downloadslosh-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.c23
-rw-r--r--tests/test_screen.py25
2 files changed, 48 insertions, 0 deletions
diff --git a/src/pane.c b/src/pane.c
index ecff425..47db104 100644
--- a/src/pane.c
+++ b/src/pane.c
@@ -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.