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}