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// TestDrawLogInaccessibleMutationAppendedBlankAdvancesViewport is a
279// regression for the streaming-render corruption where an offscreen
280// (already scrolled above the viewport) chat row changes while a new
281// chat row is appended. The renderer must not replay the inaccessible
282// row, but it must still emit the appended row and let the trailing
283// bottom-margin blank scroll the viewport so the new tail is visible.
284//
285// Previously the rescan triggered by the inaccessible change reset
286// firstChanged/lastChanged, and the appended-blank extension was only
287// re-applied when the rescan found no visible change at all. Because the
288// rescan did find the visible shift, lastChanged stayed short, the
289// implicit blank bottom margin was never emitted, and the viewport failed
290// to advance, leaving the frame shifted by one row.
291func TestDrawLogInaccessibleMutationAppendedBlankAdvancesViewport(t *testing.T) {
292 t.Setenv("TERM_PROGRAM", "")
293 var buf bytes.Buffer
294 r := NewRenderer(&buf)
295 r.Resize(50, 7)
296
297 bottom := []string{"STATUS", "editor"}
298 chat := []string{"row-00", "row-01", "row-02", "row-03", "row-04", "row-05"}
299 r.DrawLog(chat, bottom, 1, 2)
300 if want := 2; r.logViewportTop != want {
301 t.Fatalf("setup viewport top = %d, want %d", r.logViewportTop, want)
302 }
303
304 // Repeated growth exercises the fix more than once: each round mutates
305 // a row that has already scrolled above the viewport and appends one
306 // new chat row, which grows the logical buffer by exactly one row.
307 appended := []string{"new-00", "new-01"}
308 for round := 0; round < len(appended); round++ {
309 buf.Reset()
310 mutated := append([]string(nil), chat...)
311 mutated[round] = mutated[round] + "-mutated"
312 chat = append(mutated, appended[round])
313 r.DrawLog(chat, bottom, 1, 2)
314 got := buf.String()
315
316 if strings.Contains(got, "-mutated") {
317 t.Fatalf("round %d replayed an inaccessible mutated row: %q", round, got)
318 }
319 if strings.Contains(got, SeqClearScreenNoHome) || strings.Contains(got, SeqClearScrollback) {
320 t.Fatalf("round %d cleared retained scrollback: %q", round, got)
321 }
322 if !strings.Contains(got, appended[round]) {
323 t.Fatalf("round %d did not emit appended row %q: %q", round, appended[round], got)
324 }
325 if want := len(chat) + len(bottom) + 1 - r.rows; r.logViewportTop != want {
326 t.Fatalf("round %d viewport top = %d, want %d (blank tail did not scroll)", round, r.logViewportTop, want)
327 }
328 if want := len(chat) + len(bottom) - 1; r.logHardwareRow != want {
329 t.Fatalf("round %d hardware row = %d, want cursor row %d", round, r.logHardwareRow, want)
330 }
331 }
332}
333
334// TestDrawLogBottomShrinkRepaintsViewportWithoutDuplicatingScrollback is a
335// regression for a bottom-band shrink while the transcript is taller than
336// the viewport. The recovery path must repaint the cleared visible screen
337// with only the new visible tail; replaying the full logical buffer pushes
338// transcript rows that are already retained in scrollback back into it,
339// duplicating history.
340func TestDrawLogBottomShrinkRepaintsViewportWithoutDuplicatingScrollback(t *testing.T) {
341 t.Setenv("TERM_PROGRAM", "ghostty")
342 var buf bytes.Buffer
343 r := NewRenderer(&buf)
344 r.Resize(50, 4)
345
346 chat := []string{"T1", "T2", "T3", "T4", "T5"}
347 r.DrawLog(chat, []string{"b1", "b2", "b3", "b4", "b5"}, -1, 0)
348 buf.Reset()
349
350 r.DrawLog(chat, []string{"d1", "d2"}, -1, 0)
351 got := buf.String()
352
353 if strings.Contains(got, SeqClearScrollback) {
354 t.Fatalf("bottom shrink purged retained scrollback: %q", got)
355 }
356 if !strings.Contains(got, SeqClearScreenNoHome) {
357 t.Fatalf("bottom shrink did not repaint the visible viewport: %q", got)
358 }
359 for _, dup := range []string{"T1", "T2", "T3", "T4"} {
360 if strings.Contains(got, dup) {
361 t.Fatalf("bottom shrink replayed retained transcript row %q: %q", dup, got)
362 }
363 }
364 for _, want := range []string{"T5", "d1", "d2"} {
365 if !strings.Contains(got, want) {
366 t.Fatalf("bottom shrink repaint missing visible row %q: %q", want, got)
367 }
368 }
369 if want := 4; r.logViewportTop != want {
370 t.Fatalf("viewport top = %d, want %d", r.logViewportTop, want)
371 }
372 if want := 7; r.logHardwareRow != want {
373 t.Fatalf("hardware row = %d, want %d", r.logHardwareRow, want)
374 }
375}
376
377// TestDrawLogInvalidationGrowthKeepsHistory guards the scrollback-safe
378// recovery trim. When the previous frame fit the viewport, nothing has
379// scrolled into retained scrollback yet, so a following recovery repaint
380// with a taller buffer must still emit the leading rows (letting them
381// scroll into history) instead of dropping them.
382func TestDrawLogInvalidationGrowthKeepsHistory(t *testing.T) {
383 t.Setenv("TERM_PROGRAM", "")
384 var buf bytes.Buffer
385 r := NewRenderer(&buf)
386 r.Resize(50, 4)
387
388 r.DrawLog([]string{"T1", "T2"}, []string{"editor"}, -1, 0)
389 if want := 0; r.logViewportTop != want {
390 t.Fatalf("setup viewport top = %d, want %d", r.logViewportTop, want)
391 }
392 buf.Reset()
393
394 r.Invalidate()
395 r.DrawLog([]string{"T1", "T2", "T3", "T4", "T5", "T6"}, []string{"editor"}, -1, 0)
396 got := buf.String()
397 for _, want := range []string{"T1", "T2", "T3", "T4", "T5", "T6"} {
398 if !strings.Contains(got, want) {
399 t.Fatalf("recovery growth dropped history row %q: %q", want, got)
400 }
401 }
402}
403
404// TestDrawLogBottomShrinkKeepsRetainedOffscreenImage is a regression for the
405// scrollback-safe recovery trim deleting a retained offscreen image. The
406// clear step deletes Kitty images, so the repaint must not skip an image
407// escape in the prefix without re-emitting it; image frames fall back to the
408// full replay instead.
409func TestDrawLogBottomShrinkKeepsRetainedOffscreenImage(t *testing.T) {
410 t.Setenv("TERM_PROGRAM", "ghostty")
411 var buf bytes.Buffer
412 r := NewRenderer(&buf)
413 r.Resize(50, 4)
414
415 image := "\x1b_Ga=T;payload\x1b\\"
416 r.DrawLog([]string{image, "a", "b", "c"}, []string{"d", "e"}, -1, 0)
417 buf.Reset()
418
419 r.DrawLog([]string{image, "a", "b", "c"}, []string{"d"}, -1, 0)
420 got := buf.String()
421
422 if !strings.Contains(got, SeqDeleteKittyImages) {
423 t.Fatalf("bottom shrink did not repaint the visible viewport: %q", got)
424 }
425 if !strings.Contains(got, image) {
426 t.Fatalf("bottom shrink deleted a retained offscreen image without re-emitting it: %q", got)
427 }
428 if !strings.Contains(got, "d") {
429 t.Fatalf("bottom shrink repaint missing bottom row: %q", got)
430 }
431}
432
433// TestDrawLogInvalidationPreservesScrollbackSelection pins the same rule for
434// cache invalidations, which can happen during an active turn independently
435// of an inaccessible changed row.
436func TestDrawLogInvalidationPreservesScrollbackSelection(t *testing.T) {
437 t.Setenv("TERM_PROGRAM", "")
438 var buf bytes.Buffer
439 r := NewRenderer(&buf)
440 r.Resize(80, 3)
441 r.DrawLog([]string{"one", "two", "three", "four"}, []string{"input"}, 0, 0)
442 buf.Reset()
443
444 r.Invalidate()
445 r.DrawLog([]string{"one", "two", "three", "four"}, []string{"input"}, 0, 0)
446 if got := buf.String(); strings.Contains(got, SeqClearScrollback) {
447 t.Fatalf("invalidation repaint erased scrollback and native selection: %q", got)
448 }
449}
450
451// TestDrawLogPartialBottomShrinkRepaintsInaccessibleRows covers returning
452// from a moderately long dialog to a short one: the shorter bottom frame
453// starts above the currently addressable viewport, so relative cursor
454// movement cannot repaint its prefix and a full repaint is required.
455// Without the forced repaint, stale dialog rows stay on screen.
456func TestDrawLogPartialBottomShrinkRepaintsInaccessibleRows(t *testing.T) {
457 t.Setenv("TERM_PROGRAM", "ghostty")
458 var buf bytes.Buffer
459 r := NewRenderer(&buf)
460 r.Resize(80, 4)
461
462 r.DrawLog(nil, []string{"transcript 1", "transcript 2", "transcript 3", "transcript 4", "transcript 5"}, -1, 0)
463 buf.Reset()
464
465 r.DrawLog(nil, []string{"dashboard", "selected agent 1"}, -1, 0)
466 got := buf.String()
467 if !strings.Contains(got, SeqClearScreenNoHome) {
468 t.Fatalf("partial bottom shrink did not repaint the screen: %q", got)
469 }
470 for _, want := range []string{"dashboard", "selected agent 1"} {
471 if !strings.Contains(got, want) {
472 t.Fatalf("partial bottom repaint missing %q: %q", want, got)
473 }
474 }
475}
476
477// TestDrawLogChatShrinkDoesNotForceBottomRepaint pins the scope of the
478// forced repaint: chat reflow has its own coordinate-rebasing path which
479// preserves native terminal selections. Only a shrinking bottom frame
480// forces the full repaint used when returning to a short dialog.
481func TestDrawLogChatShrinkDoesNotForceBottomRepaint(t *testing.T) {
482 t.Setenv("TERM_PROGRAM", "ghostty")
483 var buf bytes.Buffer
484 r := NewRenderer(&buf)
485 r.Resize(80, 4)
486
487 r.DrawLog([]string{"chat 1", "chat 2", "chat 3", "chat 4", "chat 5", "chat 6"}, []string{"input"}, 0, 0)
488 buf.Reset()
489
490 r.DrawLog([]string{"chat 1", "chat 2", "chat 3", "chat 4", "chat 5"}, []string{"input"}, 0, 0)
491 if got := buf.String(); strings.Contains(got, SeqClearScreenNoHome) {
492 t.Fatalf("chat shrink unnecessarily repainted the screen: %q", got)
493 }
494}
495
496// TestDrawLogFullHeightShrinkRepaintsAndTracksViewport covers a dialog
497// shrink that removes a full viewport of logical rows: the incremental
498// clear-below path cannot address them, so the draw must repaint fully
499// and the recomputed viewport state must survive for the next draw.
500// A cursor move right after must update the visible selection instead of
501// being discarded as inaccessible.
502func TestDrawLogFullHeightShrinkRepaintsAndTracksViewport(t *testing.T) {
503 t.Setenv("TERM_PROGRAM", "ghostty")
504 var buf bytes.Buffer
505 r := NewRenderer(&buf)
506 r.Resize(80, 4)
507
508 r.DrawLog(nil, []string{"transcript 1", "transcript 2", "transcript 3", "transcript 4", "transcript 5", "transcript 6"}, -1, 0)
509 buf.Reset()
510
511 r.DrawLog(nil, []string{"dashboard", "selected agent 1"}, -1, 0)
512 if got := buf.String(); !strings.Contains(got, SeqClearScreenNoHome) {
513 t.Fatalf("full-height shrink did not repaint the screen: %q", got)
514 }
515 buf.Reset()
516
517 r.DrawLog(nil, []string{"dashboard", "selected agent 2"}, -1, 0)
518 if got := buf.String(); !strings.Contains(got, "selected agent 2") {
519 t.Fatalf("visible update after full-height shrink was suppressed: %q", got)
520 }
521}
522
523// TestDrawLogShrinkRecoveryMatrix pins the Zut-specific renderer state
524// across a forced full repaint: themed backgrounds still tint the frame,
525// keepScrollback terminals never purge scrollback, Kitty images are
526// cleaned, and a narrow follow-up draw stays coherent with no stale rows.
527func TestDrawLogShrinkRecoveryMatrix(t *testing.T) {
528 t.Setenv("TERM_PROGRAM", "vscode")
529 var buf bytes.Buffer
530 r := NewRenderer(&buf)
531 background := Color256(237)
532 th := Dark
533 th.Background = &background
534 r.SetTheme(th)
535 r.Resize(80, 4)
536 if !r.keepScrollback {
537 t.Fatal("vscode TERM_PROGRAM did not enable keepScrollback")
538 }
539
540 image := "\x1b_Ga=T;payload\x1b\\"
541 r.DrawLog(nil, []string{image, "transcript 1", "transcript 2", "transcript 3", "transcript 4", "transcript 5"}, -1, 0)
542 buf.Reset()
543
544 r.DrawLog(nil, []string{"dashboard", "selected agent 1"}, -1, 0)
545 got := buf.String()
546 if !strings.Contains(got, SeqClearToEnd) {
547 t.Fatalf("themed shrink recovery did not repaint in place: %q", got)
548 }
549 if strings.Contains(got, SeqClearScreenNoHome) || strings.Contains(got, SeqClearScrollback) {
550 t.Fatalf("keepScrollback repaint purged scrollback: %q", got)
551 }
552 if !strings.Contains(got, SeqDeleteKittyImages) {
553 t.Fatalf("shrink recovery did not clean Kitty images: %q", got)
554 }
555 if !strings.Contains(got, th.BackgroundStyle()) {
556 t.Fatalf("themed repaint lost the background tint: %q", got)
557 }
558 buf.Reset()
559
560 // A narrow follow-up draw must stay coherent: no stale rows, no extra
561 // full repaint, and the hardware cursor tracks the new viewport.
562 r.Resize(20, 4)
563 r.DrawLog(nil, []string{"dashboard", "selected agent 2"}, -1, 0)
564 got = buf.String()
565 if !strings.Contains(got, "selected agent 2") {
566 t.Fatalf("narrow follow-up draw lost the selection: %q", got)
567 }
568 if strings.Contains(got, "transcript") {
569 t.Fatalf("narrow follow-up draw left stale rows: %q", got)
570 }
571 for _, row := range strings.Split(got, "\r\n") {
572 if strings.ContainsAny(row, "\r\n") {
573 t.Fatalf("row embeds a newline: %q", row)
574 }
575 }
576}