okami.mom/albedo
git clone https://git.nkp.pet/okami.mom/albedo
1697fee
main
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 ( 20secAction = iota 21secPinned 22secFrequent 23secToday 24secWeek 25secEarlier 26secArchived 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 ( 34frequentLimit = 5 35frequentMinOpens = 2 36previewDebounce = 120 * time .Millisecond 37) 38 39// SessionPreviewMsg carries a fetched preview back to the viewer. 40type SessionPreviewMsg struct { 41ExpectedETag string 42Err error 43ID string 44Preview daemon.SessionPreview 45} 46 47type sessionPreviewTickMsg struct {ID string } 48 49type cachedPreview struct { 50 daemon.SessionPreview 51stamp int64 // LastAssistantAt when fetched; a newer reply invalidates it 52loading bool 53err bool 54} 55 56type sessionGroup struct { 57section int 58first int 59count 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 { 65active * daemon.Session 66// Fetch loads a preview; nil disables previews. 67Fetch func (id string ) tea.Cmd 68now func () time.Time 69previews map [string ]* cachedPreview 70section map [string ]int 71sessionIndex map [string ]int 72archivedIDs map [string ]bool 73Workspace string 74notice string 75Sessions []daemon.Session 76raw []daemon.Session 77groups []sessionGroup 78 79rename renameField 80confirm 81PickerModel 82sectionCounts [len (sectionTitles )]int 83Loading bool 84 85HasActive bool 86ArchiveView bool 87Saving 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 { 93if i := slices .IndexFunc (m .raw ,func (s daemon.Session )bool {return s .Workspace == m .Workspace });i >= 0 { 94return sessionPlace (m .raw [i ]) 95 } 96return placeText (m .Workspace ,"" ,"" ) 97} 98 99func NewSessionViewer (workspace string )SessionViewer { 100p := NewPickerModel ("" ,sessionViewerActions (workspace ),true ,"new" ) 101p .SearchInput .Placeholder = "search sessions, models, folders…" 102st := p .SearchInput .Styles () 103st .Focused .Placeholder ,st .Blurred .Placeholder = DefaultStyles .Faint ,DefaultStyles .Faint 104p .SearchInput .SetStyles (st ) 105m := SessionViewer { 106PickerModel :p , 107Workspace :workspace , 108Loading :true , 109section :map [string ]int {}, 110previews :map [string ]* cachedPreview {}, 111now :time .Now , 112 } 113m .rebuildGroups () 114return m 115} 116 117func sessionViewerActions (workspace string ) []PickerItem { 118return []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 ) { 126m .PickerModel .SetSize (width ,height ) 127m .SearchInput .SetWidth (max (1 ,width - 8 )) 128} 129 130func (m SessionViewer )clock () time.Time { 131if m .now == nil { 132return time .Now () 133 } 134return m .now () 135} 136 137func dateSection (s daemon.Session ,now time.Time )int { 138if s .LastAssistantAt == nil { 139return secEarlier 140 } 141t := time .Unix (* s .LastAssistantAt ,0 ) 142y ,mo ,d := now .Date () 143midnight := time .Date (y ,mo ,d ,0 ,0 ,0 ,0 ,now .Location ()) 144switch { 145case ! t .Before (midnight ): 146return secToday 147case ! t .Before (midnight .AddDate (0 ,0 ,- 6 )): 148return secWeek 149 } 150return secEarlier 151} 152 153func (m * SessionViewer )SetSessions (sessions []daemon.Session ,active * daemon.Session ) { 154initial := m .Loading 155m .Loading = false 156m .HasActive = active != nil 157m .raw = slices .Clone (sessions ) 158m .active = nil 159if active != nil { 160captured := * active 161m .active = & captured 162 } 163previous ,ok := m .Highlighted () 164m .rebuild () 165// Initial load favors the active or most recent session; later refreshes 166// keep the cursor. 167target := "" 168if initial && active != nil && ! m .archivedIDs [active .ID ]&& ! m .ArchiveView { 169target = active .ID 170 }else if ok && m .section [previous .ID ]!= secAction { 171target = previous .ID 172 }else if i := slices .IndexFunc (m .Sessions ,func (s daemon.Session )bool {return m .section [s .ID ]>= secToday });i >= 0 { 173target = m .Sessions [i ].ID 174 } 175m .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 () { 181highlighted ,ok := m .Highlighted () 182slot := m .Cursor 183if m .section == nil { 184m .section = map [string ]int {} 185 } 186// Session indexes refer to the daemon listing in raw. 187m .sessionIndex = make (map [string ]int ,len (m .raw )) 188for i ,session := range m .raw { 189if _ ,exists := m .sessionIndex [session .ID ];! exists { 190m .sessionIndex [session .ID ]= i 191 } 192 } 193m .archivedIDs = make (map [string ]bool ) 194for _ ,session := range m .raw { 195if session .Archived { 196m .archivedIDs [session .ID ]= true 197 } 198 } 199if m .active != nil && m .active .Archived { 200m .archivedIDs [m .active .ID ]= true 201 } 202listed := m .raw 203if m .active != nil { 204if _ ,exists := m .sessionIndex [m .active .ID ];! exists { 205listed = append ([]daemon.Session {* m .active },listed ... ) 206 } 207 } 208now := m .clock () 209clear (m .section ) 210var ordered []daemon.Session 211for _ ,s := range listed { 212if s .Pinned && ! m .archivedIDs [s .ID ] { 213m .section [s .ID ]= secPinned 214ordered = append (ordered ,s ) 215 } 216 } 217var frequent []daemon.Session 218for _ ,s := range listed { 219if _ ,taken := m .section [s .ID ];! taken && ! m .archivedIDs [s .ID ]&& s .Opens >= frequentMinOpens { 220frequent = append (frequent ,s ) 221 } 222 } 223slices .SortStableFunc (frequent ,func (a ,b daemon.Session )int { 224return cmp .Compare (b .Opens ,a .Opens ) 225 }) 226frequent = frequent [:min (len (frequent ),frequentLimit )] 227slices .SortStableFunc (frequent ,byWarm (now )) 228for _ ,s := range frequent { 229m .section [s .ID ]= secFrequent 230ordered = 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. 235dated := make ([]int ,len (listed )) 236older := secEarlier 237for i := len (listed )- 1 ;i >= 0 ;i -- { 238if listed [i ].LastAssistantAt != nil { 239older = dateSection (listed [i ],now ) 240 } 241dated [i ]= older 242 } 243var rest ,active []daemon.Session 244for i ,s := range listed { 245if m .archivedIDs [s .ID ] { 246m .section [s .ID ]= secArchived 247continue 248 } 249if _ ,taken := m .section [s .ID ];taken { 250continue 251 } 252m .section [s .ID ]= dated [i ] 253if m .active != nil && s .ID == m .active .ID { 254m .section [s .ID ]= secToday 255active = []daemon.Session {s } 256continue 257 } 258rest = 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. 262warmFirst := byWarm (now ) 263slices .SortStableFunc (rest ,func (a ,b daemon.Session )int { 264return cmp .Or (cmp .Compare (m .section [a .ID ],m .section [b .ID ]),warmFirst (a ,b )) 265 }) 266m .Sessions = slices .Concat (ordered ,active ,rest ) 267 268var items []PickerItem 269visible := m .Sessions 270if m .ArchiveView { 271visible = listed 272 }else { 273items = sessionViewerActions (m .Workspace ) 274 } 275for _ ,s := range visible { 276if m .ArchiveView == m .archivedIDs [s .ID ] { 277items = append (items ,PickerItem {ID :s .ID ,Label :sessionTitle (s ),Detail :sessionText (s .Workspace + " " + s .Model + " " + s .Provider )}) 278 } 279 } 280m .Items = items 281m .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. 284if gone := ok && ! slices .ContainsFunc (m .Filtered ,func (item PickerItem )bool {return item .ID == highlighted .ID });gone { 285m .Cursor = max (0 ,min (slot ,len (m .Filtered )- 1 )) 286 } 287m .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 () { 293m .groups = nil 294clear (m .sectionCounts [:]) 295for i ,item := range m .Filtered { 296section := m .section [item .ID ] 297m .sectionCounts [section ]++ 298if len (m .groups )== 0 || m .groups [len (m .groups )- 1 ].section != section { 299m .groups = append (m .groups ,sessionGroup {section :section ,first :i }) 300 } 301m .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 { 312return 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 { 317return func (a ,b daemon.Session )int { 318switch { 319case warm (a ,now )== warm (b ,now ): 320return 0 321case warm (a ,now ): 322return - 1 323 } 324return 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 { 333title := strings .TrimSpace (sessionText (s .Title )) 334if title == "" || title == "new session" { 335return untitled 336 } 337return title 338} 339 340func (m * SessionViewer )focus (id string ) { 341if i := slices .IndexFunc (m .Filtered ,func (item PickerItem )bool {return item .ID == id });i >= 0 { 342m .Cursor = i 343 } 344} 345 346func (m SessionViewer )session (id string ) (daemon.Session ,bool ) { 347if index ,ok := m .sessionIndex [id ];ok { 348return m .raw [index ],true 349 } 350if m .active != nil && m .active .ID == id { 351return * m .active ,true 352 } 353return 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 { 359return 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 () { 364toFavourites := ! m .favourite (m .Cursor ) 365first := - 1 366for i := range m .Filtered { 367if m .favourite (i )!= toFavourites { 368continue 369 } 370if first < 0 { 371first = i 372 } 373// Prefer a pinned or frequent session over the actions. 374if ! toFavourites || m .section [m .Filtered [i ].ID ]!= secAction { 375m .Cursor = i 376return 377 } 378 } 379if first >= 0 { 380m .Cursor = first 381 } 382} 383 384// SessionPreferenceMsg requests a single explicit change; the root model owns I/O. 385type SessionPreferenceMsg struct { 386ID ,Field ,ETag string 387Value bool 388} 389 390func (m * SessionViewer )togglePin () tea.Cmd { 391item ,ok := m .Highlighted () 392if ! ok || m .section [item .ID ]== secAction || m .ArchiveView || m .Saving { 393return nil 394 } 395session ,_ := m .session (item .ID ) 396value := ! session .Pinned 397m .Saving = true 398return func () tea.Msg { 399return SessionPreferenceMsg {ID :item .ID ,Field :"pinned" ,Value :value ,ETag :session .ETag } 400 } 401} 402 403type SessionDeleteMsg struct { 404ID string 405Condition 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 () { 414item ,ok := m .Highlighted () 415s ,isSession := m .session (item .ID ) 416if ! ok || ! isSession { 417return 418 } 419current := sessionTitle (s ) 420if current == untitled { 421current = "" 422 } 423m .rename .open (s .ID ,current ,"Name this session" ) 424m .rename .etag = s .ETag 425} 426 427func (m * SessionViewer )toggleArchive () tea.Cmd { 428item ,ok := m .Highlighted () 429if ! ok || m .section [item .ID ]== secAction || m .Saving { 430return nil 431 } 432value := ! m .archivedIDs [item .ID ] 433session ,_ := m .session (item .ID ) 434m .Saving = true 435return func () tea.Msg { 436return 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 ) { 447m .ArchiveView = open 448m .confirm .dismiss () 449m .SearchInput .SetValue ("" ) 450m .rebuild () 451m .Cursor = 0 452if ! open { 453m .focus ("archive" ) 454 } 455} 456 457func (m * SessionViewer )ForgetPreview (id string ) { 458delete (m .previews ,id ) 459m .confirm .dismiss () 460} 461 462func (m SessionViewer )Update (msg tea.Msg ) (SessionViewer , tea.Cmd ) { 463before ,_ := m .Highlighted () 464switch msg := msg .(type ) { 465case SessionPreviewMsg : 466if c := m .previews [msg .ID ];c != nil { 467c .loading ,c .err = false ,msg .Err != nil 468if ! c .err { 469c .SessionPreview = msg .Preview 470 } 471 } 472return m ,nil 473case sessionPreviewTickMsg : 474if item ,ok := m .Highlighted ();ok && item .ID == msg .ID { 475return m ,m .fetchPreview (msg .ID ) 476 } 477return m ,nil 478case tea.KeyPressMsg : 479if m .rename .active () { 480return m ,m .rename .key (msg ) 481 } 482if m .confirm .asking () { 483if ! m .confirm .key (msg ) { 484return m ,nil 485 } 486id := m .confirm .target 487m .confirm .dismiss () 488s ,_ := m .session (id ) 489return m ,func () tea.Msg { 490return SessionDeleteMsg {ID :id ,Condition : daemon.SessionCondition {ETag :s .ETag ,FamilyRevision :s .FamilyRevision }} 491 } 492 } 493if m .ArchiveView && msg .String ()== "esc" { 494m .CloseArchive () 495return m ,nil 496 } 497switch key := msg .String (); { 498case key == "ctrl+d" && m .ArchiveView : 499if item ,ok := m .Highlighted ();ok { 500m .confirm .ask ("delete" ,item .ID ,"delete" ,sessionDeletePrompt ) 501 } 502return m ,nil 503case key == "ctrl+a" : 504cmd := m .toggleArchive () 505return m ,cmd 506case key == "ctrl+s" : 507cmd := m .togglePin () 508return m ,cmd 509case key == "ctrl+r" : 510m .startRename () 511return m ,nil 512case ! m .ArchiveView && (key == "ctrl+f" || (m .SearchInput .Value ()== "" && (msg .Text == "~" || msg .Text == "/" ))): 513// A path typed into an empty search means a folder. 514query := msg .Text 515return m ,func () tea.Msg {return SessionFoldersMsg {Query :query } } 516case key == "tab" || key == "shift+tab" || ((key == "left" || key == "right" )&& m .SearchInput .Value ()== "" ): 517m .switchGroup () 518return m ,m .previewAfter (before .ID ) 519 } 520 } 521query := m .SearchInput .Value () 522picker ,cmd := m .PickerModel .Update (msg ) 523m .PickerModel = picker 524if m .SearchInput .Value ()!= query { 525m .rebuildGroups () 526 } 527return 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 { 532if item ,ok := m .Highlighted ();! ok || item .ID == before { 533return nil 534 } 535return 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 { 541item ,ok := m .Highlighted () 542if ! ok || m .Fetch == nil || m .fresh (item .ID ) { 543return nil 544 } 545id := item .ID 546return tea .Tick (previewDebounce ,func (time.Time ) tea.Msg {return sessionPreviewTickMsg {ID :id } }) 547} 548 549func (m SessionViewer )fresh (id string )bool { 550s ,ok := m .session (id ) 551if ! ok { 552return true // actions have no transcript 553 } 554c := m .previews [id ] 555return c != nil && (c .loading || c .stamp == stampOf (s )) 556} 557 558func stampOf (s daemon.Session )int64 { 559if s .LastAssistantAt == nil { 560return 0 561 } 562return * s .LastAssistantAt 563} 564 565func (m * SessionViewer )fetchPreview (id string ) tea.Cmd { 566s ,ok := m .session (id ) 567if ! ok || m .Fetch == nil || m .fresh (id ) { 568return nil 569 } 570if m .previews == nil { 571m .previews = map [string ]* cachedPreview {} 572 } 573c := m .previews [id ] 574if c == nil { 575c = & cachedPreview {} 576m .previews [id ]= c 577 } 578c .stamp ,c .loading = stampOf (s ),true 579return m .Fetch (id ) 580}