okami.mom/albedo

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

nekomimi.pettui: pointer app model, shared styles, reused glamour renderers, own transcript pane, reuse rows across older pages, gogc 500b9f48f

main
5.7 KiB151 linesraw
1// Scrollback fetching must preserve position and resume after eviction in the TUI.
2package tui
3
4import (
5	"encoding/json"
6	"fmt"
7	"net"
8	"net/http"
9	"net/http/httptest"
10	"slices"
11	"strings"
12	"testing"
13
14	"albedo/cli/internal/daemon"
15	tea "charm.land/bubbletea/v2"
16	"github.com/charmbracelet/x/ansi"
17)
18
19// turn is one user message and its reply, committed as rows seq and seq+1.
20func turn(name string, seq int64) []daemon.StreamEvent {
21	return []daemon.StreamEvent{
22		{Type: daemon.EventUser, Text: name, Source: "chat", Replayed: true},
23		{Type: daemon.EventCommitted, Seq: seq, Replayed: true},
24		{Type: daemon.EventMessage, Text: "reply to " + name + "\n" + strings.Repeat("more\n", 8), Replayed: true},
25		{Type: daemon.EventCommitted, Seq: seq + 1, Replayed: true},
26	}
27}
28
29func rowOf(view, text string) int {
30	for i, line := range strings.Split(ansi.Strip(view), "\n") {
31		if strings.Contains(line, text) {
32			return i
33		}
34	}
35	return -1
36}
37
38func TestScrollingToTheTopLoadsOlderHistoryInPlace(t *testing.T) {
39	var asked []string
40	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
41		asked = append(asked, r.URL.RawQuery)
42		_ = json.NewEncoder(w).Encode(map[string]any{"items": []any{protocolEntry("q", "user", "older question", 8), protocolEntry("a", "assistant", "older answer", 9)}, "older": nil, "newer": nil, "high_water": 9})
43	}))
44	defer server.Close()
45	conn := daemon.NewConnection(daemon.ConnectionSnapshot{Port: server.Listener.Addr().(*net.TCPAddr).Port}, nil)
46	client := daemon.NewChatClient(conn, "s")
47	m := NewChatModel(&daemon.Session{ID: "s"}, client)
48	t.Cleanup(m.Close)
49	m.SetSize(80, 20)
50	m.handleStreamEvent(daemon.StreamEvent{Type: daemon.EventReset, Before: 10, More: true, Replayed: true})
51	for i, name := range []string{"first", "second", "third"} {
52		for _, evt := range turn(name, int64(10+2*i)) {
53			m.handleStreamEvent(evt)
54		}
55	}
56	m.refreshViewportContent()
57
58	m.Follow = false
59	m.scrollOffset = 1
60	m.refreshViewportContent()
61	m, cmd := m.Update(tea.KeyPressMsg{Code: tea.KeyUp})
62	if cmd == nil || !m.loadingOlder {
63		t.Fatal("reaching the top did not ask for older history")
64	}
65	before := rowOf(m.Viewport.View(), "first")
66	if before < 0 {
67		t.Fatal("the first turn is not on screen before loading")
68	}
69	m, _ = m.Update(cmd())
70	if len(asked) != 1 || !strings.Contains(asked[0], "before=10") {
71		t.Fatalf("asked %v", asked)
72	}
73	if got := rowOf(m.Viewport.View(), "first"); got != before {
74		t.Fatalf("loading older history moved the view: row %d → %d", before, got)
75	}
76	entries := m.History.Entries()
77	if entries[0].Text != "older question" || entries[0].Seq != 8 {
78		t.Fatalf("older page not prepended: %+v", entries[0])
79	}
80	if _, more := m.olderCursor(); more {
81		t.Fatal("history start not recorded")
82	}
83	m.scrollOffset = 0
84	m.refreshViewportContent()
85	if !strings.Contains(ansi.Strip(m.Viewport.View()), "older question") {
86		t.Fatal("older page not shown above")
87	}
88	if _, cmd := m.Update(tea.KeyPressMsg{Code: tea.KeyUp}); cmd != nil {
89		t.Fatal("asked again with nothing older")
90	}
91}
92
93func TestEvictedHistoryResumesAfterItsNewestRow(t *testing.T) {
94	h := NewBoundedHistory(3, 1<<20)
95	for seq := int64(1); seq <= 3; seq++ {
96		h.Append(HistoryEntry{Kind: EntryAssistant, Text: fmt.Sprint(seq)})
97		h.Stamp(seq)
98	}
99	// Two entries from row 4: the first eviction takes row 1 only.
100	h.Append(HistoryEntry{Kind: EntryThinking, Text: "4a"})
101	h.Append(HistoryEntry{Kind: EntryAssistant, Text: "4b"})
102	h.Stamp(4)
103	if h.EvictedThrough() != 2 || h.Entries()[0].Text != "3" {
104		t.Fatalf("evicted through %d, first %q", h.EvictedThrough(), h.Entries()[0].Text)
105	}
106	// Evicting into row 4 takes both of its entries.
107	h.Append(HistoryEntry{Kind: EntryAssistant, Text: "5"})
108	h.Append(HistoryEntry{Kind: EntryAssistant, Text: "6"})
109	if h.EvictedThrough() != 4 || h.Entries()[0].Text != "5" {
110		t.Fatalf("row 4 split: evicted through %d, first %q", h.EvictedThrough(), h.Entries()[0].Text)
111	}
112	h.Prepend([]HistoryEntry{{Kind: EntryUser, Text: "old", Seq: 4}})
113	if h.EvictedThrough() != 0 || h.Entries()[0].Text != "old" {
114		t.Fatal("prepend did not take over the cursor")
115	}
116}
117
118func TestOlderPagesReuseBlocksExactlyAsAFreshRebuildDrawsThem(t *testing.T) {
119	// Kept blocks are an invariant across every kind of entry and burst
120	// boundary; an e2e sees only the screen, not which rows were reused.
121	page := func(from int) []daemon.StreamEvent {
122		var events []daemon.StreamEvent
123		for i := from; i < from+6; i++ {
124			id := fmt.Sprint(i)
125			at := int64(1_000_000 + i*60_000)
126			events = append(events,
127				daemon.StreamEvent{Type: daemon.EventUser, EntryID: "u" + id, Position: int64(i), Text: "ask " + id, Timestamp: &at},
128				daemon.StreamEvent{Type: daemon.EventThinking, EntryID: "k" + id, Position: int64(i), Text: "think " + id},
129				daemon.StreamEvent{Type: daemon.EventTool, EntryID: "t" + id, Position: int64(i), ToolName: "python", ToolArgs: map[string]any{"code": "print(" + id + ")"}, ToolResult: id},
130				daemon.StreamEvent{Type: daemon.EventMessage, EntryID: "a" + id, Position: int64(i), Text: "**answer** " + id, Timestamp: &at},
131				daemon.StreamEvent{Type: daemon.EventTurnCompleted, Source: "done"},
132			)
133		}
134		return events
135	}
136	m := newTestChatModel(t, &daemon.Session{ID: "s"})
137	m.SetSize(100, 30)
138	for _, event := range page(100) {
139		m.handleStreamEvent(event)
140	}
141	for from := 94; from >= 70; from -= 6 {
142		m.Follow, m.scrollOffset, m.loadingOlder = false, 0, true
143		m = m.showOlder(ChatOlderLoadedMsg{SessionID: m.SessionID, Generation: m.Generation, Page: &daemon.HistoryPage{Events: page(from), Before: int64(from), More: true}})
144		reused := slices.Clone(m.settledLines)
145		m.settled = nil
146		m.rebuildSettledLines()
147		if !slices.Equal(reused, m.settledLines) {
148			t.Fatalf("after the page from %d, reused rows differ from a fresh rebuild", from)
149		}
150	}
151}