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"fmt" 8"path/filepath" 9"strings" 10"time" 11 12"charm.land/lipgloss/v2" 13"github.com/charmbracelet/x/ansi" 14) 15 16// ── logo ─────────────────────────────────────────────────────────────────── 17 18// A four-point sparkle with a small companion star, drawn with half blocks so 19// each pixel is roughly square. 20var svLogoPixels = []string { 21"........#..............." , 22"........#...........#..." , 23".......###..........#..." , 24".......###........#####." , 25"......#####.........#..." , 26".....#######........#..." , 27"...###########.........." , 28"#################......." , 29"...###########.........." , 30".....#######............" , 31"......#####............." , 32".......###.........#...." , 33".......###.............." , 34"........#..............." , 35"........#..............." , 36"........................" , 37} 38 39func svLogo () []string { 40rows := len (svLogoPixels )/ 2 41width := len (svLogoPixels [0 ]) 42lines := make ([]string ,rows ) 43for r := range rows { 44var b strings.Builder 45for c := range width { 46top := svLogoPixels [2 * r ][c ]== '#' 47bottom := svLogoPixels [2 * r + 1 ][c ]== '#' 48ch := " " 49switch { 50case top && bottom : 51ch = "█" 52case top : 53ch = "▀" 54case bottom : 55ch = "▄" 56 } 57// Diagonal sweep so the gradient reads as light glancing off the mark. 58if ch != " " { 59t := (float64 (c )/ float64 (width )+ float64 (r )/ float64 (rows ))/ 2 60ch = brandInk (t ).Render (ch ) 61 } 62b .WriteString (ch ) 63 } 64lines [r ]= b .String () 65 } 66return lines 67} 68 69// ── helpers ──────────────────────────────────────────────────────────────── 70 71func svCompactAge (ts * int64 ,now time.Time )string { 72if ts == nil { 73return "—" 74 } 75d := now .Sub (time .Unix (* ts ,0 )) 76switch { 77case d < time .Minute : 78return "now" 79case d < time .Hour : 80return fmt .Sprintf ("%dm" ,int (d .Minutes ())) 81case d < 24 * time .Hour : 82return fmt .Sprintf ("%dh" ,int (d .Hours ())) 83case d < 14 * 24 * time .Hour : 84return fmt .Sprintf ("%dd" ,int (d .Hours ()/ 24 )) 85case d < 60 * 24 * time .Hour : 86return fmt .Sprintf ("%dw" ,int (d .Hours ()/ 24 / 7 )) 87 } 88return fmt .Sprintf ("%dmo" ,int (d .Hours ()/ 24 / 30 )) 89} 90 91// svCell fits plain text into exactly w columns. 92func svCell (s string ,w int ,right bool )string { 93if w <= 0 { 94return "" 95 } 96s = ansi .Truncate (s ,w ,"…" ) 97pad := strings .Repeat (" " ,max (0 ,w - ansi .StringWidth (s ))) 98if right { 99return pad + s 100 } 101return s + pad 102} 103 104type svColumns struct { 105title ,model ,workspace ,age int 106} 107 108func svLayout (width int )svColumns { 109// Row: bar(1) icon(2) space(1) title … model … workspace … age, two-space gaps. 110cols := svColumns {} 111rest := width - 4 112if width >= 32 { 113cols .age = 4 114rest -= cols .age + 2 115 } 116if width >= 56 { 117cols .model = min (20 ,max (14 ,width / 5 )) 118rest -= cols .model + 2 119 } 120if width >= 90 { 121cols .workspace = min (18 ,max (10 ,width / 8 )) 122rest -= cols .workspace + 2 123 } 124cols .title = max (1 ,rest - 1 ) 125return cols 126} 127 128// svSep divides the columns. 129func svSep ()string {return DefaultStyles .Decor .Render (" │ " ) } 130 131// ── view ─────────────────────────────────────────────────────────────────── 132 133// View shows the grouped session list beside its preview when there is room, 134// and the list alone on narrow terminals. 135func (m SessionViewer )View ()string { 136width := cmp .Or (m .Width ,80 ) 137now := m .clock () 138f := listFrame { 139title :located (m .brandName (),sessionText (m .workspacePlace ())), 140filter :promptLead ()+ m .SearchInput .View (), 141list :func (w ,h int ) []string {return m .column (w ,h ,now ) }, 142pane :func (w ,h int ) []string {return m .preview (w ,h ,now ) }, 143paneWidth :halfPane , 144footer :m .footer (width ,now ), 145 } 146return f .view (m .Width ,m .Height ) 147} 148 149// svFit pads or cuts a styled line to exactly w columns. 150func svFit (s string ,w int )string {return svCell (s ,w ,false ) } 151 152// brandName is the title's brand: the archive says so. 153func (m SessionViewer )brandName ()string { 154if m .ArchiveView { 155return "albedo archive" 156 } 157return "albedo" 158} 159 160// footer is the keys and the status, or the question while one is asked. 161func (m SessionViewer )footer (width int ,now time.Time )string { 162if m .confirm .asking () { 163return m .confirm .footer (width ,"" ) 164 } 165status ,urgent := m .counts (now ),false 166switch { 167case m .notice != "" : 168status ,urgent = DefaultStyles .Error .Render (m .notice ),true 169case m .Loading : 170status = DefaultStyles .Faint .Render ("loading…" ) 171 } 172return footerLine (width ,m .hints (),status ,urgent ) 173} 174 175// hints are the keys for the list, the archive, or a rename in progress. 176func (m SessionViewer )hints () []hint { 177switch { 178case m .rename .active (): 179return []hint {{"enter" ,"save" }, {"esc" ,"cancel" }, {"" ,"empty restores the automatic title" }} 180case m .ArchiveView : 181if _ ,ok := m .Highlighted ();! ok { 182return []hint {{"esc" ,"back" }} 183 } 184return []hint {{"↑↓" ,"move" }, {"enter" ,"open" }, {"^r" ,"rename" }, {"^a" ,"restore" }, {"^d" ,"delete" }, {"esc" ,"back" }} 185 } 186esc := "quit" 187if m .HasActive { 188esc = "back" 189 } 190return []hint {{"↑↓" ,"move" }, {"tab" ,"switch" }, {"enter" ,"open" }, {"^r" ,"rename" }, {"^s" ,"pin" }, {"^a" ,"archive" }, {"^f" ,"folders" }, {"esc" ,esc }} 191} 192 193// counts is how many sessions the list holds, and how many of them today. 194func (m SessionViewer )counts (now time.Time )string { 195count ,today := len (m .Sessions ),0 196if m .ArchiveView { 197count = len (m .archivedIDs ) 198 }else { 199for _ ,s := range m .Sessions { 200if dateSection (s ,now )== secToday { 201today ++ 202 } 203 } 204 } 205out := DefaultStyles .Faint .Render (counted (count ,"session" )) 206if today > 0 { 207out += DefaultStyles .Decor .Render (" · " )+ DefaultStyles .Success .Render (fmt .Sprintf ("%d today" ,today )) 208 } 209return out 210} 211 212// column renders items with section headings at exactly width × height, 213// scrolled to keep the cursor visible. 214func (m SessionViewer )column (width ,height int ,now time.Time ) []string { 215if len (m .Filtered )== 0 { 216message := " No matching sessions" 217switch { 218case m .ArchiveView && m .SearchInput .Value ()== "" : 219message = " No archived sessions yet" 220case m .Loading : 221message = " loading sessions…" 222 } 223return scrollWindow ([]string {DefaultStyles .Faint .Render (message )},- 1 ,width ,height ) 224 } 225 226selectedAt ,total := - 1 ,0 227for _ ,group := range m .groups { 228if group .section != secAction { 229if total > 0 && height >= 10 { 230total ++ 231 } 232total ++ 233 } 234if m .Cursor >= group .first && m .Cursor < group .first + group .count { 235selectedAt = total + m .Cursor - group .first 236 } 237total += group .count 238 } 239start := 0 240if total > height { 241start = min (max (0 ,selectedAt - height / 2 ),total - height ) 242 } 243end := min (total ,start + height ) 244out := make ([]string ,height ) 245blank := strings .Repeat (" " ,width ) 246for i := range out { 247out [i ]= blank 248 } 249// Overflow markers replace edge rows rather than adding to the window. 250first ,last := start ,end 251if total > height && height >= 3 { 252more := svFit (DefaultStyles .Faint .Render (" ···" ),width ) 253if start > 0 { 254out [0 ]= more 255first ++ 256 } 257if end < total { 258out [height - 1 ]= more 259last -- 260 } 261 } 262 263cols := svLayout (width ) 264position := 0 265for _ ,group := range m .groups { 266if group .section != secAction { 267if position > 0 && height >= 10 { 268position ++ 269 } 270if position >= first && position < last { 271out [position - start ]= svFit (sectionRule (sectionTitles [group .section ],m .sectionCounts [group .section ],width ),width ) 272 } 273position ++ 274 } 275for offset := max (0 ,first - position );offset < min (group .count ,last - position );offset ++ { 276index := group .first + offset 277item := m .Filtered [index ] 278session ,_ := m .session (item .ID ) 279out [position + offset - start ]= svFit (m .row (item ,session ,group .section ,index == m .Cursor ,width ,cols ,now ),width ) 280 } 281position += group .count 282 } 283return out 284} 285 286// scrollWindow shows exactly width × height of lines, centred on selectedAt 287// when they overflow, with ··· where lines were cut off. 288func scrollWindow (all []string ,selectedAt ,width ,height int ) []string { 289return drawWindow (len (all ),func (i int )string {return all [i ] },selectedAt ,width ,height ) 290} 291 292// drawWindow is scrollWindow over n lines that line draws on demand, so a 293// long list draws only the lines that show. 294func drawWindow (n int ,line func (int )string ,selectedAt ,width ,height int ) []string { 295start := 0 296if n > height { 297start = min (max (0 ,selectedAt - height / 2 ),n - height ) 298 } 299cut := n > height && height >= 3 300out := make ([]string ,height ) 301for i := range out { 302switch at := start + i ; { 303case cut && (i == 0 && start > 0 || i == height - 1 && at < n - 1 ): 304out [i ]= svFit (DefaultStyles .Faint .Render (" ···" ),width ) 305case at < n : 306out [i ]= svFit (line (at ),width ) 307default : 308out [i ]= strings .Repeat (" " ,width ) 309 } 310 } 311return out 312} 313 314func (m SessionViewer )row (item PickerItem ,s daemon.Session ,sec int ,selected bool ,width int ,cols svColumns ,now time.Time )string { 315// A row being renamed drops the selection surface and takes the prompt's 316// color, so it reads as a place to type rather than a highlight. 317editing := m .rename .id == item .ID 318selected = selected && ! editing 319st := func (style lipgloss.Style ) lipgloss.Style { 320if selected { 321return style .Inherit (DefaultStyles .Selected ) 322 } 323return style 324 } 325bar := " " 326if selected { 327bar = st (DefaultStyles .Agent ).Render ("▌" ) 328 } 329titleStyle := lipgloss .NewStyle () 330if selected { 331titleStyle = DefaultStyles .Bold 332 } 333fill := func (line string )string { 334if pad := width - ansi .StringWidth (line );pad > 0 { 335line += st (lipgloss .NewStyle ()).Render (strings .Repeat (" " ,pad )) 336 } 337return line 338 } 339 340if sec == secAction { 341glyph ,title ,hint ,fg := "✦ " ,"New session" ,"in " + sessionText (filepath .Base (m .Workspace )),DefaultStyles .You 342switch item .ID { 343case "archive" : 344glyph ,title ,hint ,fg = "▤ " ,"Archive" ,counted (len (m .archivedIDs ),"session" ),DefaultStyles .Muted 345case "login" : 346glyph ,title ,hint ,fg = "◇ " ,"Accounts" ,"providers" ,DefaultStyles .Muted 347 } 348titleWidth := min (ansi .StringWidth (title ),cols .title ) 349return fill (bar + st (fg ).Render (glyph )+ st (lipgloss .NewStyle ()).Render (" " )+ 350st (titleStyle .Inherit (fg )).Render (svCell (title ,titleWidth ,false ))+ 351st (DefaultStyles .Faint ).Render (svCell (" " + hint ,max (0 ,width - 4 - titleWidth ),false ))) 352 } 353 354glyph ,iconStyle := "· " ,DefaultStyles .Faint 355switch { 356case editing : 357bar ,glyph ,iconStyle = DefaultStyles .Prompt .Render ("▌" ),"✎ " ,DefaultStyles .Prompt 358case selected : 359glyph ,iconStyle = "◆ " ,DefaultStyles .Agent 360case sec == secPinned : 361glyph ,iconStyle = "★ " ,DefaultStyles .Agent 362case warm (s ,now ): 363glyph ,iconStyle = "● " ,DefaultStyles .Success 364 } 365gap := st (lipgloss .NewStyle ()).Render (" " ) 366title := st (titleStyle ).Render (svCell (item .Label ,cols .title ,false )) 367if editing { 368title = m .rename .view (cols .title ) 369 } 370line := bar + st (iconStyle ).Render (glyph )+ st (lipgloss .NewStyle ()).Render (" " )+ title 371if cols .model > 0 { 372ms := DefaultStyles .Muted 373if selected { 374ms = lipgloss .NewStyle () 375 } 376line += gap + st (ms ).Render (svCell (cmp .Or (sessionText (s .Model ),"—" ),cols .model ,false )) 377 } 378if cols .workspace > 0 { 379ws := DefaultStyles .Muted 380if s .Workspace == m .Workspace { 381ws = DefaultStyles .Faint 382 } 383line += gap + st (ws ).Render (svCell (sessionText (filepath .Base (s .Workspace )),cols .workspace ,false )) 384 } 385if cols .age > 0 { 386as := DefaultStyles .Faint 387if warm (s ,now ) { 388as = DefaultStyles .Success 389 } 390line += gap + st (as ).Render (svCell (svCompactAge (s .LastAssistantAt ,now ),cols .age ,true )) 391 } 392return fill (line ) 393} 394 395// ── preview ──────────────────────────────────────────────────────────────── 396 397// paneBox frames lines into width × height with a one-column side margin. 398func paneBox (lines []string ,inner ,width ,height int ) []string { 399out := make ([]string ,0 ,height ) 400for _ ,l := range lines [:min (len (lines ),height )] { 401out = append (out ," " + svFit (l ,inner )+ " " ) 402 } 403for len (out )< height { 404out = append (out ,strings .Repeat (" " ,width )) 405 } 406return out 407} 408 409// preview renders the highlighted row's pane at exactly width × height: the 410// session's title and metadata over its latest turns, newest at the bottom. 411func (m SessionViewer )preview (width ,height int ,now time.Time ) []string { 412inner := max (1 ,width - 2 ) 413item ,ok := m .Highlighted () 414s ,isSession := m .session (item .ID ) 415switch { 416case ! ok && m .SearchInput .Value ()== "" : 417return paneBox ([]string {"" ,DefaultStyles .Muted .Render ("Nothing listed here" )},inner ,width ,height ) 418case ! ok : 419return paneBox ([]string {"" ,DefaultStyles .Muted .Render ("No matching sessions" ),DefaultStyles .Faint .Render ("Try fewer search terms" )},inner ,width ,height ) 420case ! isSession : 421return paneBox (m .actionPreview (item .ID ,inner ,height ),inner ,width ,height ) 422 } 423 424var top []string 425for _ ,l := range svWrap (sessionTitle (s ),inner ,2 ) { 426top = append (top ,DefaultStyles .Bold .Render (l )) 427 } 428meta := []string {} 429if s .Pinned { 430meta = append (meta ,DefaultStyles .Agent .Render ("★ pinned" )) 431 } 432if s .Model != "" { 433meta = append (meta ,DefaultStyles .Muted .Render (sessionText (s .Model ))) 434 } 435if s .Workspace != "" { 436meta = append (meta ,DefaultStyles .Muted .Render (sessionText (sessionPlace (s )))) 437 } 438meta = append (meta ,DefaultStyles .Faint .Render (presentation .AssistantAge (s .LastAssistantAt ,now ))) 439if c := m .previews [s .ID ];c != nil && ! c .loading && ! c .err { 440meta = append (meta ,DefaultStyles .Faint .Render (fmt .Sprintf ("%d messages" ,s .TranscriptCount ))) 441 } 442top = append (top ,strings .Join (meta ,DefaultStyles .Decor .Render (" · " )),DefaultStyles .Decor .Render (strings .Repeat ("─" ,inner ))) 443bottom := m .transcript (s ,inner ,max (0 ,height - len (top )- 1 )) 444return paneBox (append (top ,bottom ... ),inner ,width ,height ) 445} 446 447// actionPreview is a centred splash for the New session and Accounts rows. 448func (m SessionViewer )actionPreview (id string ,inner ,height int ) []string { 449var lines []string 450switch id { 451case "new" : 452if logo := svLogo ();height >= len (logo )+ 8 { 453lines = append (append (lines ,"" ),logo ... ) 454 } 455lines = append (lines ,"" ,gradientText ("New session" ,true ),"" , 456DefaultStyles .Muted .Render ("start fresh in " )+ lipgloss .NewStyle ().Render (sessionText (m .workspacePlace ())),"" , 457DefaultStyles .Faint .Render ("enter " )+ DefaultStyles .Muted .Render ("begin" )) 458case "archive" : 459lines = []string {"" ,"" ,DefaultStyles .Muted .Bold (true ).Render ("▤ Archive" ),"" , 460DefaultStyles .Muted .Render ("Sessions kept out of the main list." ),"" , 461DefaultStyles .Faint .Render ("enter " )+ DefaultStyles .Muted .Render ("browse" )} 462default : 463lines = []string {"" ,"" ,DefaultStyles .Muted .Bold (true ).Render ("◇ Accounts" ),"" , 464DefaultStyles .Muted .Render ("add a provider, sign in, or pick" ),DefaultStyles .Muted .Render ("which one new sessions use." ),"" , 465DefaultStyles .Faint .Render ("enter " )+ DefaultStyles .Muted .Render ("manage" )} 466 } 467for i ,l := range lines { 468lines [i ]= strings .Repeat (" " ,max (0 , (inner - ansi .StringWidth (l ))/ 2 ))+ l 469 } 470return lines 471} 472 473// transcript is the tail of the session's conversation in at most height lines. 474func (m SessionViewer )transcript (s daemon.Session ,width ,height int ) []string { 475if height <= 0 { 476return nil 477 } 478c := m .previews [s .ID ] 479note := func (lines ... string ) []string { 480out := []string {"" } 481for _ ,l := range lines { 482out = append (out ,DefaultStyles .Faint .Render (l )) 483 } 484return out 485 } 486switch { 487case m .Fetch == nil : 488return nil 489case c == nil || c .loading : 490return note ("loading conversation…" ) 491case c .err : 492return note ("Could not load this preview." ,"Open the session to read its messages." ) 493case len (c .Items )== 0 : 494return note ("no messages yet" ) 495 } 496 497// Consecutive tool calls collapse into one line of names with counts. 498type block struct { 499counts map [string ]int 500kind ,text string 501tools []string 502 } 503var blocks []* block 504for _ ,it := range c .Items { 505text := sessionText (it .Preview ) 506if it .Type == "assistant" { 507text = svMarkdownMarks .Replace (text ) 508 } 509if it .Type != "tool" { 510blocks = append (blocks ,& block {kind :it .Type ,text :text }) 511continue 512 } 513if len (blocks )== 0 || blocks [len (blocks )- 1 ].kind != "tool" { 514blocks = append (blocks ,& block {kind :"tool" ,counts :map [string ]int {}}) 515 } 516b := blocks [len (blocks )- 1 ] 517for name := range strings .SplitSeq (text ,", " ) { 518if b .counts [name ]== 0 { 519b .tools = append (b .tools ,name ) 520 } 521b .counts [name ]++ 522 } 523 } 524// the same rails as the transcript: one per turn, dimmer through tools 525r := NewTranscriptRenderer () 526lanes := map [string ]lane {"user" :laneYou ,"assistant" :laneAgent ,"tool" :laneBusy } 527var lines []string 528prev := laneNone 529for bi ,b := range blocks { 530own := lanes [b .kind ] 531textStyle ,maxLines := lipgloss .NewStyle (),3 532if b .kind == "tool" { 533textStyle ,maxLines = DefaultStyles .Faint ,2 534for i ,name := range b .tools { 535if n := b .counts [name ];n > 1 { 536b .tools [i ]= fmt .Sprintf ("%s ×%d" ,name ,n ) 537 } 538 } 539b .text = strings .Join (b .tools ,", " ) 540 } 541if bi > 0 && joint (prev ,own )== laneNone { 542lines = append (lines ,"" ) 543 } 544for _ ,l := range svWrap (b .text ,max (1 ,width - railWidth ),maxLines ) { 545lines = append (lines ,r .rail (own )+ textStyle .Render (l )) 546 } 547prev = own 548 } 549lines = append ([]string {"" },lines ... ) 550if len (lines )> height { 551lines = lines [len (lines )- height :] 552lines [0 ]= DefaultStyles .Faint .Render ("··· earlier" ) 553 } 554return lines 555} 556 557// svMarkdownMarks drops emphasis and code markers that only cost width in a 558// one-paragraph excerpt. 559var svMarkdownMarks = strings .NewReplacer ("**" ,"" ,"__" ,"" ,"`" ,"" ) 560 561// svWrap word-wraps plain text into at most maxLines lines of width columns. 562func svWrap (text string ,width ,maxLines int ) []string { 563lines := strings .Split (ansi .Wrap (text ,width ," -" ),"\n" ) 564if len (lines )> maxLines { 565lines = lines [:maxLines ] 566last := strings .TrimRight (lines [maxLines - 1 ]," " ) 567lines [maxLines - 1 ]= ansi .Truncate (last + " …" ,width ,"…" ) 568 } 569return lines 570}