v0.3.58 532d7f6

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 swarm 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
64func TestDrawLogLargeBottomShrinkKeepsVisibleRowsAddressable(t *testing.T) {
65	var buf bytes.Buffer
66	r := NewRenderer(&buf)
67	r.Resize(80, 4)
68
69	// A long dialog, such as a swarm transcript, scrolls beyond the viewport.
70	r.DrawLog(nil, []string{"transcript 1", "transcript 2", "transcript 3", "transcript 4", "transcript 5", "transcript 6"}, -1, 0)
71	buf.Reset()
72
73	// Returning to the short dashboard requires a full repaint because one
74	// viewport of logical rows disappeared.
75	r.DrawLog(nil, []string{"dashboard", "selected agent 1"}, -1, 0)
76	buf.Reset()
77
78	// The dashboard remains interactive after that repaint. A cursor move must
79	// update its visible selection instead of being discarded as inaccessible.
80	r.DrawLog(nil, []string{"dashboard", "selected agent 2"}, -1, 0)
81	if got := buf.String(); !strings.Contains(got, "selected agent 2") {
82		t.Fatalf("visible update after large dialog shrink was suppressed: %q", got)
83	}
84}
85
86func TestDrawLogPartialViewportShrinkRepaintsFromDashboardStart(t *testing.T) {
87	t.Setenv("TERM_PROGRAM", "ghostty")
88	var buf bytes.Buffer
89	r := NewRenderer(&buf)
90	r.Resize(80, 4)
91
92	// This transcript overflows the viewport, but returning to the dashboard
93	// removes fewer than one viewport of rows.
94	r.DrawLog(nil, []string{"transcript 1", "transcript 2", "transcript 3", "transcript 4", "transcript 5"}, -1, 0)
95	buf.Reset()
96
97	r.DrawLog(nil, []string{"dashboard", "selected agent 1"}, -1, 0)
98	got := buf.String()
99	if !strings.Contains(got, SeqClearScreenNoHome) {
100		t.Fatalf("partial viewport shrink did not repaint the screen: %q", got)
101	}
102	for _, want := range []string{"dashboard", "selected agent 1"} {
103		if !strings.Contains(got, want) {
104			t.Fatalf("partial viewport repaint missing %q: %q", want, got)
105		}
106	}
107}
108
109func TestDrawLogChatShrinkDoesNotForceDialogRepaint(t *testing.T) {
110	t.Setenv("TERM_PROGRAM", "ghostty")
111	var buf bytes.Buffer
112	r := NewRenderer(&buf)
113	r.Resize(80, 4)
114
115	r.DrawLog([]string{"chat 1", "chat 2", "chat 3", "chat 4", "chat 5", "chat 6"}, []string{"input"}, 0, 0)
116	buf.Reset()
117
118	// Chat reflow has its own coordinate-rebasing path, which preserves native
119	// terminal selections. Only a shrinking bottom frame should force the
120	// repaint used when returning from a transcript to the dashboard.
121	r.DrawLog([]string{"chat 1", "chat 2", "chat 3", "chat 4", "chat 5"}, []string{"input"}, 0, 0)
122	if got := buf.String(); strings.Contains(got, SeqClearScreenNoHome) {
123		t.Fatalf("chat shrink unnecessarily repainted the screen: %q", got)
124	}
125}
126
127// TestDrawLogCursorMoveBreaksFastPath proves a cursor-only change
128// (no buffer change) still produces output. Without this, typing in
129// the editor would visually move the caret but the terminal would
130// keep drawing it at the old column.
131func TestDrawLogCursorMoveBreaksFastPath(t *testing.T) {
132	var buf bytes.Buffer
133	r := NewRenderer(&buf)
134	r.Resize(80, 24)
135
136	r.DrawLog([]string{"hi"}, []string{"▌ "}, 0, 2)
137	buf.Reset()
138
139	// Same buffer, different cursor column.
140	r.DrawLog([]string{"hi"}, []string{"▌ "}, 0, 3)
141	if buf.Len() == 0 {
142		t.Fatal("cursor-only change suppressed by fast path; caret would lag behind typing")
143	}
144	// And the emitted bytes must at least reposition the cursor.
145	if !strings.Contains(buf.String(), "\x1b[") {
146		t.Errorf("cursor move emission missing CSI escapes: %q", buf.String())
147	}
148}
149
150// TestDrawLogResizeForcesFullRedraw confirms a resize invalidates
151// the cache so the next DrawLog with identical inputs still emits.
152// Resize sets logInit=false; without that, a resize followed by an
153// identical buffer would falsely no-op and leave a stale frame.
154func TestDrawLogResizeForcesFullRedraw(t *testing.T) {
155	var buf bytes.Buffer
156	r := NewRenderer(&buf)
157	r.Resize(80, 24)
158	r.DrawLog([]string{"hi"}, []string{"▌ "}, 0, 2)
159	buf.Reset()
160
161	r.Resize(100, 30)
162	r.DrawLog([]string{"hi"}, []string{"▌ "}, 0, 2)
163	if buf.Len() == 0 {
164		t.Fatal("post-resize redraw skipped; the new frame would never reach the terminal")
165	}
166}
167
168// TestDrawLogInaccessibleChangePreservesScrollbackSelection covers long
169// streaming output whose changing first row has already scrolled above the
170// viewport. That row is immutable terminal history: clearing or replaying it
171// either destroys a native mouse selection or duplicates stale tool frames.
172func TestDrawLogInaccessibleChangePreservesScrollbackSelection(t *testing.T) {
173	t.Setenv("TERM_PROGRAM", "")
174	var buf bytes.Buffer
175	r := NewRenderer(&buf)
176	r.Resize(80, 3)
177	r.DrawLog([]string{"selected partial", "line 2", "line 3", "line 4"}, []string{"input"}, 0, 0)
178	buf.Reset()
179
180	// Only the historical row changed. DrawLog must leave the terminal alone
181	// instead of clearing and replaying the retained scrollback.
182	r.DrawLog([]string{"selected partial response", "line 2", "line 3", "line 4"}, []string{"input"}, 0, 0)
183	got := buf.String()
184	if strings.Contains(got, SeqClearScreenNoHome) || strings.Contains(got, SeqClearScrollback) {
185		t.Fatalf("inaccessible change cleared selected scrollback: %q", got)
186	}
187	if strings.Contains(got, "selected partial response") {
188		t.Fatalf("inaccessible row was replayed into retained scrollback: %q", got)
189	}
190}
191
192func TestDrawLogStructuralReflowAboveViewportRebasesWithoutReplay(t *testing.T) {
193	t.Setenv("TERM_PROGRAM", "ghostty")
194	var buf bytes.Buffer
195	r := NewRenderer(&buf)
196	r.Resize(80, 4)
197	oldChat := []string{
198		"user prompt",
199		"┌ bash first ─────", "│ old output", "└──────────────────",
200		"┌ bash second ────", "│ second output", "└──────────────────",
201	}
202	r.DrawLog(oldChat, []string{"input"}, 0, 0)
203	buf.Reset()
204
205	// The first tool gains wrapped rows after its top has scrolled away.
206	// Clearing the viewport and replaying the complete logical transcript
207	// leaves the old prompt in native scrollback and prints a second copy.
208	// Rebase the logical row coordinates instead, leaving inaccessible rows
209	// as historical snapshots and keeping the visible tail aligned.
210	newChat := []string{
211		"user prompt",
212		"┌ bash first ─────", "│ old output", "│ wrapped line 1", "│ wrapped line 2", "└──────────────────",
213		"┌ bash second ────", "│ second output", "└──────────────────",
214	}
215	r.DrawLog(newChat, []string{"input"}, 0, 0)
216	got := buf.String()
217	if strings.Contains(got, SeqClearScreenNoHome) || strings.Contains(got, SeqClearScrollback) {
218		t.Fatalf("structural reflow cleared the terminal: %q", got)
219	}
220	for _, replayed := range []string{"user prompt", "┌ bash first", "┌ bash second"} {
221		if strings.Contains(got, replayed) {
222			t.Fatalf("structural reflow replayed %q into scrollback: %q", replayed, got)
223		}
224	}
225
226	buf.Reset()
227	appended := append(append([]string(nil), newChat...), "┌ bash third ─────", "│ third output", "└──────────────────")
228	r.DrawLog(appended, []string{"input"}, 0, 0)
229	got = buf.String()
230	if !strings.Contains(got, "┌ bash third") || !strings.Contains(got, "│ third output") {
231		t.Fatalf("new output was not appended after coordinate rebase: %q", got)
232	}
233	for _, replayed := range []string{"user prompt", "┌ bash first", "┌ bash second"} {
234		if strings.Contains(got, replayed) {
235			t.Fatalf("append after rebase replayed %q: %q", replayed, got)
236		}
237	}
238}
239
240func TestDrawLogInaccessibleChangeStillAppendsNewOutput(t *testing.T) {
241	t.Setenv("TERM_PROGRAM", "")
242	var buf bytes.Buffer
243	r := NewRenderer(&buf)
244	r.Resize(80, 3)
245	r.DrawLog([]string{"selected partial", "line 2", "line 3", "line 4"}, []string{"input"}, 0, 0)
246	buf.Reset()
247
248	// A streaming reflow changes inaccessible history while a tool result is
249	// appended. Only the new suffix should be emitted, naturally scrolling the
250	// selected old text upward without replaying the complete frame.
251	r.DrawLog([]string{"selected partial response", "line 2", "line 3", "line 4", "tool output"}, []string{"input"}, 0, 0)
252	got := buf.String()
253	if strings.Contains(got, SeqClearScreenNoHome) || strings.Contains(got, SeqClearScrollback) {
254		t.Fatalf("append after inaccessible change cleared selected scrollback: %q", got)
255	}
256	if strings.Contains(got, "selected partial response") || strings.Contains(got, "line 2") {
257		t.Fatalf("append replayed historical rows: %q", got)
258	}
259	if !strings.Contains(got, "tool output") {
260		t.Fatalf("new tool output was not appended: %q", got)
261	}
262}
263
264// TestDrawLogInvalidationPreservesScrollbackSelection pins the same rule for
265// cache invalidations, which can happen during an active turn independently
266// of an inaccessible changed row.
267func TestDrawLogInvalidationPreservesScrollbackSelection(t *testing.T) {
268	t.Setenv("TERM_PROGRAM", "")
269	var buf bytes.Buffer
270	r := NewRenderer(&buf)
271	r.Resize(80, 3)
272	r.DrawLog([]string{"one", "two", "three", "four"}, []string{"input"}, 0, 0)
273	buf.Reset()
274
275	r.Invalidate()
276	r.DrawLog([]string{"one", "two", "three", "four"}, []string{"input"}, 0, 0)
277	if got := buf.String(); strings.Contains(got, SeqClearScrollback) {
278		t.Fatalf("invalidation repaint erased scrollback and native selection: %q", got)
279	}
280}

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 476

↑↓ moveenter openesc close