detached 83ef129

fix(keys): pass a legacy Escape before the kitty switch and keep lock bits on CSI u keys

Hold a lone trailing ESC only once a CSI u key event proved the terminal
encodes Escape as CSI 27 u, stop normalizing when the flag push fails,
and keep lock bits on every key sent as an escape code.
commit
83ef12902012f505d98906534641c43e6a734ee4
author
brice · · 2026-10-15 03:35 UTC
parent
a9ec34a
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +27 -9
M internal/usecase/client/supervisor.go +1 -0
@@ -1726,6 +1726,7 @@ func (s *Supervisor) detectTerminalCapabilities(ctx context.Context, pump *termi
17261726 // Normalize before the terminal switches, so no read escapes it.
17271727 pump.keys.enable()
17281728 if err := s.cfg.Terminal.EnableKittyKeyboard(kittykey.RequestFlags); err != nil {
1729+ pump.keys.disable()
17291730 s.logger.Warn("enabling kitty keyboard protocol failed", "err", err)
17301731 caps.KittyKeyboard = false
17311732 }
@@ -1726,6 +1726,7 @@ func (s *Supervisor) detectTerminalCapabilities(ctx context.Context, pump *termi
1726 // Normalize before the terminal switches, so no read escapes it.1726 // Normalize before the terminal switches, so no read escapes it.
1727 pump.keys.enable()1727 pump.keys.enable()
1728 if err := s.cfg.Terminal.EnableKittyKeyboard(kittykey.RequestFlags); err != nil {1728 if err := s.cfg.Terminal.EnableKittyKeyboard(kittykey.RequestFlags); err != nil {
1729+ pump.keys.disable()
1729 s.logger.Warn("enabling kitty keyboard protocol failed", "err", err)1730 s.logger.Warn("enabling kitty keyboard protocol failed", "err", err)
1730 caps.KittyKeyboard = false1731 caps.KittyKeyboard = false
1731 }1732 }
M internal/usecase/client/terminal_keys.go +3 -0
@@ -39,6 +39,9 @@ func newTerminalKeys() *terminalKeys {
3939 // kittykey.RequestFlags; input still in the old encoding passes unchanged.
4040 func (k *terminalKeys) enable() { k.enabled.Store(true) }
4141
42+// disable stops normalizing, for a terminal that refused the flags.
43+func (k *terminalKeys) disable() { k.enabled.Store(false) }
44+
4245 // normalize rewrites one read. focus is the terminal focus after the read:
4346 // a window that lost focus may never report the Ctrl release.
4447 func (k *terminalKeys) normalize(data []byte, focus domain.TerminalFocus) []byte {
@@ -39,6 +39,9 @@ func newTerminalKeys() *terminalKeys {
39 // kittykey.RequestFlags; input still in the old encoding passes unchanged.39 // kittykey.RequestFlags; input still in the old encoding passes unchanged.
40 func (k *terminalKeys) enable() { k.enabled.Store(true) }40 func (k *terminalKeys) enable() { k.enabled.Store(true) }
41 41
42+// disable stops normalizing, for a terminal that refused the flags.
43+func (k *terminalKeys) disable() { k.enabled.Store(false) }
44+
42 // normalize rewrites one read. focus is the terminal focus after the read:45 // normalize rewrites one read. focus is the terminal focus after the read:
43 // a window that lost focus may never report the Ctrl release.46 // a window that lost focus may never report the Ctrl release.
44 func (k *terminalKeys) normalize(data []byte, focus domain.TerminalFocus) []byte {47 func (k *terminalKeys) normalize(data []byte, focus domain.TerminalFocus) []byte {
M internal/usecase/keys/kittykey/normalize.go +13 -6
@@ -28,14 +28,17 @@ const (
2828 //
2929 // Input already in the OuterFlags or legacy encoding passes through
3030 // unchanged, so enabling a Normalizer before the terminal switches flags is
31-// safe. A trailing incomplete escape sequence, a lone ESC included, is held
32-// until the next call: with FlagDisambiguate the Escape key itself arrives as
33-// CSI 27 u, so a lone ESC only ends a read split inside a sequence.
31+// safe. A trailing incomplete escape sequence is held until the next call. A
32+// lone trailing ESC is held only once a CSI u key event proved the terminal
33+// reports Escape as CSI 27 u; before that it may be a real Escape press.
3434 // A Normalizer is not safe for concurrent use.
3535 type Normalizer struct {
3636 pending []byte
3737 paste bool
3838 ctrl bool
39+ // kitty records that a CSI u key event arrived, so the terminal encodes
40+ // Escape itself as one and a lone ESC can only start a split sequence.
41+ kitty bool
3942 }
4043
4144 // CtrlHeld reports whether Ctrl is held on its own: a Ctrl key is down and no
@@ -82,6 +85,10 @@ func (n *Normalizer) Normalize(data []byte) []byte {
8285 continue
8386 }
8487 params, final, size, partial := scanCSI(rest)
88+ if partial && len(rest) == 1 && !n.kitty {
89+ out = append(out, rest...)
90+ return out
91+ }
8592 if partial {
8693 n.hold(rest)
8794 return out
@@ -148,6 +155,7 @@ func (n *Normalizer) rewrite(params []byte, final byte) ([]byte, bool) {
148155 if !ok {
149156 return nil, false
150157 }
158+ n.kitty = true
151159 n.track(key.ev, true)
152160 return encodeOuter(key), true
153161 case '~', 'A', 'B', 'C', 'D', 'E', 'F', 'H', 'P', 'Q', 'S':
@@ -225,10 +233,9 @@ func encodeOuter(key fullKey) []byte {
225233 return []byte{byte(ev.Code)}
226234 case ev.Mods&^ModShift == 0 && isTextKey(ev.Code):
227235 return legacyText(ev)
228- case !isTextKey(ev.Code):
229- // Lock bits are reported for keys that do not produce text.
230- ev.Mods |= key.locks
231236 }
237+ // A key sent as an escape code reports the lock bits too.
238+ ev.Mods |= key.locks
232239 return encodeCSIu(ev, OuterFlags)
233240 }
234241
@@ -28,14 +28,17 @@ const (
28 //28 //
29 // Input already in the OuterFlags or legacy encoding passes through29 // Input already in the OuterFlags or legacy encoding passes through
30 // unchanged, so enabling a Normalizer before the terminal switches flags is30 // unchanged, so enabling a Normalizer before the terminal switches flags is
31-// safe. A trailing incomplete escape sequence, a lone ESC included, is held31+// safe. A trailing incomplete escape sequence is held until the next call. A
32-// until the next call: with FlagDisambiguate the Escape key itself arrives as32+// lone trailing ESC is held only once a CSI u key event proved the terminal
33-// CSI 27 u, so a lone ESC only ends a read split inside a sequence.33+// reports Escape as CSI 27 u; before that it may be a real Escape press.
34 // A Normalizer is not safe for concurrent use.34 // A Normalizer is not safe for concurrent use.
35 type Normalizer struct {35 type Normalizer struct {
36 pending []byte36 pending []byte
37 paste bool37 paste bool
38 ctrl bool38 ctrl bool
39+ // kitty records that a CSI u key event arrived, so the terminal encodes
40+ // Escape itself as one and a lone ESC can only start a split sequence.
41+ kitty bool
39 }42 }
40 43
41 // CtrlHeld reports whether Ctrl is held on its own: a Ctrl key is down and no44 // CtrlHeld reports whether Ctrl is held on its own: a Ctrl key is down and no
@@ -82,6 +85,10 @@ func (n *Normalizer) Normalize(data []byte) []byte {
82 continue85 continue
83 }86 }
84 params, final, size, partial := scanCSI(rest)87 params, final, size, partial := scanCSI(rest)
88+ if partial && len(rest) == 1 && !n.kitty {
89+ out = append(out, rest...)
90+ return out
91+ }
85 if partial {92 if partial {
86 n.hold(rest)93 n.hold(rest)
87 return out94 return out
@@ -148,6 +155,7 @@ func (n *Normalizer) rewrite(params []byte, final byte) ([]byte, bool) {
148 if !ok {155 if !ok {
149 return nil, false156 return nil, false
150 }157 }
158+ n.kitty = true
151 n.track(key.ev, true)159 n.track(key.ev, true)
152 return encodeOuter(key), true160 return encodeOuter(key), true
153 case '~', 'A', 'B', 'C', 'D', 'E', 'F', 'H', 'P', 'Q', 'S':161 case '~', 'A', 'B', 'C', 'D', 'E', 'F', 'H', 'P', 'Q', 'S':
@@ -225,10 +233,9 @@ func encodeOuter(key fullKey) []byte {
225 return []byte{byte(ev.Code)}233 return []byte{byte(ev.Code)}
226 case ev.Mods&^ModShift == 0 && isTextKey(ev.Code):234 case ev.Mods&^ModShift == 0 && isTextKey(ev.Code):
227 return legacyText(ev)235 return legacyText(ev)
228- case !isTextKey(ev.Code):
229- // Lock bits are reported for keys that do not produce text.
230- ev.Mods |= key.locks
231 }236 }
237+ // A key sent as an escape code reports the lock bits too.
238+ ev.Mods |= key.locks
232 return encodeCSIu(ev, OuterFlags)239 return encodeCSIu(ev, OuterFlags)
233 }240 }
234 241
M internal/usecase/keys/kittykey/normalize_test.go +10 -3
@@ -36,7 +36,8 @@ func TestNormalizerRewritesRequestFlagsToOuterFlags(t *testing.T) {
3636 {name: "altgr text wins over modifiers", in: "\x1b[113;7;64u", want: "@"},
3737 {name: "long ime commit", in: "\x1b[0;;" + strings.Repeat("19968:", 11) + "19968u", want: strings.Repeat("一", 12)},
3838 {name: "caps lock dropped from text key", in: "\x1b[97;65;65u", want: "A"},
39- {name: "caps lock kept on ctrl letter", in: "\x1b[97;69u", want: "\x1b[97;5u"},
39+ {name: "caps lock kept on ctrl letter", in: "\x1b[97;69u", want: "\x1b[97;69u"},
40+ {name: "both locks kept on alt letter", in: "\x1b[97;195u", want: "\x1b[97;195u"},
4041 {name: "num lock kept on escape", in: "\x1b[27;129u", want: "\x1b[27;129u"},
4142 {name: "caps lock kept on arrow", in: "\x1b[1;65A", want: "\x1b[1;65A"},
4243 {name: "caps lock arrow repeat", in: "\x1b[1;65:2A", want: "\x1b[1;65A"},
@@ -57,7 +58,8 @@ func TestNormalizerRewritesRequestFlagsToOuterFlags(t *testing.T) {
5758 {name: "keyboard reply untouched", in: "\x1b[?5u", want: "\x1b[?5u"},
5859 {name: "cursor report untouched", in: "\x1b[12;40R", want: "\x1b[12;40R"},
5960 {name: "paste body untouched", in: "\x1b[200~\x1b[97;1:3u\x1b[201~\x1b[97;1:3u", want: "\x1b[200~\x1b[97;1:3u\x1b[201~"},
60- {name: "lone esc held", in: "x\x1b", want: "x"},
61+ {name: "lone esc passes before any kitty key", in: "x\x1b", want: "x\x1b"},
62+ {name: "lone esc held after a kitty key", in: "\x1b[97;;97u\x1b", want: "a"},
6163 {name: "trailing garbage", in: "\x1b[97;;97u\x1b[zz", want: "a\x1b[zz"},
6264 {name: "bad event type kept", in: "\x1b[97;1:4u", want: "\x1b[97;1:4u"},
6365 }
@@ -78,7 +80,8 @@ func TestNormalizerHoldsSplitSequences(t *testing.T) {
7880 {name: "split csi u", reads: []string{"a\x1b[97;", ";98u"}, want: []string{"a", "b"}},
7981 {name: "split csi prefix", reads: []string{"\x1b[", "57442;1:3u"}, want: []string{"", ""}},
8082 {name: "split paste close", reads: []string{"\x1b[200~x\x1b[20", "1~\x1b[97;;97u"}, want: []string{"\x1b[200~x", "\x1b[201~a"}},
81- {name: "split after esc", reads: []string{"\x1b", "[57442;5u", "\x1b", "[97;;97u"}, want: []string{"", "", "", "a"}},
83+ {name: "split after esc", reads: []string{"\x1b[27u\x1b", "[57442;5u", "\x1b", "[97;;97u"}, want: []string{"\x1b[27u", "", "", "a"}},
84+ {name: "legacy escape before the switch", reads: []string{"\x1b", "\x1b[97;;97u"}, want: []string{"\x1b", "a"}},
8285 {name: "split paste open", reads: []string{"\x1b[20", "0~\x1b[97;1:3u\x1b[201~"}, want: []string{"", "\x1b[200~\x1b[97;1:3u\x1b[201~"}},
8386 }
8487 for _, tt := range tests {
@@ -94,6 +97,8 @@ func TestNormalizerHoldsSplitSequences(t *testing.T) {
9497 // TestNormalizerEverySplitMatchesOneRead feeds each input split at every byte
9598 // and requires the same output and Ctrl state as one read.
9699 func TestNormalizerEverySplitMatchesOneRead(t *testing.T) {
100+ // Each input starts with a kitty key, as a terminal in RequestFlags mode
101+ // sends before any split can matter.
97102 inputs := []string{
98103 "\x1b[57442;5u\x1b[49;5u\x1b[49;5:3u",
99104 "a\x1b[97;;97u\x1b[57442;1:3u",
@@ -102,9 +107,11 @@ func TestNormalizerEverySplitMatchesOneRead(t *testing.T) {
102107 }
103108 for _, in := range inputs {
104109 var whole Normalizer
110+ whole.Normalize([]byte("\x1b[27u"))
105111 want := string(whole.Normalize([]byte(in)))
106112 for cut := 1; cut < len(in); cut++ {
107113 var n Normalizer
114+ n.Normalize([]byte("\x1b[27u"))
108115 got := string(n.Normalize([]byte(in[:cut]))) + string(n.Normalize([]byte(in[cut:])))
109116 require.Equal(t, want, got, "input %q cut %d", in, cut)
110117 require.Equal(t, whole.CtrlHeld(), n.CtrlHeld(), "input %q cut %d", in, cut)
@@ -36,7 +36,8 @@ func TestNormalizerRewritesRequestFlagsToOuterFlags(t *testing.T) {
36 {name: "altgr text wins over modifiers", in: "\x1b[113;7;64u", want: "@"},36 {name: "altgr text wins over modifiers", in: "\x1b[113;7;64u", want: "@"},
37 {name: "long ime commit", in: "\x1b[0;;" + strings.Repeat("19968:", 11) + "19968u", want: strings.Repeat("一", 12)},37 {name: "long ime commit", in: "\x1b[0;;" + strings.Repeat("19968:", 11) + "19968u", want: strings.Repeat("一", 12)},
38 {name: "caps lock dropped from text key", in: "\x1b[97;65;65u", want: "A"},38 {name: "caps lock dropped from text key", in: "\x1b[97;65;65u", want: "A"},
39- {name: "caps lock kept on ctrl letter", in: "\x1b[97;69u", want: "\x1b[97;5u"},39+ {name: "caps lock kept on ctrl letter", in: "\x1b[97;69u", want: "\x1b[97;69u"},
40+ {name: "both locks kept on alt letter", in: "\x1b[97;195u", want: "\x1b[97;195u"},
40 {name: "num lock kept on escape", in: "\x1b[27;129u", want: "\x1b[27;129u"},41 {name: "num lock kept on escape", in: "\x1b[27;129u", want: "\x1b[27;129u"},
41 {name: "caps lock kept on arrow", in: "\x1b[1;65A", want: "\x1b[1;65A"},42 {name: "caps lock kept on arrow", in: "\x1b[1;65A", want: "\x1b[1;65A"},
42 {name: "caps lock arrow repeat", in: "\x1b[1;65:2A", want: "\x1b[1;65A"},43 {name: "caps lock arrow repeat", in: "\x1b[1;65:2A", want: "\x1b[1;65A"},
@@ -57,7 +58,8 @@ func TestNormalizerRewritesRequestFlagsToOuterFlags(t *testing.T) {
57 {name: "keyboard reply untouched", in: "\x1b[?5u", want: "\x1b[?5u"},58 {name: "keyboard reply untouched", in: "\x1b[?5u", want: "\x1b[?5u"},
58 {name: "cursor report untouched", in: "\x1b[12;40R", want: "\x1b[12;40R"},59 {name: "cursor report untouched", in: "\x1b[12;40R", want: "\x1b[12;40R"},
59 {name: "paste body untouched", in: "\x1b[200~\x1b[97;1:3u\x1b[201~\x1b[97;1:3u", want: "\x1b[200~\x1b[97;1:3u\x1b[201~"},60 {name: "paste body untouched", in: "\x1b[200~\x1b[97;1:3u\x1b[201~\x1b[97;1:3u", want: "\x1b[200~\x1b[97;1:3u\x1b[201~"},
60- {name: "lone esc held", in: "x\x1b", want: "x"},61+ {name: "lone esc passes before any kitty key", in: "x\x1b", want: "x\x1b"},
62+ {name: "lone esc held after a kitty key", in: "\x1b[97;;97u\x1b", want: "a"},
61 {name: "trailing garbage", in: "\x1b[97;;97u\x1b[zz", want: "a\x1b[zz"},63 {name: "trailing garbage", in: "\x1b[97;;97u\x1b[zz", want: "a\x1b[zz"},
62 {name: "bad event type kept", in: "\x1b[97;1:4u", want: "\x1b[97;1:4u"},64 {name: "bad event type kept", in: "\x1b[97;1:4u", want: "\x1b[97;1:4u"},
63 }65 }
@@ -78,7 +80,8 @@ func TestNormalizerHoldsSplitSequences(t *testing.T) {
78 {name: "split csi u", reads: []string{"a\x1b[97;", ";98u"}, want: []string{"a", "b"}},80 {name: "split csi u", reads: []string{"a\x1b[97;", ";98u"}, want: []string{"a", "b"}},
79 {name: "split csi prefix", reads: []string{"\x1b[", "57442;1:3u"}, want: []string{"", ""}},81 {name: "split csi prefix", reads: []string{"\x1b[", "57442;1:3u"}, want: []string{"", ""}},
80 {name: "split paste close", reads: []string{"\x1b[200~x\x1b[20", "1~\x1b[97;;97u"}, want: []string{"\x1b[200~x", "\x1b[201~a"}},82 {name: "split paste close", reads: []string{"\x1b[200~x\x1b[20", "1~\x1b[97;;97u"}, want: []string{"\x1b[200~x", "\x1b[201~a"}},
81- {name: "split after esc", reads: []string{"\x1b", "[57442;5u", "\x1b", "[97;;97u"}, want: []string{"", "", "", "a"}},83+ {name: "split after esc", reads: []string{"\x1b[27u\x1b", "[57442;5u", "\x1b", "[97;;97u"}, want: []string{"\x1b[27u", "", "", "a"}},
84+ {name: "legacy escape before the switch", reads: []string{"\x1b", "\x1b[97;;97u"}, want: []string{"\x1b", "a"}},
82 {name: "split paste open", reads: []string{"\x1b[20", "0~\x1b[97;1:3u\x1b[201~"}, want: []string{"", "\x1b[200~\x1b[97;1:3u\x1b[201~"}},85 {name: "split paste open", reads: []string{"\x1b[20", "0~\x1b[97;1:3u\x1b[201~"}, want: []string{"", "\x1b[200~\x1b[97;1:3u\x1b[201~"}},
83 }86 }
84 for _, tt := range tests {87 for _, tt := range tests {
@@ -94,6 +97,8 @@ func TestNormalizerHoldsSplitSequences(t *testing.T) {
94 // TestNormalizerEverySplitMatchesOneRead feeds each input split at every byte97 // TestNormalizerEverySplitMatchesOneRead feeds each input split at every byte
95 // and requires the same output and Ctrl state as one read.98 // and requires the same output and Ctrl state as one read.
96 func TestNormalizerEverySplitMatchesOneRead(t *testing.T) {99 func TestNormalizerEverySplitMatchesOneRead(t *testing.T) {
100+ // Each input starts with a kitty key, as a terminal in RequestFlags mode
101+ // sends before any split can matter.
97 inputs := []string{102 inputs := []string{
98 "\x1b[57442;5u\x1b[49;5u\x1b[49;5:3u",103 "\x1b[57442;5u\x1b[49;5u\x1b[49;5:3u",
99 "a\x1b[97;;97u\x1b[57442;1:3u",104 "a\x1b[97;;97u\x1b[57442;1:3u",
@@ -102,9 +107,11 @@ func TestNormalizerEverySplitMatchesOneRead(t *testing.T) {
102 }107 }
103 for _, in := range inputs {108 for _, in := range inputs {
104 var whole Normalizer109 var whole Normalizer
110+ whole.Normalize([]byte("\x1b[27u"))
105 want := string(whole.Normalize([]byte(in)))111 want := string(whole.Normalize([]byte(in)))
106 for cut := 1; cut < len(in); cut++ {112 for cut := 1; cut < len(in); cut++ {
107 var n Normalizer113 var n Normalizer
114+ n.Normalize([]byte("\x1b[27u"))
108 got := string(n.Normalize([]byte(in[:cut]))) + string(n.Normalize([]byte(in[cut:])))115 got := string(n.Normalize([]byte(in[:cut]))) + string(n.Normalize([]byte(in[cut:])))
109 require.Equal(t, want, got, "input %q cut %d", in, cut)116 require.Equal(t, want, got, "input %q cut %d", in, cut)
110 require.Equal(t, whole.CtrlHeld(), n.CtrlHeld(), "input %q cut %d", in, cut)117 require.Equal(t, whole.CtrlHeld(), n.CtrlHeld(), "input %q cut %d", in, cut)

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close