v0.7.0 4c16dd8

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

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