Something went wrong. Try again.
small gleam coding and persistent agent daemon with a detachable cli and a canvas ui forked from okami.mom/albedo
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625package tui
import ( "albedo/cli/internal/daemon" "albedo/cli/internal/presentation" "cmp" "slices" "strings" "time"
tea "charm.land/bubbletea/v2" "github.com/charmbracelet/x/ansi")
func sessionText(s string) string { return presentation.SessionText(ansi.Strip(s)) }
// Sections of the session list, in navigation order. Pinned and most used// sessions lead the dated groups.const ( secAction = iota secPinned secFrequent secToday secWeek secEarlier secArchived)
var sectionTitles = [...]string{"", "pinned", "most used", "today", "this week", "earlier", "archived"}
// 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)
// SessionPreviewMsg carries a fetched preview back to the viewer.type SessionPreviewMsg struct { ExpectedETag string Err error ID string Preview daemon.SessionPreview}
type sessionPreviewTickMsg struct{ ID string }
type cachedPreview struct { daemon.SessionPreview stamp int64 // LastAssistantAt when fetched; a newer reply invalidates it loading bool err bool}
type sessionGroup struct { section int first int count int}
// SessionViewer is the start screen: search over a grouped session list// beside a transcript preview of the highlighted session.type SessionViewer struct { active *daemon.Session // Fetch loads a preview; nil disables previews. Fetch func(id string) tea.Cmd now func() time.Time previews map[string]*cachedPreview section map[string]int sessionIndex map[string]int archivedIDs map[string]bool Workspace string notice string // raw is the root model's listing, held rather than copied: it is one // slice the whole client shares, and every row here points into it. raw []daemon.Session // order is what the list shows: pointers into raw, with the active session // standing for itself when the daemon's recent list does not carry it. order []*daemon.Session groups []sessionGroup // stale says order and the picker rows predate the listing SetSessions // kept, and first that this viewer has never built them: its first build // picks the session to open on. ensure does both. stale, first bool
rename renameField confirm PickerModel sectionCounts [len(sectionTitles)]int Loading bool
HasActive bool ArchiveView bool Saving bool}
// workspacePlace is the viewer's folder as text, its host labelled the way// the daemon labels it on any session there.func (m SessionViewer) workspacePlace() string { if i := slices.IndexFunc(m.raw, func(s daemon.Session) bool { return s.Workspace == m.Workspace }); i >= 0 { return sessionPlace(m.raw[i]) } return placeText(m.Workspace, "", "")}
func NewSessionViewer(workspace string) SessionViewer { p := NewPickerModel("", sessionViewerActions(workspace), true, "new") p.SearchInput.Placeholder = "search sessions, models, folders…" st := p.SearchInput.Styles() st.Focused.Placeholder, st.Blurred.Placeholder = DefaultStyles.Faint, DefaultStyles.Faint p.SearchInput.SetStyles(st) m := SessionViewer{ PickerModel: p, Workspace: workspace, Loading: true, section: map[string]int{}, previews: map[string]*cachedPreview{}, now: time.Now, stale: true, first: true, } m.rebuildGroups() return m}
func sessionViewerActions(workspace string) []PickerItem { return []PickerItem{ {ID: "new", Label: "New session", Detail: workspace}, {ID: "login", Label: "Accounts", Detail: "add or select a provider"}, {ID: "archive", Label: "Archive", Detail: "browse archived sessions"}, }}
func (m *SessionViewer) SetSize(width, height int) { m.PickerModel.SetSize(width, height) m.SearchInput.SetWidth(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}
// SetSessions keeps the listing the client owns. Until the screen has been// drawn once it keeps nothing else: the rows it shows (the order, its items,// the groups) are built when it opens or is drawn (ensure), so a refresh in a// chat that never opens the screen costs one slice header. Once the rows exist// they are kept current, since something may be reading them.func (m *SessionViewer) SetSessions(sessions []daemon.Session, active *daemon.Session) { m.Loading = false m.HasActive = active != nil m.raw = sessions m.active = nil if active != nil { captured := *active m.active = &captured } m.stale = true if !m.first { m.ensure() }}
// ensure builds the rows the list draws, once per listing: the order, the// picker items, and the grouped ranges. The first build picks the session to// open on; later ones keep the cursor and the highlighted session.func (m *SessionViewer) ensure() { if !m.stale { return } first := m.first m.first, m.stale = false, false previous, ok := m.Highlighted() m.rebuild() target := "" switch { case first && m.active != nil && !m.archivedIDs[m.active.ID] && !m.ArchiveView: target = m.active.ID case ok && m.section[previous.ID] != secAction: target = previous.ID default: if i := slices.IndexFunc(m.order, func(s *daemon.Session) bool { return m.section[s.ID] >= secToday }); i >= 0 { target = m.order[i].ID } } m.focus(target)}
// rebuild orders the listing into sections and refreshes the picker items,// keeping the search and the highlighted item.func (m *SessionViewer) rebuild() { highlighted, ok := m.Highlighted() slot := m.Cursor if m.section == nil { m.section = map[string]int{} } // Session indexes refer to the daemon listing in raw. m.sessionIndex = make(map[string]int, len(m.raw)) for i, session := range m.raw { if _, exists := m.sessionIndex[session.ID]; !exists { m.sessionIndex[session.ID] = i } } m.archivedIDs = make(map[string]bool) for _, session := range m.raw { if session.Archived { m.archivedIDs[session.ID] = true } } if m.active != nil && m.active.Archived { m.archivedIDs[m.active.ID] = true } // listed is every session the list knows, in the daemon's order, with the // active one ahead of it when the recent list does not carry it. listed := make([]*daemon.Session, 0, len(m.raw)+1) if m.active != nil { if _, exists := m.sessionIndex[m.active.ID]; !exists { listed = append(listed, m.active) } } for i := range m.raw { listed = append(listed, &m.raw[i]) } now := m.clock() clear(m.section) var ordered []*daemon.Session for _, s := range listed { if s.Pinned && !m.archivedIDs[s.ID] { m.section[s.ID] = secPinned ordered = append(ordered, s) } } var frequent []*daemon.Session for _, s := range listed { if _, taken := m.section[s.ID]; !taken && !m.archivedIDs[s.ID] && s.Opens >= frequentMinOpens { frequent = append(frequent, s) } } slices.SortStableFunc(frequent, func(a, b *daemon.Session) int { return cmp.Compare(b.Opens, a.Opens) }) frequent = frequent[:min(len(frequent), frequentLimit)] slices.SortStableFunc(frequent, byWarm(now)) for _, s := range frequent { m.section[s.ID] = secFrequent ordered = append(ordered, s) } // A session with no reply yet has no date of its own. The daemon lists by // activity, so it takes the group of the next older dated session; the // session in use is always today's and leads the list. dated := make([]int, len(listed)) older := secEarlier for i := len(listed) - 1; i >= 0; i-- { if listed[i].LastAssistantAt != nil { older = dateSection(listed[i], now) } dated[i] = older } var rest, active []*daemon.Session for i, s := range listed { if m.archivedIDs[s.ID] { m.section[s.ID] = secArchived continue } if _, taken := m.section[s.ID]; taken { continue } m.section[s.ID] = dated[i] if m.active != nil && s.ID == m.active.ID { m.section[s.ID] = secToday active = []*daemon.Session{s} continue } rest = append(rest, s) } // Navigation follows the grouping. Within a group warm sessions lead the // rest, and the daemon's order holds otherwise. warmFirst := byWarm(now) slices.SortStableFunc(rest, func(a, b *daemon.Session) int { return cmp.Or(cmp.Compare(m.section[a.ID], m.section[b.ID]), warmFirst(a, b)) }) m.order = slices.Concat(ordered, active, rest)
var items []PickerItem visible := m.order if m.ArchiveView { visible = listed } else { items = sessionViewerActions(m.Workspace) } for _, s := range visible { if m.ArchiveView == m.archivedIDs[s.ID] { items = append(items, PickerItem{ID: s.ID, Label: sessionTitle(*s), Detail: sessionText(s.Workspace + " " + s.Model + " " + s.Provider)}) } } m.Items = items m.applyFilter() // A session that left the list (archived, restored, deleted) leaves its // slot to the next one rather than sending the cursor to the top. if gone := ok && !slices.ContainsFunc(m.Filtered, func(item PickerItem) bool { return item.ID == highlighted.ID }); gone { m.Cursor = max(0, min(slot, len(m.Filtered)-1)) } m.rebuildGroups()}
// Group ranges index Filtered, so cursor movement and resizing can derive// physical rows without rebuilding or formatting the rest of the list.func (m *SessionViewer) rebuildGroups() { m.groups = nil clear(m.sectionCounts[:]) for i, item := range m.Filtered { section := m.section[item.ID] m.sectionCounts[section]++ if len(m.groups) == 0 || m.groups[len(m.groups)-1].section != section { m.groups = append(m.groups, sessionGroup{section: section, first: i}) } m.groups[len(m.groups)-1].count++ }}
// warmFor is how long a session stays active after its last turn: the// provider's prompt cache has expired by then, and the daemon unloads it.const warmFor = time.Hour
// warm reports whether the daemon holds the session live and its last turn// ran within warmFor.func warm(s daemon.Session, now time.Time) bool { return s.Cursor != nil && s.LastAssistantAt != nil && now.Sub(time.Unix(*s.LastAssistantAt, 0)) < warmFor}
// byWarm orders warm sessions before the rest.func byWarm(now time.Time) func(a, b *daemon.Session) int { return func(a, b *daemon.Session) int { switch { case warm(*a, now) == warm(*b, now): return 0 case warm(*a, now): return -1 } return 1 }}
const untitled = "Untitled session"
const sessionDeletePrompt = "This permanently deletes the session, its child sessions, and their history. Delete?"
func sessionTitle(s daemon.Session) string { title := strings.TrimSpace(sessionText(s.Title)) if title == "" || title == "new session" { return untitled } return title}
func (m *SessionViewer) focus(id string) { if i := slices.IndexFunc(m.Filtered, func(item PickerItem) bool { return item.ID == id }); i >= 0 { m.Cursor = i }}
func (m SessionViewer) session(id string) (daemon.Session, bool) { if index, ok := m.sessionIndex[id]; ok { return m.raw[index], true } if m.active != nil && m.active.ID == id { return *m.active, true } return daemon.Session{}, false}
// favourite reports whether a Filtered index belongs to the leading groups// (actions, pins, most used) rather than the dated groups.func (m SessionViewer) favourite(i int) bool { return i >= 0 && i < len(m.Filtered) && m.section[m.Filtered[i].ID] <= secFrequent}
// switchGroup jumps between the leading and dated groups.func (m *SessionViewer) switchGroup() { toFavourites := !m.favourite(m.Cursor) first := -1 for i := range m.Filtered { if m.favourite(i) != toFavourites { continue } if first < 0 { first = i } // 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 }}
// SessionPreferenceMsg requests a single explicit change; the root model owns I/O.type SessionPreferenceMsg struct { ID, Field, ETag string Value bool}
func (m *SessionViewer) togglePin() tea.Cmd { item, ok := m.Highlighted() if !ok || m.section[item.ID] == secAction || m.ArchiveView || m.Saving { return nil } session, _ := m.session(item.ID) value := !session.Pinned m.Saving = true return func() tea.Msg { return SessionPreferenceMsg{ID: item.ID, Field: "pinned", Value: value, ETag: session.ETag} }}
type SessionDeleteMsg struct { ID string Condition daemon.SessionCondition}
// SessionFoldersMsg browses sessions by folder, starting from Query.type SessionFoldersMsg struct{ Query string }
// startRename opens the highlighted session's name for editing, starting// from the title it shows now.func (m *SessionViewer) startRename() { item, ok := m.Highlighted() s, isSession := m.session(item.ID) if !ok || !isSession { return } current := sessionTitle(s) if current == untitled { current = "" } m.rename.open(s.ID, current, "Name this session") m.rename.etag = s.ETag}
func (m *SessionViewer) toggleArchive() tea.Cmd { item, ok := m.Highlighted() if !ok || m.section[item.ID] == secAction || m.Saving { return nil } value := !m.archivedIDs[item.ID] session, _ := m.session(item.ID) m.Saving = true return func() tea.Msg { return SessionPreferenceMsg{ID: item.ID, Field: "archived", Value: value, ETag: session.ETag} }}
func (m *SessionViewer) OpenArchive() { m.setArchive(true) }
func (m *SessionViewer) CloseArchive() { m.setArchive(false) }
// setArchive enters or leaves the archive view, which always drops the// delete confirmation and the search.func (m *SessionViewer) setArchive(open bool) { m.ArchiveView = open m.confirm.dismiss() m.SearchInput.SetValue("") m.stale = true m.ensure() m.Cursor = 0 if !open { m.focus("archive") }}
func (m *SessionViewer) ForgetPreview(id string) { delete(m.previews, id) m.confirm.dismiss()}
// Init is the sessions screen opening: it builds the rows it draws.func (m *SessionViewer) Init() tea.Cmd { m.ensure() return m.PickerModel.Init()}
func (m SessionViewer) Update(msg tea.Msg) (SessionViewer, tea.Cmd) { m.ensure() before, _ := m.Highlighted() switch msg := msg.(type) { case SessionPreviewMsg: if c := m.previews[msg.ID]; c != nil { c.loading, c.err = false, msg.Err != nil if !c.err { 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.KeyPressMsg: if m.rename.active() { return m, m.rename.key(msg) } if m.confirm.asking() { if !m.confirm.key(msg) { return m, nil } id := m.confirm.target m.confirm.dismiss() s, _ := m.session(id) return m, func() tea.Msg { return SessionDeleteMsg{ID: id, Condition: daemon.SessionCondition{ETag: s.ETag, FamilyRevision: s.FamilyRevision}} } } if m.ArchiveView && msg.String() == "esc" { m.CloseArchive() return m, nil } switch key := msg.String(); { case key == "ctrl+d" && m.ArchiveView: if item, ok := m.Highlighted(); ok { m.confirm.ask("delete", item.ID, "delete", sessionDeletePrompt) } return m, nil case key == "ctrl+a": cmd := m.toggleArchive() return m, cmd case key == "ctrl+s": cmd := m.togglePin() return m, cmd case key == "ctrl+r": m.startRename() return m, nil case !m.ArchiveView && (key == "ctrl+f" || (m.SearchInput.Value() == "" && (msg.Text == "~" || msg.Text == "/"))): // A path typed into an empty search means a folder. query := msg.Text return m, func() tea.Msg { return SessionFoldersMsg{Query: query} } case key == "tab" || key == "shift+tab" || ((key == "left" || key == "right") && m.SearchInput.Value() == ""): m.switchGroup() return m, m.previewAfter(before.ID) } } query := m.SearchInput.Value() picker, cmd := m.PickerModel.Update(msg) m.PickerModel = picker if m.SearchInput.Value() != query { m.rebuildGroups() } 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 { m.ensure() 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)}