okami.mom/albedo

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

nekomimi.petfeat(usage): time provider calls and show tok/s on the turn signoff0a3eddd

main
10.7 KiB259 linesraw
1// Clickable rows and selections that scroll are drawn and
2// driven in the terminal client: the daemon e2e sees committed text, never
3// rendered rows or mouse events, so these regressions are invisible outside
4// the TUI.
5package tui
6
7import (
8	"fmt"
9	"slices"
10	"strings"
11	"testing"
12
13	"albedo/cli/internal/daemon"
14	tea "charm.land/bubbletea/v2"
15	"github.com/charmbracelet/x/ansi"
16)
17
18// Dragging past the top edge scrolls the transcript and keeps selecting, so
19// the copy reaches rows that were never on screen together.
20func TestDraggingPastTheEdgeScrollsAndKeepsSelecting(t *testing.T) {
21	m := newTestChatModel(t, &daemon.Session{ID: "s"})
22	m.SetSize(80, 20)
23	for i := range 40 {
24		m.appendSettledEntry(HistoryEntry{Kind: EntryNote, Text: fmt.Sprintf("note %02d", i), Timestamp: int64(i + 1)})
25	}
26	m.refreshViewportContent()
27	if !m.Follow || m.scrollLimit == 0 {
28		t.Fatalf("fixture does not overflow: follow %v limit %d", m.Follow, m.scrollLimit)
29	}
30	first := 2 + m.Notices.ChromeRows()
31	at := func(row int) tea.Mouse { return tea.Mouse{X: 10, Y: first + row, Button: tea.MouseLeft} }
32	m, _ = m.Update(tea.MouseClickMsg(at(m.Viewport.Height() - 2)))
33	m, cmd := m.Update(tea.MouseMotionMsg(at(0)))
34	if cmd == nil || m.dragDir != -1 {
35		t.Fatalf("the top edge does not start a scroll: dir %d", m.dragDir)
36	}
37	start := m.scrollOffset
38	for range 5 {
39		m, cmd = m.Update(dragScrollMsg{gen: m.dragGen})
40	}
41	if m.scrollOffset != start-5 || m.dragHead.Row != m.scrollOffset || cmd == nil {
42		t.Fatalf("scrolled %d -> %d, head row %d", start, m.scrollOffset, m.dragHead.Row)
43	}
44	sel := Selection{Anchor: *m.dragAnchor, Head: m.dragHead, Gutter: railWidth}
45	text := SelectedText(m.frameLines, sel)
46	offscreen := strings.TrimSpace(ansi.Strip(m.frameLines[start-2]))
47	if !strings.HasPrefix(offscreen, "note") || !strings.Contains(text, offscreen) {
48		t.Fatalf("the copy stops at the screen, missing %q:\n%s", offscreen, text)
49	}
50
51	m, _ = m.Update(tea.MouseMotionMsg(at(m.Viewport.Height() / 2)))
52	if m.dragDir != 0 {
53		t.Fatalf("leaving the edge keeps scrolling: dir %d", m.dragDir)
54	}
55}
56
57// The signoff copies the whole turn's prose as markdown, and the cells page lists every
58// call with the one under the cursor in full.
59func rowWith(t *testing.T, lines []string, text string) int {
60	t.Helper()
61	for i, line := range lines {
62		if strings.Contains(ansi.Strip(line), text) {
63			return i
64		}
65	}
66	t.Fatalf("no row says %q", text)
67	return -1
68}
69
70func TestSignoffCopiesTheTurnAsMarkdown(t *testing.T) {
71	m := newTestChatModel(t, &daemon.Session{ID: "s"})
72	m.SetSize(80, 40)
73	m.appendSettledEntry(HistoryEntry{Kind: EntryUser, Text: "hi", Timestamp: 1})
74	m.appendSettledEntry(HistoryEntry{Kind: EntryAssistant, Text: "# first\n\n```go\nx := 1\n```", Timestamp: 2})
75	m.appendSettledEntry(HistoryEntry{Kind: EntryTool, ToolName: "bash", Timestamp: 3})
76	m.appendSettledEntry(HistoryEntry{Kind: EntryAssistant, Text: "**second**", Timestamp: 4})
77	m.appendSettledEntry(HistoryEntry{Kind: EntryTurnEnd, Mood: moodDone, Timestamp: 5})
78	m.refreshViewportContent()
79	row := rowWith(t, m.frameLines, "⧉")
80	act, _ := actionOf(m.frameLines[row])
81	if cmd := m.actAt(row); cmd == nil || m.CopyStatus != "copied" {
82		t.Fatalf("the signoff did not copy: status %q", m.CopyStatus)
83	}
84	if got, _ := m.replyMarkdown(act.key); got != "# first\n\n```go\nx := 1\n```\n\n**second**" {
85		t.Fatalf("the signoff copied %q", got)
86	}
87}
88
89// The rate sits right before the copy mark, after the turn's time and tools.
90func TestSignoffShowsItsRateBeforeTheCopyMark(t *testing.T) {
91	m := newTestChatModel(t, &daemon.Session{ID: "s"})
92	m.SetSize(80, 40)
93	m.appendSettledEntry(HistoryEntry{Kind: EntryAssistant, Text: "done", Timestamp: 1})
94	m.appendSettledEntry(HistoryEntry{Kind: EntryTurnEnd, Mood: moodDone, ElapsedMs: 5000, Tools: 1, TokensPerSecond: 84.2, Timestamp: 2})
95	m.refreshViewportContent()
96	row := ansi.Strip(m.frameLines[rowWith(t, m.frameLines, "⧉")])
97	if !strings.Contains(row, "1 tool · 84.2 tok/s · ⧉") {
98		t.Fatalf("signoff %q does not put the rate before the copy mark", row)
99	}
100}
101
102// Clicking a burst's summary lists every step in full, the commands the
103// summary only names included, and clicking it again folds them away.
104func TestClickingABurstListsItsSteps(t *testing.T) {
105	m := newTestChatModel(t, &daemon.Session{ID: "s", Workspace: "/w/albedo"})
106	m.SetSize(120, 40)
107	m.appendSettledEntry(HistoryEntry{Kind: EntryUser, Text: "go", Timestamp: 1})
108	for _, entry := range burstFixture() {
109		m.appendSettledEntry(entry)
110	}
111	m.appendSettledEntry(HistoryEntry{Kind: EntryAssistant, Text: "done", Timestamp: 9})
112	m.refreshViewportContent()
113	full := "cd cli && go test ./..."
114	row := rowWith(t, m.frameLines, "▸ thought")
115	if text := ansi.Strip(strings.Join(m.frameLines, "\n")); strings.Contains(text, full) {
116		t.Fatalf("a closed burst lists its steps:\n%s", text)
117	}
118	m.actAt(row)
119	spliced := slices.Clone(m.settledLines)
120	if m.rebuildSettledLines(); !slices.Equal(spliced, m.settledLines) {
121		t.Fatal("opening the burst drew rows a rebuild does not")
122	}
123	m.refreshViewportContent()
124	text := ansi.Strip(strings.Join(m.frameLines, "\n"))
125	for _, step := range []string{"▾ thought", "run    " + full, "read   cli/internal/tui/chat.go", "edit   priv/python/albedo_trace.py", "python · 1/0"} {
126		if !strings.Contains(text, step) {
127			t.Fatalf("the open burst does not show %q:\n%s", step, text)
128		}
129	}
130	m.actAt(rowWith(t, m.frameLines, "▾ thought"))
131	if text := ansi.Strip(strings.Join(m.frameLines, "\n")); strings.Contains(text, full) || !strings.Contains(text, "▸ thought") {
132		t.Fatalf("a second click does not close the burst:\n%s", text)
133	}
134}
135
136// A long message of yours shows its first rows and a click target; clicking
137// it shows the rest, and the row that closes it folds the message again.
138func TestClickingALongMessageShowsTheRestAndFoldsIt(t *testing.T) {
139	m := newTestChatModel(t, &daemon.Session{ID: "s"})
140	m.SetSize(100, 30)
141	var lines []string
142	for i := range 40 {
143		lines = append(lines, fmt.Sprintf("line %02d", i))
144	}
145	m.appendSettledEntry(HistoryEntry{Kind: EntryUser, Text: strings.Join(lines, "\n\n"), Timestamp: 1})
146	m.appendSettledEntry(HistoryEntry{Kind: EntryAssistant, Text: "done", Timestamp: 2})
147	m.refreshViewportContent()
148	shown := func() string { return ansi.Strip(strings.Join(m.frameLines, "\n")) }
149	if text := shown(); strings.Contains(text, "line 39") || !strings.Contains(text, "more lines · click to expand") {
150		t.Fatalf("a long message is not folded:\n%s", text)
151	}
152	spliced := func() {
153		t.Helper()
154		got := slices.Clone(m.settledLines)
155		if m.rebuildSettledLines(); !slices.Equal(got, m.settledLines) {
156			t.Fatal("the splice drew rows a rebuild does not")
157		}
158		m.refreshViewportContent()
159	}
160	click := rowWith(t, m.frameLines, "click to expand")
161	m.actAt(click)
162	spliced()
163	if text := shown(); !strings.Contains(text, "line 39") || !strings.Contains(text, "show less") {
164		t.Fatalf("clicking does not show the rest:\n%s", text)
165	}
166	m.actAt(rowWith(t, m.frameLines, "show less"))
167	spliced()
168	if text := shown(); strings.Contains(text, "line 39") || !strings.Contains(text, "click to expand") {
169		t.Fatalf("clicking again does not fold the message:\n%s", text)
170	}
171}
172
173// A web search's answer shows under its burst, cut to its first rows; the
174// click target shows the rest and folds it again, splicing what a rebuild
175// would draw.
176func TestClickingAWebSearchShowsItsWholeAnswer(t *testing.T) {
177	m := newTestChatModel(t, &daemon.Session{ID: "s"})
178	m.SetSize(100, 30)
179	var lines []string
180	for i := range 12 {
181		lines = append(lines, fmt.Sprintf("finding %02d", i))
182	}
183	search := daemon.ToolActivity{Kind: "web", Target: "latest gleam release", Detail: strings.Join(lines, "\n\n")}
184	m.appendSettledEntry(HistoryEntry{Kind: EntryUser, Text: "look it up", Timestamp: 1})
185	m.appendSettledEntry(traced([]daemon.ToolActivity{search}))
186	m.appendSettledEntry(HistoryEntry{Kind: EntryAssistant, Text: "done", Timestamp: 3})
187	m.refreshViewportContent()
188	shown := func() string { return ansi.Strip(strings.Join(m.frameLines, "\n")) }
189	if text := shown(); !strings.Contains(text, "looked up “latest gleam release”") || !strings.Contains(text, "finding 00") || strings.Contains(text, "finding 11") {
190		t.Fatalf("the search is not summarized and folded:\n%s", text)
191	}
192	spliced := func() {
193		t.Helper()
194		got := slices.Clone(m.settledLines)
195		if m.rebuildSettledLines(); !slices.Equal(got, m.settledLines) {
196			t.Fatal("the splice drew rows a rebuild does not")
197		}
198		m.refreshViewportContent()
199	}
200	m.actAt(rowWith(t, m.frameLines, "click to expand"))
201	spliced()
202	if text := shown(); !strings.Contains(text, "finding 11") || !strings.Contains(text, "show less") {
203		t.Fatalf("clicking does not show the whole answer:\n%s", text)
204	}
205	m.actAt(rowWith(t, m.frameLines, "show less"))
206	spliced()
207	if text := shown(); strings.Contains(text, "finding 11") || !strings.Contains(text, "click to expand") {
208		t.Fatalf("clicking again does not fold the answer:\n%s", text)
209	}
210}
211
212// Capability notes must not pad short rows to thousands of columns.
213func TestALongNoteWrapsRowByRowAndFolds(t *testing.T) {
214	m := newTestChatModel(t, &daemon.Session{ID: "s"})
215	m.SetSize(100, 30)
216	lines := []string{"Session capabilities changed.", strings.Repeat("instructions ", 200)}
217	for i := range 20 {
218		lines = append(lines, fmt.Sprintf("- **`dir%02d/`** — a short bullet", i))
219	}
220	m.appendSettledEntry(HistoryEntry{Kind: EntryNote, Source: "capabilities changed", Text: strings.Join(lines, "\n"), Timestamp: 1})
221	m.appendSettledEntry(HistoryEntry{Kind: EntryAssistant, Text: "done", Timestamp: 2})
222	m.refreshViewportContent()
223	bounded := func() {
224		t.Helper()
225		for _, row := range m.settledLines {
226			if ansi.StringWidth(row) > 100 {
227				t.Fatalf("a note row is wider than the screen: %d columns", ansi.StringWidth(row))
228			}
229			if plain := ansi.Strip(row); strings.TrimSpace(plain) != "" && strings.HasSuffix(plain, "  ") {
230				t.Fatalf("a note row is padded to a wider row: %q", plain)
231			}
232		}
233	}
234	spliced := func() {
235		t.Helper()
236		got := slices.Clone(m.settledLines)
237		m.rebuildSettledLines()
238		if !slices.Equal(got, m.settledLines) {
239			t.Fatal("expanding or folding the note disagrees with a rebuild")
240		}
241		m.refreshViewportContent()
242		bounded()
243	}
244	shown := func() string { return ansi.Strip(strings.Join(m.frameLines, "\n")) }
245	bounded()
246	if text := shown(); strings.Contains(text, "dir19/") || !strings.Contains(text, "more lines · click to expand") {
247		t.Fatalf("a long note is not folded:\n%s", text)
248	}
249	m.actAt(rowWith(t, m.frameLines, "click to expand"))
250	spliced()
251	if text := shown(); !strings.Contains(text, "**`dir19/`**") || !strings.Contains(text, "show less") {
252		t.Fatalf("clicking does not show the rest of the plain-text note:\n%s", text)
253	}
254	m.actAt(rowWith(t, m.frameLines, "show less"))
255	spliced()
256	if text := shown(); strings.Contains(text, "dir19/") || !strings.Contains(text, "click to expand") {
257		t.Fatalf("clicking again does not fold the note:\n%s", text)
258	}
259}