okami.mom/albedo

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

nekomimi.petfix macos terminal.app rendering4e62e9e

main
9.1 KiB293 linesraw
1package tui
2
3import (
4	"fmt"
5	"maps"
6	"regexp"
7	"slices"
8	"strings"
9	"time"
10
11	tea "charm.land/bubbletea/v2"
12)
13
14// A row action is a click target. Like the copy marks in selection.go, its
15// mark is a zero-width APC string, so the row itself says what a click on it
16// does and survives wrapping, trimming, and rebuilds. AppModel.View removes
17// it before rendering to the terminal.
18const (
19	// verbCopy copies a turn's replies as the markdown they were written in.
20	verbCopy = "copy"
21	// verbOpen lists or hides the steps of a burst.
22	verbOpen = "open"
23	// verbMore shows or folds the rest of a long message of yours, of a
24	// note, or of a web search's answer.
25	verbMore = "more"
26)
27
28var actionMark = regexp.MustCompile(`\x1b_albedo:(copy|open|more):([0-9a-f]+-[0-9a-f]+)\x1b\\`)
29
30type rowAction struct{ verb, key string }
31
32func (a rowAction) mark() string { return "\x1b_albedo:" + a.verb + ":" + a.key + "\x1b\\" }
33
34func actionOf(row string) (rowAction, bool) {
35	hit := actionMark.FindStringSubmatch(row)
36	if hit == nil {
37		return rowAction{}, false
38	}
39	return rowAction{verb: hit[1], key: hit[2]}, true
40}
41
42// entryKey names an entry by what it says, so the mark on its rows still
43// finds it after the transcript is rebuilt.
44func entryKey(entry HistoryEntry) string {
45	h := fnv1a(fnvOffset64, string(entry.Kind)+"\x00"+entry.ToolName+"\x00"+entry.Text+"\x00"+entry.ToolResult+"\x00"+fmt.Sprint(entry.ToolArgs))
46	return fmt.Sprintf("%x-%x", entry.Timestamp, h)
47}
48
49// actAt runs the action of the transcript row at absolute index row.
50func (m *ChatModel) actAt(row int) tea.Cmd {
51	if row < 0 || row >= len(m.frameLines) {
52		return nil
53	}
54	act, ok := actionOf(m.frameLines[row])
55	if !ok {
56		return nil
57	}
58	switch act.verb {
59	case verbCopy:
60		return m.copyReply(act.key)
61	case verbOpen:
62		m.toggleBurst(act.key, row)
63	case verbMore:
64		if !m.toggleFold(act.key, row) {
65			m.toggleLookup(act.key, row)
66		}
67	}
68	return nil
69}
70
71// toggleFold shows or folds the rest of the message whose fold row is at row,
72// the last row of its block. Only that block renders again and is spliced into
73// the settled rows. The clicked row keeps its place on screen: expanding opens
74// the text below it, and folding brings the new fold row to where it was.
75// It reports false when key names no message of yours and no note.
76func (m *ChatModel) toggleFold(key string, row int) bool {
77	entries := m.History.Entries()
78	at := slices.IndexFunc(entries, func(e HistoryEntry) bool {
79		return (e.Kind == EntryUser || e.Kind == EntryNote) && entryKey(e) == key
80	})
81	if at < 0 {
82		return false
83	}
84	was := m.Renderer
85	was.Open = map[string]bool{key: m.Renderer.Open[key]}
86	old, _ := was.Block(entries[:at], entries[at], m.Flags)
87	if m.Renderer.Open == nil {
88		m.Renderer.Open = map[string]bool{}
89	}
90	m.Renderer.Open[key] = !m.Renderer.Open[key]
91	m.settled = nil // kept blocks were drawn with the fold as it was
92	fresh, _ := m.Renderer.Block(entries[:at], entries[at], m.Flags)
93	from := row - m.settledOffset - (len(old) - 1)
94	if from < 0 || from+len(old) > len(m.settledLines) || m.settledLines[from+len(old)-1] != old[len(old)-1] {
95		m.rebuildSettledLines()
96		m.refreshViewportContent()
97		return true
98	}
99	screen := row - m.scrollOffset
100	if !m.Renderer.Open[key] {
101		row += len(fresh) - len(old)
102	}
103	row -= m.replaceSettled(from, len(old), fresh)
104	m.Follow, m.scrollOffset = false, max(0, row-screen)
105	m.Follow = m.scrollOffset >= m.refreshViewportContent()
106	return true
107}
108
109// toggleLookup shows or folds the rest of the web search answer whose fold
110// row is at row. Its burst renders again; the clicked row keeps its place on
111// screen, as toggleFold keeps a message's.
112func (m *ChatModel) toggleLookup(key string, row int) {
113	entries := m.History.Entries()
114	at := slices.IndexFunc(entries, func(e HistoryEntry) bool {
115		return slices.ContainsFunc(factsOf(e).looked, func(look lookup) bool { return look.key == key })
116	})
117	if at < 0 {
118		return
119	}
120	start := at
121	for start > 0 && Compact(entries[start-1], m.Flags) {
122		start--
123	}
124	end := at + 1
125	for end < len(entries) && Compact(entries[end], m.Flags) {
126		end++
127	}
128	before, burst := entries[:start], entries[start:end]
129	fold := func(lines []string) int {
130		return slices.IndexFunc(lines, func(line string) bool {
131			act, ok := actionOf(line)
132			return ok && act == rowAction{verbMore, key}
133		})
134	}
135	was := m.Renderer
136	was.Open = maps.Clone(m.Renderer.Open)
137	old := was.BurstBlock(before, burst, m.Flags)
138	if m.Renderer.Open == nil {
139		m.Renderer.Open = map[string]bool{}
140	}
141	m.Renderer.Open[key] = !m.Renderer.Open[key]
142	m.settled = nil // kept blocks were drawn with the fold as it was
143	fresh := m.Renderer.BurstBlock(before, burst, m.Flags)
144	clicked, screen := fold(old), row-m.scrollOffset
145	if end < len(entries) {
146		from := row - m.settledOffset - clicked
147		if clicked < 0 || from < 0 || from+len(old) > len(m.settledLines) || m.settledLines[row-m.settledOffset] != old[clicked] {
148			m.rebuildSettledLines()
149			m.refreshViewportContent()
150			return
151		}
152		row -= m.replaceSettled(from, len(old), fresh)
153	}
154	m.burstEpoch++
155	if !m.Renderer.Open[key] {
156		row += fold(fresh) - clicked
157	}
158	m.Follow, m.scrollOffset = false, max(0, row-screen)
159	m.Follow = m.scrollOffset >= m.refreshViewportContent()
160}
161
162// toggleBurst opens or closes the burst whose summary is at row, keeping that
163// row where it was on screen; an opened list that would end below the screen
164// scrolls up into view. Only the burst renders again: a settled one is
165// spliced into the settled rows, and the live one redraws on its own.
166func (m *ChatModel) toggleBurst(key string, row int) {
167	entries := m.History.Entries()
168	start, end, ok := burstNamed(entries, key, m.Flags)
169	if !ok {
170		return
171	}
172	before, burst := entries[:start], entries[start:end]
173	was := m.Renderer
174	was.Open = map[string]bool{key: m.Renderer.Open[key]}
175	old := was.BurstBlock(before, burst, m.Flags)
176	if m.Renderer.Open == nil {
177		m.Renderer.Open = map[string]bool{}
178	}
179	m.Renderer.Open[key] = !m.Renderer.Open[key]
180	m.settled = nil // kept blocks were drawn with the fold as it was
181	fresh := m.Renderer.BurstBlock(before, burst, m.Flags)
182	// the summary sits after the same separator in both
183	head := slices.IndexFunc(fresh, func(line string) bool {
184		_, ok := actionOf(line)
185		return ok
186	})
187	screen := row - m.scrollOffset
188	if end < len(entries) {
189		from := row - m.settledOffset - head
190		if from < 0 || from+len(old) > len(m.settledLines) || m.settledLines[from+head] != old[head] {
191			m.rebuildSettledLines()
192			m.refreshViewportContent()
193			return
194		}
195		row -= m.replaceSettled(from, len(old), fresh)
196	}
197	m.burstEpoch++
198	offset, last := row-screen, row-head+len(fresh)
199	if last-offset > m.Viewport.Height() {
200		offset = min(row, last-m.Viewport.Height())
201	}
202	m.Follow, m.scrollOffset = false, max(0, offset)
203	m.Follow = m.scrollOffset >= m.refreshViewportContent()
204}
205
206// copyReply copies a reply's markdown.
207func (m *ChatModel) copyReply(key string) tea.Cmd {
208	if text, ok := m.replyMarkdown(key); ok {
209		return m.copied(text)
210	}
211	return nil
212}
213
214// replyMarkdown is a finished turn's prose as written: every reply since
215// your message, which the turn's signoff names.
216func (m *ChatModel) replyMarkdown(key string) (string, bool) {
217	entries := m.History.Entries()
218	end := slices.IndexFunc(entries, func(e HistoryEntry) bool { return e.Kind == EntryTurnEnd && entryKey(e) == key })
219	if end < 0 {
220		return "", false
221	}
222	start := end
223	for start > 0 && entries[start-1].Kind != EntryUser {
224		start--
225	}
226	var parts []string
227	for _, e := range entries[start:end] {
228		if e.Kind == EntryAssistant {
229			parts = append(parts, e.Text)
230		}
231	}
232	return strings.Join(parts, "\n\n"), len(parts) > 0
233}
234
235func (m *ChatModel) copied(text string) tea.Cmd {
236	m.CopyStatus = "copied"
237	m.copyStatusRevision++
238	return tea.Batch(CopyText(text), m.clearCopyStatusCmd())
239}
240
241// Dragging a selection past the top or bottom edge of the transcript
242// scrolls it: after a short delay, then a row at a time while the pointer
243// stays there. The selection is in transcript rows, so it keeps growing.
244const (
245	dragScrollDelay = 150 * time.Millisecond
246	dragScrollEvery = 50 * time.Millisecond
247)
248
249type dragScrollMsg struct{ gen int }
250
251func dragScrollTick(gen int, after time.Duration) tea.Cmd {
252	return tea.Tick(after, func(time.Time) tea.Msg { return dragScrollMsg{gen} })
253}
254
255// steerDragScroll starts or stops the edge scroll for a pointer on screen
256// row of the transcript.
257func (m *ChatModel) steerDragScroll(row int) tea.Cmd {
258	dir := 0
259	switch {
260	case row <= 0 && m.scrollOffset > 0:
261		dir = -1
262	case row >= m.Viewport.Height()-1 && !m.Follow:
263		dir = 1
264	}
265	if dir == m.dragDir {
266		return nil
267	}
268	m.dragDir = dir
269	m.dragGen++
270	if dir == 0 {
271		return nil
272	}
273	return dragScrollTick(m.dragGen, dragScrollDelay)
274}
275
276// dragScrolled moves one row toward the edge and pulls the selection's head
277// along, or stops when the transcript has no more to show that way.
278func (m *ChatModel) dragScrolled(msg dragScrollMsg) tea.Cmd {
279	if m.dragAnchor == nil || msg.gen != m.dragGen || m.dragDir == 0 {
280		return nil
281	}
282	before := m.scrollOffset
283	m.scrollBy(m.dragDir)
284	if m.scrollOffset == before {
285		m.dragDir = 0
286		return nil
287	}
288	m.dragHead.Row = m.scrollOffset
289	if m.dragDir >= 0 {
290		m.dragHead.Row += m.Viewport.Height() - 1
291	}
292	return dragScrollTick(m.dragGen, dragScrollEvery)
293}