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
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 TestDrawLogStructuralReflowAboveViewportRepaintsToolFrames(t *testing.T) {
130 t.Setenv("TERM_PROGRAM", "")
131 var buf bytes.Buffer
132 r := NewRenderer(&buf)
133 r.Resize(80, 4)
134 oldChat := []string{
135 "┌ bash first ─────", "│", "│ old output", "└──────────────────",
136 "┌ bash second ────", "│ second output", "└──────────────────",
137 }
138 r.DrawLog(oldChat, []string{"input"}, 0, 0)
139 buf.Reset()
140
141 // The first tool gains wrapped rows after its top has scrolled away.
142 // Every following logical row shifts, so patching by the old indexes
143 // would draw the second header inside the first tool's border.
144 newChat := []string{
145 "┌ bash first ─────", "│", "│ old output", "│ wrapped line 1", "│ wrapped line 2", "└──────────────────",
146 "┌ bash second ────", "│ second output", "└──────────────────",
147 }
148 r.DrawLog(newChat, []string{"input"}, 0, 0)
149 got := buf.String()
150 if !strings.Contains(got, SeqClearScreenNoHome) {
151 t.Fatalf("structural reflow was patched in place instead of repainted: %q", got)
152 }
153 for _, want := range []string{"│ wrapped line 2", "┌ bash second", "└──────────────────"} {
154 if !strings.Contains(got, want) {
155 t.Fatalf("repaint missing %q: %q", want, got)
156 }
157 }
158 if strings.Contains(got, SeqClearScrollback) {
159 t.Fatalf("automatic recovery erased terminal scrollback: %q", got)
160 }
161}
162
163func TestDrawLogInaccessibleChangeStillAppendsNewOutput(t *testing.T) {
164 t.Setenv("TERM_PROGRAM", "")
165 var buf bytes.Buffer
166 r := NewRenderer(&buf)
167 r.Resize(80, 3)
168 r.DrawLog([]string{"selected partial", "line 2", "line 3", "line 4"}, []string{"input"}, 0, 0)
169 buf.Reset()
170
171 // A streaming reflow changes inaccessible history while a tool result is
172 // appended. Only the new suffix should be emitted, naturally scrolling the
173 // selected old text upward without replaying the complete frame.
174 r.DrawLog([]string{"selected partial response", "line 2", "line 3", "line 4", "tool output"}, []string{"input"}, 0, 0)
175 got := buf.String()
176 if strings.Contains(got, SeqClearScreenNoHome) || strings.Contains(got, SeqClearScrollback) {
177 t.Fatalf("append after inaccessible change cleared selected scrollback: %q", got)
178 }
179 if strings.Contains(got, "selected partial response") || strings.Contains(got, "line 2") {
180 t.Fatalf("append replayed historical rows: %q", got)
181 }
182 if !strings.Contains(got, "tool output") {
183 t.Fatalf("new tool output was not appended: %q", got)
184 }
185}
186
187// TestDrawLogInvalidationPreservesScrollbackSelection pins the same rule for
188// cache invalidations, which can happen during an active turn independently
189// of an inaccessible changed row.
190func TestDrawLogInvalidationPreservesScrollbackSelection(t *testing.T) {
191 t.Setenv("TERM_PROGRAM", "")
192 var buf bytes.Buffer
193 r := NewRenderer(&buf)
194 r.Resize(80, 3)
195 r.DrawLog([]string{"one", "two", "three", "four"}, []string{"input"}, 0, 0)
196 buf.Reset()
197
198 r.Invalidate()
199 r.DrawLog([]string{"one", "two", "three", "four"}, []string{"input"}, 0, 0)
200 if got := buf.String(); strings.Contains(got, SeqClearScrollback) {
201 t.Fatalf("invalidation repaint erased scrollback and native selection: %q", got)
202 }
203}