okami.mom/albedo

git clone https://git.nkp.pet/okami.mom/albedo

nekomimi.pettui: preserve reading row during transcript reflowbf7fef9

main
11.5 KiB283 linesraw
1// Scroll follow and reading position across streaming, truncation, and thinking are TUI-only state.
2package tui
3
4import (
5	"fmt"
6	"strings"
7	"testing"
8	"time"
9
10	"albedo/cli/internal/daemon"
11	tea "charm.land/bubbletea/v2"
12	"github.com/charmbracelet/x/ansi"
13)
14
15func newTestChatModel(t *testing.T, session *daemon.Session) ChatModel {
16	t.Helper()
17	conn := daemon.NewConnection(daemon.ConnectionSnapshot{Port: 1}, nil)
18	client := daemon.NewChatClient(conn, session.ID)
19	model := NewChatModel(session, client)
20	t.Cleanup(model.Close)
21	return model
22}
23
24func TestScrollDoesNotResumeFollowBeforeEndOfLiveOutput(t *testing.T) {
25	for _, down := range []struct {
26		msg  tea.Msg
27		name string
28		step int
29	}{
30		{name: "arrow", msg: tea.KeyPressMsg{Code: tea.KeyDown}, step: 1},
31		{name: "page", msg: tea.KeyPressMsg{Code: tea.KeyPgDown}, step: 13},
32		{name: "wheel", msg: tea.MouseWheelMsg{Button: tea.MouseWheelDown}, step: 3},
33	} {
34		t.Run(down.name, func(t *testing.T) {
35			m := newTestChatModel(t, &daemon.Session{ID: "s"})
36			m.SetSize(80, 20)
37			m.appendSettledEntry(HistoryEntry{Kind: EntryAssistant, Text: strings.Repeat("settled\n", 25)})
38			m.handleStreamEvent(daemon.StreamEvent{Type: daemon.EventText, Text: strings.Repeat("live\n", 55)})
39			m.refreshViewportContent()
40			bottom := m.scrollOffset
41			// This is beyond the end of settled lines, but far from the end of live output.
42			m.Follow = false
43			m.scrollOffset = len(m.settledLines)
44			m.refreshViewportContent()
45			before := m.scrollOffset
46			m, _ = m.Update(down.msg)
47			if m.Follow || m.scrollOffset != before+down.step || m.scrollOffset >= bottom {
48				t.Fatalf("jumped to tail: before=%d after=%d bottom=%d follow=%v", before, m.scrollOffset, bottom, m.Follow)
49			}
50			m.refreshViewportContent()
51			if m.scrollOffset != before+down.step {
52				t.Fatal("next refresh lost scroll position")
53			}
54			m, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyUp})
55			if m.scrollOffset != before+down.step-1 || m.Follow {
56				t.Fatalf("up did not move one row: offset=%d follow=%v", m.scrollOffset, m.Follow)
57			}
58			m.scrollBy(1000)
59			if !m.Follow || m.scrollOffset != bottom {
60				t.Fatalf("tail not restored at actual end: offset=%d bottom=%d", m.scrollOffset, bottom)
61			}
62		})
63	}
64}
65
66func TestReadingPositionSurvivesIncomingTranscript(t *testing.T) {
67	for _, scroll := range []struct {
68		msg  tea.Msg
69		name string
70	}{
71		{name: "keyboard", msg: tea.KeyPressMsg{Code: tea.KeyPgUp}},
72		{name: "wheel", msg: tea.MouseWheelMsg{Button: tea.MouseWheelUp}},
73	} {
74		t.Run(scroll.name, func(t *testing.T) {
75			m := newTestChatModel(t, &daemon.Session{ID: "s"})
76			m.SetSize(80, 20)
77			var text strings.Builder
78			for i := range MaxSettledLines + 50 {
79				fmt.Fprintf(&text, "row %d\n", i)
80			}
81			m.appendSettledEntry(HistoryEntry{Kind: EntryAssistant, Text: text.String()})
82			m.refreshViewportContent()
83			m, _ = m.Update(scroll.msg)
84			if m.Follow {
85				t.Fatal("scrolling up did not pause following")
86			}
87			before := m.scrollOffset
88			visible, _, _ := strings.Cut(ansi.Strip(m.Viewport.View()), "\n")
89			m.AddNotice("new activity")
90			m, _ = m.Update(ChatStreamEventMsg{SessionID: m.SessionID, Generation: m.Generation, Event: daemon.StreamEvent{Type: daemon.EventNote, Text: "a new line"}})
91			after, _, _ := strings.Cut(ansi.Strip(m.Viewport.View()), "\n")
92			if m.Follow || after != visible {
93				t.Fatalf("reading position changed: offset %d -> %d, follow=%v", before, m.scrollOffset, m.Follow)
94			}
95		})
96	}
97}
98
99// A collapsed thought follows its newest line and keeps it after settling,
100// until the next action replaces it; earlier lines stay hidden.
101func TestFragmentedThinkingStaysCollapsed(t *testing.T) {
102	m := newTestChatModel(t, &daemon.Session{ID: "s"})
103	m.SetSize(80, 20)
104	for _, step := range []struct{ text, want, hidden string }{
105		{"**Preparing resize/status tests**", "Preparing resize/status tests…", "Verifying"},
106		{"\n", "Preparing resize/status tests…", "Verifying"},
107		{"**Verifying scroll", "Verifying scroll…", "Preparing"},
108		{" behavior**\n\n", "Verifying scroll behavior…", "Preparing"},
109	} {
110		m.handleStreamEvent(daemon.StreamEvent{Type: daemon.EventThinking, Text: step.text})
111		m.refreshViewportContent()
112		visible := ansi.Strip(m.Viewport.View())
113		if !strings.Contains(visible, step.want) || strings.Contains(visible, step.hidden) || strings.Contains(visible, "**") {
114			t.Fatalf("thought did not follow its newest line: %q", visible)
115		}
116	}
117	m.settleActiveStream()
118	m.refreshViewportContent()
119	if visible := ansi.Strip(m.Viewport.View()); !strings.Contains(visible, "Verifying scroll behavior") || strings.Contains(visible, "Preparing") || strings.Contains(visible, "**") || !strings.Contains(visible, "thought") {
120		t.Fatalf("settled thought lost its latest line: %q", visible)
121	}
122}
123
124func TestInterleavedEmptyTextDoesNotSettleThinking(t *testing.T) {
125	m := newTestChatModel(t, &daemon.Session{ID: "s"})
126	m.SetSize(80, 20)
127	parts := []string{"First thinking token", " second thinking token", " third thinking token"}
128	for _, part := range parts {
129		m.handleStreamEvent(daemon.StreamEvent{Type: daemon.EventThinking, Text: part})
130		m.handleStreamEvent(daemon.StreamEvent{Type: daemon.EventText, Text: ""})
131	}
132	m.settleActiveStream()
133	entries := m.History.Entries()
134	if len(entries) != 1 {
135		t.Fatalf("expected 1 settled thinking entry, got %d entries: %+v", len(entries), entries)
136	}
137	if entries[0].Kind != EntryThinking {
138		t.Fatalf("expected EntryThinking, got %v", entries[0].Kind)
139	}
140	want := "First thinking token second thinking token third thinking token"
141	if entries[0].Text != want {
142		t.Fatalf("expected %q, got %q", want, entries[0].Text)
143	}
144}
145
146func TestCompactTranscriptEntries(t *testing.T) {
147	r := NewTranscriptRenderer()
148	flags := DisplayFlags{}
149	thinking := HistoryEntry{Kind: EntryThinking, Text: "Inspecting the repository\n" + strings.Repeat("details ", 30)}
150	tool := HistoryEntry{Kind: EntryTool, ToolName: "python", ToolArgs: map[string]any{"code": "print(1)"}, ToolResult: `{"output": "` + strings.Repeat("large ", 200) + `"}`}
151	for _, width := range []int{20, 80} {
152		for _, entry := range []HistoryEntry{thinking, tool} {
153			compact := r.RenderEntry(entry, flags, width)
154			if strings.Contains(compact, "\n") || ansi.StringWidth(compact) > width {
155				t.Fatalf("not one bounded row at %d: %q", width, compact)
156			}
157		}
158	}
159	if strings.Contains(r.RenderEntry(thinking, flags, 80), "Inspecting") {
160		t.Fatal("collapsed thinking exposed body text")
161	}
162	if !strings.Contains(r.RenderEntry(thinking, DisplayFlags{Thinking: true}, 80), "details") {
163		t.Fatal("thinking cannot be expanded")
164	}
165	styled := r.RenderEntry(HistoryEntry{Kind: EntryThinking, Text: "**Verifying scroll behavior**"}, DisplayFlags{Thinking: true}, 80)
166	if !strings.Contains(styled, "Verifying scroll behavior") || strings.Contains(styled, "\x1b[1m") {
167		t.Fatalf("expanded thinking escaped faint styling: %q", styled)
168	}
169	if !strings.Contains(r.RenderEntry(tool, DisplayFlags{Tools: true}, 80), "large") {
170		t.Fatal("tool output cannot be expanded")
171	}
172	tool.ToolResult = `{"status":"ok","output":"error: found in source\nTraceback (most recent call last): example","value":"","truncated":false}`
173	if summary := r.RenderEntry(tool, flags, 80); strings.Contains(summary, "failed") || strings.Contains(summary, "\n") {
174		t.Fatalf("successful Python cell mislabelled as failure: %q", summary)
175	}
176	failed := tool
177	failed.ToolResult = `{"status":"error","output":"trace","value":""}`
178	collapsed := r.RenderEntry(failed, flags, 80)
179	if strings.Contains(collapsed, "\n") || !strings.Contains(collapsed, "failed") {
180		t.Fatalf("failure should remain visible without flooding transcript: %q", collapsed)
181	}
182}
183
184// A unit test is required here because daemon E2E cannot drive a TUI scroll position while
185// a committed stream re-renders the transcript.
186func TestReadingPositionSurvivesCommittedAppend(t *testing.T) {
187	m := newTestChatModel(t, &daemon.Session{ID: "s"})
188	m.SetSize(80, 20)
189	for i := range 30 {
190		m.appendSettledEntry(HistoryEntry{ID: fmt.Sprintf("old-%d", i), Kind: EntryAssistant, Text: fmt.Sprintf("old row %d\n", i)})
191	}
192	m.Follow = false
193	m.scrollOffset = 8
194	m.refreshViewportContent()
195	want := m.Viewport.View()
196	m.appendSettledEntry(HistoryEntry{ID: "old-29", Kind: EntryAssistant, Text: "old row 29\nnew row\n"})
197	m.refreshViewportContent()
198	if got := m.Viewport.View(); got != want {
199		t.Fatalf("committed append moved reading position: offset=%d", m.scrollOffset)
200	}
201}
202
203func TestReadingPositionSurvivesOutputPastTheLineCap(t *testing.T) {
204	m := newTestChatModel(t, &daemon.Session{ID: "s"})
205	m.SetSize(80, 20)
206	block := func(name string) HistoryEntry {
207		return HistoryEntry{Kind: EntryAssistant, Text: name + "\n" + strings.Repeat("line\n", 15)}
208	}
209	for i := range 60 {
210		m.appendSettledEntry(block(fmt.Sprintf("block %d", i)))
211	}
212	m.Follow = false
213	m.scrollOffset = len(m.settledLines) / 2
214	m.refreshViewportContent()
215	view, offset := m.Viewport.View(), m.scrollOffset
216	// Enough output to trim everything above the reading row at the normal cap.
217	for i := range 40 {
218		m.appendSettledEntry(block(fmt.Sprintf("new %d", i)))
219		m.refreshViewportContent()
220		if m.Viewport.View() != view || m.scrollOffset != offset {
221			t.Fatalf("after %d blocks the view moved: offset %d → %d", i+1, offset, m.scrollOffset)
222		}
223	}
224	m.SetSize(80, 20)
225	if m.Viewport.View() != view {
226		t.Fatal("re-rendering moved the reading position")
227	}
228	m.scrollBy(1 << 20)
229	m.appendSettledEntry(block("tail"))
230	if !m.Follow || len(m.settledLines) > MaxSettledLines {
231		t.Fatalf("caps not restored when following: follow=%v lines=%d", m.Follow, len(m.settledLines))
232	}
233}
234
235func TestAGrowingReplyRedrawsAtMostOncePerIntervalAndShowsItsLastTokens(t *testing.T) {
236	// Redraw pacing is a timing decision inside one Update; an e2e cannot
237	// tell a throttled frame from a slow terminal.
238	m := newTestChatModel(t, &daemon.Session{ID: "s"})
239	m.SetSize(80, 20)
240	delta := func(text string) tea.Cmd {
241		var cmd tea.Cmd
242		m, cmd = m.Update(ChatStreamEventMsg{SessionID: m.SessionID, Generation: m.Generation,
243			Event: daemon.StreamEvent{Type: daemon.EventText, Text: text, MessageID: "m"}})
244		return cmd
245	}
246	delta("first ")
247	m.liveInterval = time.Hour
248	delta("second ")
249	delta("third")
250	if strings.Contains(m.Viewport.View(), "third") || !m.liveDue {
251		t.Fatal("a reply that only grew was redrawn inside its interval")
252	}
253	m, _ = m.Update(ChatLiveDrawMsg{SessionID: m.SessionID, Generation: m.Generation})
254	if !strings.Contains(ansi.Strip(m.Viewport.View()), "first second third") {
255		t.Fatalf("the deferred redraw lost the last tokens: %q", ansi.Strip(m.Viewport.View()))
256	}
257}
258
259func TestReturningToTheEndTrimsHistoryLoadedWhileReading(t *testing.T) {
260	// Older pages stretch both caps while you read; only the scroll back to
261	// the end restores them, and no daemon event marks that moment.
262	m := newTestChatModel(t, &daemon.Session{ID: "s"})
263	m.SetSize(80, 20)
264	m.History = NewBoundedHistory(10, 1<<20)
265	for i := range 10 {
266		m.appendSettledEntry(HistoryEntry{ID: fmt.Sprint("new", i), Seq: int64(100 + i), Kind: EntryAssistant, Text: "newer"})
267	}
268	m.Follow, m.scrollOffset, m.loadingOlder = false, 0, true
269	var older []daemon.StreamEvent
270	for i := range 30 {
271		older = append(older, daemon.StreamEvent{Type: daemon.EventMessage, EntryID: fmt.Sprint("old", i), Position: int64(i + 1), Text: "older"})
272	}
273	m = m.showOlder(ChatOlderLoadedMsg{SessionID: m.SessionID, Generation: m.Generation, Page: &daemon.HistoryPage{Events: older, Before: 1}})
274	if m.History.Len() <= 10 {
275		t.Fatalf("an older page was not kept while reading: %d entries", m.History.Len())
276	}
277	for !m.Follow {
278		m.scrollBy(20)
279	}
280	if m.History.Len() != 10 {
281		t.Fatalf("back at the end, history kept %d entries past its cap of 10", m.History.Len())
282	}
283}