From a157031ceb7a95b59922890016972c2e42154155 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Max=20=F0=9F=91=A8=F0=9F=8F=BD=E2=80=8D=F0=9F=92=BB=20Copl?= =?UTF-8?q?an?= Date: Tue, 21 Jul 2026 17:05:25 -0700 Subject: [PATCH] streampager: fix Shift keys (G, @, capitals) broken in Ghostty Summary: When running under Ghostty (and cmux which uses libghostty) outside of tmux, eden/scm's internal pager did not register any Shift-modified key. Root cause: - `termwiz` `UnixTerminal::set_raw_mode()` enables `modifyOtherKeys=2` (`ESC[>4;2m`). - Ghostty's `key_encode.zig` correctly implements this: `Shift+G` is sent as `ESC[27;2;71~` and `Shift+@` as `ESC[27;2;64~`. - `termwiz` `InputParser` (rev `05343b3870` pinned in `Cargo.toml`) decodes these as `Char('G')+SHIFT` and `Char('@')+SHIFT`. - `streampager`'s keymap (`keymaps/default.rs`) stores `'G'` as `Char('G')+NONE` and `prompt.rs` only accepted `(NONE, Char)`. Exact modifier match failed, so `G`, `@`, and any capital letter search were dropped. tmux masks this because `send-keys -l` sends plain `0x47` (`G+NONE`). Raw hex injection via tmux reproduces the bug: ``` tmux send-keys -H '1b5b32373b323b37317e' # ESC[27;2;71~ Shift+G ``` Old `sl` stays at top, new `sl` goes to bottom. Fix: Normalize input in `Screen::dispatch_key` and `Prompt::dispatch_key` by also trying SHIFT-stripped variants: - `Char(c)+SHIFT -> Char(c)+NONE` - `g+SHIFT -> G+NONE` for case-insensitive handling This mirrors Windows backend `normalize_shift_to_upper_case` and how kitty legacy text keys are supposed to work. It handles both `modifyOtherKeys` (`27;2;71~`) and simple kitty (`71;2u`) forms. The alternate form `103:71;2u` (`report_alternates`) is still unsupported by current `termwiz` keymap and will remain broken until `termwiz` is updated. Test Plan: - `make oss` - builds with `RUSTC_BOOTSTRAP=1` on stable 1.97 - tmux test: ```bash tmux new -d -s t; tmux send-keys -t t './out/sl log -l 5000 --pager=always' # Check plain G goes to bottom, g to top. tmux send-keys -t t -H '1b5b32373b323b37317e' # Shift+G Ghostty modifyOtherKeys # => now goes to bottom (was stuck at top) tmux send-keys -t t -l '/' ; send -H '1b5b32373b323b36347e' # Shift+@ in prompt # => now shows Search: @ (was empty) ``` Verified old `/usr/local/bin/sl` fails, new `out/sl` passes. - `cargo test -p sapling-streampager` still 5 passed. - Manual in Ghostty.app outside tmux: `G` jumps to bottom, `/` `@` search works, capital letters searchable (previously broken). Fixes #1363 Co-authored-by: Opencode --- .../lib/third-party/streampager/src/prompt.rs | 27 ++++++++++--- .../lib/third-party/streampager/src/screen.rs | 39 +++++++++++++++---- 2 files changed, 53 insertions(+), 13 deletions(-) diff --git a/eden/scm/lib/third-party/streampager/src/prompt.rs b/eden/scm/lib/third-party/streampager/src/prompt.rs index 14493dc897b54..0528533364c37 100644 --- a/eden/scm/lib/third-party/streampager/src/prompt.rs +++ b/eden/scm/lib/third-party/streampager/src/prompt.rs @@ -5,7 +5,7 @@ use std::fmt::Write; use termwiz::cell::{AttributeChange, CellAttributes}; use termwiz::color::{AnsiColor, ColorAttribute}; -use termwiz::input::KeyEvent; +use termwiz::input::{KeyCode, KeyEvent}; use termwiz::surface::change::Change; use termwiz::surface::Position; use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; @@ -371,8 +371,21 @@ impl Prompt { const CTRL: Modifiers = Modifiers::CTRL; const NONE: Modifiers = Modifiers::NONE; const ALT: Modifiers = Modifiers::ALT; - let value_width = width - self.prompt.width() - 4; - let action = match (key.modifiers, key.key) { + let mut cands = Vec::with_capacity(4); + cands.push(key.clone()); + if key.modifiers.contains(Modifiers::SHIFT) { + if let KeyCode::Char(c) = key.key { + let mut ns = key.modifiers; + ns.remove(Modifiers::SHIFT); + cands.push(KeyEvent { key: KeyCode::Char(c), modifiers: ns }); + if c.is_ascii_lowercase() { + cands.push(KeyEvent { key: KeyCode::Char(c.to_ascii_uppercase()), modifiers: ns }); + } + } + } + for (mods, k) in cands.into_iter().map(|ke| (ke.modifiers, ke.key)) { + let value_width = width - self.prompt.width() - 4; + let action = match (mods, k.clone()) { (NONE, Enter) | (CTRL, Char('j')) | (CTRL, Char('m')) => { // Finish. let _ = self.history.save(); @@ -410,10 +423,12 @@ impl Prompt { (CTRL, Char('t')) => self.state_mut().transpose_chars(), (NONE, UpArrow) => self.history.previous(), (NONE, DownArrow) => self.history.next(), - _ => return DisplayAction::None, + _ => continue, }; - self.state_mut().clamp_offset(value_width); - action + self.state_mut().clamp_offset(value_width); + return action; + } + DisplayAction::None } /// Paste some text into the prompt. diff --git a/eden/scm/lib/third-party/streampager/src/screen.rs b/eden/scm/lib/third-party/streampager/src/screen.rs index cea50925280c2..419795dc3629b 100644 --- a/eden/scm/lib/third-party/streampager/src/screen.rs +++ b/eden/scm/lib/third-party/streampager/src/screen.rs @@ -1241,19 +1241,44 @@ impl Screen { key: KeyEvent, event_sender: &EventSender, ) -> DisplayAction { - if let Some(binding) = self.keymap.get(key.modifiers, key.key) { - match binding { - Binding::Action(action) => { - let action = action.clone(); - return self.dispatch_action(action, event_sender); + // Ghostty with modifyOtherKeys enabled (termwiz enables ESC[>4;2m in set_raw_mode) + // encodes Shift+G as ESC[27;2;71~ which termwiz decodes as Char('G')+SHIFT. + // Our keymap stores 'G' as Char('G')+NONE, so we normalize by stripping SHIFT. + for candidate in Self::normalize_key_candidates(key) { + if let Some(binding) = self.keymap.get(candidate.modifiers, candidate.key) { + match binding { + Binding::Action(action) => { + let action = action.clone(); + return self.dispatch_action(action, event_sender); + } + Binding::Custom(b) => { + b.run(self.file.index()); + return DisplayAction::Render; + } + Binding::Unrecognized(_) => {} } - Binding::Custom(b) => b.run(self.file.index()), - Binding::Unrecognized(_) => {} } } DisplayAction::Render } + fn normalize_key_candidates(key: KeyEvent) -> Vec { + use termwiz::input::{KeyCode, Modifiers}; + let mut out = Vec::with_capacity(4); + out.push(key.clone()); + if key.modifiers.contains(Modifiers::SHIFT) { + if let KeyCode::Char(c) = key.key { + let mut no_shift = key.modifiers; + no_shift.remove(Modifiers::SHIFT); + out.push(KeyEvent { key: KeyCode::Char(c), modifiers: no_shift }); + if c.is_ascii_lowercase() { + out.push(KeyEvent { key: KeyCode::Char(c.to_ascii_uppercase()), modifiers: no_shift }); + } + } + } + out + } + /// Append a digit to the repeat count. pub(crate) fn append_digit_to_repeat_count(&mut self, digit: usize) { assert!(digit < 10);