predictive_echo.go RawHistoryFolder

1package client
2
3import (
4	"bytes"
5	"slices"
6	"strconv"
7	"strings"
8	"time"
9	"unicode/utf8"
10
11	vt "github.com/bnema/vev-vt"
12	"github.com/bnema/vev/internal/domain"
13	"github.com/bnema/vev/internal/protocol"
14)
15
16// Predictive local echo, after mosh's PredictionEngine
17// (src/frontend/terminaloverlay.cc).
18//
19// On a slow link the client guesses the echo of printable keys and backspace
20// on the cursor row and draws it before the daemon confirms it. The guess is
21// checked against a local mirror of the daemon's composed output once the
22// daemon acknowledges the input in Output.Echo. Anything the engine cannot
23// model (control bytes, escape sequences, wide runes, the last column) makes
24// later guesses tentative: they stay hidden until a correct guess of the same
25// epoch confirms it. A wrong tentative guess kills its epoch; a wrong
26// confirmed guess resets the engine.
27//
28// The attached loop is the only caller, so the engine holds no lock.
29
30// Adaptive display thresholds from mosh. They apply to the send interval,
31// half the smoothed round trip clamped to [20ms, 250ms].
32const (
33	echoSRTTTriggerLow  = 20 * time.Millisecond
34	echoSRTTTriggerHigh = 30 * time.Millisecond
35	echoFlagTriggerLow  = 50 * time.Millisecond
36	echoFlagTriggerHigh = 80 * time.Millisecond
37
38	echoSendIntervalMin = 20 * time.Millisecond
39	echoSendIntervalMax = 250 * time.Millisecond
40
41	// echoGlitchThreshold shows predictions when one is pending this long.
42	echoGlitchThreshold = 250 * time.Millisecond
43	// echoGlitchFlagThreshold also underlines them.
44	echoGlitchFlagThreshold = 5000 * time.Millisecond
45	// echoGlitchRepairCount quick confirmations, each at least
46	// echoGlitchRepairMinInterval apart, cure a glitch.
47	echoGlitchRepairCount       = 10
48	echoGlitchRepairMinInterval = 150 * time.Millisecond
49
50	// echoServerDelay is removed from each round-trip sample so the
51	// thresholds compare network time, as mosh's SRTT does.
52	echoServerDelay = protocol.EchoAckDelay
53
54	// maxEchoSent bounds the send-time history used for round-trip samples.
55	maxEchoSent = 256
56	// maxEchoOriginals bounds the overwritten contents kept per cell.
57	maxEchoOriginals = 8
58)
59
60type echoValidity uint8
61
62const (
63	echoInactive echoValidity = iota
64	echoPending
65	echoCorrect
66	echoCorrectNoCredit
67	echoIncorrect
68)
69
70// echoCell is one predicted cell of the prediction row.
71type echoCell struct {
72	active      bool
73	unknown     bool
74	expires     uint64
75	epoch       uint64
76	predictedAt time.Time
77	replacement vt.Cell
78	originals   []rune
79}
80
81func (c *echoCell) tentative(confirmed uint64) bool { return c.epoch > confirmed }
82
83func (c *echoCell) reset() { *c = echoCell{originals: c.originals[:0]} }
84
85// resetWithOriginal keeps a known replacement as overwritten content, so a
86// later guess that merely restores it earns no credit.
87func (c *echoCell) resetWithOriginal() {
88	if !c.active || c.unknown {
89		c.reset()
90		return
91	}
92	originals := append(c.originals, c.replacement.Rune)
93	if len(originals) > maxEchoOriginals {
94		originals = originals[len(originals)-maxEchoOriginals:]
95	}
96	*c = echoCell{originals: originals}
97}
98
99func (c *echoCell) arm(seq, epoch uint64, now time.Time) {
100	c.active = true
101	c.epoch = epoch
102	c.expires = seq
103	c.predictedAt = now
104}
105
106// echoCursor is the predicted cursor position.
107type echoCursor struct {
108	active  bool
109	row     int
110	col     int
111	expires uint64
112	epoch   uint64
113}
114
115type echoSent struct {
116	seq uint64
117	at  time.Time
118}
119
120// echoPredictor is the prediction engine of one attachment run.
121type echoPredictor struct {
122	mode   domain.EchoPredictMode
123	screen *vt.Screen
124
125	sent      []echoSent
126	echo      uint64
127	srtt      time.Duration
128	srttKnown bool
129	srttOn    bool
130	flagging  bool
131	glitch    int
132	lastQuick time.Time
133	predEpoch uint64
134	confEpoch uint64
135	holdUntil uint64
136	// held is set by the first daemon frame after an unknown key (Enter)
137	// that is not its acknowledgement: that frame may move the guessed row,
138	// so guesses stay hidden until the key is acknowledged. Before such a
139	// frame, drawn guesses stay put.
140	held       bool
141	row        int
142	cells      []echoCell
143	cursor     echoCursor
144	drawnRow   int
145	drawn      []int
146	cursorMove bool
147	apc        apcFilter
148}
149
150func newEchoPredictor(mode domain.EchoPredictMode) *echoPredictor {
151	return &echoPredictor{mode: mode, predEpoch: 1, screen: vt.NewScreen(1, 1)}
152}
153
154// applyOutput feeds one applied Output to the mirror, samples the round trip
155// when Echo advances, and validates pending guesses.
156func (p *echoPredictor) applyOutput(output protocol.Output, now time.Time) {
157	// Side effects (New == 0) may carry a placeholder size; only state
158	// frames describe the grid.
159	if output.New != 0 && output.Size.Valid() && (output.Size.Cols != p.screen.Columns() || output.Size.Rows != p.screen.Rows()) {
160		p.screen.Resize(output.Size.Cols, output.Size.Rows)
161		// The resized frame repaints the whole terminal.
162		p.forget()
163	}
164	if output.Full {
165		// A full frame repaints everything: guesses placed against the old
166		// frame would land on unrelated content.
167		p.forget()
168	}
169	data := p.apc.strip(output.Data)
170	if len(data) > 0 {
171		p.screen.Write(data)
172	}
173	if output.Echo > p.echo {
174		p.sample(output.Echo, now)
175		p.echo = output.Echo
176	}
177	p.held = p.echo < p.holdUntil && (p.held || len(data) > 0)
178	p.cull(now)
179}
180
181// sample updates the smoothed round trip from the send time of seq.
182func (p *echoPredictor) sample(seq uint64, now time.Time) {
183	keep := p.sent[:0]
184	for _, sent := range p.sent {
185		if sent.seq == seq {
186			rtt := max(now.Sub(sent.at)-echoServerDelay, 0)
187			if p.srttKnown {
188				p.srtt = (7*p.srtt + rtt) / 8
189			} else {
190				p.srtt, p.srttKnown = rtt, true
191			}
192		}
193		if sent.seq > seq {
194			keep = append(keep, sent)
195		}
196	}
197	p.sent = keep
198}
199
200func (p *echoPredictor) sendInterval() time.Duration {
201	interval := (p.srtt + 1) / 2
202	return min(max(interval, echoSendIntervalMin), echoSendIntervalMax)
203}
204
205// enabled reports whether the mirror describes a screen the engine can
206// predict on: a visible cursor on a usable grid. The mirror holds the
207// daemon's composed frame, which never enters the alternate screen, so
208// full-screen pane programs rely on epoch validation, as in mosh.
209func (p *echoPredictor) enabled() bool {
210	return p.mode != domain.EchoPredictNever && p.screen.CursorVisible() &&
211		p.screen.Columns() > 1 && p.screen.Rows() > 0
212}
213
214func (p *echoPredictor) active() bool {
215	if p.cursor.active {
216		return true
217	}
218	for i := range p.cells {
219		if p.cells[i].active {
220			return true
221		}
222	}
223	return false
224}
225
226func (p *echoPredictor) displaying() bool {
227	switch p.mode {
228	case domain.EchoPredictNever:
229		return false
230	case domain.EchoPredictAlways:
231		return true
232	}
233	return p.srttOn || p.glitch > 0
234}
235
236func (p *echoPredictor) becomeTentative() { p.predEpoch++ }
237
238// reset drops every guess. Drawn cells stay recorded so render repaints them.
239func (p *echoPredictor) reset() {
240	for i := range p.cells {
241		p.cells[i].reset()
242	}
243	p.cursor = echoCursor{}
244	p.becomeTentative()
245}
246
247// forget drops every guess and the drawn record, for when something else
248// repaints or owns the terminal (a full frame, an overlay).
249func (p *echoPredictor) forget() {
250	p.reset()
251	p.drawn = p.drawn[:0]
252	p.cursorMove = false
253}
254
255func (p *echoPredictor) killEpoch(epoch uint64) {
256	for i := range p.cells {
257		if p.cells[i].active && p.cells[i].epoch >= epoch {
258			p.cells[i].reset()
259		}
260	}
261	if p.cursor.active && p.cursor.epoch >= epoch {
262		p.cursor = echoCursor{}
263	}
264	p.becomeTentative()
265}
266
267// cull validates guesses against the mirror and updates the display
268// triggers, as mosh's PredictionEngine::cull.
269func (p *echoPredictor) cull(now time.Time) {
270	if p.mode == domain.EchoPredictNever {
271		return
272	}
273	if !p.enabled() {
274		p.reset()
275		return
276	}
277	if p.srttKnown {
278		interval := p.sendInterval()
279		if interval > echoSRTTTriggerHigh {
280			p.srttOn = true
281		} else if p.srttOn && interval <= echoSRTTTriggerLow && !p.active() {
282			p.srttOn = false
283		}
284		if interval > echoFlagTriggerHigh {
285			p.flagging = true
286		} else if interval <= echoFlagTriggerLow {
287			p.flagging = false
288		}
289	}
290	if p.glitch > echoGlitchRepairCount {
291		p.flagging = true
292	}
293	if p.row >= p.screen.Rows() || len(p.cells) != p.screen.Columns() {
294		p.cells = nil
295	}
296	for col := range p.cells {
297		cell := &p.cells[col]
298		switch p.cellValidity(col) {
299		case echoIncorrect:
300			if cell.tentative(p.confEpoch) {
301				p.killEpoch(cell.epoch)
302				continue
303			}
304			p.reset()
305			return
306		case echoCorrect:
307			p.confEpoch = max(p.confEpoch, cell.epoch)
308			if now.Sub(cell.predictedAt) < echoGlitchThreshold && p.glitch > 0 && now.Sub(p.lastQuick) >= echoGlitchRepairMinInterval {
309				p.glitch--
310				p.lastQuick = now
311			}
312			// Later guesses take the confirmed style.
313			style := p.screen.Cell(col, p.row).Style
314			for k := col; k < len(p.cells); k++ {
315				p.cells[k].replacement.Style = style
316			}
317			cell.reset()
318		case echoCorrectNoCredit:
319			cell.reset()
320		case echoPending:
321			pending := now.Sub(cell.predictedAt)
322			if pending >= echoGlitchFlagThreshold {
323				p.glitch = 2 * echoGlitchRepairCount
324			} else if pending >= echoGlitchThreshold && p.glitch < echoGlitchRepairCount {
325				p.glitch = echoGlitchRepairCount
326			}
327		}
328	}
329	if p.cursor.active && p.echo >= p.cursor.expires {
330		if p.screen.CursorRow() != p.cursor.row || p.screen.CursorCol() != p.cursor.col {
331			p.reset()
332			return
333		}
334		p.cursor = echoCursor{}
335	}
336}
337
338func (p *echoPredictor) cellValidity(col int) echoValidity {
339	cell := &p.cells[col]
340	if !cell.active {
341		return echoInactive
342	}
343	if p.echo < cell.expires {
344		return echoPending
345	}
346	if cell.unknown || isBlankRune(cell.replacement.Rune) {
347		return echoCorrectNoCredit
348	}
349	current := p.screen.Cell(col, p.row)
350	if current.Rune != cell.replacement.Rune {
351		return echoIncorrect
352	}
353	for _, original := range cell.originals {
354		if original == cell.replacement.Rune {
355			return echoCorrectNoCredit
356		}
357	}
358	return echoCorrect
359}
360
361func isBlankRune(r rune) bool { return r == 0 || r == ' ' }
362
363// isBorderRune reports a box-drawing rune: a pane border or bar the shell
364// line never shifts across.
365func isBorderRune(r rune) bool { return r >= 0x2500 && r <= 0x257f }
366
367// isPlainCell reports a cell the engine can move and repaint exactly: one
368// column, no grapheme or hyperlink payload.
369func isPlainCell(cell vt.Cell) bool {
370	return !cell.Continuation && cell.Payload.Empty() && (cell.Rune == 0 || vt.RuneWidth(cell.Rune) == 1)
371}
372
373// unknownKey gives up on the cursor row until the daemon answers this input.
374func (p *echoPredictor) unknownKey(seq uint64) {
375	p.becomeTentative()
376	p.cursor = echoCursor{}
377	p.holdUntil = seq
378}
379
380// input records the Input numbered seq and predicts its bytes.
381func (p *echoPredictor) input(seq uint64, data []byte, now time.Time) {
382	if seq == 0 || p.mode == domain.EchoPredictNever {
383		return
384	}
385	if len(p.sent) == maxEchoSent {
386		p.sent = append(p.sent[:0], p.sent[1:]...)
387	}
388	p.sent = append(p.sent, echoSent{seq: seq, at: now})
389	p.cull(now)
390	// A daemon that never acknowledges echo (Echo stays 0) could never
391	// confirm or refute a guess, so nothing is predicted before the first
392	// acknowledgement.
393	if !p.enabled() || !p.srttKnown {
394		return
395	}
396	// A bracketed paste arrives as one Input: it is not typing.
397	if bytes.Contains(data, []byte("\x1b[200~")) {
398		p.becomeTentative()
399		return
400	}
401	for len(data) > 0 {
402		if data[0] == 0x1b {
403			data = data[escapeLength(data):]
404			p.becomeTentative()
405			continue
406		}
407		r, size := utf8.DecodeRune(data)
408		data = data[size:]
409		switch {
410		case r == utf8.RuneError && size == 1:
411			p.becomeTentative()
412		case r == 0x7f:
413			p.backspace(seq, now)
414		case r == '\r':
415			// The next row's state is only known once the daemon answers.
416			p.unknownKey(seq)
417		case vt.RuneWidth(r) != 1:
418			p.becomeTentative()
419		default:
420			p.print(r, seq, now)
421		}
422	}
423}
424
425// escapeLength is the length of the escape sequence at the start of data, or
426// all of data when it is incomplete.
427func escapeLength(data []byte) int {
428	if len(data) < 2 {
429		return len(data)
430	}
431	switch data[1] {
432	case '[':
433		for i := 2; i < len(data); i++ {
434			if data[i] >= 0x40 && data[i] <= 0x7e {
435				return i + 1
436			}
437		}
438		return len(data)
439	case 'O':
440		return min(3, len(data))
441	case ']', 'P', '_', '^', 'X':
442		// OSC, DCS, APC, PM, SOS: up to BEL or ST.
443		for i := 2; i < len(data); i++ {
444			if data[i] == 0x07 {
445				return i + 1
446			}
447			if data[i] == 0x1b && i+1 < len(data) && data[i+1] == '\\' {
448				return i + 2
449			}
450		}
451		return len(data)
452	}
453	return 2
454}
455
456// initCursor starts the cursor prediction from the mirror, and the cell row
457// with it. It reports false while the cursor row is not known yet.
458func (p *echoPredictor) initCursor() bool {
459	if p.echo < p.holdUntil {
460		return false
461	}
462	if !p.cursor.active {
463		row, col := p.screen.CursorRow(), p.screen.CursorCol()
464		if row < 0 || row >= p.screen.Rows() || col < 0 || col >= p.screen.Columns() {
465			return false
466		}
467		p.cursor = echoCursor{active: true, row: row, col: col, epoch: p.predEpoch}
468	}
469	if p.cells == nil || p.row != p.cursor.row || len(p.cells) != p.screen.Columns() {
470		p.row = p.cursor.row
471		p.cells = make([]echoCell, p.screen.Columns())
472	}
473	return true
474}
475
476// effective is the cell the user will see at col: a known guess, else the
477// mirror.
478func (p *echoPredictor) effective(col int) (vt.Cell, bool) {
479	cell := &p.cells[col]
480	if cell.active {
481		if cell.unknown {
482			return vt.Cell{}, false
483		}
484		return cell.replacement, true
485	}
486	return p.screen.Cell(col, p.row), true
487}
488
489func (p *echoPredictor) moveCursor(col int, seq uint64) {
490	p.cursor.col = col
491	p.cursor.expires = seq
492	p.cursor.epoch = p.predEpoch
493}
494
495// print predicts one width-1 rune in insert mode. The shift stops at the
496// first blank, so a split layout's borders to the right never move.
497func (p *echoPredictor) print(r rune, seq uint64, now time.Time) {
498	if !p.initCursor() {
499		p.becomeTentative()
500		return
501	}
502	width := len(p.cells)
503	col := p.cursor.col
504	if col+1 >= width {
505		// mosh: the last column is tricky (wrap markers, shells differ).
506		p.becomeTentative()
507	}
508	end := col
509	for end < width-1 {
510		cell, known := p.effective(end)
511		if known && (!isPlainCell(cell) || isBorderRune(cell.Rune)) {
512			// Wide or payload text cannot be shifted exactly, and a border
513			// would move with the line.
514			p.unknownKey(seq)
515			return
516		}
517		if known && isBlankRune(cell.Rune) {
518			break
519		}
520		end++
521	}
522	for i := end; i > col; i-- {
523		cell := &p.cells[i]
524		prev, known := p.effective(i - 1)
525		original := p.screen.Cell(i, p.row).Rune
526		cell.resetWithOriginal()
527		cell.arm(seq, p.predEpoch, now)
528		cell.originals = append(cell.originals, original)
529		if i == width-1 || !known {
530			cell.unknown = true
531		} else {
532			cell.replacement = prev
533		}
534	}
535	style := vt.DefaultStyle()
536	if col > 0 {
537		if left, known := p.effective(col - 1); known {
538			style = left.Style
539		}
540	}
541	cell := &p.cells[col]
542	original := p.screen.Cell(col, p.row).Rune
543	cell.resetWithOriginal()
544	cell.arm(seq, p.predEpoch, now)
545	cell.replacement = vt.Cell{Rune: r, Style: style}
546	cell.originals = append(cell.originals, original)
547	if col < width-1 {
548		p.moveCursor(col+1, seq)
549	} else {
550		p.becomeTentative()
551		p.moveCursor(col, seq)
552	}
553}
554
555// backspace predicts DEL: the cursor moves left and the text after it moves
556// left up to the first blank.
557func (p *echoPredictor) backspace(seq uint64, now time.Time) {
558	if !p.initCursor() {
559		p.becomeTentative()
560		return
561	}
562	if p.cursor.col == 0 {
563		return
564	}
565	width := len(p.cells)
566	col := p.cursor.col - 1
567	if left, known := p.effective(col); known && isBorderRune(left.Rune) {
568		// The input starts after a border (a modal box or pane divider):
569		// backspace cannot erase it.
570		p.becomeTentative()
571		return
572	}
573	for i := col; i < width; i++ {
574		cell, known := p.effective(i)
575		if known && !isPlainCell(cell) {
576			p.unknownKey(seq)
577			return
578		}
579		if known && (isBlankRune(cell.Rune) || isBorderRune(cell.Rune)) {
580			break
581		}
582	}
583	p.moveCursor(col, seq)
584	for i := col; i < width; i++ {
585		cell := &p.cells[i]
586		original := p.screen.Cell(i, p.row).Rune
587		var next vt.Cell
588		known := false
589		if i+2 < width {
590			next, known = p.effective(i + 1)
591		}
592		blank := known && isBlankRune(next.Rune)
593		if known && isBorderRune(next.Rune) {
594			// The text ends at a border: what fills this cell is unknown.
595			known, blank = false, true
596		}
597		cell.resetWithOriginal()
598		cell.arm(seq, p.predEpoch, now)
599		cell.originals = append(cell.originals, original)
600		if !known {
601			cell.unknown = true
602		} else {
603			cell.replacement = next
604		}
605		if blank {
606			break
607		}
608	}
609}
610
611// needsTick reports whether a pending guess still waits on a timing trigger
612// (mosh's timing_tests_necessary), so the caller re-culls without output.
613func (p *echoPredictor) needsTick() bool {
614	return p.mode == domain.EchoPredictAdaptive && p.active() && !(p.glitch > 0 && p.flagging)
615}
616
617// undraw returns the bytes that repaint every drawn guess from the mirror, so
618// a daemon frame lands on the screen the daemon believes it has. A frame that
619// scrolls would otherwise move a guess where nothing ever repairs it. The
620// guesses stay pending and render draws them again after the frame.
621func (p *echoPredictor) undraw() []byte {
622	if len(p.drawn) == 0 {
623		return nil
624	}
625	var out bytes.Buffer
626	if p.drawnRow < p.screen.Rows() {
627		for _, col := range p.drawn {
628			if col < p.screen.Columns() {
629				p.restoreCell(&out, p.drawnRow, col)
630			}
631		}
632	}
633	p.drawn = p.drawn[:0]
634	if out.Len() > 0 || p.cursorMove {
635		// The daemon frame is relative to its own cursor, not the predicted one.
636		out.WriteString("\x1b[0m")
637		writeEchoCUP(&out, p.screen.CursorRow(), p.screen.CursorCol())
638		p.cursorMove = false
639	}
640	return out.Bytes()
641}
642
643// render returns the terminal bytes that bring the screen from the last
644// render to the current guesses: drawn cells that no longer show a guess are
645// repainted from the mirror, shown guesses are drawn again (a daemon frame
646// may have overwritten them), and the cursor goes to the predicted position
647// or back to the real one.
648func (p *echoPredictor) render() []byte {
649	var out bytes.Buffer
650	show := p.displaying() && p.enabled()
651	var want []int
652	// Once a frame arrived while an unknown key (Enter) is unanswered, the
653	// guesses' row may have moved, so none is drawn until the answer.
654	if show && p.cells != nil && p.row < p.screen.Rows() && !(p.held && p.echo < p.holdUntil) {
655		for col := range p.cells {
656			cell := &p.cells[col]
657			if !cell.active || cell.unknown || cell.tentative(p.confEpoch) {
658				continue
659			}
660			if p.screen.Cell(col, p.row).Equal(cell.replacement) && !p.flagging {
661				continue
662			}
663			want = append(want, col)
664		}
665	}
666	for _, col := range p.drawn {
667		if p.drawnRow == p.row && slices.Contains(want, col) {
668			continue
669		}
670		if p.drawnRow < p.screen.Rows() && col < p.screen.Columns() {
671			p.restoreCell(&out, p.drawnRow, col)
672		}
673	}
674	for _, col := range want {
675		cell := p.cells[col].replacement
676		flag := p.flagging && !(isBlankRune(cell.Rune) && isBlankRune(p.screen.Cell(col, p.row).Rune))
677		writeEchoCell(&out, p.row, col, cell, flag)
678	}
679	p.drawn = append(p.drawn[:0], want...)
680	p.drawnRow = p.row
681
682	row, col := p.screen.CursorRow(), p.screen.CursorCol()
683	moved := false
684	if show && p.cursor.active && p.cursor.epoch <= p.confEpoch {
685		moved = p.cursor.row != row || p.cursor.col != col
686		row, col = p.cursor.row, p.cursor.col
687	}
688	if out.Len() > 0 || moved || p.cursorMove {
689		out.WriteString("\x1b[0m")
690		writeEchoCUP(&out, row, col)
691	}
692	p.cursorMove = moved
693	return out.Bytes()
694}
695
696func writeEchoCUP(out *bytes.Buffer, row, col int) {
697	out.WriteString("\x1b[")
698	out.WriteString(strconv.Itoa(row + 1))
699	out.WriteByte(';')
700	out.WriteString(strconv.Itoa(col + 1))
701	out.WriteByte('H')
702}
703
704// restoreCell repaints the mirror's cell at (row, col). The right half of a
705// wide glyph is repainted from its left cell, which covers both columns.
706func (p *echoPredictor) restoreCell(out *bytes.Buffer, row, col int) {
707	cell := p.screen.Cell(col, row)
708	if cell.Continuation && col > 0 {
709		col--
710		cell = p.screen.Cell(col, row)
711	}
712	writeEchoCell(out, row, col, cell, false)
713}
714
715// writeEchoCell draws one cell. Mirror styles come from daemon output already
716// projected to this terminal's color profile, so they are written as is.
717func writeEchoCell(out *bytes.Buffer, row, col int, cell vt.Cell, underline bool) {
718	writeEchoCUP(out, row, col)
719	out.WriteString(echoSGR(cell.Style, underline))
720	switch {
721	case cell.Continuation || cell.Rune == 0:
722		out.WriteByte(' ')
723	case cell.Payload.Grapheme() != "":
724		out.WriteString(cell.Payload.Grapheme())
725	default:
726		out.WriteRune(cell.Rune)
727	}
728}
729
730func echoSGR(style vt.Style, underline bool) string {
731	var b strings.Builder
732	b.WriteString("\x1b[0")
733	if style.Bold {
734		b.WriteString(";1")
735	}
736	if style.Attrs&vt.AttrDim != 0 {
737		b.WriteString(";2")
738	}
739	if style.Italic {
740		b.WriteString(";3")
741	}
742	switch {
743	case underline:
744		b.WriteString(";4")
745	case style.Attrs&vt.AttrUnderline != 0:
746		switch style.UnderlineStyle {
747		case vt.UnderlineDouble:
748			b.WriteString(";21")
749		case vt.UnderlineCurly:
750			b.WriteString(";4:3")
751		case vt.UnderlineDotted:
752			b.WriteString(";4:4")
753		case vt.UnderlineDashed:
754			b.WriteString(";4:5")
755		default:
756			b.WriteString(";4")
757		}
758	}
759	if style.Attrs&vt.AttrBlink != 0 {
760		b.WriteString(";5")
761	}
762	if style.Inverse {
763		b.WriteString(";7")
764	}
765	if style.Attrs&vt.AttrStrikethrough != 0 {
766		b.WriteString(";9")
767	}
768	writeEchoColor(&b, style.HasForegroundRGB, style.ForegroundRGB, style.Foreground, 38, 30, 90)
769	writeEchoColor(&b, style.HasBackgroundRGB, style.BackgroundRGB, style.Background, 48, 40, 100)
770	switch {
771	case style.HasUnderlineColorRGB:
772		b.WriteString(";58;2;" + strconv.Itoa(int(style.UnderlineColorRGB.R)) + ";" + strconv.Itoa(int(style.UnderlineColorRGB.G)) + ";" + strconv.Itoa(int(style.UnderlineColorRGB.B)))
773	case style.HasUnderlineColor:
774		b.WriteString(";58;5;" + strconv.Itoa(style.UnderlineColor))
775	}
776	b.WriteByte('m')
777	return b.String()
778}
779
780func writeEchoColor(b *strings.Builder, hasRGB bool, rgb vt.RGB, index, extended, normal, bright int) {
781	switch {
782	case hasRGB:
783		b.WriteString(";" + strconv.Itoa(extended) + ";2;" + strconv.Itoa(int(rgb.R)) + ";" + strconv.Itoa(int(rgb.G)) + ";" + strconv.Itoa(int(rgb.B)))
784	case index < 0:
785	case index < 8:
786		b.WriteString(";" + strconv.Itoa(normal+index))
787	case index < 16:
788		b.WriteString(";" + strconv.Itoa(bright+index-8))
789	case index < 256:
790		b.WriteString(";" + strconv.Itoa(extended) + ";5;" + strconv.Itoa(index))
791	}
792}
793
794// apcFilter drops APC strings (ESC _ ... ST), which carry kitty graphics, from
795// the mirror's input: the engine needs text only, and decoding images would
796// cost the client CPU and memory. Its state spans Output boundaries.
797type apcFilter struct {
798	state uint8
799}
800
801const (
802	apcText uint8 = iota
803	apcEscape
804	apcBody
805	apcBodyEscape
806)
807
808func (f *apcFilter) strip(data []byte) []byte {
809	if f.state == apcText && bytes.IndexByte(data, 0x1b) < 0 {
810		return data
811	}
812	out := make([]byte, 0, len(data))
813	for _, b := range data {
814		switch f.state {
815		case apcText:
816			if b == 0x1b {
817				f.state = apcEscape
818				continue
819			}
820			out = append(out, b)
821		case apcEscape:
822			if b == '_' {
823				f.state = apcBody
824				continue
825			}
826			out = append(out, 0x1b, b)
827			f.state = apcText
828			if b == 0x1b {
829				out = out[:len(out)-1]
830				f.state = apcEscape
831			}
832		case apcBody:
833			if b == 0x1b {
834				f.state = apcBodyEscape
835			}
836		case apcBodyEscape:
837			switch b {
838			case '\\':
839				f.state = apcText
840			case 0x1b:
841			default:
842				f.state = apcBody
843			}
844		}
845	}
846	return out
847}

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 12

Tags 11

↑↓ moveenter openesc close