okami.mom/albedo

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

dawntui: stacked pane takes two fifths; context drops its note and facts when the list is short2f6c51d

main
13.4 KiB442 linesraw
1package tui
2
3import (
4	"albedo/cli/internal/daemon"
5	"cmp"
6	"context"
7	"errors"
8	"fmt"
9	"slices"
10	"strings"
11
12	tea "charm.land/bubbletea/v2"
13	"github.com/charmbracelet/x/ansi"
14	"golang.org/x/text/language"
15	"golang.org/x/text/message"
16)
17
18// ContextDetail is one section's content, read a page at a time and scrolled
19// line by line.
20type ContextDetail struct {
21	Value   *daemon.ContextPage
22	Error   string
23	Section daemon.ContextSection
24	Page    int
25	Scroll  int
26	// visible is the body rows the last frame drew; page jumps move by it.
27	visible int
28	// wrapped is wrappedFrom's content at wrappedWidth, kept because every
29	// scroll step reads it and a page can be long.
30	wrapped      []string
31	wrappedWidth int
32	wrappedFrom  *daemon.ContextPage
33}
34
35// rows is the page's content wrapped to width.
36func (d *ContextDetail) rows(width int) []string {
37	if d.Value == nil {
38		return nil
39	}
40	if d.wrappedFrom != d.Value || d.wrappedWidth != width {
41		d.wrapped, d.wrappedWidth, d.wrappedFrom = wrapContextContent(d.Value.Content, width), width, d.Value
42	}
43	return d.wrapped
44}
45
46// scroll moves the content by lines, within the rows the last frame showed.
47func (d *ContextDetail) scroll(by int) {
48	d.Scroll = min(max(0, d.Scroll+by), max(0, len(d.wrapped)-d.visible))
49}
50
51// list is the reader's body at exactly width × height: where the page comes
52// from, any warning, then the scrolled rows.
53func (d *ContextDetail) list(width, height int) []string {
54	sec := d.Section
55	head := []string{DefaultStyles.Faint.Render(fmt.Sprintf("%s · page %d/%d · %s", sec.Source, d.Page+1, sec.Pages, contextCount(sec.ByteCount, "byte")))}
56	var rows []string
57	switch {
58	case d.Error != "":
59		head = append(head, DefaultStyles.Error.Render(d.Error))
60	case d.Value == nil:
61		head = append(head, DefaultStyles.Faint.Render("loading inspectable prepared content…"))
62	default:
63		if d.Value.Omitted != "" {
64			head = append(head, DefaultStyles.Warning.Render("omitted: "+d.Value.Omitted))
65		}
66		head = append(head, "")
67		rows = d.rows(max(1, width-2))
68	}
69	room := max(0, height-len(head))
70	d.visible = room
71	d.Scroll = min(d.Scroll, max(0, len(rows)-room))
72
73	out := make([]string, 0, height)
74	for _, line := range head {
75		out = append(out, "  "+line)
76	}
77	for _, row := range rows[d.Scroll:min(len(rows), d.Scroll+room)] {
78		out = append(out, "  "+row)
79	}
80	for len(out) < height {
81		out = append(out, "")
82	}
83	return out
84}
85
86type ContextDoneMsg struct{}
87
88type contextSnapshotLoadedMsg struct {
89	Snapshot *daemon.ContextSnapshot
90	Err      error
91	Gen      int
92}
93
94type contextPageLoadedMsg struct {
95	Err       error
96	Data      *daemon.ContextPage
97	SectionID string
98	Page      int
99	Gen       int
100}
101
102// ContextInspectorModel lists the sections of the prepared request beside
103// the highlighted section's facts. Enter reads a section's content.
104type ContextInspectorModel struct {
105	Conn      *daemon.Connection
106	SessionID string
107	Snapshot  *daemon.ContextSnapshot
108	Detail    *ContextDetail
109	pageStatus
110	listView
111}
112
113func NewContextInspectorModel(conn *daemon.Connection, sessionID string) ContextInspectorModel {
114	return ContextInspectorModel{
115		Conn:       conn,
116		SessionID:  sessionID,
117		pageStatus: newPageStatus(true),
118		listView:   newListView("Search sections"),
119	}
120}
121
122func (m *ContextInspectorModel) SetSize(width, height int) {
123	m.pageStatus.SetSize(width, height)
124	m.listView.setSize(width, height)
125}
126
127func (m ContextInspectorModel) Init() tea.Cmd {
128	return m.loadSnapshotCmd(m.Generation)
129}
130
131var compactionStatuses = map[string]string{
132	"not_configured": "not configured", "not_needed": "not needed", "compacted": "applied", "unknown": "state unavailable",
133}
134
135func (m ContextInspectorModel) loadSnapshotCmd(gen int) tea.Cmd {
136	return func() tea.Msg {
137		if m.Conn == nil {
138			return contextSnapshotLoadedMsg{Err: errors.New("daemon connection unavailable"), Gen: gen}
139		}
140
141		snapshot, err := daemon.GetContextSnapshot(context.Background(), m.Conn, m.SessionID)
142		if err != nil {
143			return contextSnapshotLoadedMsg{Err: err, Gen: gen}
144		}
145		return contextSnapshotLoadedMsg{Snapshot: &snapshot, Gen: gen}
146	}
147}
148
149func (m ContextInspectorModel) loadPageCmd(sectionID string, page int, gen int) tea.Cmd {
150	return func() tea.Msg {
151		if m.Conn == nil {
152			return contextPageLoadedMsg{SectionID: sectionID, Page: page, Err: errors.New("daemon connection unavailable"), Gen: gen}
153		}
154		data, err := daemon.GetContextPage(context.Background(), m.Conn, m.SessionID, m.Snapshot.SnapshotID, sectionID, page)
155		if err != nil {
156			return contextPageLoadedMsg{SectionID: sectionID, Page: page, Err: err, Gen: gen}
157		}
158		return contextPageLoadedMsg{SectionID: sectionID, Page: page, Data: &data, Gen: gen}
159	}
160}
161
162// current is the section under the cursor.
163func (m ContextInspectorModel) current() (daemon.ContextSection, bool) {
164	row, ok := m.highlighted()
165	if !ok || m.Snapshot == nil {
166		return daemon.ContextSection{}, false
167	}
168	i := slices.IndexFunc(m.Snapshot.Sections, func(sec daemon.ContextSection) bool { return sec.ID == row.key })
169	if i < 0 {
170		return daemon.ContextSection{}, false
171	}
172	return m.Snapshot.Sections[i], true
173}
174
175func (m *ContextInspectorModel) setSections() {
176	var rows []listEntry
177	if m.Snapshot != nil {
178		rows = make([]listEntry, len(m.Snapshot.Sections))
179		for i, sec := range m.Snapshot.Sections {
180			rows[i] = sectionEntry(i, sec)
181		}
182	}
183	m.setRows(rows)
184}
185
186func sectionEntry(i int, sec daemon.ContextSection) listEntry {
187	tag := DefaultStyles.Faint.Render("no content")
188	if sec.Pages > 0 {
189		tag = DefaultStyles.Muted.Render(contextCount(sec.Pages, "page"))
190	}
191	return listEntry{
192		key:    sec.ID,
193		lead:   DefaultStyles.Faint.Render(fmt.Sprintf("%d.", i+1)),
194		name:   sec.Label,
195		desc:   sec.Source,
196		tag:    tag,
197		search: []string{sec.Kind, sec.Preview},
198		detail: func(width int) []string { return sectionDetails(sec, width) },
199	}
200}
201
202// sectionDetails is the pane: where a section comes from, how much of it
203// there is, and its preview.
204func sectionDetails(sec daemon.ContextSection, width int) []string {
205	pages := "content unavailable"
206	if sec.Pages > 0 {
207		pages = contextCount(sec.Pages, "page")
208	}
209	lines := paneTitle(sec.Label, "", width)
210	lines = append(lines, factRows("source", sec.Source, width)...)
211	lines = append(lines, factRows("items", contextCount(sec.ItemCount, "item"), width)...)
212	lines = append(lines, factRows("bytes", contextCount(sec.ByteCount, "measured byte"), width)...)
213	lines = append(lines, factRows("pages", pages, width)...)
214	if sec.Preview != "" {
215		lines = append(lines, "")
216		lines = append(lines, factRows("preview", sec.Preview, width)...)
217	}
218	lines = append(lines, "")
219	return append(lines, paneNote("enter reads this section a page at a time", width)...)
220}
221
222// requestFacts is the prepared request's standing, drawn above the sections
223// so it reads without opening one.
224func (m ContextInspectorModel) requestFacts(width int) []string {
225	snap := m.Snapshot
226	if snap == nil {
227		return nil
228	}
229	if snap.State == "pending" {
230		value := "not prepared for this running session yet"
231		if snap.Reason != "" {
232			value += ". " + snap.Reason
233		}
234		return factRows("request", value, width)
235	}
236	label := snap.Model
237	if snap.Provider != "" {
238		label = snap.Provider + " · " + snap.Model
239	}
240	if snap.Protocol != "" {
241		label += " · " + snap.Protocol
242	}
243	window := "not reported"
244	if snap.ContextWindowTokens != nil {
245		window = contextCount(*snap.ContextWindowTokens, "token") + " (configured)"
246	}
247	compaction := snap.Compaction
248	lines := factRows("model", label, width)
249	lines = append(lines, factRows("window", window, width)...)
250	strategy := cmp.Or(compaction.Strategy, "none")
251	if status := compactionStatuses[compaction.Status]; status != "" {
252		strategy += " · " + status
253	}
254	lines = append(lines, factRows("compact", strategy, width)...)
255	if compaction.TriggerFreePercent != nil {
256		lines = append(lines, factRows("trigger", fmt.Sprintf("keep %g%% free", *compaction.TriggerFreePercent), width)...)
257	}
258	switch {
259	case compaction.ProviderInputTokens != nil:
260		measured := contextCount(*compaction.ProviderInputTokens, "token") + " from provider"
261		if compaction.ProviderCachedInputTokens != nil {
262			measured += " · " + contextCount(*compaction.ProviderCachedInputTokens, "cached token")
263		}
264		lines = append(lines, factRows("input", measured, width)...)
265	case compaction.EstimatedInputTokens != nil:
266		method := cmp.Or(compaction.EstimateMethod, "method not reported")
267		lines = append(lines, factRows("input", contextCount(*compaction.EstimatedInputTokens, "token")+" estimated · "+method, width)...)
268	}
269	return lines
270}
271
272// emptyText says why there are no section rows.
273func (m ContextInspectorModel) emptyText() string {
274	switch {
275	case m.Loading && m.Snapshot == nil:
276		return "loading prepared request snapshot…"
277	case m.Snapshot == nil:
278		return "no prepared request to inspect"
279	case m.Snapshot.State == "pending":
280		return "sections appear once a request is prepared"
281	}
282	return "the prepared request contains no inspectable sections"
283}
284
285// list draws the request's standing above the sections, then the sections,
286// at exactly width × height.
287func (m ContextInspectorModel) list(width, height int) []string {
288	head := m.requestFacts(width)
289	// The note and the facts give up their rows, in that order, to the
290	// sections when the list is short, as it is with the pane stacked below.
291	if height >= len(head)+8 {
292		head = append(head, paneNote("read-only · durable transcript and request-only context are separate", width)...)
293		head = append(head, "")
294	} else if height < len(head)+4 {
295		head = nil
296	}
297	for i := range head {
298		head[i] = svFit(head[i], width)
299	}
300	lv := m.listView
301	lv.Empty = m.emptyText()
302	return append(head, lv.list(width, max(1, height-len(head)))...)
303}
304
305func (m ContextInspectorModel) footer(width int) string {
306	hints := []hint{{"↑↓", "move"}, {"enter", "inspect"}, {"ctrl+r", "refresh"}, {"esc", "back"}}
307	var status string
308	urgent := true
309	switch {
310	case m.Error != "":
311		status = DefaultStyles.Error.Render(m.Error)
312	case m.Loading:
313		status, urgent = DefaultStyles.Faint.Render("loading…"), false
314	case m.Snapshot != nil:
315		status, urgent = DefaultStyles.Faint.Render(counted(len(m.Snapshot.Sections), "section")), false
316	}
317	return footerLine(width, hints, status, urgent)
318}
319
320// readerFooter is the footer while a section's content is open.
321func readerFooter(width int) string {
322	return footerLine(width, []hint{{"↑↓", "scroll"}, {"pgup/pgdn", "jump"}, {"←→", "page"}, {"esc", "sections"}}, "", false)
323}
324
325func (m ContextInspectorModel) Update(msg tea.Msg) (ContextInspectorModel, tea.Cmd) {
326	switch msg := msg.(type) {
327	case contextSnapshotLoadedMsg:
328		if !m.settle(msg.Gen, msg.Err, &m.Loading) {
329			return m, nil
330		}
331		m.Snapshot, m.Error = msg.Snapshot, ""
332		m.setSections()
333		return m, nil
334
335	case contextPageLoadedMsg:
336		if msg.Gen != m.Generation || m.Detail == nil || m.Detail.Section.ID != msg.SectionID || m.Detail.Page != msg.Page {
337			return m, nil
338		}
339		if msg.Err != nil {
340			m.Detail.Error = msg.Err.Error()
341		} else {
342			m.Detail.Value, m.Detail.Error = msg.Data, ""
343		}
344		return m, nil
345
346	case tea.KeyPressMsg:
347		if m.Detail != nil {
348			return m.readerKey(msg)
349		}
350		return m.sectionsKey(msg)
351	}
352	cmd := m.listView.update(msg)
353	return m, cmd
354}
355
356// readerKey scrolls and pages through a section's content; esc returns to
357// the sections.
358func (m ContextInspectorModel) readerKey(msg tea.KeyPressMsg) (ContextInspectorModel, tea.Cmd) {
359	d := m.Detail
360	switch msg.String() {
361	case "esc", "ctrl+c", "ctrl+d":
362		m.Detail = nil
363		m.Generation = nextPageGeneration()
364	case "up":
365		d.scroll(-1)
366	case "down":
367		d.scroll(1)
368	case "pgup":
369		d.scroll(-max(1, d.visible))
370	case "pgdown":
371		d.scroll(max(1, d.visible))
372	case "left", "right":
373		page := d.Page - 1
374		if msg.String() == "right" {
375			page = d.Page + 1
376		}
377		if page >= 0 && page < d.Section.Pages {
378			d.Page, d.Value, d.Scroll, d.Error = page, nil, 0, ""
379			m.Generation = nextPageGeneration()
380			return m, m.loadPageCmd(d.Section.ID, page, m.Generation)
381		}
382	}
383	return m, nil
384}
385
386func (m ContextInspectorModel) sectionsKey(msg tea.KeyPressMsg) (ContextInspectorModel, tea.Cmd) {
387	switch msg.String() {
388	case "esc", "ctrl+c", "ctrl+d":
389		return m, func() tea.Msg { return ContextDoneMsg{} }
390	case "enter":
391		if sec, ok := m.current(); ok && sec.Pages > 0 {
392			m.Detail = &ContextDetail{Section: sec}
393			m.Generation = nextPageGeneration()
394			return m, m.loadPageCmd(sec.ID, 0, m.Generation)
395		}
396		return m, nil
397	case "ctrl+r":
398		m.Loading, m.Snapshot, m.Error = true, nil, ""
399		m.setSections()
400		m.Generation = nextPageGeneration()
401		return m, m.loadSnapshotCmd(m.Generation)
402	}
403	cmd := m.listView.update(msg)
404	return m, cmd
405}
406
407func wrapContextContent(content string, width int) []string {
408	if width <= 1 {
409		width = 76
410	}
411	return strings.Split(ansi.Wrap(content, width, " "), "\n")
412}
413
414var englishPrinter = message.NewPrinter(language.English)
415
416func contextCount(n int, unit string) string {
417	if n != 1 {
418		unit += "s"
419	}
420	return englishPrinter.Sprintf("%d", n) + " " + unit
421}
422
423// reader is the section's content as a list with no filter and no pane.
424func (m ContextInspectorModel) reader() listFrame {
425	title := brand("albedo") + " " + DefaultStyles.Muted.Render("/context")
426	return listFrame{
427		title:  title,
428		right:  DefaultStyles.Faint.Render(m.Detail.Section.Label),
429		list:   m.Detail.list,
430		footer: readerFooter(max(1, m.Width)),
431	}
432}
433
434func (m ContextInspectorModel) View() string {
435	if m.Detail != nil {
436		return m.reader().view(m.Width, m.Height)
437	}
438	title := brand("albedo") + " " + DefaultStyles.Muted.Render("/context")
439	f := m.listView.frame(title, DefaultStyles.Faint.Render("prepared request"), m.footer(max(1, m.Width)))
440	f.list = m.list
441	return f.view(m.Width, m.Height)
442}