// Scroll follow and reading position across streaming, truncation, and thinking are TUI-only state.
package tui
import (
"fmt"
"strings"
"testing"
"albedo/cli/internal/daemon"
tea "charm.land/bubbletea/v2"
"github.com/charmbracelet/x/ansi"
)
func newTestChatModel(t *testing.T, session *daemon.Session) ChatModel {
t.Helper()
conn := daemon.NewConnection(daemon.ConnectionSnapshot{Port: 1}, nil)
client := daemon.NewChatClient(conn, session.ID)
model := NewChatModel(session, client)
t.Cleanup(model.Close)
return model
}
func TestScrollDoesNotResumeFollowBeforeEndOfLiveOutput(t *testing.T) {
for _, down := range []struct {
msg tea.Msg
name string
step int
}{
{name: "arrow", msg: tea.KeyPressMsg{Code: tea.KeyDown}, step: 1},
{name: "page", msg: tea.KeyPressMsg{Code: tea.KeyPgDown}, step: 13},
{name: "wheel", msg: tea.MouseWheelMsg{Button: tea.MouseWheelDown}, step: 3},
} {
t.Run(down.name, func(t *testing.T) {
m := newTestChatModel(t, &daemon.Session{ID: "s"})
m.SetSize(80, 20)
m.appendSettledEntry(HistoryEntry{Kind: EntryAssistant, Text: strings.Repeat("settled\n", 25)})
m.handleStreamEvent(daemon.StreamEvent{Type: daemon.EventText, Text: strings.Repeat("live\n", 55)})
m.refreshViewportContent()
bottom := m.scrollOffset
// This is beyond the end of settled lines, but far from the end of live output.
m.Follow = false
m.scrollOffset = len(m.settledLines)
m.refreshViewportContent()
before := m.scrollOffset
m, _ = m.Update(down.msg)
if m.Follow || m.scrollOffset != before+down.step || m.scrollOffset >= bottom {
t.Fatalf("jumped to tail: before=%d after=%d bottom=%d follow=%v", before, m.scrollOffset, bottom, m.Follow)
}
m.refreshViewportContent()
if m.scrollOffset != before+down.step {
t.Fatal("next refresh lost scroll position")
}
m, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyUp})
if m.scrollOffset != before+down.step-1 || m.Follow {
t.Fatalf("up did not move one row: offset=%d follow=%v", m.scrollOffset, m.Follow)
}
m.scrollBy(1000)
if !m.Follow || m.scrollOffset != bottom {
t.Fatalf("tail not restored at actual end: offset=%d bottom=%d", m.scrollOffset, bottom)
}
})
}
}
func TestReadingPositionSurvivesIncomingTranscript(t *testing.T) {
for _, scroll := range []struct {
msg tea.Msg
name string
}{
{name: "keyboard", msg: tea.KeyPressMsg{Code: tea.KeyPgUp}},
{name: "wheel", msg: tea.MouseWheelMsg{Button: tea.MouseWheelUp}},
} {
t.Run(scroll.name, func(t *testing.T) {
m := newTestChatModel(t, &daemon.Session{ID: "s"})
m.SetSize(80, 20)
var text strings.Builder
for i := range MaxSettledLines + 50 {
fmt.Fprintf(&text, "row %d\n", i)
}
m.appendSettledEntry(HistoryEntry{Kind: EntryAssistant, Text: text.String()})
m.refreshViewportContent()
m, _ = m.Update(scroll.msg)
if m.Follow {
t.Fatal("scrolling up did not pause following")
}
before := m.scrollOffset
visible, _, _ := strings.Cut(ansi.Strip(m.Viewport.View()), "\n")
m.AddNotice("new activity")
m, _ = m.Update(ChatStreamEventMsg{SessionID: m.SessionID, Generation: m.Generation, Event: daemon.StreamEvent{Type: daemon.EventNote, Text: "a new line"}})
after, _, _ := strings.Cut(ansi.Strip(m.Viewport.View()), "\n")
if m.Follow || after != visible {
t.Fatalf("reading position changed: offset %d -> %d, follow=%v", before, m.scrollOffset, m.Follow)
}
})
}
}
// A collapsed thought follows its newest line and keeps it after settling,
// until the next action replaces it; earlier lines stay hidden.
func TestFragmentedThinkingStaysCollapsed(t *testing.T) {
m := newTestChatModel(t, &daemon.Session{ID: "s"})
m.SetSize(80, 20)
for _, step := range []struct{ text, want, hidden string }{
{"**Preparing resize/status tests**", "Preparing resize/status tests…", "Verifying"},
{"\n", "Preparing resize/status tests…", "Verifying"},
{"**Verifying scroll", "Verifying scroll…", "Preparing"},
{" behavior**\n\n", "Verifying scroll behavior…", "Preparing"},
} {
m.handleStreamEvent(daemon.StreamEvent{Type: daemon.EventThinking, Text: step.text})
m.refreshViewportContent()
visible := ansi.Strip(m.Viewport.View())
if !strings.Contains(visible, step.want) || strings.Contains(visible, step.hidden) || strings.Contains(visible, "**") {
t.Fatalf("thought did not follow its newest line: %q", visible)
}
}
m.settleActiveStream()
m.refreshViewportContent()
if visible := ansi.Strip(m.Viewport.View()); !strings.Contains(visible, "Verifying scroll behavior") || strings.Contains(visible, "Preparing") || strings.Contains(visible, "**") || !strings.Contains(visible, "thought") {
t.Fatalf("settled thought lost its latest line: %q", visible)
}
}
func TestInterleavedEmptyTextDoesNotSettleThinking(t *testing.T) {
m := newTestChatModel(t, &daemon.Session{ID: "s"})
m.SetSize(80, 20)
parts := []string{"First thinking token", " second thinking token", " third thinking token"}
for _, part := range parts {
m.handleStreamEvent(daemon.StreamEvent{Type: daemon.EventThinking, Text: part})
m.handleStreamEvent(daemon.StreamEvent{Type: daemon.EventText, Text: ""})
}
m.settleActiveStream()
entries := m.History.Entries()
if len(entries) != 1 {
t.Fatalf("expected 1 settled thinking entry, got %d entries: %+v", len(entries), entries)
}
if entries[0].Kind != EntryThinking {
t.Fatalf("expected EntryThinking, got %v", entries[0].Kind)
}
want := "First thinking token second thinking token third thinking token"
if entries[0].Text != want {
t.Fatalf("expected %q, got %q", want, entries[0].Text)
}
}
func TestCompactTranscriptEntries(t *testing.T) {
r := NewTranscriptRenderer()
flags := DisplayFlags{}
thinking := HistoryEntry{Kind: EntryThinking, Text: "Inspecting the repository\n" + strings.Repeat("details ", 30)}
tool := HistoryEntry{Kind: EntryTool, ToolName: "python", ToolArgs: map[string]any{"code": "print(1)"}, ToolResult: `{"output": "` + strings.Repeat("large ", 200) + `"}`}
for _, width := range []int{20, 80} {
for _, entry := range []HistoryEntry{thinking, tool} {
compact := r.RenderEntry(entry, flags, width)
if strings.Contains(compact, "\n") || ansi.StringWidth(compact) > width {
t.Fatalf("not one bounded row at %d: %q", width, compact)
}
}
}
if strings.Contains(r.RenderEntry(thinking, flags, 80), "Inspecting") {
t.Fatal("collapsed thinking exposed body text")
}
if !strings.Contains(r.RenderEntry(thinking, DisplayFlags{Thinking: true}, 80), "details") {
t.Fatal("thinking cannot be expanded")
}
styled := r.RenderEntry(HistoryEntry{Kind: EntryThinking, Text: "**Verifying scroll behavior**"}, DisplayFlags{Thinking: true}, 80)
if !strings.Contains(styled, "Verifying scroll behavior") || strings.Contains(styled, "\x1b[1m") {
t.Fatalf("expanded thinking escaped faint styling: %q", styled)
}
if !strings.Contains(r.RenderEntry(tool, DisplayFlags{Tools: true}, 80), "large") {
t.Fatal("tool output cannot be expanded")
}
tool.ToolResult = `{"status":"ok","output":"error: found in source\nTraceback (most recent call last): example","value":"","truncated":false}`
if summary := r.RenderEntry(tool, flags, 80); strings.Contains(summary, "failed") || strings.Contains(summary, "\n") {
t.Fatalf("successful Python cell mislabelled as failure: %q", summary)
}
failed := tool
failed.ToolResult = `{"status":"error","output":"trace","value":""}`
collapsed := r.RenderEntry(failed, flags, 80)
if strings.Contains(collapsed, "\n") || !strings.Contains(collapsed, "failed") {
t.Fatalf("failure should remain visible without flooding transcript: %q", collapsed)
}
}
func TestReadingPositionSurvivesOutputPastTheLineCap(t *testing.T) {
m := newTestChatModel(t, &daemon.Session{ID: "s"})
m.SetSize(80, 20)
block := func(name string) HistoryEntry {
return HistoryEntry{Kind: EntryAssistant, Text: name + "\n" + strings.Repeat("line\n", 15)}
}
for i := range 60 {
m.appendSettledEntry(block(fmt.Sprintf("block %d", i)))
}
m.Follow = false
m.scrollOffset = len(m.settledLines) / 2
m.refreshViewportContent()
view, offset := m.Viewport.View(), m.scrollOffset
// Enough output to trim everything above the reading row at the normal cap.
for i := range 40 {
m.appendSettledEntry(block(fmt.Sprintf("new %d", i)))
m.refreshViewportContent()
if m.Viewport.View() != view || m.scrollOffset != offset {
t.Fatalf("after %d blocks the view moved: offset %d → %d", i+1, offset, m.scrollOffset)
}
}
m.SetSize(80, 20)
if m.Viewport.View() != view {
t.Fatal("re-rendering moved the reading position")
}
m.scrollBy(1 << 20)
m.appendSettledEntry(block("tail"))
if !m.Follow || len(m.settledLines) > MaxSettledLines {
t.Fatalf("caps not restored when following: follow=%v lines=%d", m.Follow, len(m.settledLines))
}
}