okami.mom/albedo

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

nekomimi.pettui: preserve reading row during transcript reflowbf7fef9

main
16.0 KiB504 linesraw
1package tui
2
3import (
4	"albedo/cli/internal/daemon"
5	"cmp"
6	"context"
7	"fmt"
8	"maps"
9	"slices"
10	"strings"
11	"time"
12
13	tea "charm.land/bubbletea/v2"
14	"github.com/charmbracelet/x/ansi"
15)
16
17func (m *ChatModel) syncViewportHeight() {
18	if m == nil || m.History == nil || m.Height <= 0 {
19		return
20	}
21	m.Viewport.SetHeight(max(1, m.Height-6-m.chromeRows()))
22	m.refreshViewportContent()
23}
24
25func (m *ChatModel) rebuildSettledLines() {
26	// Re-rendering retained history must not move the reading position. Keep
27	// the content row under the viewport rather than anchoring to the tail.
28	readingOffset := m.scrollOffset
29	var readingRow string
30	if readingOffset >= 0 && readingOffset < len(m.frameLines) {
31		readingRow = m.frameLines[readingOffset]
32	}
33	m.settledLines = nil
34	m.settledLinesBytes = 0
35	m.droppedSettledLines = 0
36	m.userRows = nil
37	m.rebuilding = true
38	entries := m.History.Entries()
39	// Only a reader scrolled up keeps the blocks: each older page rebuilds
40	// everything retained, and reuses what the last rebuild rendered.
41	var kept map[settledKey]settledBlock
42	if !m.Follow {
43		kept = make(map[settledKey]settledBlock, len(entries))
44	}
45	for i := range entries {
46		key, named := m.settledKey(entries[:i], entries[i])
47		block, ok := m.settled[key]
48		if !named || !ok {
49			block.rows, block.head = m.Renderer.Settle(entries[:i], entries[i], m.Flags)
50		}
51		if named && kept != nil {
52			kept[key] = block
53		}
54		m.appendRows(entries[i], block)
55	}
56	m.settled = kept
57	m.rebuilding = false
58	m.settledOffset = len(m.headerLines())
59	m.scrollOffset = readingOffset
60	if readingRow != "" {
61		rows := append(m.headerLines(), m.settledLines...)
62		if readingOffset >= len(rows) || rows[readingOffset] != readingRow {
63			for i, row := range rows {
64				if row == readingRow {
65					m.scrollOffset = i
66					break
67				}
68			}
69		}
70	}
71	m.trimSettledLines()
72}
73
74// headerLines lead the transcript: where older history stands. Their count
75// only changes with what is loaded, and refreshViewportContent keeps the
76// reading position steady when it does.
77func (m ChatModel) headerLines() []string {
78	var line string
79	switch {
80	case m.loadingOlder:
81		line = DefaultStyles.Faint.Render("↑ loading earlier messages…")
82	case m.hasOlder():
83		line = DefaultStyles.Faint.Render("↑ earlier messages load as you scroll up")
84	case m.History.TruncationNotice() != "":
85		line = DefaultStyles.Warning.Render(m.History.TruncationNotice())
86	default:
87		return nil
88	}
89	return []string{markChrome + m.Renderer.rail(laneNone) + line, ""}
90}
91
92// hasOlder is whether scrolling past the top can show more: rows trimmed
93// from the rendering, or transcript the daemon has not sent.
94func (m ChatModel) hasOlder() bool {
95	_, more := m.olderCursor()
96	return m.droppedSettledLines > 0 || more
97}
98
99// olderCursor is the row the next older page ends before. Evicted entries
100// move it forward: everything up to the newest evicted row is re-read.
101func (m ChatModel) olderCursor() (int64, bool) {
102	before, more := m.olderBefore, m.olderMore
103	if through := m.History.EvictedThrough(); through > 0 && through >= before {
104		before, more = through+1, true
105	}
106	return before, more && before > 0
107}
108
109// settledBlock is an entry's settled rows, head indexing its own first one.
110type settledBlock struct {
111	rows []string
112	head int
113}
114
115// settledKey names what an entry's settled rows depend on: the entry, the
116// author and time before it, how it joins the entry above, and the burst it
117// ends, by the IDs at its ends and how it joins the entry above that.
118type settledKey struct {
119	workspace      string
120	id             string
121	burstFirst     string
122	burstLast      string
123	prior          prior
124	burst          int
125	epoch          int
126	width          int
127	follows        lane
128	burstFollows   lane
129	flags          DisplayFlags
130	separated      bool
131	burstSeparated bool
132}
133
134// settledKey is false for an entry, or a burst end, without an ID to name.
135func (m ChatModel) settledKey(before []HistoryEntry, entry HistoryEntry) (settledKey, bool) {
136	key := settledKey{id: entry.ID, prior: priorOf(before), epoch: m.burstEpoch, width: m.Renderer.BodyWidth, workspace: m.Renderer.Workspace, flags: m.Flags}
137	if entry.ID == "" || Compact(entry, m.Flags) {
138		return key, false
139	}
140	if n := len(before); n > 0 {
141		key.follows, key.separated = laneOf(before[n-1]), Separated(&before[n-1], entry, m.Flags)
142	}
143	burst := trailingBurst(before, m.Flags)
144	if len(burst) == 0 {
145		return key, true
146	}
147	key.burstFirst, key.burstLast, key.burst = burst[0].ID, burst[len(burst)-1].ID, len(burst)
148	if n := len(before) - len(burst); n > 0 {
149		key.burstFollows, key.burstSeparated = laneOf(before[n-1]), Separated(&before[n-1], burst[0], m.Flags)
150	}
151	return key, key.burstFirst != "" && key.burstLast != ""
152}
153
154// appendBlock settles entry after the entries before it.
155func (m *ChatModel) appendBlock(before []HistoryEntry, entry HistoryEntry) {
156	var block settledBlock
157	block.rows, block.head = m.Renderer.Settle(before, entry, m.Flags)
158	m.appendRows(entry, block)
159}
160
161func (m *ChatModel) appendRows(entry HistoryEntry, block settledBlock) {
162	if entry.Kind == EntryUser && (entry.Source == "" || entry.Source == "chat") {
163		m.userRows = append(m.userRows, len(m.settledLines)+block.head)
164	}
165	for _, row := range block.rows {
166		m.settledLines = append(m.settledLines, row)
167		m.settledLinesBytes += int64(len(row))
168	}
169	m.trimSettledLines()
170}
171
172// trimSettledLines drops the oldest rows past the caps. While you are scrolled
173// up the caps stretch by readingSlack and the row you are reading is never
174// dropped, so output arriving meanwhile cannot push your place off the top;
175// the normal caps apply again once the transcript follows its end.
176func (m *ChatModel) trimSettledLines() {
177	if m.rebuilding {
178		return
179	}
180	maxLines, maxBytes := MaxSettledLines, int64(MaxSettledLinesBytes)
181	if !m.Follow {
182		maxLines, maxBytes = maxLines*readingSlack, maxBytes*readingSlack
183	}
184	dropped := 0
185	for len(m.settledLines) > maxLines || m.settledLinesBytes > maxBytes {
186		if len(m.settledLines) == 0 || !m.Follow && dropped >= m.scrollOffset-m.settledOffset {
187			break
188		}
189		removed := m.settledLines[0]
190		m.settledLinesBytes -= int64(len(removed))
191		m.settledLines[0] = ""
192		m.settledLines = m.settledLines[1:]
193		dropped++
194	}
195
196	if dropped > 0 {
197		m.droppedSettledLines += dropped
198		m.scrollOffset = max(0, m.scrollOffset-dropped)
199		kept := m.userRows[:0]
200		for _, row := range m.userRows {
201			if row >= dropped {
202				kept = append(kept, row-dropped)
203			}
204		}
205		m.userRows = kept
206		// Rendered rows own their text: body chunks are concatenated with a
207		// nonempty rail; separators retain only small rail strings or empty text.
208		fresh := make([]string, len(m.settledLines))
209		copy(fresh, m.settledLines)
210		clear(m.settledLines)
211		m.settledLines = fresh
212	}
213}
214
215// replaceSettled swaps the n settled rows at from for rows, and returns how
216// many rows the caps then dropped from the top.
217func (m *ChatModel) replaceSettled(from, n int, rows []string) int {
218	m.settledLinesBytes += rowBytes(rows) - rowBytes(m.settledLines[from:from+n])
219	m.settledLines = slices.Replace(m.settledLines, from, from+n, rows...)
220	for i, at := range m.userRows {
221		if at >= from+n {
222			m.userRows[i] += len(rows) - n
223		}
224	}
225	dropped := m.droppedSettledLines
226	m.trimSettledLines()
227	return m.droppedSettledLines - dropped
228}
229
230func rowBytes(rows []string) int64 {
231	var n int64
232	for _, row := range rows {
233		n += int64(len(row))
234	}
235	return n
236}
237
238func (m *ChatModel) appendSettledEntry(entry HistoryEntry) {
239	if Compact(entry, m.Flags) {
240		facts := factsOf(entry)
241		entry.facts = &facts
242	}
243	if m.History.Replace(entry) {
244		m.burstEpoch++
245		m.rebuildSettledLines()
246		return
247	}
248	m.appendBlock(m.History.Entries(), entry)
249	m.History.Append(entry)
250}
251
252// settleActiveStream appends assembled text and retains a live thought's last
253// line as the compact action until the next action replaces it.
254func (m *ChatModel) settleActiveStream() {
255	watched := !m.transcript.thinkingSince.IsZero()
256	for _, entry := range m.transcript.settle(m.AgentName) {
257		if watched && entry.Kind == EntryThinking {
258			m.ThoughtProgressText = cmp.Or(thinkingLine(entry.Text), "thinking")
259		}
260		m.appendSettledEntry(entry)
261	}
262}
263
264func (m *ChatModel) refreshViewportContent() int {
265	if m.Viewport.Height() != max(1, m.Height-6-m.chromeRows()) {
266		m.SetSize(m.Width, m.Height)
267		return m.scrollLimit
268	}
269	if m.Follow {
270		// Reading older history stretched both caps; at the end they return.
271		m.History.Trim()
272		m.trimSettledLines()
273		m.settled = nil
274	}
275	allLines := m.headerLines()
276	if !m.Follow {
277		// a header growing or shrinking above must not move what you read
278		m.scrollOffset = max(0, m.scrollOffset+len(allLines)-m.settledOffset)
279	}
280	m.settledOffset = len(allLines)
281	allLines = append(allLines, m.settledLines...)
282
283	last, stacks := laneNone, false
284	if entries := m.History.Entries(); len(entries) > 0 {
285		last = laneOf(entries[len(entries)-1])
286		stacks = Compact(entries[len(entries)-1], m.Flags)
287		if burst := trailingBurst(entries, m.Flags); len(burst) > 0 {
288			if key := m.rowsKey(); key != m.burstRowsKey {
289				m.burstRows = m.Renderer.BurstBlock(entries[:len(entries)-len(burst)], burst, m.Flags)
290				m.burstRowsKey = key
291				m.burstRenders++
292			}
293			allLines = append(allLines, m.burstRows...)
294		}
295	}
296	if m.transcript.activeKind != StreamKindNone && m.transcript.activeText != "" &&
297		(m.transcript.activeKind != StreamKindThinking || m.Flags.Thinking) {
298		activeEntry := HistoryEntry{Kind: m.transcript.activeEntryKind(), Speaker: m.AgentName, Text: m.transcript.activeText, Live: true}
299		allLines = append(allLines, m.liveBlock(activeEntry)...)
300		last, stacks = laneOf(activeEntry), false
301	}
302
303	// One live-tail slot stays occupied by the last action until another begins.
304	action := ""
305	switch {
306	case m.transcript.activeKind == StreamKindThinking && m.transcript.activeText != "" && !m.Flags.Thinking:
307		action = m.renderThought()
308	case m.toolLabel() != "" && (m.latestProgress() != nil || !m.Flags.Tools):
309		action = m.renderProgress()
310	case m.ThoughtProgressText != "" && !m.Flags.Thinking:
311		width := max(1, m.Renderer.BodyWidth-railWidth)
312		action = markChrome + DefaultStyles.Faint.Render(ansi.Truncate(m.ThoughtProgressText, width, "…"))
313	}
314	if action != "" {
315		if len(allLines) > 0 && !stacks {
316			allLines = append(allLines, strings.TrimRight(m.Renderer.rail(joint(last, laneBusy)), " "))
317		}
318		allLines = append(allLines, m.Renderer.rail(laneBusy)+action)
319	}
320	allLines = append(allLines, m.pendingRows()...)
321	m.frameLines = allLines
322
323	totalLines := len(allLines)
324	vpHeight := max(1, m.Viewport.Height())
325	maxScroll := max(0, totalLines-vpHeight)
326	m.scrollLimit = maxScroll
327
328	var visibleSlice []string
329	if m.Follow {
330		m.scrollOffset = maxScroll
331		visibleSlice = allLines[maxScroll:totalLines]
332	} else {
333		m.scrollOffset = max(0, min(m.scrollOffset, maxScroll))
334		end := min(totalLines, m.scrollOffset+vpHeight)
335		if m.scrollOffset < end {
336			visibleSlice = allLines[m.scrollOffset:end]
337		}
338	}
339	m.Viewport.rows = visibleSlice
340	return maxScroll
341}
342
343// rowsKey names what rows drawn after the settled transcript depend on.
344func (m ChatModel) rowsKey() burstRowsKey {
345	return burstRowsKey{entries: m.History.Len(), evicted: m.History.EvictedCount(), epoch: m.burstEpoch, width: m.Renderer.BodyWidth, workspace: m.Renderer.Workspace, flags: m.Flags}
346}
347
348// liveBlock is the reply streaming in as rows, rendered again only when the
349// reply or the transcript end before it changed.
350func (m *ChatModel) liveBlock(entry HistoryEntry) []string {
351	key := liveRowsKey{burstRowsKey: m.rowsKey(), text: entry.Text, kind: entry.Kind}
352	if key != m.liveRowsKey {
353		start := time.Now()
354		m.liveRows, _ = m.Renderer.Block(m.History.Entries(), entry, m.Flags)
355		m.liveRowsKey, m.liveDrawn = key, start
356		m.liveInterval = max(liveFrame, 4*time.Since(start))
357	}
358	return m.liveRows
359}
360
361// pendingRows are your messages the daemon has not echoed yet, greyed out at
362// the end of the transcript where they will settle. Each follows whatever is
363// live above it, so the rails and names join up as they will once settled.
364func (m ChatModel) pendingRows() []string {
365	if len(m.pendingUsers) == 0 && len(m.pendingContinuations) == 0 {
366		return nil
367	}
368	before := slices.Clone(recent(m.History.Entries()))
369	if m.transcript.activeKind != StreamKindNone && m.transcript.activeText != "" {
370		before = append(before, HistoryEntry{Kind: m.transcript.activeEntryKind(), Speaker: m.AgentName})
371	}
372	if m.toolLabel() != "" && (m.latestProgress() != nil || !m.Flags.Tools) {
373		before = append(before, HistoryEntry{Kind: EntryTool, Speaker: m.AgentName})
374	} else if m.ThoughtProgressText != "" && !m.Flags.Thinking {
375		before = append(before, HistoryEntry{Kind: EntryThinking, Speaker: m.AgentName})
376	}
377	var rows []string
378	for _, p := range m.pendingUsers {
379		state := sending
380		if p.Queued {
381			state = queued
382		}
383		text := p.Text
384		if p.Expired {
385			state = unresolved
386			text += "\nOperation " + p.OperationID + " expired; its outcome is unknown."
387		}
388		if p.BlockingReason != "" && !p.Expired {
389			text += "\nWaiting: " + p.BlockingReason
390		}
391		entry := HistoryEntry{Kind: EntryUser, Speaker: "You", Text: text, Timestamp: p.At, Pending: state}
392		block, _ := m.Renderer.Block(before, entry, m.Flags)
393		rows = append(rows, block...)
394		before = append(before, entry)
395	}
396	for _, id := range slices.Sorted(maps.Keys(m.pendingContinuations)) {
397		if m.pendingContinuations[id].Expired {
398			entry := HistoryEntry{Kind: EntryError, Text: "Continue operation " + id + " expired; its outcome is unresolved."}
399			block, _ := m.Renderer.Block(before, entry, m.Flags)
400			rows = append(rows, block...)
401		}
402	}
403	return rows
404}
405
406// jumpToYou scrolls to the start of your previous or next message. Past
407// your newest message it follows the live transcript again.
408func (m *ChatModel) jumpToYou(back bool) {
409	at := m.scrollOffset
410	if m.Follow {
411		at = m.scrollLimit
412	}
413	target := -1
414	for _, row := range m.userRows {
415		row += m.settledOffset
416		if back && row < at {
417			target = row
418		}
419		if !back && row > at {
420			target = row
421			break
422		}
423	}
424	switch {
425	case target >= 0 && target < m.scrollLimit:
426		m.Follow = false
427		m.scrollOffset = target
428	case !back || target >= 0:
429		m.Follow = true
430	}
431	m.refreshViewportContent()
432}
433
434// scrollBy moves through the complete rendered transcript, including live output.
435func (m *ChatModel) scrollBy(rows int) {
436	if rows < 0 {
437		m.Follow = false
438	} else if m.Follow {
439		return
440	}
441	m.scrollOffset = max(0, m.scrollOffset+rows)
442	maxScroll := m.refreshViewportContent()
443	if rows > 0 && m.scrollOffset >= maxScroll {
444		m.Follow = true
445		m.refreshViewportContent()
446	}
447}
448
449// loadOlder runs when you are scrolled to the top of the transcript: rows
450// trimmed from the rendering come back from retained history first, then a
451// page is asked of the daemon.
452func (m *ChatModel) loadOlder() tea.Cmd {
453	if m.Follow || m.scrollOffset > 0 || m.loadingOlder {
454		return nil
455	}
456	if m.droppedSettledLines > 0 {
457		m.rebuildSettledLines()
458		m.refreshViewportContent()
459		return nil
460	}
461	before, more := m.olderCursor()
462	if !more {
463		return nil
464	}
465	m.loadingOlder = true
466	m.refreshViewportContent()
467	client, id, generation := m.client, m.SessionID, m.Generation
468	return func() tea.Msg {
469		page, err := client.History(context.Background(), before, olderPageRows)
470		return ChatOlderLoadedMsg{SessionID: id, Generation: generation, Page: page, Err: err}
471	}
472}
473
474// showOlder puts a fetched page above what is shown without moving it.
475func (m *ChatModel) showOlder(msg ChatOlderLoadedMsg) ChatModel {
476	if msg.SessionID != m.SessionID || msg.Generation != m.Generation || !m.loadingOlder {
477		return *m
478	}
479	m.loadingOlder = false
480	if msg.Err != nil {
481		m.AddError(fmt.Sprintf("Could not load earlier messages: %v", msg.Err))
482		return *m
483	}
484	events := make([]daemon.StreamEvent, 0, len(msg.Page.Events))
485	for _, event := range msg.Page.Events {
486		if event.EntryID == "" || m.History.Find(event.EntryID) < 0 {
487			events = append(events, event)
488		}
489	}
490	older := replayTranscript(events, m.AgentName)
491	for i := range older {
492		if Compact(older[i], m.Flags) {
493			facts := factsOf(older[i])
494			older[i].facts = &facts
495		}
496	}
497	fromEnd := m.settledOffset + len(m.settledLines) - m.scrollOffset
498	m.History.Prepend(older)
499	m.olderBefore, m.olderMore = msg.Page.Before, msg.Page.More && msg.Page.Before > 0
500	m.rebuildSettledLines()
501	m.scrollOffset = max(0, m.settledOffset+len(m.settledLines)-fromEnd)
502	m.refreshViewportContent()
503	return *m
504}