okami.mom/albedo
git clone https://git.nkp.pet/okami.mom/albedo
8101869
main
1package tui 2 3import ( 4"albedo/cli/internal/config" 5"albedo/cli/internal/daemon" 6"context" 7"errors" 8"maps" 9"slices" 10"strings" 11 12"charm.land/bubbles/v2/textinput" 13 tea"charm.land/bubbletea/v2" 14) 15 16// The /model picker lists every saved profile's models at once, grouped by 17// profile, and lets each row carry a reasoning effort before you switch. 18 19type ModelPickerSelectMsg struct { 20// RaiseCap is set when you changed the model's context cap here: true 21// raises it to the provider's maximum, false restores the default window. 22RaiseCap * bool 23CapKey string 24Model string 25Provider string 26// Effort is the chosen reasoning level. Empty lets the daemon choose. 27Effort string 28} 29 30type ModelPickerCancelMsg struct {} 31 32type modelCatalogLoadedMsg struct { 33Generation int 34Err error 35Profile string 36Models []daemon.Model 37} 38 39// profileCatalog is one profile's models: the ones the session and the 40// profile already use, then whatever the daemon lists. 41type profileCatalog struct { 42settings config.Settings 43name string 44models []daemon.Model 45loading bool 46failed bool 47} 48 49// modelRow is a row you can pick: a profile's model, or the search text sent 50// to a profile as a model id. 51type modelRow struct { 52profile string 53// hits are the byte offsets of the id's characters the search matched. 54hits []int 55model daemon.Model 56typed bool 57} 58 59func (r modelRow )key ()string { 60if r .typed { 61return "typed\x00" + r .profile 62 } 63return r .profile + "\x00" + r .model .ID 64} 65 66type ModelPickerModel struct { 67Generation int 68readCtx context.Context 69cancelReads context.CancelFunc 70Conn * daemon.Connection 71// caps are cap choices made here, by model id: the cap is per model, so 72// the same model under two profiles shares it. 73caps map [string ]bool 74efforts map [string ]string 75// The session's selection when the picker opened. 76Profile ,Model ,Effort string 77Error string 78rows []modelRow 79 80catalogs []profileCatalog 81search textinput.Model 82Width ,Height int 83cursor int 84Saving bool 85// browsing is set once you move through the list, so arrows change the 86// effort even while a search is typed. 87browsing bool 88} 89 90func NewModelPickerModel (conn * daemon.Connection ,profiles config.Profiles ,model ,profile ,effort string )ModelPickerModel { 91if profile == "" { 92profile = profiles .Active 93 } 94names := slices .Sorted (maps .Keys (profiles .Providers )) 95slices .SortStableFunc (names ,func (a ,b string )int { 96if a == b { 97return 0 98 } 99if a == profile { 100return - 1 101 } 102if b == profile { 103return 1 104 } 105return 0 106 }) 107 108catalogs := make ([]profileCatalog ,0 ,len (names )) 109for _ ,name := range names { 110settings := profiles .Providers [name ] 111seeds := []string {settings .Model } 112if name == profile { 113seeds = []string {model ,settings .Model } 114 } 115catalogs = append (catalogs ,profileCatalog { 116name :name , 117settings :settings , 118loading :true , 119models :mergeModels (seeds ,nil ), 120 }) 121 } 122 123search := newFilter ("Search models or type a model ID" ) 124 125readCtx ,cancelReads := context .WithCancel (context .Background ()) 126m := ModelPickerModel { 127Generation :nextPageGeneration (), 128readCtx :readCtx , 129cancelReads :cancelReads , 130Conn :conn , 131Profile :profile , 132Model :model , 133Effort :effort , 134catalogs :catalogs , 135efforts :map [string ]string {}, 136caps :map [string ]bool {}, 137search :search , 138 } 139m .refilter (true ) 140current := modelRow {profile :profile ,model : daemon.Model {ID :model }}.key () 141m .cursor = max (0 ,slices .IndexFunc (m .rows ,func (r modelRow )bool {return r .key ()== current })) 142return m 143} 144 145func (m * ModelPickerModel )SetSize (width ,height int ) { 146m .Width = width 147m .Height = height 148m .search .SetWidth (max (1 ,width - 6 )) 149} 150 151func (m * ModelPickerModel )Close () { 152if m .cancelReads != nil { 153m .cancelReads () 154 } 155m .Generation = nextPageGeneration () 156} 157 158func (m ModelPickerModel )Init () tea.Cmd { 159cmds := []tea.Cmd {textinput .Blink } 160for _ ,c := range m .catalogs { 161cmds = append (cmds ,m .listCmd (c )) 162 } 163return tea .Batch (cmds ... ) 164} 165 166func (m ModelPickerModel )listCmd (c profileCatalog ) tea.Cmd { 167conn ,ctx ,generation := m .Conn ,m .readCtx ,m .Generation 168return func () tea.Msg { 169if conn == nil { 170return modelCatalogLoadedMsg {Generation :generation ,Profile :c .name ,Err :errors .New ("daemon connection unavailable" )} 171 } 172models ,err := daemon .ListProfileModels (ctx ,conn ,c .settings ) 173return modelCatalogLoadedMsg {Generation :generation ,Profile :c .name ,Models :models ,Err :err } 174 } 175} 176 177func utf16Length (s string )int { 178length := 0 179for _ ,char := range s { 180length ++ 181if char > 0xffff { 182length ++ 183 } 184 } 185return length 186} 187 188func validateModelID (m string )error { 189m = strings .TrimSpace (m ) 190if m == "" || utf16Length (m )> 512 || strings .ContainsFunc (m ,func (r rune )bool {return r < 0x20 || r == 0x7f }) { 191return errors .New ("enter a model id of 1–512 characters" ) 192 } 193return nil 194} 195 196// searchKey folds case and separators, so "gpt.5" finds "gpt-5.1". 197const searchSeparators = " -_.:/" 198 199func (m ModelPickerModel )highlighted () (modelRow ,bool ) { 200if m .cursor < 0 || m .cursor >= len (m .rows ) { 201return modelRow {},false 202 } 203return m .rows [m .cursor ],true 204} 205 206func (m ModelPickerModel )catalog (profile string )profileCatalog { 207if i := slices .IndexFunc (m .catalogs ,func (c profileCatalog )bool {return c .name == profile });i >= 0 { 208return m .catalogs [i ] 209 } 210return profileCatalog {} 211} 212 213// effort is the level a row would switch with: your pick, else the session's 214// level when the model has it, else the daemon's default. 215func (m ModelPickerModel )effort (r modelRow )string { 216levels := r .model .Efforts 217switch { 218case len (levels )== 0 : 219return "" 220case slices .Contains (levels ,m .efforts [r .key ()]): 221return m .efforts [r .key ()] 222case slices .Contains (levels ,m .Effort ): 223return m .Effort 224 } 225return defaultEffort (levels ) 226} 227 228// defaultEffort mirrors the daemon: medium, else the first of high, xhigh or 229// max, else the highest level. 230func defaultEffort (levels []string )string { 231if slices .Contains (levels ,"medium" ) { 232return "medium" 233 } 234if i := slices .IndexFunc (levels ,func (l string )bool {return l == "high" || l == "xhigh" || l == "max" });i >= 0 { 235return levels [i ] 236 } 237return levels [len (levels )- 1 ] 238} 239 240// effortSteps are the levels that fill a square. None and off fill nothing. 241func effortSteps (levels []string ) []string { 242return slices .DeleteFunc (slices .Clone (levels ),func (l string )bool {return l == "none" || l == "off" }) 243} 244 245// stepEffort moves the highlighted row's effort one level, stopping at the 246// ends. It reports whether the row has levels to step through. 247func (m * ModelPickerModel )stepEffort (by int )bool { 248r ,ok := m .highlighted () 249if ! ok || len (r .model .Efforts )== 0 { 250return false 251 } 252levels := r .model .Efforts 253i := slices .Index (levels ,m .effort (r ))+ by 254m .efforts [r .key ()]= levels [min (max (i ,0 ),len (levels )- 1 )] 255return true 256} 257 258// raisable reports whether a model's provider offers a window past its default. 259func raisable (model daemon.Model )bool { 260return model .MaxContext > model .Context && model .Context > 0 261} 262 263// raised is the cap a row would switch with: your choice here, else the saved one. 264func (m ModelPickerModel )raised (r modelRow )bool { 265if raised ,ok := m .caps [r .model .CapKey ];ok { 266return raised 267 } 268return r .model .Raised 269} 270 271// window is the context a row would switch with. 272func (m ModelPickerModel )window (r modelRow )int { 273if raisable (r .model )&& m .raised (r ) { 274return r .model .MaxContext 275 } 276return r .model .Context 277} 278 279func (m * ModelPickerModel )move (by int ) { 280if len (m .rows )> 0 { 281m .cursor = min (max (m .cursor + by ,0 ),len (m .rows )- 1 ) 282m .browsing = true 283 } 284} 285 286func (m ModelPickerModel )Update (msg tea.Msg ) (ModelPickerModel , tea.Cmd ) { 287switch msg := msg .(type ) { 288case modelCatalogLoadedMsg : 289if msg .Generation != m .Generation { 290return m ,nil 291 } 292i := slices .IndexFunc (m .catalogs ,func (c profileCatalog )bool {return c .name == msg .Profile }) 293if i < 0 { 294return m ,nil 295 } 296m .catalogs = slices .Clone (m .catalogs ) 297c := & m .catalogs [i ] 298c .loading ,c .failed = false ,msg .Err != nil 299seeds := make ([]string ,len (c .models )) 300for j ,model := range c .models { 301seeds [j ]= model .ID 302 } 303c .models = mergeModels (seeds ,msg .Models ) 304m .refilter (false ) 305return m ,nil 306 307case tea.KeyPressMsg : 308if m .Saving { 309return m ,nil 310 } 311switch msg .String () { 312case "esc" ,"ctrl+c" ,"ctrl+d" : 313return m ,func () tea.Msg {return ModelPickerCancelMsg {} } 314case "enter" : 315r ,ok := m .highlighted () 316if ! ok { 317return m ,nil 318 } 319if err := validateModelID (r .model .ID );err != nil { 320m .Error = err .Error () 321return m ,nil 322 } 323m .Error = "" 324choice := ModelPickerSelectMsg {Model :r .model .ID ,CapKey :r .model .CapKey ,Provider :r .profile ,Effort :m .effort (r )} 325if raised ,changed := m .caps [r .model .CapKey ];changed && raised != r .model .Raised { 326choice .RaiseCap = & raised 327 } 328return m ,func () tea.Msg {return choice } 329case "up" ,"ctrl+p" ,"down" ,"ctrl+n" ,"pgup" ,"pgdown" : 330m .move (map [string ]int {"up" :- 1 ,"ctrl+p" :- 1 ,"down" :1 ,"ctrl+n" :1 ,"pgup" :- max (1 ,m .Height / 2 ),"pgdown" :max (1 ,m .Height / 2 )}[msg .String ()]) 331return m ,nil 332case "tab" : 333if r ,ok := m .highlighted ();ok && raisable (r .model ) { 334m .caps [r .model .CapKey ]= ! m .raised (r ) 335return m ,nil 336 } 337case "left" ,"right" : 338// Arrows edit a typed search until you move into the list. 339by := map [string ]int {"left" :- 1 ,"right" :1 }[msg .String ()] 340if (m .search .Value ()== "" || m .browsing )&& m .stepEffort (by ) { 341return m ,nil 342 } 343 } 344 } 345 346before := m .search .Value () 347var cmd tea.Cmd 348m .search ,cmd = m .search .Update (msg ) 349if m .search .Value ()!= before { 350m .browsing = false 351m .Error = "" 352m .refilter (true ) 353 } 354return m ,cmd 355} 356 357// ── view ─────────────────────────────────────────────────────────────────── 358 359// The row prefix is the selection bar and a space. 360const modelRowPrefix = 2