v0.2.1 7bd1a3c

screen_osc_test.go RawHistoryFolder

1package vt
2
3import (
4	"strings"
5	"testing"
6
7	"github.com/stretchr/testify/require"
8)
9
10func TestOnBellFiresForLoneBEL(t *testing.T) {
11	s := NewScreen(10, 2)
12	rang := 0
13	s.OnBell = func() { rang++ }
14	s.Write([]byte("hi\ahi\a"))
15	require.Equal(t, 2, rang)
16	require.Equal(t, "hihi", strings.TrimRight(rowString(s.Frame.Row(0)), " "))
17}
18
19func TestOnBellIgnoresOSCTerminator(t *testing.T) {
20	s := NewScreen(10, 2)
21	rang := 0
22	s.OnBell = func() { rang++ }
23	s.Write([]byte("\x1b]0;title\a")) // BEL terminates the OSC, it is not a bell
24	require.Equal(t, 0, rang)
25}
26
27func TestBELWithoutCallbackIsDiscarded(t *testing.T) {
28	s := NewScreen(10, 2)
29	s.Write([]byte("a\ab")) // no OnBell set: must not panic
30	require.Equal(t, "ab", strings.TrimRight(rowString(s.Frame.Row(0)), " "))
31}
32
33func TestTerminalTitleRetainsOSC0AndOSC2(t *testing.T) {
34	tests := []struct {
35		name string
36		seq  string
37		want string
38	}{
39		{name: "OSC 0 BEL terminated", seq: "\x1b]0;zero title\x07", want: "zero title"},
40		{name: "OSC 0 ST terminated", seq: "\x1b]0;zero title\x1b\\", want: "zero title"},
41		{name: "OSC 2 BEL terminated", seq: "\x1b]2;two title\x07", want: "two title"},
42		{name: "OSC 2 ST terminated", seq: "\x1b]2;two title\x1b\\", want: "two title"},
43	}
44
45	for _, tt := range tests {
46		t.Run(tt.name, func(t *testing.T) {
47			s := NewScreen(10, 2)
48			bells := 0
49			s.OnBell = func() { bells++ }
50			s.ClearDamage()
51
52			s.Write([]byte(tt.seq))
53
54			require.Equal(t, tt.want, s.TerminalTitle())
55			require.Equal(t, 0, bells)
56			require.Empty(t, s.Damage())
57			require.Equal(t, strings.Repeat(" ", s.Frame.Width), rowString(s.Frame.Row(0)))
58		})
59	}
60}
61
62func TestTerminalTitleHandlesSplitWritesReplacementAndClear(t *testing.T) {
63	s := NewScreen(10, 2)
64
65	s.Write([]byte("\x1b]0;split"))
66	s.Write([]byte(" title\x1b"))
67	require.Empty(t, s.TerminalTitle())
68	s.Write([]byte("\\"))
69	require.Equal(t, "split title", s.TerminalTitle())
70
71	s.Write([]byte("\x1b]2;replacement\x07"))
72	require.Equal(t, "replacement", s.TerminalTitle())
73
74	s.Write([]byte("\x1b]0;\x07"))
75	require.Empty(t, s.TerminalTitle())
76}
77
78func TestTerminalTitleIgnoresUnrelatedAndOversizedOSC(t *testing.T) {
79	s := NewScreen(10, 2)
80	s.Write([]byte("\x1b]0;retained\x07"))
81
82	for _, seq := range []string{
83		"\x1b]1;icon title\x07",
84		"\x1b]20;not selector two\x1b\\",
85		"\x1b]777;other;x;y\x07",
86	} {
87		s.Write([]byte(seq))
88		require.Equal(t, "retained", s.TerminalTitle())
89	}
90
91	payload := strings.Repeat("x", maxEscapeBufferLen+1)
92	s.Write([]byte("\x1b]2;" + payload))
93	require.Equal(t, "retained", s.TerminalTitle())
94	require.Empty(t, s.escapeBuf)
95}
96
97func TestOnNotifyOSC9(t *testing.T) {
98	s := NewScreen(10, 2)
99	var gotTitle, gotBody string
100	calls := 0
101	s.OnNotify = func(title, body string) { gotTitle, gotBody = title, body; calls++ }
102	s.Write([]byte("\x1b]9;agent done\x07"))
103	require.Equal(t, 1, calls)
104	require.Equal(t, "", gotTitle)
105	require.Equal(t, "agent done", gotBody)
106}
107
108func TestOSC9ProgressTransitions(t *testing.T) {
109	tests := []struct {
110		name string
111		seqs []string
112		want []bool
113	}{
114		{
115			name: "finish fires once",
116			seqs: []string{"9;4;1;50", "9;4;0;100", "9;4;0;100"},
117			want: []bool{false},
118		},
119		{
120			name: "error fires once repeat no fire",
121			seqs: []string{"9;4;2;0", "9;4;2;50"},
122			want: []bool{true},
123		},
124		{
125			name: "indeterminate clear fires",
126			seqs: []string{"9;4;3", "9;4;0"},
127			want: []bool{false},
128		},
129		{
130			name: "paused clear fires",
131			seqs: []string{"9;4;4;80", "9;4;0;80"},
132			want: []bool{false},
133		},
134		{
135			name: "ongoing updates silent",
136			seqs: []string{"9;4;1;10", "9;4;1;50", "9;4;3;90", "9;4;4;90"},
137		},
138		{
139			name: "bare clear silent",
140			seqs: []string{"9;4;0"},
141		},
142		{
143			name: "bare progress ignored",
144			seqs: []string{"9;4"},
145		},
146		{
147			name: "unparseable ignored",
148			seqs: []string{"9;4;bogus", "9;4;99", "9;4;1", "9;4;0"},
149			want: []bool{false},
150		},
151		{
152			name: "error active clear fires error then finish",
153			seqs: []string{"9;4;2;0", "9;4;1;50", "9;4;0;100"},
154			want: []bool{true, false},
155		},
156		{
157			name: "error clear only fires error",
158			seqs: []string{"9;4;2;0", "9;4;0;100"},
159			want: []bool{true},
160		},
161	}
162
163	for _, tt := range tests {
164		t.Run(tt.name, func(t *testing.T) {
165			s := NewScreen(10, 2)
166			var got []bool
167			s.OnProgress = func(errored bool) { got = append(got, errored) }
168			for _, seq := range tt.seqs {
169				s.Write([]byte("\x1b]" + seq + "\x07"))
170			}
171			require.Equal(t, tt.want, got)
172		})
173	}
174}
175
176func TestOSC9ProgressSplitAcrossWritesFiresOnce(t *testing.T) {
177	s := NewScreen(10, 2)
178	calls := 0
179	s.OnProgress = func(bool) { calls++ }
180	s.Write([]byte("\x1b]9;4;1"))
181	s.Write([]byte(";50\x07\x1b]9;4;0;100\x07"))
182	require.Equal(t, 1, calls)
183}
184
185func TestOSC9ProgressNilCallbackDoesNotPanic(t *testing.T) {
186	s := NewScreen(10, 2)
187	require.NotPanics(t, func() {
188		s.Write([]byte("\x1b]9;4;1;50\x07"))
189		s.Write([]byte("\x1b]9;4;0;100\x07"))
190		s.Write([]byte("\x1b]9;4;2;0\x07"))
191	})
192}
193
194func TestOSC9ProgressSTTerminated(t *testing.T) {
195	s := NewScreen(10, 2)
196	var got []bool
197	s.OnProgress = func(errored bool) { got = append(got, errored) }
198	s.Write([]byte("\x1b]9;4;1;50\x1b\\\x1b]9;4;0;100\x1b\\"))
199	require.Equal(t, []bool{false}, got)
200}
201
202func TestOSC9ProgressStateTrackedBeforeCallbackSet(t *testing.T) {
203	s := NewScreen(10, 2)
204	s.Write([]byte("\x1b]9;4;1;50\x07"))
205	var got []bool
206	s.OnProgress = func(errored bool) { got = append(got, errored) }
207	s.Write([]byte("\x1b]9;4;0;100\x07"))
208	require.Equal(t, []bool{false}, got)
209}
210
211func TestOnNotifyIgnoresOSC9Progress(t *testing.T) {
212	s := NewScreen(10, 2)
213	calls := 0
214	s.OnNotify = func(string, string) { calls++ }
215	s.Write([]byte("\x1b]9;4;1;50\x07"))
216	s.Write([]byte("\x1b]9;4;0;100\x07"))
217	s.Write([]byte("\x1b]9;4;2;0\x07"))
218	require.Equal(t, 0, calls)
219}
220
221func TestOnNotifyOSC940RemainsGeneric(t *testing.T) {
222	s := NewScreen(10, 2)
223	var gotTitle, gotBody string
224	calls := 0
225	s.OnNotify = func(title, body string) { gotTitle, gotBody = title, body; calls++ }
226	s.Write([]byte("\x1b]9;40;not progress\x07"))
227	require.Equal(t, 1, calls)
228	require.Equal(t, "", gotTitle)
229	require.Equal(t, "40;not progress", gotBody)
230}
231
232func TestOnNotifyOSC777STTerminated(t *testing.T) {
233	s := NewScreen(10, 2)
234	var gotTitle, gotBody string
235	calls := 0
236	s.OnNotify = func(title, body string) { gotTitle, gotBody = title, body; calls++ }
237	s.Write([]byte("\x1b]777;notify;Claude;needs input\x1b\\"))
238	require.Equal(t, 1, calls)
239	require.Equal(t, "Claude", gotTitle)
240	require.Equal(t, "needs input", gotBody)
241}
242
243func TestOnNotifyIgnoresOtherOSC(t *testing.T) {
244	s := NewScreen(10, 2)
245	calls := 0
246	s.OnNotify = func(string, string) { calls++ }
247	s.Write([]byte("\x1b]0;window title\x07"))  // title: discarded
248	s.Write([]byte("\x1b]777;other;x;y\x1b\\")) // not "notify": discarded
249	require.Equal(t, 0, calls)
250}
251
252func TestOnNotifySplitAcrossWrites(t *testing.T) {
253	s := NewScreen(10, 2)
254	calls := 0
255	s.OnNotify = func(string, string) { calls++ }
256	s.Write([]byte("\x1b]9;par"))
257	s.Write([]byte("tial\x07"))
258	require.Equal(t, 1, calls)
259}
260
261func TestOnClipboardBELTerminated(t *testing.T) {
262	s := NewScreen(10, 2)
263	var got string
264	calls := 0
265	s.OnClipboard = func(b64 string) { got = b64; calls++ }
266	s.Write([]byte("\x1b]52;c;aGVsbG8=\x07"))
267	require.Equal(t, 1, calls)
268	require.Equal(t, "aGVsbG8=", got)
269}
270
271func TestOnClipboardSTTerminated(t *testing.T) {
272	s := NewScreen(10, 2)
273	var got string
274	calls := 0
275	s.OnClipboard = func(b64 string) { got = b64; calls++ }
276	s.Write([]byte("\x1b]52;c;aGVsbG8=\x1b\\"))
277	require.Equal(t, 1, calls)
278	require.Equal(t, "aGVsbG8=", got)
279}
280
281func TestOnClipboardQueryIgnored(t *testing.T) {
282	s := NewScreen(10, 2)
283	calls := 0
284	s.OnClipboard = func(string) { calls++ }
285	s.Write([]byte("\x1b]52;c;?\x07"))
286	require.Equal(t, 0, calls)
287}
288
289func TestOnClipboardEmptySelection(t *testing.T) {
290	s := NewScreen(10, 2)
291	var got string
292	calls := 0
293	s.OnClipboard = func(b64 string) { got = b64; calls++ }
294	s.Write([]byte("\x1b]52;;aGVsbG8=\x07"))
295	require.Equal(t, 1, calls)
296	require.Equal(t, "aGVsbG8=", got)
297}
298
299func TestOnClipboardNilCallbackDoesNotPanic(t *testing.T) {
300	s := NewScreen(10, 2)
301	require.NotPanics(t, func() {
302		s.Write([]byte("\x1b]52;c;aGVsbG8=\x07"))
303	})
304}
305
306func TestOnClipboardSplitAcrossWrites(t *testing.T) {
307	s := NewScreen(10, 2)
308	var got string
309	calls := 0
310	s.OnClipboard = func(b64 string) { got = b64; calls++ }
311	s.Write([]byte("\x1b]52;c;aGVs"))
312	s.Write([]byte("bG8=\x07"))
313	require.Equal(t, 1, calls)
314	require.Equal(t, "aGVsbG8=", got)
315}
316
317func TestOnClipboardNoSecondSemicolonIgnored(t *testing.T) {
318	s := NewScreen(10, 2)
319	calls := 0
320	s.OnClipboard = func(string) { calls++ }
321	s.Write([]byte("\x1b]52;c\x07")) // no second ";" -> no data field
322	require.Equal(t, 0, calls)
323}
324
325func TestOSCSequences(t *testing.T) {
326	tests := []struct {
327		name string
328		run  func(t *testing.T)
329	}{
330		{
331			name: "BEL-terminated OSC is ignored",
332			run: func(t *testing.T) {
333				s := NewScreen(20, 2)
334				s.Write([]byte("\x1b]0;~/Projects/ymux - fish\x07fish$ "))
335
336				assertCell(t, s, 0, 0, 'f')
337				assertCell(t, s, 1, 0, 'i')
338				assertCell(t, s, 2, 0, 's')
339				assertCell(t, s, 3, 0, 'h')
340				assertCell(t, s, 4, 0, '$')
341				assertCell(t, s, 5, 0, ' ')
342				if s.Col != 6 || s.Row != 0 {
343					t.Errorf("cursor at col=%d row=%d, want col=6 row=0", s.Col, s.Row)
344				}
345			},
346		},
347		{
348			name: "ST-terminated OSC is ignored",
349			run: func(t *testing.T) {
350				s := NewScreen(20, 2)
351				s.Write([]byte("\x1b]133;A;click_events=1\x1b\\> "))
352
353				assertCell(t, s, 0, 0, '>')
354				assertCell(t, s, 1, 0, ' ')
355				if s.Col != 2 || s.Row != 0 {
356					t.Errorf("cursor at col=%d row=%d, want col=2 row=0", s.Col, s.Row)
357				}
358			},
359		},
360		{
361			name: "multiple OSC sequences in a row are all ignored",
362			run: func(t *testing.T) {
363				s := NewScreen(20, 2)
364				s.Write([]byte("\x1b]7;file://host/tmp\x07\x1b]11;?\\=\x07\x1b]133;B\x1b\\prompt"))
365
366				assertCell(t, s, 0, 0, 'p')
367				assertCell(t, s, 1, 0, 'r')
368				assertCell(t, s, 2, 0, 'o')
369				assertCell(t, s, 3, 0, 'm')
370				assertCell(t, s, 4, 0, 'p')
371				assertCell(t, s, 5, 0, 't')
372				if s.Col != 6 || s.Row != 0 {
373					t.Errorf("cursor at col=%d row=%d, want col=6 row=0", s.Col, s.Row)
374				}
375			},
376		},
377		{
378			name: "BEL-terminated OSC split across writes is ignored",
379			run: func(t *testing.T) {
380				s := NewScreen(20, 2)
381				s.Write([]byte("\x1b]0;~/Projects/ymux"))
382				s.Write([]byte(" - fish\x07fish$ "))
383
384				assertCell(t, s, 0, 0, 'f')
385				assertCell(t, s, 1, 0, 'i')
386				assertCell(t, s, 2, 0, 's')
387				assertCell(t, s, 3, 0, 'h')
388				assertCell(t, s, 4, 0, '$')
389				assertCell(t, s, 5, 0, ' ')
390				if s.Col != 6 || s.Row != 0 {
391					t.Errorf("cursor at col=%d row=%d, want col=6 row=0", s.Col, s.Row)
392				}
393			},
394		},
395		{
396			name: "ST-terminated OSC split across writes is ignored",
397			run: func(t *testing.T) {
398				s := NewScreen(20, 2)
399				s.Write([]byte("\x1b]133;A;click_events=1\x1b"))
400				s.Write([]byte("\\> "))
401
402				assertCell(t, s, 0, 0, '>')
403				assertCell(t, s, 1, 0, ' ')
404				if s.Col != 2 || s.Row != 0 {
405					t.Errorf("cursor at col=%d row=%d, want col=2 row=0", s.Col, s.Row)
406				}
407			},
408		},
409		{
410			name: "unterminated OSC sequence is bounded and dropped",
411			run: func(t *testing.T) {
412				s := NewScreen(20, 2)
413				payload := strings.Repeat("x", maxEscapeBufferLen+1)
414				s.Write([]byte("\x1b]0;" + payload))
415				s.Write([]byte("OK"))
416
417				assertCell(t, s, 0, 0, 'O')
418				assertCell(t, s, 1, 0, 'K')
419				if len(s.escapeBuf) != 0 {
420					t.Fatalf("escapeBuf length = %d, want 0 after overlong unterminated OSC", len(s.escapeBuf))
421				}
422			},
423		},
424	}
425
426	for _, tt := range tests {
427		t.Run(tt.name, tt.run)
428	}
429}

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