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
86// TestDrawLogCursorMoveBreaksFastPath proves a cursor-only change
87// (no buffer change) still produces output. Without this, typing in
88// the editor would visually move the caret but the terminal would
89// keep drawing it at the old column.
90func TestDrawLogCursorMoveBreaksFastPath(t *testing.T) {
91 var buf bytes.Buffer
92 r := NewRenderer(&buf)
93 r.Resize(80, 24)
94
95 r.DrawLog([]string{"hi"}, []string{"▌ "}, 0, 2)
96 buf.Reset()
97
98 // Same buffer, different cursor column.
99 r.DrawLog([]string{"hi"}, []string{"▌ "}, 0, 3)
100 if buf.Len() == 0 {
101 t.Fatal("cursor-only change suppressed by fast path; caret would lag behind typing")
102 }
103 // And the emitted bytes must at least reposition the cursor.
104 if !strings.Contains(buf.String(), "\x1b[") {
105 t.Errorf("cursor move emission missing CSI escapes: %q", buf.String())
106 }
107}
108
109// TestDrawLogResizeForcesFullRedraw confirms a resize invalidates
110// the cache so the next DrawLog with identical inputs still emits.
111// Resize sets logInit=false; without that, a resize followed by an
112// identical buffer would falsely no-op and leave a stale frame.
113func TestDrawLogResizeForcesFullRedraw(t *testing.T) {
114 var buf bytes.Buffer
115 r := NewRenderer(&buf)
116 r.Resize(80, 24)
117 r.DrawLog([]string{"hi"}, []string{"▌ "}, 0, 2)
118 buf.Reset()
119
120 r.Resize(100, 30)
121 r.DrawLog([]string{"hi"}, []string{"▌ "}, 0, 2)
122 if buf.Len() == 0 {
123 t.Fatal("post-resize redraw skipped; the new frame would never reach the terminal")
124 }
125}
126
127// TestDrawLogInaccessibleChangePreservesScrollbackSelection covers long
128// streaming output whose changing first row has already scrolled above the
129// viewport. That row is immutable terminal history: clearing or replaying it
130// either destroys a native mouse selection or duplicates stale tool frames.
131func TestDrawLogInaccessibleChangePreservesScrollbackSelection(t *testing.T) {
132 t.Setenv("TERM_PROGRAM", "")
133 var buf bytes.Buffer
134 r := NewRenderer(&buf)
135 r.Resize(80, 3)
136 r.DrawLog([]string{"selected partial", "line 2", "line 3", "line 4"}, []string{"input"}, 0, 0)
137 buf.Reset()
138
139 // Only the historical row changed. DrawLog must leave the terminal alone
140 // instead of clearing and replaying the retained scrollback.
141 r.DrawLog([]string{"selected partial response", "line 2", "line 3", "line 4"}, []string{"input"}, 0, 0)
142 got := buf.String()
143 if strings.Contains(got, SeqClearScreenNoHome) || strings.Contains(got, SeqClearScrollback) {
144 t.Fatalf("inaccessible change cleared selected scrollback: %q", got)
145 }
146 if strings.Contains(got, "selected partial response") {
147 t.Fatalf("inaccessible row was replayed into retained scrollback: %q", got)
148 }
149}
150
151func TestDrawLogStructuralReflowAboveViewportRebasesWithoutReplay(t *testing.T) {
152 t.Setenv("TERM_PROGRAM", "ghostty")
153 var buf bytes.Buffer
154 r := NewRenderer(&buf)
155 r.Resize(80, 4)
156 oldChat := []string{
157 "user prompt",
158 "┌ bash first ─────", "│ old output", "└──────────────────",
159 "┌ bash second ────", "│ second output", "└──────────────────",
160 }
161 r.DrawLog(oldChat, []string{"input"}, 0, 0)
162 buf.Reset()
163
164 // The first tool gains wrapped rows after its top has scrolled away.
165 // Clearing the viewport and replaying the complete logical transcript
166 // leaves the old prompt in native scrollback and prints a second copy.
167 // Rebase the logical row coordinates instead, leaving inaccessible rows
168 // as historical snapshots and keeping the visible tail aligned.
169 newChat := []string{
170 "user prompt",
171 "┌ bash first ─────", "│ old output", "│ wrapped line 1", "│ wrapped line 2", "└──────────────────",
172 "┌ bash second ────", "│ second output", "└──────────────────",
173 }
174 r.DrawLog(newChat, []string{"input"}, 0, 0)
175 got := buf.String()
176 if strings.Contains(got, SeqClearScreenNoHome) || strings.Contains(got, SeqClearScrollback) {
177 t.Fatalf("structural reflow cleared the terminal: %q", got)
178 }
179 for _, replayed := range []string{"user prompt", "┌ bash first", "┌ bash second"} {
180 if strings.Contains(got, replayed) {
181 t.Fatalf("structural reflow replayed %q into scrollback: %q", replayed, got)
182 }
183 }
184
185 buf.Reset()
186 appended := append(append([]string(nil), newChat...), "┌ bash third ─────", "│ third output", "└──────────────────")
187 r.DrawLog(appended, []string{"input"}, 0, 0)
188 got = buf.String()
189 if !strings.Contains(got, "┌ bash third") || !strings.Contains(got, "│ third output") {
190 t.Fatalf("new output was not appended after coordinate rebase: %q", got)
191 }
192 for _, replayed := range []string{"user prompt", "┌ bash first", "┌ bash second"} {
193 if strings.Contains(got, replayed) {
194 t.Fatalf("append after rebase replayed %q: %q", replayed, got)
195 }
196 }
197}
198
199func TestDrawLogInaccessibleChangeStillAppendsNewOutput(t *testing.T) {
200 t.Setenv("TERM_PROGRAM", "")
201 var buf bytes.Buffer
202 r := NewRenderer(&buf)
203 r.Resize(80, 3)
204 r.DrawLog([]string{"selected partial", "line 2", "line 3", "line 4"}, []string{"input"}, 0, 0)
205 buf.Reset()
206
207 // A streaming reflow changes inaccessible history while a tool result is
208 // appended. Only the new suffix should be emitted, naturally scrolling the
209 // selected old text upward without replaying the complete frame.
210 r.DrawLog([]string{"selected partial response", "line 2", "line 3", "line 4", "tool output"}, []string{"input"}, 0, 0)
211 got := buf.String()
212 if strings.Contains(got, SeqClearScreenNoHome) || strings.Contains(got, SeqClearScrollback) {
213 t.Fatalf("append after inaccessible change cleared selected scrollback: %q", got)
214 }
215 if strings.Contains(got, "selected partial response") || strings.Contains(got, "line 2") {
216 t.Fatalf("append replayed historical rows: %q", got)
217 }
218 if !strings.Contains(got, "tool output") {
219 t.Fatalf("new tool output was not appended: %q", got)
220 }
221}
222
223// TestDrawLogInvalidationPreservesScrollbackSelection pins the same rule for
224// cache invalidations, which can happen during an active turn independently
225// of an inaccessible changed row.
226func TestDrawLogInvalidationPreservesScrollbackSelection(t *testing.T) {
227 t.Setenv("TERM_PROGRAM", "")
228 var buf bytes.Buffer
229 r := NewRenderer(&buf)
230 r.Resize(80, 3)
231 r.DrawLog([]string{"one", "two", "three", "four"}, []string{"input"}, 0, 0)
232 buf.Reset()
233
234 r.Invalidate()
235 r.DrawLog([]string{"one", "two", "three", "four"}, []string{"input"}, 0, 0)
236 if got := buf.String(); strings.Contains(got, SeqClearScrollback) {
237 t.Fatalf("invalidation repaint erased scrollback and native selection: %q", got)
238 }
239}