detached 024b0ea

feat(protocol): add bounded terminal capability probe parser

commit
024b0eac9d311a16d6e80b1643f916cece34a9ad
author
brice · · 2026-08-20 21:05 UTC
parent
0780e0d
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +227 -0
A protocol/terminalquery/query.go +178 -0
@@ -0,0 +1,178 @@
1+// Package terminalquery provides bounded parsers for terminal capability
2+// probes. Probe responses are terminal input, not application input: callers
3+// can remove only the two exact responses while replaying every unrelated byte.
4+package terminalquery
5+
6+import "bytes"
7+
8+const (
9+ // KittyGraphicsQuery asks a terminal whether it understands the Kitty
10+ // graphics protocol. The image id is deliberately reserved for probing and
11+ // the query carries no image payload.
12+ KittyGraphicsQuery = "\x1b_Gi=31,s=1,v=1,a=q;\x1b\\"
13+ // DeviceAttributesQuery is the primary DA1 query. It is paired with the
14+ // Kitty query so a graphics declaration requires both terminal identity and
15+ // protocol support.
16+ DeviceAttributesQuery = "\x1b[c"
17+
18+ maxProbeResponseBytes = 256
19+)
20+
21+// Probe incrementally recognizes the bounded responses to KittyGraphicsQuery
22+// and DeviceAttributesQuery. Feed returns bytes that are not recognized probe
23+// responses in their original order. A response may be split across any
24+// number of calls.
25+type Probe struct {
26+ pending []byte
27+ kitty bool
28+ da1 bool
29+}
30+
31+// Feed consumes one terminal input fragment and returns unrelated input. The
32+// returned slice is owned by the Probe and is invalidated by the next call.
33+func (p *Probe) Feed(data []byte) []byte {
34+ if p == nil || len(data) == 0 {
35+ return append([]byte(nil), data...)
36+ }
37+ p.pending = append(p.pending, data...)
38+ return p.scan(false)
39+}
40+
41+// Finish flushes an incomplete or unrelated prefix without recognizing any
42+// further response. It is used when the bounded probe deadline expires.
43+func (p *Probe) Finish() []byte {
44+ if p == nil {
45+ return nil
46+ }
47+ return p.scan(true)
48+}
49+
50+// Ready reports whether both independent capability responses were observed.
51+func (p *Probe) Ready() bool { return p != nil && p.kitty && p.da1 }
52+
53+// KittyGraphics reports whether the Kitty graphics response was observed.
54+func (p *Probe) KittyGraphics() bool { return p != nil && p.kitty }
55+
56+// DA1 reports whether a primary device-attributes response was observed.
57+func (p *Probe) DA1() bool { return p != nil && p.da1 }
58+
59+func (p *Probe) scan(flush bool) []byte {
60+ var unrelated []byte
61+ for len(p.pending) != 0 {
62+ kittyStart := bytes.Index(p.pending, []byte("\x1b_G"))
63+ da1Start := bytes.Index(p.pending, []byte("\x1b["))
64+ start := -1
65+ if kittyStart >= 0 {
66+ start = kittyStart
67+ }
68+ if da1Start >= 0 && (start < 0 || da1Start < start) {
69+ start = da1Start
70+ }
71+ if start < 0 {
72+ if flush || len(p.pending) > 2 {
73+ keep := 0
74+ if !flush {
75+ keep = suffixPrefixLen(p.pending)
76+ }
77+ if len(p.pending) > keep {
78+ unrelated = append(unrelated, p.pending[:len(p.pending)-keep]...)
79+ p.pending = p.pending[len(p.pending)-keep:]
80+ }
81+ }
82+ break
83+ }
84+ if start > 0 {
85+ unrelated = append(unrelated, p.pending[:start]...)
86+ p.pending = p.pending[start:]
87+ }
88+ if p.pending[1] == '_' {
89+ end, complete := probeTerminator(p.pending)
90+ if !complete {
91+ if flush || len(p.pending) > maxProbeResponseBytes {
92+ unrelated = append(unrelated, p.pending[0])
93+ p.pending = p.pending[1:]
94+ continue
95+ }
96+ break
97+ }
98+ response := p.pending[:end]
99+ if validKittyResponse(response) {
100+ p.kitty = true
101+ p.pending = p.pending[end:]
102+ continue
103+ }
104+ unrelated = append(unrelated, p.pending[0])
105+ p.pending = p.pending[1:]
106+ continue
107+ }
108+ end, complete := da1Terminator(p.pending)
109+ if !complete {
110+ if flush || len(p.pending) > maxProbeResponseBytes {
111+ unrelated = append(unrelated, p.pending[0])
112+ p.pending = p.pending[1:]
113+ continue
114+ }
115+ break
116+ }
117+ response := p.pending[:end]
118+ if validDA1Response(response) {
119+ p.da1 = true
120+ p.pending = p.pending[end:]
121+ continue
122+ }
123+ unrelated = append(unrelated, p.pending[0])
124+ p.pending = p.pending[1:]
125+ }
126+ return unrelated
127+}
128+
129+func probeTerminator(data []byte) (int, bool) {
130+ for i := 3; i+1 < len(data) && i <= maxProbeResponseBytes; i++ {
131+ if data[i] == '\x1b' && data[i+1] == '\\' {
132+ return i + 2, true
133+ }
134+ }
135+ return 0, false
136+}
137+
138+func da1Terminator(data []byte) (int, bool) {
139+ for i := 2; i < len(data) && i <= maxProbeResponseBytes; i++ {
140+ if data[i] >= 0x40 && data[i] <= 0x7e {
141+ return i + 1, true
142+ }
143+ }
144+ return 0, false
145+}
146+
147+func validKittyResponse(data []byte) bool {
148+ if len(data) < len("\x1b_Gi=31;OK\x1b\\") || !bytes.HasPrefix(data, []byte("\x1b_G")) || !bytes.HasSuffix(data, []byte("\x1b\\")) {
149+ return false
150+ }
151+ head := data[3 : len(data)-2]
152+ return bytes.Contains(head, []byte("i=31")) && bytes.Contains(head, []byte("OK"))
153+}
154+
155+func validDA1Response(data []byte) bool {
156+ if len(data) < 4 || !bytes.HasPrefix(data, []byte("\x1b[")) || data[len(data)-1] != 'c' {
157+ return false
158+ }
159+ // DA1 is a primary response. Secondary DA (ESC[>...c) is deliberately
160+ // not sufficient for a direct graphics declaration.
161+ return data[2] == '?' && len(data) <= maxProbeResponseBytes
162+}
163+
164+func suffixPrefixLen(data []byte) int {
165+ for n := min(len(data), 2); n > 0; n-- {
166+ if bytes.HasPrefix([]byte("\x1b_G"), data[len(data)-n:]) || bytes.HasPrefix([]byte("\x1b["), data[len(data)-n:]) {
167+ return n
168+ }
169+ }
170+ return 0
171+}
172+
173+func min(a, b int) int {
174+ if a < b {
175+ return a
176+ }
177+ return b
178+}
@@ -0,0 +1,178 @@
1+// Package terminalquery provides bounded parsers for terminal capability
2+// probes. Probe responses are terminal input, not application input: callers
3+// can remove only the two exact responses while replaying every unrelated byte.
4+package terminalquery
5+
6+import "bytes"
7+
8+const (
9+ // KittyGraphicsQuery asks a terminal whether it understands the Kitty
10+ // graphics protocol. The image id is deliberately reserved for probing and
11+ // the query carries no image payload.
12+ KittyGraphicsQuery = "\x1b_Gi=31,s=1,v=1,a=q;\x1b\\"
13+ // DeviceAttributesQuery is the primary DA1 query. It is paired with the
14+ // Kitty query so a graphics declaration requires both terminal identity and
15+ // protocol support.
16+ DeviceAttributesQuery = "\x1b[c"
17+
18+ maxProbeResponseBytes = 256
19+)
20+
21+// Probe incrementally recognizes the bounded responses to KittyGraphicsQuery
22+// and DeviceAttributesQuery. Feed returns bytes that are not recognized probe
23+// responses in their original order. A response may be split across any
24+// number of calls.
25+type Probe struct {
26+ pending []byte
27+ kitty bool
28+ da1 bool
29+}
30+
31+// Feed consumes one terminal input fragment and returns unrelated input. The
32+// returned slice is owned by the Probe and is invalidated by the next call.
33+func (p *Probe) Feed(data []byte) []byte {
34+ if p == nil || len(data) == 0 {
35+ return append([]byte(nil), data...)
36+ }
37+ p.pending = append(p.pending, data...)
38+ return p.scan(false)
39+}
40+
41+// Finish flushes an incomplete or unrelated prefix without recognizing any
42+// further response. It is used when the bounded probe deadline expires.
43+func (p *Probe) Finish() []byte {
44+ if p == nil {
45+ return nil
46+ }
47+ return p.scan(true)
48+}
49+
50+// Ready reports whether both independent capability responses were observed.
51+func (p *Probe) Ready() bool { return p != nil && p.kitty && p.da1 }
52+
53+// KittyGraphics reports whether the Kitty graphics response was observed.
54+func (p *Probe) KittyGraphics() bool { return p != nil && p.kitty }
55+
56+// DA1 reports whether a primary device-attributes response was observed.
57+func (p *Probe) DA1() bool { return p != nil && p.da1 }
58+
59+func (p *Probe) scan(flush bool) []byte {
60+ var unrelated []byte
61+ for len(p.pending) != 0 {
62+ kittyStart := bytes.Index(p.pending, []byte("\x1b_G"))
63+ da1Start := bytes.Index(p.pending, []byte("\x1b["))
64+ start := -1
65+ if kittyStart >= 0 {
66+ start = kittyStart
67+ }
68+ if da1Start >= 0 && (start < 0 || da1Start < start) {
69+ start = da1Start
70+ }
71+ if start < 0 {
72+ if flush || len(p.pending) > 2 {
73+ keep := 0
74+ if !flush {
75+ keep = suffixPrefixLen(p.pending)
76+ }
77+ if len(p.pending) > keep {
78+ unrelated = append(unrelated, p.pending[:len(p.pending)-keep]...)
79+ p.pending = p.pending[len(p.pending)-keep:]
80+ }
81+ }
82+ break
83+ }
84+ if start > 0 {
85+ unrelated = append(unrelated, p.pending[:start]...)
86+ p.pending = p.pending[start:]
87+ }
88+ if p.pending[1] == '_' {
89+ end, complete := probeTerminator(p.pending)
90+ if !complete {
91+ if flush || len(p.pending) > maxProbeResponseBytes {
92+ unrelated = append(unrelated, p.pending[0])
93+ p.pending = p.pending[1:]
94+ continue
95+ }
96+ break
97+ }
98+ response := p.pending[:end]
99+ if validKittyResponse(response) {
100+ p.kitty = true
101+ p.pending = p.pending[end:]
102+ continue
103+ }
104+ unrelated = append(unrelated, p.pending[0])
105+ p.pending = p.pending[1:]
106+ continue
107+ }
108+ end, complete := da1Terminator(p.pending)
109+ if !complete {
110+ if flush || len(p.pending) > maxProbeResponseBytes {
111+ unrelated = append(unrelated, p.pending[0])
112+ p.pending = p.pending[1:]
113+ continue
114+ }
115+ break
116+ }
117+ response := p.pending[:end]
118+ if validDA1Response(response) {
119+ p.da1 = true
120+ p.pending = p.pending[end:]
121+ continue
122+ }
123+ unrelated = append(unrelated, p.pending[0])
124+ p.pending = p.pending[1:]
125+ }
126+ return unrelated
127+}
128+
129+func probeTerminator(data []byte) (int, bool) {
130+ for i := 3; i+1 < len(data) && i <= maxProbeResponseBytes; i++ {
131+ if data[i] == '\x1b' && data[i+1] == '\\' {
132+ return i + 2, true
133+ }
134+ }
135+ return 0, false
136+}
137+
138+func da1Terminator(data []byte) (int, bool) {
139+ for i := 2; i < len(data) && i <= maxProbeResponseBytes; i++ {
140+ if data[i] >= 0x40 && data[i] <= 0x7e {
141+ return i + 1, true
142+ }
143+ }
144+ return 0, false
145+}
146+
147+func validKittyResponse(data []byte) bool {
148+ if len(data) < len("\x1b_Gi=31;OK\x1b\\") || !bytes.HasPrefix(data, []byte("\x1b_G")) || !bytes.HasSuffix(data, []byte("\x1b\\")) {
149+ return false
150+ }
151+ head := data[3 : len(data)-2]
152+ return bytes.Contains(head, []byte("i=31")) && bytes.Contains(head, []byte("OK"))
153+}
154+
155+func validDA1Response(data []byte) bool {
156+ if len(data) < 4 || !bytes.HasPrefix(data, []byte("\x1b[")) || data[len(data)-1] != 'c' {
157+ return false
158+ }
159+ // DA1 is a primary response. Secondary DA (ESC[>...c) is deliberately
160+ // not sufficient for a direct graphics declaration.
161+ return data[2] == '?' && len(data) <= maxProbeResponseBytes
162+}
163+
164+func suffixPrefixLen(data []byte) int {
165+ for n := min(len(data), 2); n > 0; n-- {
166+ if bytes.HasPrefix([]byte("\x1b_G"), data[len(data)-n:]) || bytes.HasPrefix([]byte("\x1b["), data[len(data)-n:]) {
167+ return n
168+ }
169+ }
170+ return 0
171+}
172+
173+func min(a, b int) int {
174+ if a < b {
175+ return a
176+ }
177+ return b
178+}
A protocol/terminalquery/query_test.go +49 -0
@@ -0,0 +1,49 @@
1+package terminalquery
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+)
8+
9+func TestProbeRequiresKittyAndDA1(t *testing.T) {
10+ for _, tc := range []struct {
11+ name string
12+ data string
13+ kitty bool
14+ da1 bool
15+ unrelated string
16+ }{
17+ {name: "both and input", data: "x\x1b[?1;2c" + "\x1b_Gi=31;OK\x1b\\y", kitty: true, da1: true, unrelated: "xy"},
18+ {name: "secondary DA is insufficient", data: "\x1b[>1;2c\x1b_Gi=31;OK\x1b\\", kitty: true, unrelated: "\x1b[>1;2c"},
19+ {name: "ordinary input", data: "hello", unrelated: "hello"},
20+ } {
21+ t.Run(tc.name, func(t *testing.T) {
22+ var p Probe
23+ got := append(p.Feed([]byte(tc.data)), p.Finish()...)
24+ require.Equal(t, tc.kitty, p.KittyGraphics())
25+ require.Equal(t, tc.da1, p.DA1())
26+ require.Equal(t, tc.unrelated, string(got))
27+ })
28+ }
29+}
30+
31+func TestProbeRecognizesFragmentedResponsesAndReplaysInput(t *testing.T) {
32+ var p Probe
33+ var unrelated []byte
34+ for _, fragment := range []string{"a\x1b", "[?1;2", "c", "b\x1b_Gi=31;", "OK\x1b", "\\z"} {
35+ unrelated = append(unrelated, p.Feed([]byte(fragment))...)
36+ }
37+ unrelated = append(unrelated, p.Finish()...)
38+ require.True(t, p.Ready())
39+ require.Equal(t, "abz", string(unrelated))
40+}
41+
42+func TestProbeBoundsUnterminatedResponse(t *testing.T) {
43+ var p Probe
44+ input := append([]byte("prefix"), []byte("\x1b_G")...)
45+ input = append(input, make([]byte, maxProbeResponseBytes+1)...)
46+ got := append(p.Feed(input), p.Finish()...)
47+ require.False(t, p.Ready())
48+ require.Equal(t, input, got)
49+}
@@ -0,0 +1,49 @@
1+package terminalquery
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+)
8+
9+func TestProbeRequiresKittyAndDA1(t *testing.T) {
10+ for _, tc := range []struct {
11+ name string
12+ data string
13+ kitty bool
14+ da1 bool
15+ unrelated string
16+ }{
17+ {name: "both and input", data: "x\x1b[?1;2c" + "\x1b_Gi=31;OK\x1b\\y", kitty: true, da1: true, unrelated: "xy"},
18+ {name: "secondary DA is insufficient", data: "\x1b[>1;2c\x1b_Gi=31;OK\x1b\\", kitty: true, unrelated: "\x1b[>1;2c"},
19+ {name: "ordinary input", data: "hello", unrelated: "hello"},
20+ } {
21+ t.Run(tc.name, func(t *testing.T) {
22+ var p Probe
23+ got := append(p.Feed([]byte(tc.data)), p.Finish()...)
24+ require.Equal(t, tc.kitty, p.KittyGraphics())
25+ require.Equal(t, tc.da1, p.DA1())
26+ require.Equal(t, tc.unrelated, string(got))
27+ })
28+ }
29+}
30+
31+func TestProbeRecognizesFragmentedResponsesAndReplaysInput(t *testing.T) {
32+ var p Probe
33+ var unrelated []byte
34+ for _, fragment := range []string{"a\x1b", "[?1;2", "c", "b\x1b_Gi=31;", "OK\x1b", "\\z"} {
35+ unrelated = append(unrelated, p.Feed([]byte(fragment))...)
36+ }
37+ unrelated = append(unrelated, p.Finish()...)
38+ require.True(t, p.Ready())
39+ require.Equal(t, "abz", string(unrelated))
40+}
41+
42+func TestProbeBoundsUnterminatedResponse(t *testing.T) {
43+ var p Probe
44+ input := append([]byte("prefix"), []byte("\x1b_G")...)
45+ input = append(input, make([]byte, maxProbeResponseBytes+1)...)
46+ got := append(p.Feed(input), p.Finish()...)
47+ require.False(t, p.Ready())
48+ require.Equal(t, input, got)
49+}

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