Something went wrong. Try again.
This repository has no description
Something went wrong. Try again.
Go
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414package tui
import ( "albedo/cli/internal/daemon" "sort" "strings" "time"
tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/x/ansi")
func sessionText(s string) string { return daemon.SessionText(ansi.Strip(s)) }
// Sections of the session list, in navigation order. Pinned and most used// sessions form the favourites column; the rest are grouped by date.const ( secAction = iota secPinned secFrequent secToday secWeek secEarlier)
var sectionTitles = [...]string{"", "pinned", "most used", "today", "this week", "earlier"}
// frequentLimit caps the most-used group; a session needs frequentMinOpens// opens before it counts as used rather than merely visited.const ( frequentLimit = 5 frequentMinOpens = 2 previewDebounce = 120 * time.Millisecond)
// PreviewItem is one transcript excerpt from GET /sessions/{id}/preview.type PreviewItem struct { Type string `json:"type"` Preview string `json:"preview"`}
type SessionPreview struct { Items []PreviewItem `json:"items"` Total int `json:"total"`}
// SessionPreviewMsg carries a fetched preview back to the viewer.type SessionPreviewMsg struct { ID string Preview SessionPreview Err error}
type sessionPreviewTickMsg struct{ ID string }
type cachedPreview struct { SessionPreview stamp int64 // LastAssistantAt when fetched; a newer reply invalidates it loading bool err bool}
// SessionViewer is the start screen: search on top, favourites and recent// sessions as lists, and a transcript preview of the highlighted session.type SessionViewer struct { PickerModel Sessions []daemon.Session Workspace string Loading bool HasActive bool
// PrefsPath stores pins and open counts; empty keeps them in memory only. PrefsPath string // Fetch loads a preview; nil disables previews. Fetch func(id string) tea.Cmd
prefs sessionPrefs raw []daemon.Session active *daemon.Session section map[string]int previews map[string]*cachedPreview notice string now func() time.Time}
func NewSessionViewer(workspace string) SessionViewer { p := NewPickerModel("", sessionViewerActions(workspace), true, "new") p.SearchInput.Placeholder = "search sessions, models, folders…" p.SearchInput.PlaceholderStyle = DefaultStyles.Faint p.SearchInput.Cursor.Style = DefaultStyles.Cursor return SessionViewer{ PickerModel: p, Workspace: workspace, Loading: true, section: map[string]int{}, previews: map[string]*cachedPreview{}, now: time.Now, }}
func sessionViewerActions(workspace string) []PickerItem { return []PickerItem{ {ID: "new", Label: "New session", Detail: workspace}, {ID: "login", Label: "Accounts", Detail: "add or select a provider"}, }}
// LoadPrefs reads pins and open counts from path and keeps them there.func (m *SessionViewer) LoadPrefs(path string) { m.PrefsPath = path m.prefs = loadSessionPrefs(path) m.rebuild()}
func (m *SessionViewer) SetSize(width, height int) { m.PickerModel.SetSize(width, height) m.SearchInput.Width = max(1, width-8)}
func (m SessionViewer) clock() time.Time { if m.now == nil { return time.Now() } return m.now()}
func dateSection(s daemon.Session, now time.Time) int { if s.LastAssistantAt == nil { return secEarlier } t := time.Unix(*s.LastAssistantAt, 0) y, mo, d := now.Date() midnight := time.Date(y, mo, d, 0, 0, 0, 0, now.Location()) switch { case !t.Before(midnight): return secToday case !t.Before(midnight.AddDate(0, 0, -6)): return secWeek } return secEarlier}
func (m *SessionViewer) SetSessions(sessions []daemon.Session, active *daemon.Session) { m.Loading = false m.HasActive = active != nil m.raw = sessions m.active = active previous, ok := m.Highlighted() m.rebuild() // Initial load favors the active or most recent session; later refreshes // keep the cursor. target := "" if active != nil { target = active.ID } else if ok && m.section[previous.ID] != secAction { target = previous.ID } else { for _, s := range m.Sessions { if m.section[s.ID] >= secToday { target = s.ID break } } } m.focus(target)}
// Prune forgets pins and counts for sessions the daemon no longer has. Call it// only with a complete, successfully loaded session list.func (m *SessionViewer) Prune(sessions []daemon.Session) { known := make(map[string]bool, len(sessions)) for _, s := range sessions { known[s.ID] = true } if m.prefs.forget(known) { m.savePrefs() }}
// rebuild orders sessions into sections and refreshes the picker items,// keeping the search and the highlighted item.func (m *SessionViewer) rebuild() { if m.section == nil { m.section = map[string]int{} } listed := m.raw if m.active != nil && !containsSession(listed, m.active.ID) { listed = append([]daemon.Session{*m.active}, listed...) } now := m.clock() byID := make(map[string]daemon.Session, len(listed)) for _, s := range listed { byID[s.ID] = s } clear(m.section) var ordered []daemon.Session for _, id := range m.prefs.Pinned { if s, ok := byID[id]; ok { m.section[id] = secPinned ordered = append(ordered, s) } } var frequent []daemon.Session for _, s := range listed { if _, taken := m.section[s.ID]; !taken && m.prefs.Opens[s.ID] >= frequentMinOpens { frequent = append(frequent, s) } } sort.SliceStable(frequent, func(i, j int) bool { return m.prefs.Opens[frequent[i].ID] > m.prefs.Opens[frequent[j].ID] }) for _, s := range frequent[:min(len(frequent), frequentLimit)] { m.section[s.ID] = secFrequent ordered = append(ordered, s) } var rest []daemon.Session for _, s := range listed { if _, taken := m.section[s.ID]; !taken { m.section[s.ID] = dateSection(s, now) rest = append(rest, s) } } // Navigation follows the grouping; the daemon's order holds within a day group. sort.SliceStable(rest, func(i, j int) bool { return m.section[rest[i].ID] < m.section[rest[j].ID] }) m.Sessions = append(ordered, rest...)
items := sessionViewerActions(m.Workspace) for _, s := range m.Sessions { items = append(items, PickerItem{ID: s.ID, Label: sessionTitle(s), Detail: sessionText(s.Workspace + " " + s.Model + " " + s.Provider)}) } m.Items = items m.applyFilter()}
func sessionTitle(s daemon.Session) string { title := strings.TrimSpace(sessionText(s.Title)) if title == "" || title == "new session" { return "Untitled session" } return title}
func containsSession(sessions []daemon.Session, id string) bool { for _, s := range sessions { if s.ID == id { return true } } return false}
func (m *SessionViewer) focus(id string) { for i, item := range m.Filtered { if item.ID == id { m.Cursor = i return } }}
func (m SessionViewer) session(id string) (daemon.Session, bool) { for _, s := range m.Sessions { if s.ID == id { return s, true } } return daemon.Session{}, false}
// favourite reports whether a Filtered index belongs to the left-hand column// (actions, pins, most used) rather than the dated list.func (m SessionViewer) favourite(i int) bool { return i >= 0 && i < len(m.Filtered) && m.section[m.Filtered[i].ID] <= secFrequent}
// switchColumn jumps between the favourites and the dated sessions.func (m *SessionViewer) switchColumn() { toFavourites := !m.favourite(m.Cursor) first := -1 for i := range m.Filtered { if m.favourite(i) != toFavourites { continue } if first < 0 { first = i } // Heading left, prefer a pinned or frequent session over the actions. if !toFavourites || m.section[m.Filtered[i].ID] != secAction { m.Cursor = i return } } if first >= 0 { m.Cursor = first }}
// RecordOpen counts an open for the most-used group.func (m *SessionViewer) RecordOpen(id string) { if _, ok := m.session(id); !ok { return } m.prefs.recordOpen(id) m.savePrefs()}
func (m *SessionViewer) togglePin() { item, ok := m.Highlighted() if !ok || m.section[item.ID] == secAction { return } m.prefs.togglePin(item.ID) m.savePrefs() m.rebuild() m.focus(item.ID)}
func (m *SessionViewer) savePrefs() { m.notice = "" if err := m.prefs.save(m.PrefsPath); err != nil { m.notice = "could not save pins: " + err.Error() }}
func (m SessionViewer) Update(msg tea.Msg) (SessionViewer, tea.Cmd) { before, _ := m.Highlighted() switch msg := msg.(type) { case SessionPreviewMsg: if c := m.previews[msg.ID]; c != nil { c.loading = false c.err = msg.Err != nil if msg.Err == nil { c.SessionPreview = msg.Preview } } return m, nil case sessionPreviewTickMsg: if item, ok := m.Highlighted(); ok && item.ID == msg.ID { return m, m.fetchPreview(msg.ID) } return m, nil case tea.KeyMsg: switch msg.Type { case tea.KeyCtrlS: m.togglePin() return m, nil case tea.KeyTab, tea.KeyShiftTab: m.switchColumn() return m, m.previewAfter(before.ID) case tea.KeyLeft, tea.KeyRight: // Arrows edit the query once there is one. if m.SearchInput.Value() == "" { m.switchColumn() return m, m.previewAfter(before.ID) } } } picker, cmd := m.PickerModel.Update(msg) m.PickerModel = picker return m, tea.Batch(cmd, m.previewAfter(before.ID))}
// previewAfter schedules a preview once the highlight moves to a new row.func (m SessionViewer) previewAfter(before string) tea.Cmd { if item, ok := m.Highlighted(); !ok || item.ID == before { return nil } return m.PreviewCmd()}
// PreviewCmd requests the highlighted session's preview after a short pause,// so scrolling through the list does not fire a request per row.func (m SessionViewer) PreviewCmd() tea.Cmd { item, ok := m.Highlighted() if !ok || m.Fetch == nil || m.fresh(item.ID) { return nil } id := item.ID return tea.Tick(previewDebounce, func(time.Time) tea.Msg { return sessionPreviewTickMsg{ID: id} })}
func (m SessionViewer) fresh(id string) bool { s, ok := m.session(id) if !ok { return true // actions have no transcript } c := m.previews[id] return c != nil && (c.loading || c.stamp == stampOf(s))}
func stampOf(s daemon.Session) int64 { if s.LastAssistantAt == nil { return 0 } return *s.LastAssistantAt}
func (m *SessionViewer) fetchPreview(id string) tea.Cmd { s, ok := m.session(id) if !ok || m.Fetch == nil || m.fresh(id) { return nil } if m.previews == nil { m.previews = map[string]*cachedPreview{} } c := m.previews[id] if c == nil { c = &cachedPreview{} m.previews[id] = c } c.stamp, c.loading = stampOf(s), true return m.Fetch(id)}