detached 8f2d811

feat(vt): track kitty keyboard enhancement flags (#34)

Handle CSI > u (push), CSI < u (pop), CSI = u (set/or/clear) and
CSI ? u (report) with a bounded per-screen stack. Main and alternate
screens keep independent stacks and RIS clears them. Expose the active
flags through Screen.KittyKeyboardFlags and ModeSnapshot.KittyKeyboard.

terminalquery.Probe now recognizes the CSI ? <flags> u response to
KittyKeyboardQuery and reports it through KittyKeyboard.
commit
8f2d811b26b29679c50952b12db6dcb8b5badeb2
author
bnema · · 2026-09-27 14:10 UTC
committer
GitHub · · 2026-09-27 14:10 UTC
parent
31cc24f
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
9 files · +260 -1
M protocol/terminalquery/query.go +28 -0
@@ -14,6 +14,10 @@ const (
1414 // Kitty query so a graphics declaration requires both terminal identity and
1515 // protocol support.
1616 DeviceAttributesQuery = "\x1b[c"
17+ // KittyKeyboardQuery asks for the current kitty keyboard enhancement
18+ // flags. Send it before DeviceAttributesQuery: every terminal answers DA1,
19+ // so a DA1 response without a keyboard response means no support.
20+ KittyKeyboardQuery = "\x1b[?u"
1721
1822 maxProbeResponseBytes = 256
1923 )
@@ -26,6 +30,8 @@ type Probe struct {
2630 pending []byte
2731 kitty bool
2832 da1 bool
33+ // keyboard is set once a KittyKeyboardQuery response was observed.
34+ keyboard bool
2935 }
3036
3137 // Feed consumes one terminal input fragment and returns unrelated input. The
@@ -62,6 +68,10 @@ func (p *Probe) Ready() bool { return p != nil && p.kitty && p.da1 }
6268 // KittyGraphics reports whether the Kitty graphics response was observed.
6369 func (p *Probe) KittyGraphics() bool { return p != nil && p.kitty }
6470
71+// KittyKeyboard reports whether a kitty keyboard protocol response was
72+// observed. It is meaningful once DA1 is true.
73+func (p *Probe) KittyKeyboard() bool { return p != nil && p.keyboard }
74+
6575 // DA1 reports whether a primary device-attributes response was observed.
6676 func (p *Probe) DA1() bool { return p != nil && p.da1 }
6777
@@ -132,6 +142,11 @@ func (p *Probe) scan(flush bool) []byte {
132142 p.pending = p.pending[end:]
133143 continue
134144 }
145+ if validKittyKeyboardResponse(response) {
146+ p.keyboard = true
147+ p.pending = p.pending[end:]
148+ continue
149+ }
135150 unrelated = append(unrelated, p.pending[0])
136151 p.pending = p.pending[1:]
137152 }
@@ -213,3 +228,16 @@ func min(a, b int) int {
213228 }
214229 return b
215230 }
231+
232+// validKittyKeyboardResponse accepts ESC [ ? <decimal flags> u.
233+func validKittyKeyboardResponse(data []byte) bool {
234+ if len(data) < 5 || !bytes.HasPrefix(data, []byte("\x1b[?")) || data[len(data)-1] != 'u' {
235+ return false
236+ }
237+ for _, b := range data[3 : len(data)-1] {
238+ if b < '0' || b > '9' {
239+ return false
240+ }
241+ }
242+ return true
243+}
@@ -14,6 +14,10 @@ const (
14 // Kitty query so a graphics declaration requires both terminal identity and14 // Kitty query so a graphics declaration requires both terminal identity and
15 // protocol support.15 // protocol support.
16 DeviceAttributesQuery = "\x1b[c"16 DeviceAttributesQuery = "\x1b[c"
17+ // KittyKeyboardQuery asks for the current kitty keyboard enhancement
18+ // flags. Send it before DeviceAttributesQuery: every terminal answers DA1,
19+ // so a DA1 response without a keyboard response means no support.
20+ KittyKeyboardQuery = "\x1b[?u"
17 21
18 maxProbeResponseBytes = 25622 maxProbeResponseBytes = 256
19 )23 )
@@ -26,6 +30,8 @@ type Probe struct {
26 pending []byte30 pending []byte
27 kitty bool31 kitty bool
28 da1 bool32 da1 bool
33+ // keyboard is set once a KittyKeyboardQuery response was observed.
34+ keyboard bool
29 }35 }
30 36
31 // Feed consumes one terminal input fragment and returns unrelated input. The37 // Feed consumes one terminal input fragment and returns unrelated input. The
@@ -62,6 +68,10 @@ func (p *Probe) Ready() bool { return p != nil && p.kitty && p.da1 }
62 // KittyGraphics reports whether the Kitty graphics response was observed.68 // KittyGraphics reports whether the Kitty graphics response was observed.
63 func (p *Probe) KittyGraphics() bool { return p != nil && p.kitty }69 func (p *Probe) KittyGraphics() bool { return p != nil && p.kitty }
64 70
71+// KittyKeyboard reports whether a kitty keyboard protocol response was
72+// observed. It is meaningful once DA1 is true.
73+func (p *Probe) KittyKeyboard() bool { return p != nil && p.keyboard }
74+
65 // DA1 reports whether a primary device-attributes response was observed.75 // DA1 reports whether a primary device-attributes response was observed.
66 func (p *Probe) DA1() bool { return p != nil && p.da1 }76 func (p *Probe) DA1() bool { return p != nil && p.da1 }
67 77
@@ -132,6 +142,11 @@ func (p *Probe) scan(flush bool) []byte {
132 p.pending = p.pending[end:]142 p.pending = p.pending[end:]
133 continue143 continue
134 }144 }
145+ if validKittyKeyboardResponse(response) {
146+ p.keyboard = true
147+ p.pending = p.pending[end:]
148+ continue
149+ }
135 unrelated = append(unrelated, p.pending[0])150 unrelated = append(unrelated, p.pending[0])
136 p.pending = p.pending[1:]151 p.pending = p.pending[1:]
137 }152 }
@@ -213,3 +228,16 @@ func min(a, b int) int {
213 }228 }
214 return b229 return b
215 }230 }
231+
232+// validKittyKeyboardResponse accepts ESC [ ? <decimal flags> u.
233+func validKittyKeyboardResponse(data []byte) bool {
234+ if len(data) < 5 || !bytes.HasPrefix(data, []byte("\x1b[?")) || data[len(data)-1] != 'u' {
235+ return false
236+ }
237+ for _, b := range data[3 : len(data)-1] {
238+ if b < '0' || b > '9' {
239+ return false
240+ }
241+ }
242+ return true
243+}
M protocol/terminalquery/query_test.go +31 -0
@@ -81,3 +81,34 @@ func TestProbeBoundsUnterminatedResponse(t *testing.T) {
8181 require.False(t, p.Ready())
8282 require.Equal(t, input, got)
8383 }
84+
85+func TestProbeRecognizesKittyKeyboardResponse(t *testing.T) {
86+ for _, tc := range []struct {
87+ name string
88+ fragments []string
89+ keyboard bool
90+ da1 bool
91+ unrelated string
92+ }{
93+ {name: "supported", fragments: []string{"\x1b[?0u\x1b[?62;c"}, keyboard: true, da1: true},
94+ {name: "supported with flags", fragments: []string{"\x1b[?15u"}, keyboard: true},
95+ {name: "unsupported", fragments: []string{"\x1b[?62;22c"}, da1: true},
96+ {name: "fragmented", fragments: []string{"a\x1b", "[?", "1", "u", "b"}, keyboard: true, unrelated: "ab"},
97+ {name: "empty flags rejected", fragments: []string{"\x1b[?u"}, unrelated: "\x1b[?u"},
98+ {name: "non digit rejected", fragments: []string{"\x1b[?1;2u"}, unrelated: "\x1b[?1;2u"},
99+ {name: "no private marker rejected", fragments: []string{"\x1b[1u"}, unrelated: "\x1b[1u"},
100+ {name: "truncated flushes", fragments: []string{"\x1b[?1"}, unrelated: "\x1b[?1"},
101+ } {
102+ t.Run(tc.name, func(t *testing.T) {
103+ var p Probe
104+ var got []byte
105+ for _, f := range tc.fragments {
106+ got = append(got, p.Feed([]byte(f))...)
107+ }
108+ got = append(got, p.Finish()...)
109+ require.Equal(t, tc.keyboard, p.KittyKeyboard())
110+ require.Equal(t, tc.da1, p.DA1())
111+ require.Equal(t, tc.unrelated, string(got))
112+ })
113+ }
114+}
@@ -81,3 +81,34 @@ func TestProbeBoundsUnterminatedResponse(t *testing.T) {
81 require.False(t, p.Ready())81 require.False(t, p.Ready())
82 require.Equal(t, input, got)82 require.Equal(t, input, got)
83 }83 }
84+
85+func TestProbeRecognizesKittyKeyboardResponse(t *testing.T) {
86+ for _, tc := range []struct {
87+ name string
88+ fragments []string
89+ keyboard bool
90+ da1 bool
91+ unrelated string
92+ }{
93+ {name: "supported", fragments: []string{"\x1b[?0u\x1b[?62;c"}, keyboard: true, da1: true},
94+ {name: "supported with flags", fragments: []string{"\x1b[?15u"}, keyboard: true},
95+ {name: "unsupported", fragments: []string{"\x1b[?62;22c"}, da1: true},
96+ {name: "fragmented", fragments: []string{"a\x1b", "[?", "1", "u", "b"}, keyboard: true, unrelated: "ab"},
97+ {name: "empty flags rejected", fragments: []string{"\x1b[?u"}, unrelated: "\x1b[?u"},
98+ {name: "non digit rejected", fragments: []string{"\x1b[?1;2u"}, unrelated: "\x1b[?1;2u"},
99+ {name: "no private marker rejected", fragments: []string{"\x1b[1u"}, unrelated: "\x1b[1u"},
100+ {name: "truncated flushes", fragments: []string{"\x1b[?1"}, unrelated: "\x1b[?1"},
101+ } {
102+ t.Run(tc.name, func(t *testing.T) {
103+ var p Probe
104+ var got []byte
105+ for _, f := range tc.fragments {
106+ got = append(got, p.Feed([]byte(f))...)
107+ }
108+ got = append(got, p.Finish()...)
109+ require.Equal(t, tc.keyboard, p.KittyKeyboard())
110+ require.Equal(t, tc.da1, p.DA1())
111+ require.Equal(t, tc.unrelated, string(got))
112+ })
113+ }
114+}
M query_test.go +1 -1
@@ -33,7 +33,7 @@ func TestScreenQueryResponses(t *testing.T) {
3333 {name: "color scheme DSR dark", input: "\x1b[?996n", want: ColorSchemeReportDark},
3434 {name: "color scheme DSR light", input: "\x1b[?996n", want: ColorSchemeReportLight},
3535 {name: "DECRQM unknown mode", input: "\x1b[?1337$p", want: "\x1b[?1337;0$y"},
36- {name: "kitty keyboard query unanswered", input: "\x1b[?u", want: ""},
36+ {name: "kitty keyboard query reports flags", input: "\x1b[?u", want: "\x1b[?0u"},
3737 {name: "XTVERSION unanswered", input: "\x1b[>0q", want: ""},
3838 {name: "DA split across writes", input: "", want: "\x1b[?62;22c"},
3939 }
@@ -33,7 +33,7 @@ func TestScreenQueryResponses(t *testing.T) {
33 {name: "color scheme DSR dark", input: "\x1b[?996n", want: ColorSchemeReportDark},33 {name: "color scheme DSR dark", input: "\x1b[?996n", want: ColorSchemeReportDark},
34 {name: "color scheme DSR light", input: "\x1b[?996n", want: ColorSchemeReportLight},34 {name: "color scheme DSR light", input: "\x1b[?996n", want: ColorSchemeReportLight},
35 {name: "DECRQM unknown mode", input: "\x1b[?1337$p", want: "\x1b[?1337;0$y"},35 {name: "DECRQM unknown mode", input: "\x1b[?1337$p", want: "\x1b[?1337;0$y"},
36- {name: "kitty keyboard query unanswered", input: "\x1b[?u", want: ""},36+ {name: "kitty keyboard query reports flags", input: "\x1b[?u", want: "\x1b[?0u"},
37 {name: "XTVERSION unanswered", input: "\x1b[>0q", want: ""},37 {name: "XTVERSION unanswered", input: "\x1b[>0q", want: ""},
38 {name: "DA split across writes", input: "", want: "\x1b[?62;22c"},38 {name: "DA split across writes", input: "", want: "\x1b[?62;22c"},
39 }39 }
M screen.go +1 -0
@@ -91,6 +91,7 @@ type Screen struct {
9191 mouseMode int
9292 mouseSGR bool
9393 bracketedPaste bool
94+ kittyKeyboard kittyKeyboardStack
9495 originMode bool
9596 insertMode bool
9697 autoWrapMode bool
@@ -91,6 +91,7 @@ type Screen struct {
91 mouseMode int91 mouseMode int
92 mouseSGR bool92 mouseSGR bool
93 bracketedPaste bool93 bracketedPaste bool
94+ kittyKeyboard kittyKeyboardStack
94 originMode bool95 originMode bool
95 insertMode bool96 insertMode bool
96 autoWrapMode bool97 autoWrapMode bool
M screen_csi.go +2 -0
@@ -156,7 +156,9 @@ func (s *Screen) applyCSI(params string, cmd byte) {
156156 case 'u':
157157 if params == "" {
158158 s.restoreCursor()
159+ return
159160 }
161+ s.applyKittyKeyboard(params)
160162 case 'h':
161163 s.setMode(private, parts, true)
162164 case 'l':
@@ -156,7 +156,9 @@ func (s *Screen) applyCSI(params string, cmd byte) {
156 case 'u':156 case 'u':
157 if params == "" {157 if params == "" {
158 s.restoreCursor()158 s.restoreCursor()
159+ return
159 }160 }
161+ s.applyKittyKeyboard(params)
160 case 'h':162 case 'h':
161 s.setMode(private, parts, true)163 s.setMode(private, parts, true)
162 case 'l':164 case 'l':
A screen_keyboard.go +109 -0
@@ -0,0 +1,109 @@
1+package vt
2+
3+import (
4+ "strconv"
5+ "strings"
6+)
7+
8+// Kitty keyboard progressive enhancement flags.
9+// https://sw.kovidgoyal.net/kitty/keyboard-protocol/#progressive-enhancement
10+const (
11+ KittyKeyboardDisambiguate = 1 << 0
12+ KittyKeyboardReportEvents = 1 << 1
13+ KittyKeyboardAlternateKeys = 1 << 2
14+ KittyKeyboardAllKeysAsEscape = 1 << 3
15+ KittyKeyboardAssociatedText = 1 << 4
16+
17+ kittyKeyboardFlagMask = 1<<5 - 1
18+ // maxKittyKeyboardStack bounds the per-screen flag stack. When full, a
19+ // push evicts the oldest entry, as the protocol recommends.
20+ maxKittyKeyboardStack = 16
21+)
22+
23+// kittyKeyboardStack is one screen's stack of keyboard enhancement flags. The
24+// current flags are the top entry, or zero when the stack is empty.
25+type kittyKeyboardStack []int
26+
27+func (k kittyKeyboardStack) current() int {
28+ if len(k) == 0 {
29+ return 0
30+ }
31+ return k[len(k)-1]
32+}
33+
34+func (k *kittyKeyboardStack) push(flags int) {
35+ if len(*k) == maxKittyKeyboardStack {
36+ copy(*k, (*k)[1:])
37+ *k = (*k)[:len(*k)-1]
38+ }
39+ *k = append(*k, flags&kittyKeyboardFlagMask)
40+}
41+
42+func (k *kittyKeyboardStack) pop(n int) {
43+ *k = (*k)[:len(*k)-min(n, len(*k))]
44+}
45+
46+// set applies CSI = flags ; mode u. Mode 1 replaces, 2 sets bits, 3 clears
47+// bits. With an empty stack the result becomes the single entry.
48+func (k *kittyKeyboardStack) set(flags, mode int) {
49+ cur := k.current()
50+ switch mode {
51+ case 1:
52+ cur = flags
53+ case 2:
54+ cur |= flags
55+ case 3:
56+ cur &^= flags
57+ default:
58+ return
59+ }
60+ cur &= kittyKeyboardFlagMask
61+ if len(*k) == 0 {
62+ *k = append(*k, cur)
63+ return
64+ }
65+ (*k)[len(*k)-1] = cur
66+}
67+
68+// KittyKeyboardFlags reports the kitty keyboard enhancement flags currently
69+// requested by the application on the active (main or alternate) screen.
70+func (s *Screen) KittyKeyboardFlags() int { return s.kittyKeyboard.current() }
71+
72+// applyKittyKeyboard handles CSI u sequences with a ?, >, <, or = prefix. It
73+// reports whether params belonged to the kitty keyboard protocol.
74+func (s *Screen) applyKittyKeyboard(params string) bool {
75+ if params == "" {
76+ return false
77+ }
78+ prefix, rest := params[0], params[1:]
79+ switch prefix {
80+ case '?':
81+ if rest != "" {
82+ return true
83+ }
84+ resp := make([]byte, 0, 8)
85+ resp = append(resp, "\x1b[?"...)
86+ resp = strconv.AppendInt(resp, int64(s.kittyKeyboard.current()), 10)
87+ resp = append(resp, 'u')
88+ s.respond(resp)
89+ case '>':
90+ flags, _ := kittyKeyboardParams(rest)
91+ s.kittyKeyboard.push(flags)
92+ case '<':
93+ n, _ := kittyKeyboardParams(rest)
94+ s.kittyKeyboard.pop(max(n, 1))
95+ case '=':
96+ flags, mode := kittyKeyboardParams(rest)
97+ s.kittyKeyboard.set(flags, max(mode, 1))
98+ default:
99+ return false
100+ }
101+ return true
102+}
103+
104+// kittyKeyboardParams parses up to two decimal parameters. Missing or
105+// malformed values are zero.
106+func kittyKeyboardParams(params string) (first, second int) {
107+ a, b, _ := strings.Cut(params, ";")
108+ return parseCSIInt(a), parseCSIInt(b)
109+}
@@ -0,0 +1,109 @@
1+package vt
2+
3+import (
4+ "strconv"
5+ "strings"
6+)
7+
8+// Kitty keyboard progressive enhancement flags.
9+// https://sw.kovidgoyal.net/kitty/keyboard-protocol/#progressive-enhancement
10+const (
11+ KittyKeyboardDisambiguate = 1 << 0
12+ KittyKeyboardReportEvents = 1 << 1
13+ KittyKeyboardAlternateKeys = 1 << 2
14+ KittyKeyboardAllKeysAsEscape = 1 << 3
15+ KittyKeyboardAssociatedText = 1 << 4
16+
17+ kittyKeyboardFlagMask = 1<<5 - 1
18+ // maxKittyKeyboardStack bounds the per-screen flag stack. When full, a
19+ // push evicts the oldest entry, as the protocol recommends.
20+ maxKittyKeyboardStack = 16
21+)
22+
23+// kittyKeyboardStack is one screen's stack of keyboard enhancement flags. The
24+// current flags are the top entry, or zero when the stack is empty.
25+type kittyKeyboardStack []int
26+
27+func (k kittyKeyboardStack) current() int {
28+ if len(k) == 0 {
29+ return 0
30+ }
31+ return k[len(k)-1]
32+}
33+
34+func (k *kittyKeyboardStack) push(flags int) {
35+ if len(*k) == maxKittyKeyboardStack {
36+ copy(*k, (*k)[1:])
37+ *k = (*k)[:len(*k)-1]
38+ }
39+ *k = append(*k, flags&kittyKeyboardFlagMask)
40+}
41+
42+func (k *kittyKeyboardStack) pop(n int) {
43+ *k = (*k)[:len(*k)-min(n, len(*k))]
44+}
45+
46+// set applies CSI = flags ; mode u. Mode 1 replaces, 2 sets bits, 3 clears
47+// bits. With an empty stack the result becomes the single entry.
48+func (k *kittyKeyboardStack) set(flags, mode int) {
49+ cur := k.current()
50+ switch mode {
51+ case 1:
52+ cur = flags
53+ case 2:
54+ cur |= flags
55+ case 3:
56+ cur &^= flags
57+ default:
58+ return
59+ }
60+ cur &= kittyKeyboardFlagMask
61+ if len(*k) == 0 {
62+ *k = append(*k, cur)
63+ return
64+ }
65+ (*k)[len(*k)-1] = cur
66+}
67+
68+// KittyKeyboardFlags reports the kitty keyboard enhancement flags currently
69+// requested by the application on the active (main or alternate) screen.
70+func (s *Screen) KittyKeyboardFlags() int { return s.kittyKeyboard.current() }
71+
72+// applyKittyKeyboard handles CSI u sequences with a ?, >, <, or = prefix. It
73+// reports whether params belonged to the kitty keyboard protocol.
74+func (s *Screen) applyKittyKeyboard(params string) bool {
75+ if params == "" {
76+ return false
77+ }
78+ prefix, rest := params[0], params[1:]
79+ switch prefix {
80+ case '?':
81+ if rest != "" {
82+ return true
83+ }
84+ resp := make([]byte, 0, 8)
85+ resp = append(resp, "\x1b[?"...)
86+ resp = strconv.AppendInt(resp, int64(s.kittyKeyboard.current()), 10)
87+ resp = append(resp, 'u')
88+ s.respond(resp)
89+ case '>':
90+ flags, _ := kittyKeyboardParams(rest)
91+ s.kittyKeyboard.push(flags)
92+ case '<':
93+ n, _ := kittyKeyboardParams(rest)
94+ s.kittyKeyboard.pop(max(n, 1))
95+ case '=':
96+ flags, mode := kittyKeyboardParams(rest)
97+ s.kittyKeyboard.set(flags, max(mode, 1))
98+ default:
99+ return false
100+ }
101+ return true
102+}
103+
104+// kittyKeyboardParams parses up to two decimal parameters. Missing or
105+// malformed values are zero.
106+func kittyKeyboardParams(params string) (first, second int) {
107+ a, b, _ := strings.Cut(params, ";")
108+ return parseCSIInt(a), parseCSIInt(b)
109+}
A screen_keyboard_test.go +79 -0
@@ -0,0 +1,79 @@
1+package vt
2+
3+import (
4+ "strings"
5+ "testing"
6+
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestScreenKittyKeyboardFlags(t *testing.T) {
11+ tests := []struct {
12+ name string
13+ input string
14+ want int
15+ }{
16+ {name: "default", input: "", want: 0},
17+ {name: "push", input: "\x1b[>1u", want: 1},
18+ {name: "push without flags", input: "\x1b[>u", want: 0},
19+ {name: "push masks unknown bits", input: "\x1b[>255u", want: 31},
20+ {name: "push twice", input: "\x1b[>1u\x1b[>11u", want: 11},
21+ {name: "pop default one", input: "\x1b[>1u\x1b[>11u\x1b[<u", want: 1},
22+ {name: "pop n", input: "\x1b[>1u\x1b[>3u\x1b[>11u\x1b[<2u", want: 1},
23+ {name: "pop past empty", input: "\x1b[>1u\x1b[<5u", want: 0},
24+ {name: "pop empty", input: "\x1b[<u", want: 0},
25+ {name: "set replace on empty", input: "\x1b[=5u", want: 5},
26+ {name: "set replace", input: "\x1b[>1u\x1b[=8;1u", want: 8},
27+ {name: "set or", input: "\x1b[>1u\x1b[=8;2u", want: 9},
28+ {name: "set clear", input: "\x1b[>11u\x1b[=2;3u", want: 9},
29+ {name: "set unknown mode ignored", input: "\x1b[>1u\x1b[=8;4u", want: 1},
30+ {name: "set replaces top only", input: "\x1b[>1u\x1b[>2u\x1b[=4u\x1b[<u", want: 1},
31+ {name: "malformed flags are zero", input: "\x1b[>xu", want: 0},
32+ {name: "plain CSI u restores cursor only", input: "\x1b[>3u\x1b[u", want: 3},
33+ {name: "RIS resets", input: "\x1b[>3u\x1bc", want: 0},
34+ {name: "alternate screen has its own stack", input: "\x1b[>1u\x1b[?1049h", want: 0},
35+ {name: "alternate screen push", input: "\x1b[>1u\x1b[?1049h\x1b[>15u", want: 15},
36+ {name: "leaving alternate restores main stack", input: "\x1b[>1u\x1b[?1049h\x1b[>15u\x1b[?1049l", want: 1},
37+ }
38+ for _, tt := range tests {
39+ t.Run(tt.name, func(t *testing.T) {
40+ s := NewScreen(10, 3)
41+ s.Write([]byte(tt.input))
42+ require.Equal(t, tt.want, s.KittyKeyboardFlags())
43+ require.Equal(t, tt.want, s.Snapshot().Modes().KittyKeyboard)
44+ })
45+ }
46+}
47+
48+func TestScreenKittyKeyboardStackIsBounded(t *testing.T) {
49+ s := NewScreen(10, 3)
50+ s.Write([]byte("\x1b[>1u"))
51+ s.Write([]byte(strings.Repeat("\x1b[>2u", maxKittyKeyboardStack)))
52+ require.Len(t, s.kittyKeyboard, maxKittyKeyboardStack)
53+ s.Write([]byte(strings.Repeat("\x1b[<u", maxKittyKeyboardStack-1)))
54+ require.Equal(t, 2, s.KittyKeyboardFlags(), "oldest entry was evicted")
55+ s.Write([]byte("\x1b[<u"))
56+ require.Equal(t, 0, s.KittyKeyboardFlags())
57+}
58+
59+func TestScreenKittyKeyboardQuery(t *testing.T) {
60+ tests := []struct {
61+ name string
62+ input string
63+ want []string
64+ }{
65+ {name: "empty", input: "\x1b[?u", want: []string{"\x1b[?0u"}},
66+ {name: "after push", input: "\x1b[>11u\x1b[?u", want: []string{"\x1b[?11u"}},
67+ {name: "with params is ignored", input: "\x1b[?1u", want: nil},
68+ {name: "split across writes", input: "\x1b[>3u\x1b[?", want: nil},
69+ }
70+ for _, tt := range tests {
71+ t.Run(tt.name, func(t *testing.T) {
72+ s := NewScreen(10, 3)
73+ var got []string
74+ s.OnResponse = func(b []byte) { got = append(got, string(b)) }
75+ s.Write([]byte(tt.input))
76+ require.Equal(t, tt.want, got)
77+ })
78+ }
79+}
@@ -0,0 +1,79 @@
1+package vt
2+
3+import (
4+ "strings"
5+ "testing"
6+
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestScreenKittyKeyboardFlags(t *testing.T) {
11+ tests := []struct {
12+ name string
13+ input string
14+ want int
15+ }{
16+ {name: "default", input: "", want: 0},
17+ {name: "push", input: "\x1b[>1u", want: 1},
18+ {name: "push without flags", input: "\x1b[>u", want: 0},
19+ {name: "push masks unknown bits", input: "\x1b[>255u", want: 31},
20+ {name: "push twice", input: "\x1b[>1u\x1b[>11u", want: 11},
21+ {name: "pop default one", input: "\x1b[>1u\x1b[>11u\x1b[<u", want: 1},
22+ {name: "pop n", input: "\x1b[>1u\x1b[>3u\x1b[>11u\x1b[<2u", want: 1},
23+ {name: "pop past empty", input: "\x1b[>1u\x1b[<5u", want: 0},
24+ {name: "pop empty", input: "\x1b[<u", want: 0},
25+ {name: "set replace on empty", input: "\x1b[=5u", want: 5},
26+ {name: "set replace", input: "\x1b[>1u\x1b[=8;1u", want: 8},
27+ {name: "set or", input: "\x1b[>1u\x1b[=8;2u", want: 9},
28+ {name: "set clear", input: "\x1b[>11u\x1b[=2;3u", want: 9},
29+ {name: "set unknown mode ignored", input: "\x1b[>1u\x1b[=8;4u", want: 1},
30+ {name: "set replaces top only", input: "\x1b[>1u\x1b[>2u\x1b[=4u\x1b[<u", want: 1},
31+ {name: "malformed flags are zero", input: "\x1b[>xu", want: 0},
32+ {name: "plain CSI u restores cursor only", input: "\x1b[>3u\x1b[u", want: 3},
33+ {name: "RIS resets", input: "\x1b[>3u\x1bc", want: 0},
34+ {name: "alternate screen has its own stack", input: "\x1b[>1u\x1b[?1049h", want: 0},
35+ {name: "alternate screen push", input: "\x1b[>1u\x1b[?1049h\x1b[>15u", want: 15},
36+ {name: "leaving alternate restores main stack", input: "\x1b[>1u\x1b[?1049h\x1b[>15u\x1b[?1049l", want: 1},
37+ }
38+ for _, tt := range tests {
39+ t.Run(tt.name, func(t *testing.T) {
40+ s := NewScreen(10, 3)
41+ s.Write([]byte(tt.input))
42+ require.Equal(t, tt.want, s.KittyKeyboardFlags())
43+ require.Equal(t, tt.want, s.Snapshot().Modes().KittyKeyboard)
44+ })
45+ }
46+}
47+
48+func TestScreenKittyKeyboardStackIsBounded(t *testing.T) {
49+ s := NewScreen(10, 3)
50+ s.Write([]byte("\x1b[>1u"))
51+ s.Write([]byte(strings.Repeat("\x1b[>2u", maxKittyKeyboardStack)))
52+ require.Len(t, s.kittyKeyboard, maxKittyKeyboardStack)
53+ s.Write([]byte(strings.Repeat("\x1b[<u", maxKittyKeyboardStack-1)))
54+ require.Equal(t, 2, s.KittyKeyboardFlags(), "oldest entry was evicted")
55+ s.Write([]byte("\x1b[<u"))
56+ require.Equal(t, 0, s.KittyKeyboardFlags())
57+}
58+
59+func TestScreenKittyKeyboardQuery(t *testing.T) {
60+ tests := []struct {
61+ name string
62+ input string
63+ want []string
64+ }{
65+ {name: "empty", input: "\x1b[?u", want: []string{"\x1b[?0u"}},
66+ {name: "after push", input: "\x1b[>11u\x1b[?u", want: []string{"\x1b[?11u"}},
67+ {name: "with params is ignored", input: "\x1b[?1u", want: nil},
68+ {name: "split across writes", input: "\x1b[>3u\x1b[?", want: nil},
69+ }
70+ for _, tt := range tests {
71+ t.Run(tt.name, func(t *testing.T) {
72+ s := NewScreen(10, 3)
73+ var got []string
74+ s.OnResponse = func(b []byte) { got = append(got, string(b)) }
75+ s.Write([]byte(tt.input))
76+ require.Equal(t, tt.want, got)
77+ })
78+ }
79+}
M screen_modes.go +6 -0
@@ -17,6 +17,7 @@ type screenState struct {
1717 kittyPendingDisplay *kittygraphics.Controls
1818 originMode bool
1919 insertMode bool
20+ kittyKeyboard kittyKeyboardStack
2021 }
2122
2223 // SyncUpdateActive reports whether DEC private mode 2026 (synchronized update)
@@ -118,6 +119,7 @@ func (s *Screen) reset() {
118119 s.mouseMode = 0
119120 s.mouseSGR = false
120121 s.bracketedPaste = false
122+ s.kittyKeyboard = nil
121123 s.colorSchemeMode = false
122124 s.resetScrollRegion()
123125 s.fullRedraw()
@@ -225,7 +227,10 @@ func (s *Screen) enterAlternateScreen() {
225227 kittyPendingDisplay: s.kittyPendingDisplay,
226228 originMode: s.originMode,
227229 insertMode: s.insertMode,
230+ kittyKeyboard: s.kittyKeyboard,
228231 }
232+ // The main and alternate screens keep independent keyboard stacks.
233+ s.kittyKeyboard = nil
229234 }
230235 s.graphics = nil
231236 s.kittyPendingDisplay = nil
@@ -255,6 +260,7 @@ func (s *Screen) exitAlternateScreen() {
255260 s.kittyPendingDisplay = state.kittyPendingDisplay
256261 s.originMode = state.originMode
257262 s.insertMode = state.insertMode
263+ s.kittyKeyboard = state.kittyKeyboard
258264 s.alternate = nil
259265 s.fullRedraw()
260266 }
@@ -17,6 +17,7 @@ type screenState struct {
17 kittyPendingDisplay *kittygraphics.Controls17 kittyPendingDisplay *kittygraphics.Controls
18 originMode bool18 originMode bool
19 insertMode bool19 insertMode bool
20+ kittyKeyboard kittyKeyboardStack
20 }21 }
21 22
22 // SyncUpdateActive reports whether DEC private mode 2026 (synchronized update)23 // SyncUpdateActive reports whether DEC private mode 2026 (synchronized update)
@@ -118,6 +119,7 @@ func (s *Screen) reset() {
118 s.mouseMode = 0119 s.mouseMode = 0
119 s.mouseSGR = false120 s.mouseSGR = false
120 s.bracketedPaste = false121 s.bracketedPaste = false
122+ s.kittyKeyboard = nil
121 s.colorSchemeMode = false123 s.colorSchemeMode = false
122 s.resetScrollRegion()124 s.resetScrollRegion()
123 s.fullRedraw()125 s.fullRedraw()
@@ -225,7 +227,10 @@ func (s *Screen) enterAlternateScreen() {
225 kittyPendingDisplay: s.kittyPendingDisplay,227 kittyPendingDisplay: s.kittyPendingDisplay,
226 originMode: s.originMode,228 originMode: s.originMode,
227 insertMode: s.insertMode,229 insertMode: s.insertMode,
230+ kittyKeyboard: s.kittyKeyboard,
228 }231 }
232+ // The main and alternate screens keep independent keyboard stacks.
233+ s.kittyKeyboard = nil
229 }234 }
230 s.graphics = nil235 s.graphics = nil
231 s.kittyPendingDisplay = nil236 s.kittyPendingDisplay = nil
@@ -255,6 +260,7 @@ func (s *Screen) exitAlternateScreen() {
255 s.kittyPendingDisplay = state.kittyPendingDisplay260 s.kittyPendingDisplay = state.kittyPendingDisplay
256 s.originMode = state.originMode261 s.originMode = state.originMode
257 s.insertMode = state.insertMode262 s.insertMode = state.insertMode
263+ s.kittyKeyboard = state.kittyKeyboard
258 s.alternate = nil264 s.alternate = nil
259 s.fullRedraw()265 s.fullRedraw()
260 }266 }
M screen_snapshot.go +3 -0
@@ -22,6 +22,8 @@ type ModeSnapshot struct {
2222 ColorSchemeMode bool
2323 MouseTracking int
2424 MouseSGR bool
25+ // KittyKeyboard is the active screen's kitty keyboard enhancement flags.
26+ KittyKeyboard int
2527 }
2628
2729 // ScreenSnapshot is an owned immutable capture of the active terminal
@@ -73,6 +75,7 @@ func (s *Screen) Snapshot() ScreenSnapshot {
7375 ColorSchemeMode: s.ColorSchemeMode(),
7476 MouseTracking: mouseTracking,
7577 MouseSGR: mouseSGR,
78+ KittyKeyboard: s.KittyKeyboardFlags(),
7679 },
7780 title: s.TerminalTitle(),
7881 graphics: s.GraphicsSnapshot(),
@@ -22,6 +22,8 @@ type ModeSnapshot struct {
22 ColorSchemeMode bool22 ColorSchemeMode bool
23 MouseTracking int23 MouseTracking int
24 MouseSGR bool24 MouseSGR bool
25+ // KittyKeyboard is the active screen's kitty keyboard enhancement flags.
26+ KittyKeyboard int
25 }27 }
26 28
27 // ScreenSnapshot is an owned immutable capture of the active terminal29 // ScreenSnapshot is an owned immutable capture of the active terminal
@@ -73,6 +75,7 @@ func (s *Screen) Snapshot() ScreenSnapshot {
73 ColorSchemeMode: s.ColorSchemeMode(),75 ColorSchemeMode: s.ColorSchemeMode(),
74 MouseTracking: mouseTracking,76 MouseTracking: mouseTracking,
75 MouseSGR: mouseSGR,77 MouseSGR: mouseSGR,
78+ KittyKeyboard: s.KittyKeyboardFlags(),
76 },79 },
77 title: s.TerminalTitle(),80 title: s.TerminalTitle(),
78 graphics: s.GraphicsSnapshot(),81 graphics: s.GraphicsSnapshot(),

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 2

Tags 13

↑↓ moveenter openesc close