// Scroll follow and reading position across streaming, truncation, and thinking are TUI-only state. package tui import ( "fmt" "strings" "testing" "time" "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) } } // A unit test is required here because daemon E2E cannot drive a TUI scroll position while // a committed stream re-renders the transcript. func TestReadingPositionSurvivesCommittedAppend(t *testing.T) { m := newTestChatModel(t, &daemon.Session{ID: "s"}) m.SetSize(80, 20) for i := range 30 { m.appendSettledEntry(HistoryEntry{ID: fmt.Sprintf("old-%d", i), Kind: EntryAssistant, Text: fmt.Sprintf("old row %d\n", i)}) } m.Follow = false m.scrollOffset = 8 m.refreshViewportContent() want := m.Viewport.View() m.appendSettledEntry(HistoryEntry{ID: "old-29", Kind: EntryAssistant, Text: "old row 29\nnew row\n"}) m.refreshViewportContent() if got := m.Viewport.View(); got != want { t.Fatalf("committed append moved reading position: offset=%d", m.scrollOffset) } } 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)) } } func TestAGrowingReplyRedrawsAtMostOncePerIntervalAndShowsItsLastTokens(t *testing.T) { // Redraw pacing is a timing decision inside one Update; an e2e cannot // tell a throttled frame from a slow terminal. m := newTestChatModel(t, &daemon.Session{ID: "s"}) m.SetSize(80, 20) delta := func(text string) tea.Cmd { var cmd tea.Cmd m, cmd = m.Update(ChatStreamEventMsg{SessionID: m.SessionID, Generation: m.Generation, Event: daemon.StreamEvent{Type: daemon.EventText, Text: text, MessageID: "m"}}) return cmd } delta("first ") m.liveInterval = time.Hour delta("second ") delta("third") if strings.Contains(m.Viewport.View(), "third") || !m.liveDue { t.Fatal("a reply that only grew was redrawn inside its interval") } m, _ = m.Update(ChatLiveDrawMsg{SessionID: m.SessionID, Generation: m.Generation}) if !strings.Contains(ansi.Strip(m.Viewport.View()), "first second third") { t.Fatalf("the deferred redraw lost the last tokens: %q", ansi.Strip(m.Viewport.View())) } } func TestReturningToTheEndTrimsHistoryLoadedWhileReading(t *testing.T) { // Older pages stretch both caps while you read; only the scroll back to // the end restores them, and no daemon event marks that moment. m := newTestChatModel(t, &daemon.Session{ID: "s"}) m.SetSize(80, 20) m.History = NewBoundedHistory(10, 1<<20) for i := range 10 { m.appendSettledEntry(HistoryEntry{ID: fmt.Sprint("new", i), Seq: int64(100 + i), Kind: EntryAssistant, Text: "newer"}) } m.Follow, m.scrollOffset, m.loadingOlder = false, 0, true var older []daemon.StreamEvent for i := range 30 { older = append(older, daemon.StreamEvent{Type: daemon.EventMessage, EntryID: fmt.Sprint("old", i), Position: int64(i + 1), Text: "older"}) } m = m.showOlder(ChatOlderLoadedMsg{SessionID: m.SessionID, Generation: m.Generation, Page: &daemon.HistoryPage{Events: older, Before: 1}}) if m.History.Len() <= 10 { t.Fatalf("an older page was not kept while reading: %d entries", m.History.Len()) } for !m.Follow { m.scrollBy(20) } if m.History.Len() != 10 { t.Fatalf("back at the end, history kept %d entries past its cap of 10", m.History.Len()) } }