1package client
2
3import (
4 "bytes"
5 "sync"
6 "time"
7 "unicode/utf8"
8)
9
10// This file owns user-input routing for a client-owned picker interaction.
11//
12// The attach loop is the single owner of the picker model: it publishes who
13// owns user input through pickerConsumer, and the stdin pump only decodes
14// batches into identified operations. The pump never mutates the model, so
15// the two goroutines never share it.
16//
17// While an interaction owns input, every byte read from the terminal
18// belongs to the picker: unknown or oversized sequences are consumed and
19// dropped rather than forwarded to the session. There is deliberately no
20// fallback to the PTY pipeline.
21
22// pickerPendingLimit bounds a withheld escape-sequence prefix. A longer
23// incomplete sequence is unrecognized input and is discarded.
24const pickerPendingLimit = 8
25
26// pickerEscapeDeadline bounds how long a lone ESC is withheld while the
27// picker owns input, so ESC and the start of a CSI/SS3 sequence are told
28// apart without ever falling back to the session pipeline.
29const pickerEscapeDeadline = 20 * time.Millisecond
30
31// Bracketed-paste delimiters. Their content is dropped: a paste must never
32// become a run of modal commands, and the picker has no text field that
33// wants pasted input.
34const (
35 pasteOpenMarker = "\x1b[200~"
36 pasteCloseMarker = "\x1b[201~"
37)
38
39// pickerInputState is the attach loop's publication of the current input
40// owner: zero interaction with drain unset means the session owns input.
41type pickerInputState struct {
42 interaction uint64
43 generation uint64
44 // drain consumes and drops every batch while the interaction is
45 // retired but its authoritative repaint is still in flight.
46 drain bool
47}
48
49// pickerEventKind distinguishes a command token from a typed rune. Events
50// keep their arrival order: a batch must never reorder input.
51type pickerEventKind uint8
52
53const (
54 pickerEventKey pickerEventKind = iota
55 pickerEventRune
56)
57
58// pickerEvent is one decoded input event.
59type pickerEvent struct {
60 kind pickerEventKind
61 key string
62 r rune
63}
64
65// pickerConsumer is the slot shared between the attach loop and the stdin
66// pump. It protects the published owner, the withheld escape or UTF-8
67// prefix, and the paste state; it holds no picker model.
68type pickerConsumer struct {
69 mu sync.Mutex
70 state pickerInputState
71 pending []byte
72 pasting bool
73 pasteTail []byte
74}
75
76// setOwned publishes the interaction that now owns user input.
77func (c *pickerConsumer) setOwned(interaction, generation uint64) {
78 if c == nil {
79 return
80 }
81 c.mu.Lock()
82 c.state = pickerInputState{interaction: interaction, generation: generation}
83 c.resetDecoderLocked()
84 c.mu.Unlock()
85}
86
87// clear releases input back to the session and purges the decoder state.
88func (c *pickerConsumer) clear() {
89 if c == nil {
90 return
91 }
92 c.mu.Lock()
93 c.state = pickerInputState{}
94 c.resetDecoderLocked()
95 c.mu.Unlock()
96}
97
98// resetDecoderLocked drops a withheld prefix and any paste state. A retired
99// owner must never leak a half-decoded sequence into the next one.
100func (c *pickerConsumer) resetDecoderLocked() {
101 c.pending = nil
102 c.pasting = false
103 c.pasteTail = nil
104}
105
106// consume decodes one read or admitted automation batch into an identified
107// operation. The second result is false when the session owns input, so the
108// caller keeps its normal pipeline. During the release window every batch is
109// consumed and dropped.
110func (c *pickerConsumer) consume(record terminalReadResult) (pickerConsumeOutcome, bool) {
111 if c == nil {
112 return pickerConsumeOutcome{}, false
113 }
114 c.mu.Lock()
115 state := c.state
116 if state.interaction == 0 && !state.drain {
117 c.mu.Unlock()
118 return pickerConsumeOutcome{}, false
119 }
120 outcome := pickerConsumeOutcome{
121 consumed: true, actionID: record.actionID,
122 generation: state.generation, interaction: state.interaction,
123 }
124 switch {
125 case state.drain:
126 // Retired interaction: consume and drop, including any prefix.
127 c.resetDecoderLocked()
128 case record.keys == nil && record.text == "":
129 var batch pickerInputBatch
130 c.pending, c.pasting, c.pasteTail = c.decodeLocked(record.data, &batch)
131 outcome.events = batch.events
132 default:
133 outcome.events = automationEvents(record.keys, record.text)
134 }
135 c.mu.Unlock()
136 return outcome, true
137}
138
139// flushPending resolves a withheld prefix once its disambiguation window
140// expired. A bare escape becomes Escape; an incomplete escape sequence is
141// dropped; an incomplete UTF-8 prefix is dropped. A paste in progress stays
142// in progress: its content is never replayed as commands.
143func (c *pickerConsumer) flushPending() (pickerConsumeOutcome, bool) {
144 if c == nil {
145 return pickerConsumeOutcome{}, false
146 }
147 c.mu.Lock()
148 state, pending := c.state, c.pending
149 c.pending = nil
150 c.mu.Unlock()
151 if len(pending) == 0 || state.drain {
152 return pickerConsumeOutcome{}, false
153 }
154 outcome := pickerConsumeOutcome{consumed: true, generation: state.generation, interaction: state.interaction}
155 if string(pending) == "\x1b" {
156 outcome.events = []pickerEvent{{kind: pickerEventKey, key: "Escape"}}
157 }
158 return outcome, true
159}
160
161// hasPending reports whether an escape or UTF-8 prefix is withheld.
162func (c *pickerConsumer) hasPending() bool {
163 if c == nil {
164 return false
165 }
166 c.mu.Lock()
167 defer c.mu.Unlock()
168 return len(c.pending) != 0
169}
170
171// automationEvents converts one admitted ui-driver op into ordered events.
172func automationEvents(keys []string, text string) []pickerEvent {
173 events := make([]pickerEvent, 0, len(keys)+len(text))
174 for _, key := range keys {
175 events = append(events, pickerEvent{kind: pickerEventKey, key: key})
176 }
177 for _, r := range text {
178 events = append(events, pickerEvent{kind: pickerEventRune, r: r})
179 }
180 return events
181}
182
183// decoder carries the paste state across reads: a paste's boundaries can
184// span several terminal reads, and its content must never be interpreted.
185type decoder struct {
186 pending []byte
187 pasting bool
188 tail []byte
189}
190
191// decodeLocked decodes bytes under the consumer lock and returns the next
192// pending prefix, paste state, and paste tail.
193func (c *pickerConsumer) decodeLocked(data []byte, batch *pickerInputBatch) ([]byte, bool, []byte) {
194 d := decoder{pending: c.pending, pasting: c.pasting, tail: c.pasteTail}
195 d.decode(data, batch)
196 return d.pending, d.pasting, d.tail
197}
198
199// decode consumes one read into ordered events. A non-nil pending result is
200// an incomplete escape or UTF-8 prefix to withhold until the next read.
201func (d *decoder) decode(data []byte, batch *pickerInputBatch) {
202 buf := data
203 if len(d.pending) != 0 {
204 buf = append(append([]byte(nil), d.pending...), data...)
205 d.pending = nil
206 }
207 if d.pasting {
208 buf = append(append([]byte(nil), d.tail...), buf...)
209 d.tail = nil
210 }
211 for i := 0; i < len(buf); {
212 if d.pasting {
213 end := bytes.Index(buf[i:], []byte(pasteCloseMarker))
214 if end < 0 {
215 // Hold only enough trailing bytes to recognize a marker split
216 // across reads; everything else in the paste is dropped.
217 keep := len(pasteCloseMarker) - 1
218 if len(buf)-i > keep {
219 d.tail = append([]byte(nil), buf[len(buf)-keep:]...)
220 } else {
221 d.tail = append([]byte(nil), buf[i:]...)
222 }
223 return
224 }
225 i += end + len(pasteCloseMarker)
226 d.pasting = false
227 continue
228 }
229 switch b := buf[i]; {
230 case b == '\r' || b == '\n':
231 batch.events = append(batch.events, pickerEvent{kind: pickerEventKey, key: "Enter"})
232 i++
233 case b == 0x7f || b == 0x08:
234 batch.events = append(batch.events, pickerEvent{kind: pickerEventKey, key: "Backspace"})
235 i++
236 case b == 0x03:
237 batch.events = append(batch.events, pickerEvent{kind: pickerEventKey, key: "Ctrl+C"})
238 i++
239 case b == 0x1b:
240 consumed, held, paste := decodeEscape(buf[i:], batch)
241 if held != nil {
242 d.pending = held
243 return
244 }
245 if paste {
246 d.pasting = true
247 }
248 i += consumed
249 case b < 0x20:
250 // Other control bytes carry no picker meaning; consume them so
251 // they cannot reach the session.
252 i++
253 case b < utf8.RuneSelf:
254 batch.events = append(batch.events, pickerEvent{kind: pickerEventKey, key: string(rune(b))})
255 i++
256 default:
257 r, size := utf8.DecodeRune(buf[i:])
258 if r == utf8.RuneError && size <= 1 {
259 if !utf8PrefixIncomplete(buf[i:]) {
260 i++ // Invalid UTF-8: consume the byte and keep scanning.
261 continue
262 }
263 d.pending = append([]byte(nil), buf[i:]...)
264 return
265 }
266 batch.events = append(batch.events, pickerEvent{kind: pickerEventRune, r: r})
267 i += size
268 }
269 }
270}
271
272// decodeEscape consumes one escape-led sequence starting at buf[0]. It
273// returns the number of bytes consumed; a non-nil result is an incomplete
274// prefix to withhold until the next read. paste reports the bracketed-paste
275// start marker.
276func decodeEscape(buf []byte, batch *pickerInputBatch) (consumed int, held []byte, paste bool) {
277 if len(buf) == 1 {
278 return 0, append([]byte(nil), buf...), false
279 }
280 if buf[1] != '[' && buf[1] != 'O' {
281 // ESC followed by an ordinary byte: the escape stands alone.
282 batch.events = append(batch.events, pickerEvent{kind: pickerEventKey, key: "Escape"})
283 return 1, nil, false
284 }
285 if len(buf) == 2 {
286 return 0, append([]byte(nil), buf...), false
287 }
288 switch buf[2] {
289 case 'A':
290 batch.events = append(batch.events, pickerEvent{kind: pickerEventKey, key: "Up"})
291 return 3, nil, false
292 case 'B':
293 batch.events = append(batch.events, pickerEvent{kind: pickerEventKey, key: "Down"})
294 return 3, nil, false
295 }
296 // Any other CSI/SS3 sequence (mouse reports, probes, paste markers) is
297 // consumed through its final byte and dropped.
298 final := -1
299 for i := 2; i < len(buf); i++ {
300 if buf[i] >= 0x40 && buf[i] <= 0x7e {
301 final = i
302 break
303 }
304 }
305 if final < 0 {
306 if len(buf) > pickerPendingLimit {
307 return len(buf), nil, false
308 }
309 return 0, append([]byte(nil), buf...), false
310 }
311 seq := buf[:final+1]
312 if string(seq) == pasteOpenMarker {
313 return final + 1, nil, true
314 }
315 return final + 1, nil, false
316}
317
318// utf8PrefixIncomplete reports whether b is a valid but incomplete UTF-8
319// sequence start, which must be withheld until its continuation bytes
320// arrive instead of being dropped.
321func utf8PrefixIncomplete(b []byte) bool {
322 if len(b) == 0 || !utf8.RuneStart(b[0]) {
323 return false
324 }
325 need := 0
326 switch {
327 case b[0]&0xe0 == 0xc0:
328 need = 2
329 case b[0]&0xf0 == 0xe0:
330 need = 3
331 case b[0]&0xf8 == 0xf0:
332 need = 4
333 default:
334 return false
335 }
336 if len(b) >= need {
337 return false
338 }
339 for _, c := range b[1:] {
340 if c&0xc0 != 0x80 {
341 return false
342 }
343 }
344 return true
345}