okami.mom/albedo
git clone https://git.nkp.pet/okami.mom/albedo
f758a72
main
1package tui 2 3import ( 4"cmp" 5"fmt" 6"slices" 7"strings" 8 9"charm.land/lipgloss/v2" 10"github.com/charmbracelet/x/ansi" 11) 12 13// modelColumns are the widths every listed row shares, so the effort ladders 14// line up. 15type modelColumns struct {name ,slots ,label ,tag int } 16 17func (c modelColumns )cluster ()int { 18switch { 19case c .slots == 0 : 20return 0 21case c .label == 0 : 22return c .slots + 4 23 } 24return c .slots + 5 + c .label 25} 26 27func (m ModelPickerModel )layout (width int )modelColumns { 28var c modelColumns 29for _ ,r := range m .rows { 30if r .typed { 31continue 32 } 33c .name = max (c .name ,ansi .StringWidth (r .model .ID )) 34c .slots = max (c .slots ,len (effortSteps (r .model .Efforts ))) 35for _ ,level := range r .model .Efforts { 36c .label = max (c .label ,ansi .StringWidth (level )) 37 } 38c .tag = max (c .tag ,ansi .StringWidth (m .tag (r ))) 39 } 40room := width - modelRowPrefix - 1 41if c .tag > 0 { 42room -= c .tag + 2 43 } 44// Narrow rows shorten names first, then drop the label, then the ladder. 45longest := c .name 46over := func ()bool {return c .name + min (c .cluster (),1 )* 2 + c .cluster ()> room } 47fit := func () {c .name = min (longest ,max (min (longest ,16 ),room - min (c .cluster (),1 )* 2 - c .cluster ())) } 48if over () { 49fit () 50 } 51if over () { 52c .label = 0 53fit () 54 } 55if over () { 56c .slots = 0 57 } 58c .name = max (1 ,min (c .name ,room )) 59return c 60} 61 62// tag names a listed row's standing: the session's model, or the model the 63// profile opens new sessions with. 64func (m ModelPickerModel )tag (r modelRow )string { 65switch { 66case r .typed : 67return "" 68case r .profile == m .Profile && r .model .ID == m .Model : 69return "current" 70case r .model .ID == m .catalog (r .profile ).settings .Model : 71return "default" 72 } 73return "" 74} 75 76func (m ModelPickerModel )View ()string { 77width := cmp .Or (m .Width ,80 ) 78f := listFrame { 79title :brand ("albedo" )+ " " + DefaultStyles .Muted .Render ("/model" ), 80right :m .selection (), 81filter :promptLead ()+ m .search .View (), 82list :m .list , 83pane :m .details , 84footer :m .footer (width ), 85 } 86if r ,ok := m .highlighted ();ok { 87f .summary = m .summary (r ) 88 } 89return f .view (m .Width ,m .Height ) 90} 91 92// selection is the session's profile, model and effort for the title. 93func (m ModelPickerModel )selection ()string { 94model := DefaultStyles .Muted .Render (m .Model ) 95if m .Effort != "" { 96model += DefaultStyles .Faint .Render (":" + m .Effort ) 97 } 98return DefaultStyles .Faint .Render (m .Profile )+ DefaultStyles .Decor .Render (" · " )+ model 99} 100 101// list renders the rows grouped by profile at exactly width × height. 102func (m ModelPickerModel )list (width ,height int ) []string { 103cols := m .layout (width ) 104// Rules and notes are drawn here; a model row only once it shows. 105type line struct { 106text string 107row int 108 } 109var all []line 110selectedAt := - 1 111note := func (text string ) {all = append (all ,line {text :text ,row :- 1 }) } 112section := func (label string ,count int ) { 113if len (all )> 0 && height >= 10 { 114note ("" ) 115 } 116note (sectionRule (label ,count ,width )) 117 } 118add := func (i int ) { 119if i == m .cursor { 120selectedAt = len (all ) 121 } 122all = append (all ,line {row :i }) 123 } 124 125i := 0 126for i < len (m .rows )&& ! m .rows [i ].typed { 127start ,profile := i ,m .rows [i ].profile 128for i < len (m .rows )&& ! m .rows [i ].typed && m .rows [i ].profile == profile { 129i ++ 130 } 131section (profile ,i - start ) 132for j := start ;j < i ;j ++ { 133add (j ) 134 } 135switch c := m .catalog (profile ); { 136case c .loading : 137note (DefaultStyles .Faint .Render (" listing models…" )) 138case c .failed : 139note (DefaultStyles .Faint .Render (" Could not list models. Type a model ID to continue." )) 140 } 141 } 142if i < len (m .rows ) { 143section ("as typed" ,len (m .rows )- i ) 144for ;i < len (m .rows );i ++ { 145add (i ) 146 } 147 } 148if len (m .rows )== 0 { 149note (DefaultStyles .Faint .Render (" no models match" )) 150 } 151return drawWindow (len (all ),func (i int )string { 152l := all [i ] 153if l .row < 0 { 154return l .text 155 } 156return m .row (m .rows [l .row ],l .row == m .cursor ,width ,cols ) 157 },selectedAt ,width ,height ) 158} 159 160func (m ModelPickerModel )row (r modelRow ,selected bool ,width int ,cols modelColumns )string { 161bar ,nameStyle := " " ,lipgloss .NewStyle () 162if selected { 163bar ,nameStyle = selectBar (),DefaultStyles .Bold 164 } 165line := bar + " " 166if r .typed { 167line += nameStyle .Render (ansi .Truncate (r .model .ID ,max (1 ,width / 2 ),"…" ))+ DefaultStyles .Faint .Render (" on " + r .profile ) 168 }else { 169line += markedCell (r .model .ID ,r .hits ,cols .name ,nameStyle ) 170if cols .cluster ()> 0 { 171line += " " + m .ladder (r ,selected ,cols ) 172 } 173if tag := m .tag (r );tag != "" { 174style := DefaultStyles .Faint 175if tag == "current" { 176style = DefaultStyles .Success 177 } 178line += strings .Repeat (" " ,max (2 ,width - ansi .StringWidth (line )- ansi .StringWidth (tag )- 1 ))+ style .Render (tag ) 179 } 180 } 181if selected { 182return selectedLine (line ,width ) 183 } 184return line 185} 186 187// markedCell fits s to width like svCell and draws the characters at hits in 188// the prompt color, so a search shows what it matched. 189func markedCell (s string ,hits []int ,width int ,base lipgloss.Style )string { 190if width <= 0 { 191return "" 192 } 193plain := ansi .Truncate (s ,width ,"…" ) 194kept := len (plain ) 195if plain != s { 196kept -= len ("…" ) 197 } 198hit := DefaultStyles .Prompt .Inherit (base ) 199var b strings.Builder 200start ,marked := 0 ,false 201flush := func (end int ) { 202if end > start { 203style := base 204if marked { 205style = hit 206 } 207b .WriteString (style .Render (plain [start :end ])) 208 } 209 } 210for i := range plain { 211if m := i < kept && slices .Contains (hits ,i );m != marked { 212flush (i ) 213start ,marked = i ,m 214 } 215 } 216flush (len (plain )) 217return b .String ()+ strings .Repeat (" " ,max (0 ,width - ansi .StringWidth (plain ))) 218} 219 220// ladder draws a row's effort as squares filling toward the highest level, 221// with arrows on the selected row where there is a level to step to. 222func (m ModelPickerModel )ladder (r modelRow ,selected bool ,cols modelColumns )string { 223levels := r .model .Efforts 224if len (levels )== 0 { 225return strings .Repeat (" " ,cols .cluster ()) 226 } 227effort := m .effort (r ) 228steps := effortSteps (levels ) 229filled := slices .Index (steps ,effort )+ 1 230var squares strings.Builder 231for i := range cols .slots { 232switch { 233case i >= len (steps ): 234squares .WriteByte (' ' ) 235case i >= filled : 236squares .WriteString (DefaultStyles .Decor .Render ("▱" )) 237case selected : 238squares .WriteString (brandInk (float64 (i )/ float64 (max (1 ,len (steps )- 1 ))).Render ("▰" )) 239default : 240squares .WriteString (DefaultStyles .Muted .Render ("▰" )) 241 } 242 } 243arrow := func (glyph string ,open bool )string { 244if ! selected { 245return " " 246 } 247if open { 248return DefaultStyles .Muted .Render (glyph ) 249 } 250return DefaultStyles .Decor .Render (glyph ) 251 } 252at := slices .Index (levels ,effort ) 253out := arrow ("‹" ,at > 0 )+ " " + squares .String ()+ " " + arrow ("›" ,at < len (levels )- 1 ) 254if cols .label > 0 { 255label := DefaultStyles .Faint 256if selected { 257label = lipgloss .NewStyle () 258 } 259out += " " + label .Render (svCell (effort ,cols .label ,false )) 260 } 261return out 262} 263 264// facts are what the catalog knows about a row's model, as label and value. 265func (m ModelPickerModel )facts (r modelRow ) [][2 ]string { 266var facts [][2 ]string 267if r .model .Context > 0 { 268facts = append (facts , [2 ]string {"context" ,compactTokens (m .window (r ))+ " tokens" }) 269 } 270if raisable (r .model ) { 271state := DefaultStyles .Faint .Render ("default" )+ DefaultStyles .Decor .Render (" · " )+ 272DefaultStyles .Faint .Render ("tab raises to " + compactTokens (r .model .MaxContext )) 273if m .raised (r ) { 274state = brandInk (1 ).Bold (true ).Render ("raised" )+ DefaultStyles .Decor .Render (" · " )+ 275DefaultStyles .Faint .Render ("tab restores " + compactTokens (r .model .Context )) 276 } 277facts = append (facts , [2 ]string {"cap" ,state }) 278 } 279if r .model .Output > 0 { 280facts = append (facts , [2 ]string {"output" ,compactTokens (r .model .Output )+ " tokens" }) 281 } 282if len (r .model .Input )> 0 { 283facts = append (facts , [2 ]string {"input" ,strings .Join (r .model .Input ,", " )}) 284 } 285return facts 286} 287 288// compactTokens writes a token count as 8k, 400k or 1.05m. 289func compactTokens (n int )string { 290switch { 291case n >= 1_000_000 : 292return strings .TrimSuffix (strings .TrimRight (fmt .Sprintf ("%.2f" ,float64 (n )/ 1e6 ),"0" ),"." )+ "m" 293case n >= 1000 : 294return fmt .Sprintf ("%dk" ,n / 1000 ) 295 } 296return fmt .Sprint (n ) 297} 298 299// summary is the highlighted row's facts on one line, for narrow screens. 300func (m ModelPickerModel )summary (r modelRow )string { 301if r .typed { 302return DefaultStyles .Faint .Render ("sent to " + r .profile + " as typed" ) 303 } 304var parts []string 305if r .model .Context > 0 { 306parts = append (parts ,compactTokens (m .window (r ))+ " context" ) 307 } 308if raisable (r .model )&& m .raised (r ) { 309parts = append (parts ,"cap raised" ) 310 } 311if r .model .Output > 0 { 312parts = append (parts ,compactTokens (r .model .Output )+ " output" ) 313 } 314if len (r .model .Input )> 0 { 315parts = append (parts ,strings .Join (r .model .Input ,", " )) 316 } 317if effort := m .effort (r );effort != "" { 318parts = append (parts ,effort + " effort" ) 319 } 320return DefaultStyles .Faint .Render (strings .Join (parts ," · " )) 321} 322 323// details renders the highlighted row's pane at exactly width × height. 324func (m ModelPickerModel )details (width ,height int ) []string { 325inner := max (1 ,width - 2 ) 326r ,ok := m .highlighted () 327if ! ok { 328return paneBox ([]string {"" ,DefaultStyles .Muted .Render ("nothing matches" ),DefaultStyles .Faint .Render ("Type a model ID to use it as entered" )},inner ,width ,height ) 329 } 330var lines []string 331for _ ,l := range svWrap (r .model .ID ,inner ,2 ) { 332lines = append (lines ,DefaultStyles .Bold .Render (l )) 333 } 334meta := []string {DefaultStyles .Muted .Render (r .profile )} 335if protocol := m .catalog (r .profile ).settings .Protocol ;protocol != "" { 336meta = append (meta ,DefaultStyles .Faint .Render (protocol )) 337 } 338if tag := m .tag (r );tag != "" { 339style := DefaultStyles .Faint 340if tag == "current" { 341style = DefaultStyles .Success 342 } 343meta = append (meta ,style .Render (tag )) 344 } 345lines = append (lines ,strings .Join (meta ,DefaultStyles .Decor .Render (" · " )),DefaultStyles .Decor .Render (strings .Repeat ("─" ,inner ))) 346 347note := func (text string ) { 348for _ ,l := range svWrap (text ,inner ,3 ) { 349lines = append (lines ,DefaultStyles .Faint .Render (l )) 350 } 351 } 352if r .typed { 353note (r .profile + " does not list this id, so it is sent as typed and the daemon picks the effort." ) 354 }else { 355facts := m .facts (r ) 356if levels := r .model .Efforts ;len (levels )> 0 { 357chosen := m .effort (r ) 358marks := make ([]string ,len (levels )) 359for i ,level := range levels { 360marks [i ]= DefaultStyles .Faint .Render (level ) 361if level == chosen { 362marks [i ]= brandInk (1 ).Bold (true ).Render (level ) 363 } 364 } 365facts = append (facts , [2 ]string {"effort" ,strings .Join (marks ,DefaultStyles .Decor .Render (" · " ))}) 366 } 367for _ ,f := range facts { 368lines = append (lines ,DefaultStyles .Muted .Render (svCell (f [0 ],9 ,false ))+ f [1 ]) 369 } 370if len (facts )== 0 { 371note ("the catalog knows nothing else about this model." ) 372 } 373 } 374lines = append (lines ,"" ) 375note ("enter switches this session and makes it the default for new sessions." ) 376return paneBox (lines ,inner ,width ,height ) 377} 378 379func (m ModelPickerModel )footer (width int )string { 380hints := []hint {{"↑↓" ,"move" }, {"←→" ,"effort" }} 381if r ,ok := m .highlighted ();ok && raisable (r .model ) { 382hints = append (hints ,hint {"tab" ,"cap" }) 383 } 384left := " " + keyHints (append (hints ,hint {"enter" ,"switch" },hint {"esc" ,"back" })... ) 385loading ,models := 0 ,0 386for _ ,c := range m .catalogs { 387if c .loading { 388loading ++ 389 } 390models += len (c .models ) 391 } 392var right string 393switch { 394case m .Saving : 395right = DefaultStyles .Busy .Render ("switching…" ) 396case m .Error != "" : 397right = DefaultStyles .Error .Render (m .Error ) 398case loading > 0 : 399right = DefaultStyles .Faint .Render (fmt .Sprintf ("%d of %d profiles listed…" ,len (m .catalogs )- loading ,len (m .catalogs ))) 400default : 401right = DefaultStyles .Faint .Render (counted (models ,"model" ))+ DefaultStyles .Decor .Render (" · " )+ 402DefaultStyles .Faint .Render (counted (len (m .catalogs ),"profile" )) 403 } 404gap := width - ansi .StringWidth (left )- ansi .StringWidth (right )- 1 405if gap >= 3 { 406return left + strings .Repeat (" " ,gap )+ right 407 } 408if m .Error != "" { 409return " " + right 410 } 411return left 412} 413 414func counted (n int ,noun string )string { 415if n == 1 { 416return "1 " + noun 417 } 418return fmt .Sprintf ("%d %ss" ,n ,noun ) 419}