okami.mom/albedo

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

dawntui: sessions search back to substring match per word, listed order1697fee

main
15.8 KiB580 linesraw
1package tui
2
3import (
4	"albedo/cli/internal/daemon"
5	"albedo/cli/internal/presentation"
6	"cmp"
7	"slices"
8	"strings"
9	"time"
10
11	tea "charm.land/bubbletea/v2"
12	"github.com/charmbracelet/x/ansi"
13)
14
15func sessionText(s string) string { return presentation.SessionText(ansi.Strip(s)) }
16
17// Sections of the session list, in navigation order. Pinned and most used
18// sessions lead the dated groups.
19const (
20	secAction = iota
21	secPinned
22	secFrequent
23	secToday
24	secWeek
25	secEarlier
26	secArchived
27)
28
29var sectionTitles = [...]string{"", "pinned", "most used", "today", "this week", "earlier", "archived"}
30
31// frequentLimit caps the most-used group; a session needs frequentMinOpens
32// opens before it counts as used rather than merely visited.
33const (
34	frequentLimit    = 5
35	frequentMinOpens = 2
36	previewDebounce  = 120 * time.Millisecond
37)
38
39// SessionPreviewMsg carries a fetched preview back to the viewer.
40type SessionPreviewMsg struct {
41	ExpectedETag string
42	Err          error
43	ID           string
44	Preview      daemon.SessionPreview
45}
46
47type sessionPreviewTickMsg struct{ ID string }
48
49type cachedPreview struct {
50	daemon.SessionPreview
51	stamp   int64 // LastAssistantAt when fetched; a newer reply invalidates it
52	loading bool
53	err     bool
54}
55
56type sessionGroup struct {
57	section int
58	first   int
59	count   int
60}
61
62// SessionViewer is the start screen: search over a grouped session list
63// beside a transcript preview of the highlighted session.
64type SessionViewer struct {
65	active *daemon.Session
66	// Fetch loads a preview; nil disables previews.
67	Fetch        func(id string) tea.Cmd
68	now          func() time.Time
69	previews     map[string]*cachedPreview
70	section      map[string]int
71	sessionIndex map[string]int
72	archivedIDs  map[string]bool
73	Workspace    string
74	notice       string
75	Sessions     []daemon.Session
76	raw          []daemon.Session
77	groups       []sessionGroup
78
79	rename renameField
80	confirm
81	PickerModel
82	sectionCounts [len(sectionTitles)]int
83	Loading       bool
84
85	HasActive   bool
86	ArchiveView bool
87	Saving      bool
88}
89
90// workspacePlace is the viewer's folder as text, its host labelled the way
91// the daemon labels it on any session there.
92func (m SessionViewer) workspacePlace() string {
93	if i := slices.IndexFunc(m.raw, func(s daemon.Session) bool { return s.Workspace == m.Workspace }); i >= 0 {
94		return sessionPlace(m.raw[i])
95	}
96	return placeText(m.Workspace, "", "")
97}
98
99func NewSessionViewer(workspace string) SessionViewer {
100	p := NewPickerModel("", sessionViewerActions(workspace), true, "new")
101	p.SearchInput.Placeholder = "search sessions, models, folders…"
102	st := p.SearchInput.Styles()
103	st.Focused.Placeholder, st.Blurred.Placeholder = DefaultStyles.Faint, DefaultStyles.Faint
104	p.SearchInput.SetStyles(st)
105	m := SessionViewer{
106		PickerModel: p,
107		Workspace:   workspace,
108		Loading:     true,
109		section:     map[string]int{},
110		previews:    map[string]*cachedPreview{},
111		now:         time.Now,
112	}
113	m.rebuildGroups()
114	return m
115}
116
117func sessionViewerActions(workspace string) []PickerItem {
118	return []PickerItem{
119		{ID: "new", Label: "New session", Detail: workspace},
120		{ID: "login", Label: "Accounts", Detail: "add or select a provider"},
121		{ID: "archive", Label: "Archive", Detail: "browse archived sessions"},
122	}
123}
124
125func (m *SessionViewer) SetSize(width, height int) {
126	m.PickerModel.SetSize(width, height)
127	m.SearchInput.SetWidth(max(1, width-8))
128}
129
130func (m SessionViewer) clock() time.Time {
131	if m.now == nil {
132		return time.Now()
133	}
134	return m.now()
135}
136
137func dateSection(s daemon.Session, now time.Time) int {
138	if s.LastAssistantAt == nil {
139		return secEarlier
140	}
141	t := time.Unix(*s.LastAssistantAt, 0)
142	y, mo, d := now.Date()
143	midnight := time.Date(y, mo, d, 0, 0, 0, 0, now.Location())
144	switch {
145	case !t.Before(midnight):
146		return secToday
147	case !t.Before(midnight.AddDate(0, 0, -6)):
148		return secWeek
149	}
150	return secEarlier
151}
152
153func (m *SessionViewer) SetSessions(sessions []daemon.Session, active *daemon.Session) {
154	initial := m.Loading
155	m.Loading = false
156	m.HasActive = active != nil
157	m.raw = slices.Clone(sessions)
158	m.active = nil
159	if active != nil {
160		captured := *active
161		m.active = &captured
162	}
163	previous, ok := m.Highlighted()
164	m.rebuild()
165	// Initial load favors the active or most recent session; later refreshes
166	// keep the cursor.
167	target := ""
168	if initial && active != nil && !m.archivedIDs[active.ID] && !m.ArchiveView {
169		target = active.ID
170	} else if ok && m.section[previous.ID] != secAction {
171		target = previous.ID
172	} else if i := slices.IndexFunc(m.Sessions, func(s daemon.Session) bool { return m.section[s.ID] >= secToday }); i >= 0 {
173		target = m.Sessions[i].ID
174	}
175	m.focus(target)
176}
177
178// rebuild orders sessions into sections and refreshes the picker items,
179// keeping the search and the highlighted item.
180func (m *SessionViewer) rebuild() {
181	highlighted, ok := m.Highlighted()
182	slot := m.Cursor
183	if m.section == nil {
184		m.section = map[string]int{}
185	}
186	// Session indexes refer to the daemon listing in raw.
187	m.sessionIndex = make(map[string]int, len(m.raw))
188	for i, session := range m.raw {
189		if _, exists := m.sessionIndex[session.ID]; !exists {
190			m.sessionIndex[session.ID] = i
191		}
192	}
193	m.archivedIDs = make(map[string]bool)
194	for _, session := range m.raw {
195		if session.Archived {
196			m.archivedIDs[session.ID] = true
197		}
198	}
199	if m.active != nil && m.active.Archived {
200		m.archivedIDs[m.active.ID] = true
201	}
202	listed := m.raw
203	if m.active != nil {
204		if _, exists := m.sessionIndex[m.active.ID]; !exists {
205			listed = append([]daemon.Session{*m.active}, listed...)
206		}
207	}
208	now := m.clock()
209	clear(m.section)
210	var ordered []daemon.Session
211	for _, s := range listed {
212		if s.Pinned && !m.archivedIDs[s.ID] {
213			m.section[s.ID] = secPinned
214			ordered = append(ordered, s)
215		}
216	}
217	var frequent []daemon.Session
218	for _, s := range listed {
219		if _, taken := m.section[s.ID]; !taken && !m.archivedIDs[s.ID] && s.Opens >= frequentMinOpens {
220			frequent = append(frequent, s)
221		}
222	}
223	slices.SortStableFunc(frequent, func(a, b daemon.Session) int {
224		return cmp.Compare(b.Opens, a.Opens)
225	})
226	frequent = frequent[:min(len(frequent), frequentLimit)]
227	slices.SortStableFunc(frequent, byWarm(now))
228	for _, s := range frequent {
229		m.section[s.ID] = secFrequent
230		ordered = append(ordered, s)
231	}
232	// A session with no reply yet has no date of its own. The daemon lists by
233	// activity, so it takes the group of the next older dated session; the
234	// session in use is always today's and leads the list.
235	dated := make([]int, len(listed))
236	older := secEarlier
237	for i := len(listed) - 1; i >= 0; i-- {
238		if listed[i].LastAssistantAt != nil {
239			older = dateSection(listed[i], now)
240		}
241		dated[i] = older
242	}
243	var rest, active []daemon.Session
244	for i, s := range listed {
245		if m.archivedIDs[s.ID] {
246			m.section[s.ID] = secArchived
247			continue
248		}
249		if _, taken := m.section[s.ID]; taken {
250			continue
251		}
252		m.section[s.ID] = dated[i]
253		if m.active != nil && s.ID == m.active.ID {
254			m.section[s.ID] = secToday
255			active = []daemon.Session{s}
256			continue
257		}
258		rest = append(rest, s)
259	}
260	// Navigation follows the grouping. Within a group warm sessions lead the
261	// rest, and the daemon's order holds otherwise.
262	warmFirst := byWarm(now)
263	slices.SortStableFunc(rest, func(a, b daemon.Session) int {
264		return cmp.Or(cmp.Compare(m.section[a.ID], m.section[b.ID]), warmFirst(a, b))
265	})
266	m.Sessions = slices.Concat(ordered, active, rest)
267
268	var items []PickerItem
269	visible := m.Sessions
270	if m.ArchiveView {
271		visible = listed
272	} else {
273		items = sessionViewerActions(m.Workspace)
274	}
275	for _, s := range visible {
276		if m.ArchiveView == m.archivedIDs[s.ID] {
277			items = append(items, PickerItem{ID: s.ID, Label: sessionTitle(s), Detail: sessionText(s.Workspace + " " + s.Model + " " + s.Provider)})
278		}
279	}
280	m.Items = items
281	m.applyFilter()
282	// A session that left the list (archived, restored, deleted) leaves its
283	// slot to the next one rather than sending the cursor to the top.
284	if gone := ok && !slices.ContainsFunc(m.Filtered, func(item PickerItem) bool { return item.ID == highlighted.ID }); gone {
285		m.Cursor = max(0, min(slot, len(m.Filtered)-1))
286	}
287	m.rebuildGroups()
288}
289
290// Group ranges index Filtered, so cursor movement and resizing can derive
291// physical rows without rebuilding or formatting the rest of the list.
292func (m *SessionViewer) rebuildGroups() {
293	m.groups = nil
294	clear(m.sectionCounts[:])
295	for i, item := range m.Filtered {
296		section := m.section[item.ID]
297		m.sectionCounts[section]++
298		if len(m.groups) == 0 || m.groups[len(m.groups)-1].section != section {
299			m.groups = append(m.groups, sessionGroup{section: section, first: i})
300		}
301		m.groups[len(m.groups)-1].count++
302	}
303}
304
305// warmFor is how long a session stays active after its last turn: the
306// provider's prompt cache has expired by then, and the daemon unloads it.
307const warmFor = time.Hour
308
309// warm reports whether the daemon holds the session live and its last turn
310// ran within warmFor.
311func warm(s daemon.Session, now time.Time) bool {
312	return s.Cursor != nil && s.LastAssistantAt != nil && now.Sub(time.Unix(*s.LastAssistantAt, 0)) < warmFor
313}
314
315// byWarm orders warm sessions before the rest.
316func byWarm(now time.Time) func(a, b daemon.Session) int {
317	return func(a, b daemon.Session) int {
318		switch {
319		case warm(a, now) == warm(b, now):
320			return 0
321		case warm(a, now):
322			return -1
323		}
324		return 1
325	}
326}
327
328const untitled = "Untitled session"
329
330const sessionDeletePrompt = "This permanently deletes the session, its child sessions, and their history. Delete?"
331
332func sessionTitle(s daemon.Session) string {
333	title := strings.TrimSpace(sessionText(s.Title))
334	if title == "" || title == "new session" {
335		return untitled
336	}
337	return title
338}
339
340func (m *SessionViewer) focus(id string) {
341	if i := slices.IndexFunc(m.Filtered, func(item PickerItem) bool { return item.ID == id }); i >= 0 {
342		m.Cursor = i
343	}
344}
345
346func (m SessionViewer) session(id string) (daemon.Session, bool) {
347	if index, ok := m.sessionIndex[id]; ok {
348		return m.raw[index], true
349	}
350	if m.active != nil && m.active.ID == id {
351		return *m.active, true
352	}
353	return daemon.Session{}, false
354}
355
356// favourite reports whether a Filtered index belongs to the leading groups
357// (actions, pins, most used) rather than the dated groups.
358func (m SessionViewer) favourite(i int) bool {
359	return i >= 0 && i < len(m.Filtered) && m.section[m.Filtered[i].ID] <= secFrequent
360}
361
362// switchGroup jumps between the leading and dated groups.
363func (m *SessionViewer) switchGroup() {
364	toFavourites := !m.favourite(m.Cursor)
365	first := -1
366	for i := range m.Filtered {
367		if m.favourite(i) != toFavourites {
368			continue
369		}
370		if first < 0 {
371			first = i
372		}
373		// Prefer a pinned or frequent session over the actions.
374		if !toFavourites || m.section[m.Filtered[i].ID] != secAction {
375			m.Cursor = i
376			return
377		}
378	}
379	if first >= 0 {
380		m.Cursor = first
381	}
382}
383
384// SessionPreferenceMsg requests a single explicit change; the root model owns I/O.
385type SessionPreferenceMsg struct {
386	ID, Field, ETag string
387	Value           bool
388}
389
390func (m *SessionViewer) togglePin() tea.Cmd {
391	item, ok := m.Highlighted()
392	if !ok || m.section[item.ID] == secAction || m.ArchiveView || m.Saving {
393		return nil
394	}
395	session, _ := m.session(item.ID)
396	value := !session.Pinned
397	m.Saving = true
398	return func() tea.Msg {
399		return SessionPreferenceMsg{ID: item.ID, Field: "pinned", Value: value, ETag: session.ETag}
400	}
401}
402
403type SessionDeleteMsg struct {
404	ID        string
405	Condition daemon.SessionCondition
406}
407
408// SessionFoldersMsg browses sessions by folder, starting from Query.
409type SessionFoldersMsg struct{ Query string }
410
411// startRename opens the highlighted session's name for editing, starting
412// from the title it shows now.
413func (m *SessionViewer) startRename() {
414	item, ok := m.Highlighted()
415	s, isSession := m.session(item.ID)
416	if !ok || !isSession {
417		return
418	}
419	current := sessionTitle(s)
420	if current == untitled {
421		current = ""
422	}
423	m.rename.open(s.ID, current, "Name this session")
424	m.rename.etag = s.ETag
425}
426
427func (m *SessionViewer) toggleArchive() tea.Cmd {
428	item, ok := m.Highlighted()
429	if !ok || m.section[item.ID] == secAction || m.Saving {
430		return nil
431	}
432	value := !m.archivedIDs[item.ID]
433	session, _ := m.session(item.ID)
434	m.Saving = true
435	return func() tea.Msg {
436		return SessionPreferenceMsg{ID: item.ID, Field: "archived", Value: value, ETag: session.ETag}
437	}
438}
439
440func (m *SessionViewer) OpenArchive() { m.setArchive(true) }
441
442func (m *SessionViewer) CloseArchive() { m.setArchive(false) }
443
444// setArchive enters or leaves the archive view, which always drops the
445// delete confirmation and the search.
446func (m *SessionViewer) setArchive(open bool) {
447	m.ArchiveView = open
448	m.confirm.dismiss()
449	m.SearchInput.SetValue("")
450	m.rebuild()
451	m.Cursor = 0
452	if !open {
453		m.focus("archive")
454	}
455}
456
457func (m *SessionViewer) ForgetPreview(id string) {
458	delete(m.previews, id)
459	m.confirm.dismiss()
460}
461
462func (m SessionViewer) Update(msg tea.Msg) (SessionViewer, tea.Cmd) {
463	before, _ := m.Highlighted()
464	switch msg := msg.(type) {
465	case SessionPreviewMsg:
466		if c := m.previews[msg.ID]; c != nil {
467			c.loading, c.err = false, msg.Err != nil
468			if !c.err {
469				c.SessionPreview = msg.Preview
470			}
471		}
472		return m, nil
473	case sessionPreviewTickMsg:
474		if item, ok := m.Highlighted(); ok && item.ID == msg.ID {
475			return m, m.fetchPreview(msg.ID)
476		}
477		return m, nil
478	case tea.KeyPressMsg:
479		if m.rename.active() {
480			return m, m.rename.key(msg)
481		}
482		if m.confirm.asking() {
483			if !m.confirm.key(msg) {
484				return m, nil
485			}
486			id := m.confirm.target
487			m.confirm.dismiss()
488			s, _ := m.session(id)
489			return m, func() tea.Msg {
490				return SessionDeleteMsg{ID: id, Condition: daemon.SessionCondition{ETag: s.ETag, FamilyRevision: s.FamilyRevision}}
491			}
492		}
493		if m.ArchiveView && msg.String() == "esc" {
494			m.CloseArchive()
495			return m, nil
496		}
497		switch key := msg.String(); {
498		case key == "ctrl+d" && m.ArchiveView:
499			if item, ok := m.Highlighted(); ok {
500				m.confirm.ask("delete", item.ID, "delete", sessionDeletePrompt)
501			}
502			return m, nil
503		case key == "ctrl+a":
504			cmd := m.toggleArchive()
505			return m, cmd
506		case key == "ctrl+s":
507			cmd := m.togglePin()
508			return m, cmd
509		case key == "ctrl+r":
510			m.startRename()
511			return m, nil
512		case !m.ArchiveView && (key == "ctrl+f" || (m.SearchInput.Value() == "" && (msg.Text == "~" || msg.Text == "/"))):
513			// A path typed into an empty search means a folder.
514			query := msg.Text
515			return m, func() tea.Msg { return SessionFoldersMsg{Query: query} }
516		case key == "tab" || key == "shift+tab" || ((key == "left" || key == "right") && m.SearchInput.Value() == ""):
517			m.switchGroup()
518			return m, m.previewAfter(before.ID)
519		}
520	}
521	query := m.SearchInput.Value()
522	picker, cmd := m.PickerModel.Update(msg)
523	m.PickerModel = picker
524	if m.SearchInput.Value() != query {
525		m.rebuildGroups()
526	}
527	return m, tea.Batch(cmd, m.previewAfter(before.ID))
528}
529
530// previewAfter schedules a preview once the highlight moves to a new row.
531func (m SessionViewer) previewAfter(before string) tea.Cmd {
532	if item, ok := m.Highlighted(); !ok || item.ID == before {
533		return nil
534	}
535	return m.PreviewCmd()
536}
537
538// PreviewCmd requests the highlighted session's preview after a short pause,
539// so scrolling through the list does not fire a request per row.
540func (m SessionViewer) PreviewCmd() tea.Cmd {
541	item, ok := m.Highlighted()
542	if !ok || m.Fetch == nil || m.fresh(item.ID) {
543		return nil
544	}
545	id := item.ID
546	return tea.Tick(previewDebounce, func(time.Time) tea.Msg { return sessionPreviewTickMsg{ID: id} })
547}
548
549func (m SessionViewer) fresh(id string) bool {
550	s, ok := m.session(id)
551	if !ok {
552		return true // actions have no transcript
553	}
554	c := m.previews[id]
555	return c != nil && (c.loading || c.stamp == stampOf(s))
556}
557
558func stampOf(s daemon.Session) int64 {
559	if s.LastAssistantAt == nil {
560		return 0
561	}
562	return *s.LastAssistantAt
563}
564
565func (m *SessionViewer) fetchPreview(id string) tea.Cmd {
566	s, ok := m.session(id)
567	if !ok || m.Fetch == nil || m.fresh(id) {
568		return nil
569	}
570	if m.previews == nil {
571		m.previews = map[string]*cachedPreview{}
572	}
573	c := m.previews[id]
574	if c == nil {
575		c = &cachedPreview{}
576		m.previews[id] = c
577	}
578	c.stamp, c.loading = stampOf(s), true
579	return m.Fetch(id)
580}