main ab6686c

query.go RawHistoryFolder

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.
4package terminalquery
5
6import "bytes"
7
8const (
9	// KittyGraphicsQuery asks a terminal whether it understands the Kitty
10	// graphics protocol. The image id is deliberately reserved for probing and
11	// the 1×1 RGB payload is validated without being retained by a=q.
12	KittyGraphicsQuery = "\x1b_Gi=31,s=1,v=1,a=q,t=d,f=24;AAAA\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	// 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"
21
22	maxProbeResponseBytes = 256
23)
24
25// Probe incrementally recognizes the bounded responses to KittyGraphicsQuery
26// and DeviceAttributesQuery. Feed returns bytes that are not recognized probe
27// responses in their original order. A response may be split across any
28// number of calls.
29type Probe struct {
30	pending []byte
31	kitty   bool
32	da1     bool
33	// keyboard is set once a KittyKeyboardQuery response was observed.
34	keyboard bool
35}
36
37// Feed consumes one terminal input fragment and returns unrelated input. The
38// returned slice is owned by the Probe and is invalidated by the next call.
39func (p *Probe) Feed(data []byte) []byte {
40	if p == nil || len(data) == 0 {
41		return append([]byte(nil), data...)
42	}
43	// Never retain or copy an unbounded caller fragment into pending. scan
44	// leaves at most a bounded incomplete probe prefix, so feeding bounded
45	// chunks also prevents a small suffix from retaining a large backing array.
46	var unrelated []byte
47	for len(data) != 0 {
48		n := min(len(data), maxProbeResponseBytes+2)
49		p.pending = append(p.pending, data[:n]...)
50		unrelated = append(unrelated, p.scan(false)...)
51		data = data[n:]
52	}
53	return unrelated
54}
55
56// Finish flushes an incomplete or unrelated prefix without recognizing any
57// further response. It is used when the bounded probe deadline expires.
58func (p *Probe) Finish() []byte {
59	if p == nil {
60		return nil
61	}
62	return p.scan(true)
63}
64
65// Ready reports whether both independent capability responses were observed.
66func (p *Probe) Ready() bool { return p != nil && p.kitty && p.da1 }
67
68// KittyGraphics reports whether the Kitty graphics response was observed.
69func (p *Probe) KittyGraphics() bool { return p != nil && p.kitty }
70
71// KittyKeyboard reports whether a kitty keyboard protocol response was
72// observed. It is meaningful once DA1 is true.
73func (p *Probe) KittyKeyboard() bool { return p != nil && p.keyboard }
74
75// DA1 reports whether a primary device-attributes response was observed.
76func (p *Probe) DA1() bool { return p != nil && p.da1 }
77
78func (p *Probe) scan(flush bool) []byte {
79	var unrelated []byte
80	for len(p.pending) != 0 {
81		kittyStart := bytes.Index(p.pending, []byte("\x1b_G"))
82		da1Start := bytes.Index(p.pending, []byte("\x1b["))
83		start := -1
84		if kittyStart >= 0 {
85			start = kittyStart
86		}
87		if da1Start >= 0 && (start < 0 || da1Start < start) {
88			start = da1Start
89		}
90		if start < 0 {
91			if flush || len(p.pending) > 2 {
92				keep := 0
93				if !flush {
94					keep = suffixPrefixLen(p.pending)
95				}
96				if len(p.pending) > keep {
97					unrelated = append(unrelated, p.pending[:len(p.pending)-keep]...)
98					p.pending = p.pending[len(p.pending)-keep:]
99				}
100			}
101			p.retainPending()
102			break
103		}
104		if start > 0 {
105			unrelated = append(unrelated, p.pending[:start]...)
106			p.pending = p.pending[start:]
107		}
108		if p.pending[1] == '_' {
109			end, complete := probeTerminator(p.pending)
110			if !complete {
111				if flush || len(p.pending) > maxProbeResponseBytes {
112					unrelated = append(unrelated, p.pending[0])
113					p.pending = p.pending[1:]
114					continue
115				}
116				p.retainPending()
117				break
118			}
119			response := p.pending[:end]
120			if validKittyResponse(response) {
121				p.kitty = true
122				p.pending = p.pending[end:]
123				continue
124			}
125			unrelated = append(unrelated, p.pending[0])
126			p.pending = p.pending[1:]
127			continue
128		}
129		end, complete := da1Terminator(p.pending)
130		if !complete {
131			if flush || len(p.pending) > maxProbeResponseBytes {
132				unrelated = append(unrelated, p.pending[0])
133				p.pending = p.pending[1:]
134				continue
135			}
136			p.retainPending()
137			break
138		}
139		response := p.pending[:end]
140		if validDA1Response(response) {
141			p.da1 = true
142			p.pending = p.pending[end:]
143			continue
144		}
145		if validKittyKeyboardResponse(response) {
146			p.keyboard = true
147			p.pending = p.pending[end:]
148			continue
149		}
150		unrelated = append(unrelated, p.pending[0])
151		p.pending = p.pending[1:]
152	}
153	return unrelated
154}
155
156func (p *Probe) retainPending() {
157	if len(p.pending) == 0 {
158		p.pending = nil
159		return
160	}
161	p.pending = append([]byte(nil), p.pending...)
162}
163
164func probeTerminator(data []byte) (int, bool) {
165	for i := 3; i+1 < len(data) && i <= maxProbeResponseBytes; i++ {
166		if data[i] == '\x1b' && data[i+1] == '\\' {
167			return i + 2, true
168		}
169	}
170	return 0, false
171}
172
173func da1Terminator(data []byte) (int, bool) {
174	for i := 2; i < len(data) && i <= maxProbeResponseBytes; i++ {
175		if data[i] >= 0x40 && data[i] <= 0x7e {
176			return i + 1, true
177		}
178	}
179	return 0, false
180}
181
182func validKittyResponse(data []byte) bool {
183	return bytes.Equal(data, []byte("\x1b_Gi=31;OK\x1b\\"))
184}
185
186func validDA1Response(data []byte) bool {
187	if len(data) < 5 || !bytes.HasPrefix(data, []byte("\x1b[")) || data[len(data)-1] != 'c' || data[2] != '?' || len(data) > maxProbeResponseBytes {
188		return false
189	}
190	// DA1 is a primary response. Secondary DA (ESC[>...c) is deliberately
191	// not sufficient for a direct graphics declaration. The parameter body is
192	// a non-empty semicolon-separated list of decimal numbers.
193	body := data[3 : len(data)-1]
194	if len(body) == 0 || body[0] == ';' {
195		return false
196	}
197	previousSeparator := false
198	for _, b := range body {
199		switch {
200		case b >= '0' && b <= '9':
201			previousSeparator = false
202		case b == ';':
203			if previousSeparator {
204				return false
205			}
206			previousSeparator = true
207		default:
208			return false
209		}
210	}
211	// Kitty terminates its DA1 parameter list with a separator. DEC treats the
212	// resulting empty final parameter as zero, so both forms are valid.
213	return true
214}
215
216func suffixPrefixLen(data []byte) int {
217	for n := min(len(data), 2); n > 0; n-- {
218		if bytes.HasPrefix([]byte("\x1b_G"), data[len(data)-n:]) || bytes.HasPrefix([]byte("\x1b["), data[len(data)-n:]) {
219			return n
220		}
221	}
222	return 0
223}
224
225func min(a, b int) int {
226	if a < b {
227		return a
228	}
229	return b
230}
231
232// validKittyKeyboardResponse accepts ESC [ ? <decimal flags> u.
233func 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}

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