predictive_echo_test.go RawHistoryFolder

1package client
2
3import (
4	"bytes"
5	"strings"
6	"testing"
7	"time"
8
9	"github.com/bnema/vev/internal/domain"
10	"github.com/bnema/vev/internal/protocol"
11)
12
13var echoTestStart = time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
14
15// echoHarness drives one predictor against a 20x3 mirror whose prompt "$ "
16// leaves the cursor at row 0, column 2.
17type echoHarness struct {
18	t   *testing.T
19	p   *echoPredictor
20	now time.Time
21	seq uint64
22}
23
24func newEchoHarness(t *testing.T, mode domain.EchoPredictMode) *echoHarness {
25	t.Helper()
26	h := &echoHarness{t: t, p: newEchoPredictor(mode), now: echoTestStart}
27	h.p.applyOutput(protocol.Output{Epoch: 1, New: 1, Full: true, Size: domain.Size{Cols: 20, Rows: 3}, Data: []byte("\x1b[H\x1b[2J$ ")}, h.now)
28	return h
29}
30
31// warm sends one empty input and acknowledges it after rtt of network time.
32func (h *echoHarness) warm(rtt time.Duration) {
33	h.seq++
34	h.p.input(h.seq, nil, h.now)
35	h.now = h.now.Add(rtt + echoServerDelay)
36	h.p.applyOutput(protocol.Output{Epoch: 1, Echo: h.seq}, h.now)
37}
38
39func (h *echoHarness) typed(data string) []byte {
40	h.seq++
41	h.p.input(h.seq, []byte(data), h.now)
42	return h.p.render()
43}
44
45// daemon applies a state frame that writes data and acknowledges echo.
46func (h *echoHarness) daemon(data string, echo uint64) []byte {
47	h.now = h.now.Add(time.Millisecond)
48	h.p.applyOutput(protocol.Output{Epoch: 1, New: 2, Echo: echo, Size: domain.Size{Cols: 20, Rows: 3}, Data: []byte(data)}, h.now)
49	return h.p.render()
50}
51
52// confirm types a and lets the daemon echo it, which confirms the first epoch.
53func (h *echoHarness) confirm() {
54	h.t.Helper()
55	if got := h.typed("a"); len(got) != 0 {
56		h.t.Fatalf("first guess is tentative, rendered %q", got)
57	}
58	h.daemon("\x1b[1;3Ha", h.seq)
59	if h.p.confEpoch == 0 {
60		h.t.Fatal("echoed guess did not confirm its epoch")
61	}
62}
63
64func TestEchoPredictorDisplay(t *testing.T) {
65	t.Parallel()
66	tests := []struct {
67		name string
68		mode domain.EchoPredictMode
69		rtt  time.Duration
70		want string
71	}{
72		{name: "fast link hides guesses", mode: domain.EchoPredictAdaptive, rtt: 10 * time.Millisecond, want: ""},
73		{name: "slow link shows guesses", mode: domain.EchoPredictAdaptive, rtt: 100 * time.Millisecond, want: "\x1b[1;4H\x1b[0mb\x1b[0m\x1b[1;5H"},
74		{name: "very slow link underlines guesses", mode: domain.EchoPredictAdaptive, rtt: 200 * time.Millisecond, want: "\x1b[1;4H\x1b[0;4mb\x1b[0m\x1b[1;5H"},
75		{name: "always shows on a fast link", mode: domain.EchoPredictAlways, rtt: 10 * time.Millisecond, want: "\x1b[1;4H\x1b[0mb\x1b[0m\x1b[1;5H"},
76		{name: "never predicts", mode: domain.EchoPredictNever, rtt: 200 * time.Millisecond, want: ""},
77	}
78	for _, tt := range tests {
79		t.Run(tt.name, func(t *testing.T) {
80			t.Parallel()
81			h := newEchoHarness(t, tt.mode)
82			h.warm(tt.rtt)
83			if tt.mode != domain.EchoPredictNever {
84				h.confirm()
85			}
86			if got := string(h.typed("b")); got != tt.want {
87				t.Fatalf("render = %q, want %q", got, tt.want)
88			}
89		})
90	}
91}
92
93// TestEchoPredictorNeverDrawsStaleGuesses covers daemon frames that arrive
94// before the echo acknowledgement and move or replace the guessed row.
95func TestEchoPredictorNeverDrawsStaleGuesses(t *testing.T) {
96	t.Parallel()
97	tests := []struct {
98		name  string
99		input string
100		frame protocol.Output
101	}{
102		{
103			name:  "enter then a scrolled prompt before the ack",
104			input: "b\r",
105			frame: protocol.Output{Epoch: 1, New: 2, Size: domain.Size{Cols: 20, Rows: 3}, Data: []byte("b\r\nfoo\r\nbar\r\n$ ")},
106		},
107		{
108			name:  "full frame before the ack",
109			input: "b",
110			frame: protocol.Output{Epoch: 1, New: 2, Full: true, Size: domain.Size{Cols: 20, Rows: 3}, Data: []byte("\x1b[H\x1b[2Jother")},
111		},
112	}
113	for _, tt := range tests {
114		t.Run(tt.name, func(t *testing.T) {
115			t.Parallel()
116			h := newEchoHarness(t, domain.EchoPredictAlways)
117			h.warm(100 * time.Millisecond)
118			h.confirm()
119			h.typed(tt.input)
120			h.p.undraw()
121			h.p.applyOutput(tt.frame, h.now)
122			if got := h.p.render(); bytes.Contains(got, []byte("b")) {
123				t.Fatalf("render = %q redraws a guess over the new frame", got)
124			}
125		})
126	}
127}
128
129func TestEchoPredictorEnterKeepsDrawnGuessesUntilAFrame(t *testing.T) {
130	t.Parallel()
131	h := newEchoHarness(t, domain.EchoPredictAlways)
132	h.warm(100 * time.Millisecond)
133	h.confirm()
134	if got := h.typed("b"); !bytes.Contains(got, []byte("b")) {
135		t.Fatalf("render = %q, want the guess drawn", got)
136	}
137	if got := h.typed("\r"); bytes.Contains(got, []byte(" ")) {
138		t.Fatalf("render after Enter = %q erases the typed guess before any frame", got)
139	}
140}
141
142func TestEchoPredictorUndrawRestoresMirror(t *testing.T) {
143	t.Parallel()
144	h := newEchoHarness(t, domain.EchoPredictAlways)
145	h.warm(100 * time.Millisecond)
146	h.confirm()
147	if got := h.typed("b"); !bytes.Contains(got, []byte("b")) {
148		t.Fatalf("render = %q, want the guess drawn", got)
149	}
150	if got, want := string(h.p.undraw()), "\x1b[1;4H\x1b[0m \x1b[0m\x1b[1;4H"; got != want {
151		t.Fatalf("undraw = %q, want %q", got, want)
152	}
153	if got := h.p.undraw(); len(got) != 0 {
154		t.Fatalf("second undraw = %q, want nothing", got)
155	}
156}
157
158func TestEchoPredictorConfirmationRepaintsFromMirror(t *testing.T) {
159	t.Parallel()
160	h := newEchoHarness(t, domain.EchoPredictAdaptive)
161	h.warm(200 * time.Millisecond)
162	h.confirm()
163	if got := string(h.typed("b")); !strings.Contains(got, "\x1b[0;4mb") {
164		t.Fatalf("pending guess is not underlined: %q", got)
165	}
166	got := string(h.daemon("\x1b[1;4Hb", h.seq))
167	if want := "\x1b[1;4H\x1b[0mb\x1b[0m\x1b[1;5H"; got != want {
168		t.Fatalf("confirmed render = %q, want %q", got, want)
169	}
170	if h.p.active() {
171		t.Fatal("confirmed guess is still active")
172	}
173}
174
175func TestEchoPredictorWrongEchoErasesGuess(t *testing.T) {
176	t.Parallel()
177	h := newEchoHarness(t, domain.EchoPredictAdaptive)
178	h.warm(100 * time.Millisecond)
179	h.confirm()
180	if got := string(h.typed("p")); !strings.Contains(got, "p") {
181		t.Fatalf("guess not drawn: %q", got)
182	}
183	// A password prompt echoes nothing.
184	got := string(h.daemon("", h.seq))
185	if want := "\x1b[1;4H\x1b[0m \x1b[0m\x1b[1;4H"; got != want {
186		t.Fatalf("render after wrong echo = %q, want %q", got, want)
187	}
188	if h.p.active() {
189		t.Fatal("wrong guess survived")
190	}
191	// The reset makes the next guess tentative again.
192	if got := h.typed("q"); len(got) != 0 {
193		t.Fatalf("guess after reset rendered %q", got)
194	}
195}
196
197func TestEchoPredictorBackspace(t *testing.T) {
198	t.Parallel()
199	h := newEchoHarness(t, domain.EchoPredictAdaptive)
200	h.warm(100 * time.Millisecond)
201	h.confirm()
202	got := string(h.typed("\x7f"))
203	if want := "\x1b[1;3H\x1b[0m \x1b[0m\x1b[1;3H"; got != want {
204		t.Fatalf("backspace render = %q, want %q", got, want)
205	}
206	got = string(h.daemon("\x1b[1;3H \x1b[1;3H", h.seq))
207	if want := "\x1b[1;3H\x1b[0m \x1b[0m\x1b[1;3H"; got != want {
208		t.Fatalf("confirmed backspace render = %q, want %q", got, want)
209	}
210	if h.p.active() {
211		t.Fatal("confirmed backspace is still active")
212	}
213}
214
215func TestEchoPredictorInsertShiftsText(t *testing.T) {
216	t.Parallel()
217	h := newEchoHarness(t, domain.EchoPredictAlways)
218	h.warm(10 * time.Millisecond)
219	// "$ ac" with the cursor on c.
220	h.p.applyOutput(protocol.Output{Epoch: 1, New: 2, Size: domain.Size{Cols: 20, Rows: 3}, Data: []byte("ac\x1b[1;4H")}, h.now)
221	h.p.confEpoch = h.p.predEpoch
222	got := string(h.typed("b"))
223	if want := "\x1b[1;4H\x1b[0mb\x1b[1;5H\x1b[0mc\x1b[0m\x1b[1;5H"; got != want {
224		t.Fatalf("insert render = %q, want %q", got, want)
225	}
226}
227
228func TestEchoPredictorUnknownInputIsTentative(t *testing.T) {
229	t.Parallel()
230	tests := []struct {
231		name  string
232		input string
233	}{
234		{name: "control byte", input: "\x03"},
235		{name: "escape sequence", input: "\x1b[A"},
236		{name: "kitty CSI-u key", input: "\x1b[97;5u"},
237		{name: "carriage return", input: "\r"},
238		{name: "wide rune", input: "界"},
239		{name: "bracketed paste", input: "\x1b[200~text\x1b[201~"},
240	}
241	for _, tt := range tests {
242		t.Run(tt.name, func(t *testing.T) {
243			t.Parallel()
244			h := newEchoHarness(t, domain.EchoPredictAdaptive)
245			h.warm(100 * time.Millisecond)
246			h.confirm()
247			h.typed(tt.input)
248			if got := h.typed("z"); len(got) != 0 {
249				t.Fatalf("guess after %s rendered %q", tt.name, got)
250			}
251		})
252	}
253}
254
255func TestEchoPredictorWrongTentativeGuessKillsItsEpoch(t *testing.T) {
256	t.Parallel()
257	h := newEchoHarness(t, domain.EchoPredictAdaptive)
258	h.warm(100 * time.Millisecond)
259	h.confirm()
260	confirmed := h.p.confEpoch
261	h.typed("\x03z")
262	// The daemon cleared the line instead of echoing z.
263	h.daemon("\x1b[1;1H\x1b[2K$ ", h.seq)
264	if h.p.active() {
265		t.Fatal("killed epoch left guesses")
266	}
267	if h.p.confEpoch != confirmed {
268		t.Fatalf("confirmed epoch = %d, want %d", h.p.confEpoch, confirmed)
269	}
270}
271
272func TestEchoPredictorHiddenCursorDisables(t *testing.T) {
273	t.Parallel()
274	h := newEchoHarness(t, domain.EchoPredictAlways)
275	h.p.applyOutput(protocol.Output{Epoch: 1, New: 2, Size: domain.Size{Cols: 20, Rows: 3}, Data: []byte("\x1b[?25l")}, h.now)
276	h.p.confEpoch = h.p.predEpoch
277	if got := h.typed("b"); len(got) != 0 {
278		t.Fatalf("hidden cursor rendered %q", got)
279	}
280	if h.p.active() {
281		t.Fatal("hidden cursor kept guesses")
282	}
283}
284
285func TestEchoPredictorSRTTHysteresis(t *testing.T) {
286	t.Parallel()
287	h := newEchoHarness(t, domain.EchoPredictAdaptive)
288	h.warm(200 * time.Millisecond)
289	if !h.p.srttOn || !h.p.flagging {
290		t.Fatalf("slow link: srttOn=%v flagging=%v, want both on", h.p.srttOn, h.p.flagging)
291	}
292	sawMiddle := false
293	for range 64 {
294		h.warm(0)
295		interval := h.p.sendInterval()
296		if interval > echoSRTTTriggerLow && interval <= echoSRTTTriggerHigh {
297			sawMiddle = true
298			if !h.p.srttOn {
299				t.Fatalf("display turned off at send interval %v", interval)
300			}
301		}
302		if interval <= echoFlagTriggerLow && h.p.flagging {
303			t.Fatalf("still flagging at send interval %v", interval)
304		}
305		if interval <= echoSRTTTriggerLow {
306			break
307		}
308	}
309	if !sawMiddle {
310		t.Fatal("never sampled the hysteresis band")
311	}
312	if h.p.srttOn {
313		t.Fatal("display still on after the link recovered")
314	}
315}
316
317func TestEchoPredictorSRTTStaysOnWhileGuessesShow(t *testing.T) {
318	t.Parallel()
319	h := newEchoHarness(t, domain.EchoPredictAdaptive)
320	h.warm(100 * time.Millisecond)
321	h.confirm()
322	h.typed("b")
323	// Fast acknowledgements of other input keep the b guess pending.
324	h.p.srtt = 0
325	h.p.cull(h.now)
326	if !h.p.srttOn {
327		t.Fatal("display turned off while a guess is shown")
328	}
329}
330
331func TestEchoPredictorGlitchShowsAndFlags(t *testing.T) {
332	t.Parallel()
333	h := newEchoHarness(t, domain.EchoPredictAdaptive)
334	h.warm(0)
335	h.confirm()
336	if got := h.typed("b"); len(got) != 0 {
337		t.Fatalf("fast link rendered %q", got)
338	}
339	if !h.p.needsTick() {
340		t.Fatal("pending guess needs no timing tick")
341	}
342	h.now = h.now.Add(echoGlitchThreshold)
343	h.p.cull(h.now)
344	if got := string(h.p.render()); got != "\x1b[1;4H\x1b[0mb\x1b[0m\x1b[1;5H" {
345		t.Fatalf("glitch render = %q", got)
346	}
347	h.now = h.now.Add(echoGlitchFlagThreshold)
348	h.p.cull(h.now)
349	h.p.cull(h.now)
350	if !h.p.flagging {
351		t.Fatal("long glitch does not underline")
352	}
353	if h.p.needsTick() {
354		t.Fatal("timing tick still armed once every trigger fired")
355	}
356}
357
358func TestEchoPredictorResizeForgetsGuesses(t *testing.T) {
359	t.Parallel()
360	h := newEchoHarness(t, domain.EchoPredictAlways)
361	h.warm(100 * time.Millisecond)
362	h.confirm()
363	h.typed("b")
364	h.p.applyOutput(protocol.Output{Epoch: 2, New: 1, Full: true, Size: domain.Size{Cols: 30, Rows: 3}, Data: []byte("\x1b[H\x1b[2J$ ")}, h.now)
365	if h.p.active() || len(h.p.drawn) != 0 {
366		t.Fatal("resize kept guesses")
367	}
368	if got := h.p.render(); len(got) != 0 {
369		t.Fatalf("render after resize = %q", got)
370	}
371}
372
373func TestEchoSGR(t *testing.T) {
374	t.Parallel()
375	style := newEchoHarness(t, domain.EchoPredictAlways).p.screen.Cell(0, 0).Style
376	style.Bold = true
377	style.Foreground = 9
378	style.Background = 200
379	if got, want := echoSGR(style, true), "\x1b[0;1;4;91;48;5;200m"; got != want {
380		t.Fatalf("echoSGR = %q, want %q", got, want)
381	}
382	style.HasForegroundRGB = true
383	style.ForegroundRGB.R, style.ForegroundRGB.G, style.ForegroundRGB.B = 1, 2, 3
384	if got, want := echoSGR(style, false), "\x1b[0;1;38;2;1;2;3;48;5;200m"; got != want {
385		t.Fatalf("echoSGR rgb = %q, want %q", got, want)
386	}
387}
388
389func TestEchoPredictorWaitsForFirstAcknowledgement(t *testing.T) {
390	t.Parallel()
391	h := newEchoHarness(t, domain.EchoPredictAlways)
392	h.p.confEpoch = h.p.predEpoch
393	if got := h.typed("b"); len(got) != 0 || h.p.active() {
394		t.Fatalf("predicted before any echo acknowledgement: %q", got)
395	}
396}
397
398func TestEchoPredictorRefusesUnshiftableText(t *testing.T) {
399	t.Parallel()
400	tests := []struct {
401		name  string
402		frame string
403	}{
404		{name: "wide rune after the cursor", frame: "a界\x1b[1;3H"},
405		{name: "pane border after the cursor", frame: "ab│\x1b[1;3H"},
406	}
407	for _, tt := range tests {
408		t.Run(tt.name, func(t *testing.T) {
409			t.Parallel()
410			h := newEchoHarness(t, domain.EchoPredictAlways)
411			h.warm(10 * time.Millisecond)
412			h.p.applyOutput(protocol.Output{Epoch: 1, New: 2, Size: domain.Size{Cols: 20, Rows: 3}, Data: []byte(tt.frame)}, h.now)
413			h.p.confEpoch = h.p.predEpoch
414			if got := h.typed("x"); len(got) != 0 || h.p.active() {
415				t.Fatalf("shifted unshiftable text: %q", got)
416			}
417		})
418	}
419}
420
421func TestEchoPredictorRestoresWideCellWhole(t *testing.T) {
422	t.Parallel()
423	h := newEchoHarness(t, domain.EchoPredictAlways)
424	h.p.applyOutput(protocol.Output{Epoch: 1, New: 2, Size: domain.Size{Cols: 20, Rows: 3}, Data: []byte("\x1b[1;1H\x1b[4:3m界\x1b[0m")}, h.now)
425	var out bytes.Buffer
426	h.p.restoreCell(&out, 0, 1)
427	if got, want := out.String(), "\x1b[1;1H\x1b[0;4:3m界"; got != want {
428		t.Fatalf("restoreCell = %q, want %q", got, want)
429	}
430}
431
432func TestEchoPredictorSameSizeResizeErasesGuesses(t *testing.T) {
433	t.Parallel()
434	h := newEchoHarness(t, domain.EchoPredictAlways)
435	h.warm(10 * time.Millisecond)
436	h.confirm()
437	if got := h.typed("b"); len(got) == 0 {
438		t.Fatal("guess not drawn")
439	}
440	h.p.reset()
441	if got, want := string(h.p.render()), "\x1b[1;4H\x1b[0m \x1b[0m\x1b[1;4H"; got != want {
442		t.Fatalf("render after reset = %q, want %q", got, want)
443	}
444}
445
446// TestEchoPredictorTypesAheadInModalInput covers daemon modals (command
447// palette, prompt, copy search): the daemon places the real cursor on their
448// input line inside a bordered box, and guesses stay inside the box.
449func TestEchoPredictorTypesAheadInModalInput(t *testing.T) {
450	t.Parallel()
451	h := newEchoHarness(t, domain.EchoPredictAlways)
452	// Row 2: "│> " then the cursor, with the box's right border at column 15.
453	h.daemon("\x1b[2;1H│> \x1b[2;16H│\x1b[2;4H", 0)
454	h.warm(10 * time.Millisecond)
455	if got := h.typed("s"); len(got) != 0 {
456		t.Fatalf("first guess is tentative, rendered %q", got)
457	}
458	h.daemon("\x1b[2;4Hs", h.seq)
459	got := string(h.typed("p"))
460	if want := "\x1b[2;5H\x1b[0mp\x1b[0m\x1b[2;6H"; got != want {
461		t.Fatalf("render = %q, want %q", got, want)
462	}
463	if cell := h.p.screen.Cell(15, 1); cell.Rune != '│' {
464		t.Fatalf("box border moved: %q", cell.Rune)
465	}
466}
467
468// TestEchoPredictorBackspaceStopsAtBorder holds backspace past an empty
469// modal query: the input's left border is never predicted away.
470func TestEchoPredictorBackspaceStopsAtBorder(t *testing.T) {
471	t.Parallel()
472	h := newEchoHarness(t, domain.EchoPredictAlways)
473	h.daemon("\x1b[2;1H│\x1b[2;16H│\x1b[2;2H", 0)
474	h.warm(10 * time.Millisecond)
475	h.typed("s")
476	h.daemon("\x1b[2;2Hs", h.seq)
477	h.typed("\x7f")
478	got := string(h.typed("\x7f"))
479	if strings.Contains(got, "\x1b[2;1H") {
480		t.Fatalf("backspace predicted over the border: %q", got)
481	}
482	if h.p.cursor.active && h.p.cursor.col < 1 {
483		t.Fatalf("predicted cursor crossed the border: col %d", h.p.cursor.col)
484	}
485}
486
487func TestApcFilterStripsGraphicsAcrossOutputs(t *testing.T) {
488	t.Parallel()
489	var f apcFilter
490	chunks := []string{"a\x1b_Gf=100;AAA", "A\x1b", "\\b\x1b[1mc\x1b"}
491	var got []byte
492	for _, chunk := range chunks {
493		got = append(got, f.strip([]byte(chunk))...)
494	}
495	got = append(got, f.strip([]byte("[0m"))...)
496	if want := "ab\x1b[1mc\x1b[0m"; string(got) != want {
497		t.Fatalf("strip = %q, want %q", got, want)
498	}
499}
500
501func TestEscapeLength(t *testing.T) {
502	t.Parallel()
503	tests := []struct {
504		in   string
505		want int
506	}{
507		{in: "\x1b[A", want: 3},
508		{in: "\x1bOA", want: 3},
509		{in: "\x1b]11;?\x07x", want: 7},
510		{in: "\x1b]11;?\x1b\\x", want: 8},
511		{in: "\x1b]11;", want: 5},
512		{in: "\x1bx", want: 2},
513	}
514	for _, tt := range tests {
515		if got := escapeLength([]byte(tt.in)); got != tt.want {
516			t.Errorf("escapeLength(%q) = %d, want %d", tt.in, got, tt.want)
517		}
518	}
519}

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