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 Unverified
B5690EEEBB952194The key is unknown
9 files · +260 -1
M protocol/terminalquery/query.go +28 -0
| @@ -14,6 +14,10 @@ const ( | |||
| 14 | 14 | // Kitty query so a graphics declaration requires both terminal identity and | |
| 15 | 15 | // protocol support. | |
| 16 | 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 | 22 | maxProbeResponseBytes = 256 | |
| 19 | 23 | ) | |
| @@ -26,6 +30,8 @@ type Probe struct { | |||
| 26 | 30 | pending []byte | |
| 27 | 31 | kitty bool | |
| 28 | 32 | da1 bool | |
| 33 | + | // keyboard is set once a KittyKeyboardQuery response was observed. | |
| 34 | + | keyboard bool | |
| 29 | 35 | } | |
| 30 | 36 | ||
| 31 | 37 | // 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 | 68 | // KittyGraphics reports whether the Kitty graphics response was observed. | |
| 63 | 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 | 75 | // DA1 reports whether a primary device-attributes response was observed. | |
| 66 | 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 | 142 | p.pending = p.pending[end:] | |
| 133 | 143 | continue | |
| 134 | 144 | } | |
| 145 | + | if validKittyKeyboardResponse(response) { | |
| 146 | + | p.keyboard = true | |
| 147 | + | p.pending = p.pending[end:] | |
| 148 | + | continue | |
| 149 | + | } | |
| 135 | 150 | unrelated = append(unrelated, p.pending[0]) | |
| 136 | 151 | p.pending = p.pending[1:] | |
| 137 | 152 | } | |
| @@ -213,3 +228,16 @@ func min(a, b int) int { | |||
| 213 | 228 | } | |
| 214 | 229 | 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 | + | } | |
| @@ -14,6 +14,10 @@ const ( | |||||
| 14 | // Kitty query so a graphics declaration requires both terminal identity and | 14 | // 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 = 256 | 22 | maxProbeResponseBytes = 256 | ||
| 19 | ) | 23 | ) | ||
| @@ -26,6 +30,8 @@ type Probe struct { | |||||
| 26 | pending []byte | 30 | pending []byte | ||
| 27 | kitty bool | 31 | kitty bool | ||
| 28 | da1 bool | 32 | 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. The | 37 | // 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 | continue | 143 | 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 b | 229 | 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) { | |||
| 81 | 81 | require.False(t, p.Ready()) | |
| 82 | 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 | + | } | |
| @@ -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) { | |||
| 33 | 33 | {name: "color scheme DSR dark", input: "\x1b[?996n", want: ColorSchemeReportDark}, | |
| 34 | 34 | {name: "color scheme DSR light", input: "\x1b[?996n", want: ColorSchemeReportLight}, | |
| 35 | 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 | 37 | {name: "XTVERSION unanswered", input: "\x1b[>0q", want: ""}, | |
| 38 | 38 | {name: "DA split across writes", input: "", want: "\x1b[?62;22c"}, | |
| 39 | 39 | } | |
| @@ -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 { | |||
| 91 | 91 | mouseMode int | |
| 92 | 92 | mouseSGR bool | |
| 93 | 93 | bracketedPaste bool | |
| 94 | + | kittyKeyboard kittyKeyboardStack | |
| 94 | 95 | originMode bool | |
| 95 | 96 | insertMode bool | |
| 96 | 97 | autoWrapMode bool | |
| @@ -91,6 +91,7 @@ type Screen struct { | |||||
| 91 | mouseMode int | 91 | mouseMode int | ||
| 92 | mouseSGR bool | 92 | mouseSGR bool | ||
| 93 | bracketedPaste bool | 93 | bracketedPaste bool | ||
| 94 | + | kittyKeyboard kittyKeyboardStack | |||
| 94 | originMode bool | 95 | originMode bool | ||
| 95 | insertMode bool | 96 | insertMode bool | ||
| 96 | autoWrapMode bool | 97 | autoWrapMode bool | ||
M screen_csi.go +2 -0
| @@ -156,7 +156,9 @@ func (s *Screen) applyCSI(params string, cmd byte) { | |||
| 156 | 156 | case 'u': | |
| 157 | 157 | if params == "" { | |
| 158 | 158 | s.restoreCursor() | |
| 159 | + | return | |
| 159 | 160 | } | |
| 161 | + | s.applyKittyKeyboard(params) | |
| 160 | 162 | case 'h': | |
| 161 | 163 | s.setMode(private, parts, true) | |
| 162 | 164 | 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 { | |||
| 17 | 17 | kittyPendingDisplay *kittygraphics.Controls | |
| 18 | 18 | originMode bool | |
| 19 | 19 | insertMode bool | |
| 20 | + | kittyKeyboard kittyKeyboardStack | |
| 20 | 21 | } | |
| 21 | 22 | ||
| 22 | 23 | // SyncUpdateActive reports whether DEC private mode 2026 (synchronized update) | |
| @@ -118,6 +119,7 @@ func (s *Screen) reset() { | |||
| 118 | 119 | s.mouseMode = 0 | |
| 119 | 120 | s.mouseSGR = false | |
| 120 | 121 | s.bracketedPaste = false | |
| 122 | + | s.kittyKeyboard = nil | |
| 121 | 123 | s.colorSchemeMode = false | |
| 122 | 124 | s.resetScrollRegion() | |
| 123 | 125 | s.fullRedraw() | |
| @@ -225,7 +227,10 @@ func (s *Screen) enterAlternateScreen() { | |||
| 225 | 227 | kittyPendingDisplay: s.kittyPendingDisplay, | |
| 226 | 228 | originMode: s.originMode, | |
| 227 | 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 | 235 | s.graphics = nil | |
| 231 | 236 | s.kittyPendingDisplay = nil | |
| @@ -255,6 +260,7 @@ func (s *Screen) exitAlternateScreen() { | |||
| 255 | 260 | s.kittyPendingDisplay = state.kittyPendingDisplay | |
| 256 | 261 | s.originMode = state.originMode | |
| 257 | 262 | s.insertMode = state.insertMode | |
| 263 | + | s.kittyKeyboard = state.kittyKeyboard | |
| 258 | 264 | s.alternate = nil | |
| 259 | 265 | s.fullRedraw() | |
| 260 | 266 | } | |
| @@ -17,6 +17,7 @@ type screenState struct { | |||||
| 17 | kittyPendingDisplay *kittygraphics.Controls | 17 | kittyPendingDisplay *kittygraphics.Controls | ||
| 18 | originMode bool | 18 | originMode bool | ||
| 19 | insertMode bool | 19 | 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 = 0 | 119 | s.mouseMode = 0 | ||
| 119 | s.mouseSGR = false | 120 | s.mouseSGR = false | ||
| 120 | s.bracketedPaste = false | 121 | s.bracketedPaste = false | ||
| 122 | + | s.kittyKeyboard = nil | |||
| 121 | s.colorSchemeMode = false | 123 | 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 = nil | 235 | s.graphics = nil | ||
| 231 | s.kittyPendingDisplay = nil | 236 | s.kittyPendingDisplay = nil | ||
| @@ -255,6 +260,7 @@ func (s *Screen) exitAlternateScreen() { | |||||
| 255 | s.kittyPendingDisplay = state.kittyPendingDisplay | 260 | s.kittyPendingDisplay = state.kittyPendingDisplay | ||
| 256 | s.originMode = state.originMode | 261 | s.originMode = state.originMode | ||
| 257 | s.insertMode = state.insertMode | 262 | s.insertMode = state.insertMode | ||
| 263 | + | s.kittyKeyboard = state.kittyKeyboard | |||
| 258 | s.alternate = nil | 264 | s.alternate = nil | ||
| 259 | s.fullRedraw() | 265 | s.fullRedraw() | ||
| 260 | } | 266 | } | ||
M screen_snapshot.go +3 -0
| @@ -22,6 +22,8 @@ type ModeSnapshot struct { | |||
| 22 | 22 | ColorSchemeMode bool | |
| 23 | 23 | MouseTracking int | |
| 24 | 24 | MouseSGR bool | |
| 25 | + | // KittyKeyboard is the active screen's kitty keyboard enhancement flags. | |
| 26 | + | KittyKeyboard int | |
| 25 | 27 | } | |
| 26 | 28 | ||
| 27 | 29 | // ScreenSnapshot is an owned immutable capture of the active terminal | |
| @@ -73,6 +75,7 @@ func (s *Screen) Snapshot() ScreenSnapshot { | |||
| 73 | 75 | ColorSchemeMode: s.ColorSchemeMode(), | |
| 74 | 76 | MouseTracking: mouseTracking, | |
| 75 | 77 | MouseSGR: mouseSGR, | |
| 78 | + | KittyKeyboard: s.KittyKeyboardFlags(), | |
| 76 | 79 | }, | |
| 77 | 80 | title: s.TerminalTitle(), | |
| 78 | 81 | graphics: s.GraphicsSnapshot(), | |
| @@ -22,6 +22,8 @@ type ModeSnapshot struct { | |||||
| 22 | ColorSchemeMode bool | 22 | ColorSchemeMode bool | ||
| 23 | MouseTracking int | 23 | MouseTracking int | ||
| 24 | MouseSGR bool | 24 | 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 terminal | 29 | // 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(), | ||