v0.37.0 e363e10

render_test.go RawHistoryFolder

1package tui
2
3import (
4	"bytes"
5	"strings"
6	"testing"
7)
8
9// TestDrawLogIdleNoOpEmitsNothing pins the cursor-blink fix: when
10// DrawLog is called with the exact same buffer and cursor position
11// as the previous call, it must emit ZERO bytes.
12//
13// The bug this regresses: at the 120ms animation tick the renderer
14// used to always emit SeqHideCursor + cursor-position +
15// SeqShowCursor, which resets the terminal's blink timer. Faster
16// than the OS blink interval, so an idle dialog editor (e.g. a
17// re-opened subagent transcript whose agent isn't producing output)
18// rendered the caret as a solid non-blinking block.
19//
20// With the no-op fast path the renderer leaves the screen alone
21// on idle frames, letting the terminal run its own blink cycle.
22func TestDrawLogIdleNoOpEmitsNothing(t *testing.T) {
23	var buf bytes.Buffer
24	r := NewRenderer(&buf)
25	r.Resize(80, 24)
26
27	chat := []string{"hello", "world"}
28	bottom := []string{"▌ "}
29	// First draw populates the renderer's cached buffer.
30	r.DrawLog(chat, bottom, 0, 2)
31	first := buf.Len()
32	if first == 0 {
33		t.Fatal("first DrawLog wrote nothing; setup is broken")
34	}
35	buf.Reset()
36
37	// Identical second draw: same content, same cursor placement.
38	r.DrawLog(chat, bottom, 0, 2)
39	if buf.Len() != 0 {
40		t.Fatalf("idle re-draw emitted %d bytes; expected 0 so terminal blink keeps ticking\n%q",
41			buf.Len(), buf.String())
42	}
43}
44
45// TestDrawLogContentChangeBreaksFastPath proves the no-op fast path
46// only fires when nothing changed. A buffer mutation must still
47// produce output, otherwise streaming agent replies would freeze on
48// screen.
49func TestDrawLogContentChangeBreaksFastPath(t *testing.T) {
50	var buf bytes.Buffer
51	r := NewRenderer(&buf)
52	r.Resize(80, 24)
53
54	r.DrawLog([]string{"hello"}, []string{"▌ "}, 0, 2)
55	buf.Reset()
56
57	// New chat row lands.
58	r.DrawLog([]string{"hello", "world"}, []string{"▌ "}, 0, 2)
59	if buf.Len() == 0 {
60		t.Fatal("content change suppressed by fast path; streaming output would freeze")
61	}
62}
63
64// TestDrawLogCursorMoveBreaksFastPath proves a cursor-only change
65// (no buffer change) still produces output. Without this, typing in
66// the editor would visually move the caret but the terminal would
67// keep drawing it at the old column.
68func TestDrawLogCursorMoveBreaksFastPath(t *testing.T) {
69	var buf bytes.Buffer
70	r := NewRenderer(&buf)
71	r.Resize(80, 24)
72
73	r.DrawLog([]string{"hi"}, []string{"▌ "}, 0, 2)
74	buf.Reset()
75
76	// Same buffer, different cursor column.
77	r.DrawLog([]string{"hi"}, []string{"▌ "}, 0, 3)
78	if buf.Len() == 0 {
79		t.Fatal("cursor-only change suppressed by fast path; caret would lag behind typing")
80	}
81	// And the emitted bytes must at least reposition the cursor.
82	if !strings.Contains(buf.String(), "\x1b[") {
83		t.Errorf("cursor move emission missing CSI escapes: %q", buf.String())
84	}
85}
86
87// TestDrawLogResizeForcesFullRedraw confirms a resize invalidates
88// the cache so the next DrawLog with identical inputs still emits.
89// Resize sets logInit=false; without that, a resize followed by an
90// identical buffer would falsely no-op and leave a stale frame.
91func TestDrawLogResizeForcesFullRedraw(t *testing.T) {
92	var buf bytes.Buffer
93	r := NewRenderer(&buf)
94	r.Resize(80, 24)
95	r.DrawLog([]string{"hi"}, []string{"▌ "}, 0, 2)
96	buf.Reset()
97
98	r.Resize(100, 30)
99	r.DrawLog([]string{"hi"}, []string{"▌ "}, 0, 2)
100	if buf.Len() == 0 {
101		t.Fatal("post-resize redraw skipped; the new frame would never reach the terminal")
102	}
103}
104
105// TestDrawLogInaccessibleChangePreservesScrollbackSelection covers long
106// streaming output whose changing first row has already scrolled above the
107// viewport. That row is immutable terminal history: clearing or replaying it
108// either destroys a native mouse selection or duplicates stale tool frames.
109func TestDrawLogInaccessibleChangePreservesScrollbackSelection(t *testing.T) {
110	t.Setenv("TERM_PROGRAM", "")
111	var buf bytes.Buffer
112	r := NewRenderer(&buf)
113	r.Resize(80, 3)
114	r.DrawLog([]string{"selected partial", "line 2", "line 3", "line 4"}, []string{"input"}, 0, 0)
115	buf.Reset()
116
117	// Only the historical row changed. DrawLog must leave the terminal alone
118	// instead of clearing and replaying the retained scrollback.
119	r.DrawLog([]string{"selected partial response", "line 2", "line 3", "line 4"}, []string{"input"}, 0, 0)
120	got := buf.String()
121	if strings.Contains(got, SeqClearScreenNoHome) || strings.Contains(got, SeqClearScrollback) {
122		t.Fatalf("inaccessible change cleared selected scrollback: %q", got)
123	}
124	if strings.Contains(got, "selected partial response") {
125		t.Fatalf("inaccessible row was replayed into retained scrollback: %q", got)
126	}
127}
128
129func TestDrawLogStructuralGrowthScrollsBeforeRepaintingVisibleTail(t *testing.T) {
130	t.Setenv("TERM_PROGRAM", "ghostty")
131	var buf bytes.Buffer
132	r := NewRenderer(&buf)
133	r.Resize(80, 3)
134	r.DrawLog([]string{"old 0", "old 1"}, []string{"working 0s"}, -1, 0)
135	buf.Reset()
136
137	// Chat and the bottom band both grow while an old row is above the
138	// viewport. Scroll by the logical growth before repainting the visible
139	// tail. Homing and clearing the viewport here makes terminals visibly
140	// jump during every streaming markdown reflow.
141	chat := []string{"new row", "old 0", "old 1"}
142	r.DrawLog(chat, []string{"working 0s", "editor"}, 1, 3)
143	got := buf.String()
144	if strings.Contains(got, SeqCursorHome+SeqClearToEnd) {
145		t.Fatalf("structural growth reset the visible viewport: %q", got)
146	}
147	if strings.Contains(got, SeqClearScreenNoHome) || strings.Contains(got, SeqClearScrollback) {
148		t.Fatalf("structural growth cleared retained scrollback: %q", got)
149	}
150	if !strings.Contains(got, "\r\n\r\n") {
151		t.Fatalf("structural growth did not advance the viewport by two consecutive rows: %q", got)
152	}
153	if strings.Contains(got, "old 1") {
154		t.Fatalf("viewport repaint included a row above the visible tail: %q", got)
155	}
156	for _, visible := range []string{"working 0s", "editor", SeqShowCursor, "\x1b[3C"} {
157		if !strings.Contains(got, visible) {
158			t.Fatalf("viewport repaint missing %q: %q", visible, got)
159		}
160	}
161	if want := len(r.logLines) - r.rows; r.logViewportTop != want {
162		t.Fatalf("viewport top = %d, want %d", r.logViewportTop, want)
163	}
164	if want := len(chat) + 1; r.logHardwareRow != want {
165		t.Fatalf("hardware row = %d, want cursor row %d", r.logHardwareRow, want)
166	}
167
168	buf.Reset()
169	r.DrawLog(chat, []string{"working 1s", "editor"}, 1, 3)
170	if got := buf.String(); !strings.Contains(got, "working 1s") {
171		t.Fatalf("status update after viewport repaint was lost: %q", got)
172	}
173
174	buf.Reset()
175	r.DrawLog(append(chat, "next tool"), []string{"working 2s", "editor"}, 1, 3)
176	if got := buf.String(); !strings.Contains(got, "next tool") {
177		t.Fatalf("append after viewport repaint was lost: %q", got)
178	}
179}
180
181func TestDrawLogStructuralShrinkRepaintsVisibleTailInPlace(t *testing.T) {
182	t.Setenv("TERM_PROGRAM", "ghostty")
183	var buf bytes.Buffer
184	r := NewRenderer(&buf)
185	r.Resize(80, 4)
186	r.DrawLog([]string{"partial", "old 0", "old 1", "old 2"}, []string{"working", "editor"}, 1, 2)
187	buf.Reset()
188
189	chat := []string{"old 0", "old 1", "old 2"}
190	r.DrawLog(chat, []string{"working", "editor"}, 1, 2)
191	got := buf.String()
192	if strings.Contains(got, SeqCursorHome+SeqClearToEnd) {
193		t.Fatalf("structural shrink reset the visible viewport: %q", got)
194	}
195	for _, visible := range []string{"old 2", "working", "editor"} {
196		if !strings.Contains(got, visible) {
197			t.Fatalf("viewport repaint missing %q after shrink: %q", visible, got)
198		}
199	}
200	if !strings.Contains(got, "\r\x1b[0m"+SeqClearLine+"old 2") {
201		t.Fatalf("structural shrink did not return to column zero before repaint: %q", got)
202	}
203	if want := len(r.logLines) - r.rows; r.logViewportTop != want {
204		t.Fatalf("viewport top = %d, want %d", r.logViewportTop, want)
205	}
206}
207
208func TestDrawLogStructuralGrowthIgnoresImagesOutsideVisibleTail(t *testing.T) {
209	t.Setenv("TERM_PROGRAM", "ghostty")
210	var buf bytes.Buffer
211	r := NewRenderer(&buf)
212	r.Resize(80, 3)
213	image := "\x1b_Ga=T;payload\x1b\\"
214	r.DrawLog([]string{image, "line 1", "line 2", "line 3"}, []string{"input"}, -1, 0)
215	buf.Reset()
216
217	r.DrawLog([]string{"new row", image, "line 1", "line 2", "line 3"}, []string{"input"}, -1, 0)
218	got := buf.String()
219	if strings.Contains(got, SeqCursorHome+SeqClearToEnd) {
220		t.Fatalf("image in retained scrollback forced a viewport reset: %q", got)
221	}
222	if !strings.Contains(got, "line 3") || !strings.Contains(got, "input") {
223		t.Fatalf("visible tail was not repainted after growth: %q", got)
224	}
225}
226
227func TestDrawLogStructuralReflowRepaintsVisibleImageFootprint(t *testing.T) {
228	t.Setenv("TERM_PROGRAM", "ghostty")
229	var buf bytes.Buffer
230	r := NewRenderer(&buf)
231	r.Resize(80, 4)
232	image := "\x1b_Ga=T;payload\x1b\\"
233	boxedBlank := "  │      │  "
234	r.DrawLog([]string{image, boxedBlank, "caption"}, []string{"working 0s"}, -1, 0)
235	buf.Reset()
236
237	// The visible tail begins deep inside an image footprint spanning more
238	// than one screen. Real footprint rows may retain their sentinel or be
239	// wrapped in empty tool-box edges. A viewport reset deletes Kitty images,
240	// so it must scan across both forms and replay the escape row above them.
241	r.DrawLog([]string{
242		"new row", image, boxedBlank, imageFootprintSentinel, boxedBlank,
243		boxedBlank, boxedBlank, "caption",
244	}, []string{"working 1s"}, -1, 0)
245	got := buf.String()
246	if !strings.Contains(got, SeqDeleteKittyImages) {
247		t.Fatalf("viewport reset did not clear stale Kitty images: %q", got)
248	}
249	if !strings.Contains(got, image) {
250		t.Fatalf("viewport reset did not repaint the visible image: %q", got)
251	}
252}
253
254func TestDrawLogInaccessibleChangeStillAppendsNewOutput(t *testing.T) {
255	t.Setenv("TERM_PROGRAM", "")
256	var buf bytes.Buffer
257	r := NewRenderer(&buf)
258	r.Resize(80, 3)
259	r.DrawLog([]string{"selected partial", "line 2", "line 3", "line 4"}, []string{"input"}, 0, 0)
260	buf.Reset()
261
262	// A streaming reflow changes inaccessible history while a tool result is
263	// appended. Only the new suffix should be emitted, naturally scrolling the
264	// selected old text upward without replaying the complete frame.
265	r.DrawLog([]string{"selected partial response", "line 2", "line 3", "line 4", "tool output"}, []string{"input"}, 0, 0)
266	got := buf.String()
267	if strings.Contains(got, SeqClearScreenNoHome) || strings.Contains(got, SeqClearScrollback) {
268		t.Fatalf("append after inaccessible change cleared selected scrollback: %q", got)
269	}
270	if strings.Contains(got, "selected partial response") || strings.Contains(got, "line 2") {
271		t.Fatalf("append replayed historical rows: %q", got)
272	}
273	if !strings.Contains(got, "tool output") {
274		t.Fatalf("new tool output was not appended: %q", got)
275	}
276}
277
278// TestDrawLogInvalidationPreservesScrollbackSelection pins the same rule for
279// cache invalidations, which can happen during an active turn independently
280// of an inaccessible changed row.
281func TestDrawLogInvalidationPreservesScrollbackSelection(t *testing.T) {
282	t.Setenv("TERM_PROGRAM", "")
283	var buf bytes.Buffer
284	r := NewRenderer(&buf)
285	r.Resize(80, 3)
286	r.DrawLog([]string{"one", "two", "three", "four"}, []string{"input"}, 0, 0)
287	buf.Reset()
288
289	r.Invalidate()
290	r.DrawLog([]string{"one", "two", "three", "four"}, []string{"input"}, 0, 0)
291	if got := buf.String(); strings.Contains(got, SeqClearScrollback) {
292		t.Fatalf("invalidation repaint erased scrollback and native selection: %q", got)
293	}
294}
295
296// TestDrawLogPartialBottomShrinkRepaintsInaccessibleRows covers returning
297// from a moderately long dialog to a short one: the shorter bottom frame
298// starts above the currently addressable viewport, so relative cursor
299// movement cannot repaint its prefix and a full repaint is required.
300// Without the forced repaint, stale dialog rows stay on screen.
301func TestDrawLogPartialBottomShrinkRepaintsInaccessibleRows(t *testing.T) {
302	t.Setenv("TERM_PROGRAM", "ghostty")
303	var buf bytes.Buffer
304	r := NewRenderer(&buf)
305	r.Resize(80, 4)
306
307	r.DrawLog(nil, []string{"transcript 1", "transcript 2", "transcript 3", "transcript 4", "transcript 5"}, -1, 0)
308	buf.Reset()
309
310	r.DrawLog(nil, []string{"dashboard", "selected agent 1"}, -1, 0)
311	got := buf.String()
312	if !strings.Contains(got, SeqClearScreenNoHome) {
313		t.Fatalf("partial bottom shrink did not repaint the screen: %q", got)
314	}
315	for _, want := range []string{"dashboard", "selected agent 1"} {
316		if !strings.Contains(got, want) {
317			t.Fatalf("partial bottom repaint missing %q: %q", want, got)
318		}
319	}
320}
321
322// TestDrawLogChatShrinkDoesNotForceBottomRepaint pins the scope of the
323// forced repaint: chat reflow has its own coordinate-rebasing path which
324// preserves native terminal selections. Only a shrinking bottom frame
325// forces the full repaint used when returning to a short dialog.
326func TestDrawLogChatShrinkDoesNotForceBottomRepaint(t *testing.T) {
327	t.Setenv("TERM_PROGRAM", "ghostty")
328	var buf bytes.Buffer
329	r := NewRenderer(&buf)
330	r.Resize(80, 4)
331
332	r.DrawLog([]string{"chat 1", "chat 2", "chat 3", "chat 4", "chat 5", "chat 6"}, []string{"input"}, 0, 0)
333	buf.Reset()
334
335	r.DrawLog([]string{"chat 1", "chat 2", "chat 3", "chat 4", "chat 5"}, []string{"input"}, 0, 0)
336	if got := buf.String(); strings.Contains(got, SeqClearScreenNoHome) {
337		t.Fatalf("chat shrink unnecessarily repainted the screen: %q", got)
338	}
339}
340
341// TestDrawLogFullHeightShrinkRepaintsAndTracksViewport covers a dialog
342// shrink that removes a full viewport of logical rows: the incremental
343// clear-below path cannot address them, so the draw must repaint fully
344// and the recomputed viewport state must survive for the next draw.
345// A cursor move right after must update the visible selection instead of
346// being discarded as inaccessible.
347func TestDrawLogFullHeightShrinkRepaintsAndTracksViewport(t *testing.T) {
348	t.Setenv("TERM_PROGRAM", "ghostty")
349	var buf bytes.Buffer
350	r := NewRenderer(&buf)
351	r.Resize(80, 4)
352
353	r.DrawLog(nil, []string{"transcript 1", "transcript 2", "transcript 3", "transcript 4", "transcript 5", "transcript 6"}, -1, 0)
354	buf.Reset()
355
356	r.DrawLog(nil, []string{"dashboard", "selected agent 1"}, -1, 0)
357	if got := buf.String(); !strings.Contains(got, SeqClearScreenNoHome) {
358		t.Fatalf("full-height shrink did not repaint the screen: %q", got)
359	}
360	buf.Reset()
361
362	r.DrawLog(nil, []string{"dashboard", "selected agent 2"}, -1, 0)
363	if got := buf.String(); !strings.Contains(got, "selected agent 2") {
364		t.Fatalf("visible update after full-height shrink was suppressed: %q", got)
365	}
366}
367
368// TestDrawLogShrinkRecoveryMatrix pins the Zut-specific renderer state
369// across a forced full repaint: themed backgrounds still tint the frame,
370// keepScrollback terminals never purge scrollback, Kitty images are
371// cleaned, and a narrow follow-up draw stays coherent with no stale rows.
372func TestDrawLogShrinkRecoveryMatrix(t *testing.T) {
373	t.Setenv("TERM_PROGRAM", "vscode")
374	var buf bytes.Buffer
375	r := NewRenderer(&buf)
376	background := Color256(237)
377	th := Dark
378	th.Background = &background
379	r.SetTheme(th)
380	r.Resize(80, 4)
381	if !r.keepScrollback {
382		t.Fatal("vscode TERM_PROGRAM did not enable keepScrollback")
383	}
384
385	image := "\x1b_Ga=T;payload\x1b\\"
386	r.DrawLog(nil, []string{image, "transcript 1", "transcript 2", "transcript 3", "transcript 4", "transcript 5"}, -1, 0)
387	buf.Reset()
388
389	r.DrawLog(nil, []string{"dashboard", "selected agent 1"}, -1, 0)
390	got := buf.String()
391	if !strings.Contains(got, SeqClearToEnd) {
392		t.Fatalf("themed shrink recovery did not repaint in place: %q", got)
393	}
394	if strings.Contains(got, SeqClearScreenNoHome) || strings.Contains(got, SeqClearScrollback) {
395		t.Fatalf("keepScrollback repaint purged scrollback: %q", got)
396	}
397	if !strings.Contains(got, SeqDeleteKittyImages) {
398		t.Fatalf("shrink recovery did not clean Kitty images: %q", got)
399	}
400	if !strings.Contains(got, th.BackgroundStyle()) {
401		t.Fatalf("themed repaint lost the background tint: %q", got)
402	}
403	buf.Reset()
404
405	// A narrow follow-up draw must stay coherent: no stale rows, no extra
406	// full repaint, and the hardware cursor tracks the new viewport.
407	r.Resize(20, 4)
408	r.DrawLog(nil, []string{"dashboard", "selected agent 2"}, -1, 0)
409	got = buf.String()
410	if !strings.Contains(got, "selected agent 2") {
411		t.Fatalf("narrow follow-up draw lost the selection: %q", got)
412	}
413	if strings.Contains(got, "transcript") {
414		t.Fatalf("narrow follow-up draw left stale rows: %q", got)
415	}
416	for _, row := range strings.Split(got, "\r\n") {
417		if strings.ContainsAny(row, "\r\n") {
418			t.Fatalf("row embeds a newline: %q", row)
419		}
420	}
421}

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 1

Tags 477

↑↓ moveenter openesc close