package tui import ( "bytes" "strings" "testing" ) // TestDrawLogIdleNoOpEmitsNothing pins the cursor-blink fix: when // DrawLog is called with the exact same buffer and cursor position // as the previous call, it must emit ZERO bytes. // // The bug this regresses: at the 120ms animation tick the renderer // used to always emit SeqHideCursor + cursor-position + // SeqShowCursor, which resets the terminal's blink timer. Faster // than the OS blink interval, so an idle dialog editor (e.g. a // re-opened swarm transcript whose agent isn't producing output) // rendered the caret as a solid non-blinking block. // // With the no-op fast path the renderer leaves the screen alone // on idle frames, letting the terminal run its own blink cycle. func TestDrawLogIdleNoOpEmitsNothing(t *testing.T) { var buf bytes.Buffer r := NewRenderer(&buf) r.Resize(80, 24) chat := []string{"hello", "world"} bottom := []string{"▌ "} // First draw populates the renderer's cached buffer. r.DrawLog(chat, bottom, 0, 2) first := buf.Len() if first == 0 { t.Fatal("first DrawLog wrote nothing; setup is broken") } buf.Reset() // Identical second draw: same content, same cursor placement. r.DrawLog(chat, bottom, 0, 2) if buf.Len() != 0 { t.Fatalf("idle re-draw emitted %d bytes; expected 0 so terminal blink keeps ticking\n%q", buf.Len(), buf.String()) } } // TestDrawLogContentChangeBreaksFastPath proves the no-op fast path // only fires when nothing changed. A buffer mutation must still // produce output, otherwise streaming agent replies would freeze on // screen. func TestDrawLogContentChangeBreaksFastPath(t *testing.T) { var buf bytes.Buffer r := NewRenderer(&buf) r.Resize(80, 24) r.DrawLog([]string{"hello"}, []string{"▌ "}, 0, 2) buf.Reset() // New chat row lands. r.DrawLog([]string{"hello", "world"}, []string{"▌ "}, 0, 2) if buf.Len() == 0 { t.Fatal("content change suppressed by fast path; streaming output would freeze") } } // TestDrawLogCursorMoveBreaksFastPath proves a cursor-only change // (no buffer change) still produces output. Without this, typing in // the editor would visually move the caret but the terminal would // keep drawing it at the old column. func TestDrawLogCursorMoveBreaksFastPath(t *testing.T) { var buf bytes.Buffer r := NewRenderer(&buf) r.Resize(80, 24) r.DrawLog([]string{"hi"}, []string{"▌ "}, 0, 2) buf.Reset() // Same buffer, different cursor column. r.DrawLog([]string{"hi"}, []string{"▌ "}, 0, 3) if buf.Len() == 0 { t.Fatal("cursor-only change suppressed by fast path; caret would lag behind typing") } // And the emitted bytes must at least reposition the cursor. if !strings.Contains(buf.String(), "\x1b[") { t.Errorf("cursor move emission missing CSI escapes: %q", buf.String()) } } // TestDrawLogResizeForcesFullRedraw confirms a resize invalidates // the cache so the next DrawLog with identical inputs still emits. // Resize sets logInit=false; without that, a resize followed by an // identical buffer would falsely no-op and leave a stale frame. func TestDrawLogResizeForcesFullRedraw(t *testing.T) { var buf bytes.Buffer r := NewRenderer(&buf) r.Resize(80, 24) r.DrawLog([]string{"hi"}, []string{"▌ "}, 0, 2) buf.Reset() r.Resize(100, 30) r.DrawLog([]string{"hi"}, []string{"▌ "}, 0, 2) if buf.Len() == 0 { t.Fatal("post-resize redraw skipped; the new frame would never reach the terminal") } } // TestDrawLogInaccessibleChangePreservesScrollbackSelection covers long // streaming output whose changing first row has already scrolled above the // viewport. That row is immutable terminal history: clearing or replaying it // either destroys a native mouse selection or duplicates stale tool frames. func TestDrawLogInaccessibleChangePreservesScrollbackSelection(t *testing.T) { t.Setenv("TERM_PROGRAM", "") var buf bytes.Buffer r := NewRenderer(&buf) r.Resize(80, 3) r.DrawLog([]string{"selected partial", "line 2", "line 3", "line 4"}, []string{"input"}, 0, 0) buf.Reset() // Only the historical row changed. DrawLog must leave the terminal alone // instead of clearing and replaying the retained scrollback. r.DrawLog([]string{"selected partial response", "line 2", "line 3", "line 4"}, []string{"input"}, 0, 0) got := buf.String() if strings.Contains(got, SeqClearScreenNoHome) || strings.Contains(got, SeqClearScrollback) { t.Fatalf("inaccessible change cleared selected scrollback: %q", got) } if strings.Contains(got, "selected partial response") { t.Fatalf("inaccessible row was replayed into retained scrollback: %q", got) } } func TestDrawLogStructuralReflowAboveViewportRepaintsToolFrames(t *testing.T) { t.Setenv("TERM_PROGRAM", "") var buf bytes.Buffer r := NewRenderer(&buf) r.Resize(80, 4) oldChat := []string{ "┌ bash first ─────", "│", "│ old output", "└──────────────────", "┌ bash second ────", "│ second output", "└──────────────────", } r.DrawLog(oldChat, []string{"input"}, 0, 0) buf.Reset() // The first tool gains wrapped rows after its top has scrolled away. // Every following logical row shifts, so patching by the old indexes // would draw the second header inside the first tool's border. newChat := []string{ "┌ bash first ─────", "│", "│ old output", "│ wrapped line 1", "│ wrapped line 2", "└──────────────────", "┌ bash second ────", "│ second output", "└──────────────────", } r.DrawLog(newChat, []string{"input"}, 0, 0) got := buf.String() if !strings.Contains(got, SeqClearScreenNoHome) { t.Fatalf("structural reflow was patched in place instead of repainted: %q", got) } for _, want := range []string{"│ wrapped line 2", "┌ bash second", "└──────────────────"} { if !strings.Contains(got, want) { t.Fatalf("repaint missing %q: %q", want, got) } } if strings.Contains(got, SeqClearScrollback) { t.Fatalf("automatic recovery erased terminal scrollback: %q", got) } } func TestDrawLogInaccessibleChangeStillAppendsNewOutput(t *testing.T) { t.Setenv("TERM_PROGRAM", "") var buf bytes.Buffer r := NewRenderer(&buf) r.Resize(80, 3) r.DrawLog([]string{"selected partial", "line 2", "line 3", "line 4"}, []string{"input"}, 0, 0) buf.Reset() // A streaming reflow changes inaccessible history while a tool result is // appended. Only the new suffix should be emitted, naturally scrolling the // selected old text upward without replaying the complete frame. r.DrawLog([]string{"selected partial response", "line 2", "line 3", "line 4", "tool output"}, []string{"input"}, 0, 0) got := buf.String() if strings.Contains(got, SeqClearScreenNoHome) || strings.Contains(got, SeqClearScrollback) { t.Fatalf("append after inaccessible change cleared selected scrollback: %q", got) } if strings.Contains(got, "selected partial response") || strings.Contains(got, "line 2") { t.Fatalf("append replayed historical rows: %q", got) } if !strings.Contains(got, "tool output") { t.Fatalf("new tool output was not appended: %q", got) } } // TestDrawLogInvalidationPreservesScrollbackSelection pins the same rule for // cache invalidations, which can happen during an active turn independently // of an inaccessible changed row. func TestDrawLogInvalidationPreservesScrollbackSelection(t *testing.T) { t.Setenv("TERM_PROGRAM", "") var buf bytes.Buffer r := NewRenderer(&buf) r.Resize(80, 3) r.DrawLog([]string{"one", "two", "three", "four"}, []string{"input"}, 0, 0) buf.Reset() r.Invalidate() r.DrawLog([]string{"one", "two", "three", "four"}, []string{"input"}, 0, 0) if got := buf.String(); strings.Contains(got, SeqClearScrollback) { t.Fatalf("invalidation repaint erased scrollback and native selection: %q", got) } }