okami.mom/albedo
git clone https://git.nkp.pet/okami.mom/albedo
5924d14
main
1// Package tui implements the interactive CLI and its terminal views. 2package tui 3 4import ( 5"albedo/cli/internal/config" 6"albedo/cli/internal/daemon" 7"errors" 8"maps" 9"slices" 10 11 tea"charm.land/bubbletea/v2" 12"github.com/charmbracelet/x/ansi" 13) 14 15type AppState int 16 17const ( 18AppStateChat AppState = iota 19AppStateSessionPicker 20AppStateModelPicker 21AppStateExtensionPicker 22AppStateTreePicker 23AppStateContextInspector 24AppStatePageView 25AppStateLogin 26AppStateCapabilityPage 27AppStateWebhooksPage 28AppStateAgents 29AppStateFolderPicker 30AppStateQuota 31) 32 33type pendingSessionIntents struct { 34users []PendingUserTurn 35continuations map [string ]pendingOperation 36} 37 38type AppModel struct { 39pendingBySession map [string ]pendingSessionIntents 40pendingCreations map [string ]pendingOperation 41ActiveSession * daemon.Session 42openBrowser func (url string ) 43Conn * daemon.Connection 44Profiles config.Profiles 45SettingsETags map [string ]string 46UI daemon.UIPreferences 47Workspace string 48Sessions []daemon.Session 49Notices Notices 50CommandCatalog []daemon.SessionCommand 51ExtensionPicker ExtensionPickerModel 52CapabilityPage CapabilityPageModel 53WebhooksPage WebhooksPageModel 54TreePicker TreePickerModel 55SessionPicker SessionViewer 56PageView PageViewModel 57ContextInspector ContextInspectorModel 58Login LoginModel 59ModelPicker ModelPickerModel 60FolderPicker FolderPicker 61Agents AgentsViewModel 62Quota QuotaModel 63 64Chat ChatModel 65SettingsGen int 66State AppState 67Width int 68Height int 69 70// SessionGen invalidates earlier session-list and session-creation replies. 71SessionGen int 72CatalogGen int 73ModelGen int 74CommandGen int 75ProfileGen int 76// folderReturn is the screen the folder picker goes back to. 77folderReturn AppState 78UISaving bool 79StandaloneLogin bool 80// listingSessions says a session listing is in flight. 81listingSessions bool 82 83// Graphemes says the terminal measures grapheme clusters; see ChatModel. 84Graphemes bool 85} 86 87func (m * AppModel )newChatModel (session * daemon.Session )ChatModel { 88chat := NewChatModel (session ,daemon .NewChatClient (m .Conn ,session .ID )) 89if pending ,ok := m .pendingBySession [session .ID ];ok { 90chat .pendingUsers = slices .Clone (pending .users ) 91chat .pendingContinuations = maps .Clone (pending .continuations ) 92delete (m .pendingBySession ,session .ID ) 93 } 94chat .Flags .Thinking = m .UI .Thinking 95chat .Flags .Tools = m .UI .Tools 96chat .Notices = slices .Clone (m .Notices ) 97chat .graphemes = m .Graphemes 98m .Notices = nil 99return chat 100} 101 102// NewAppModel requires an established daemon connection and panics if conn is nil. 103func NewAppModel (conn * daemon.Connection ,bootstrap Bootstrap ,initialSession * daemon.Session ,workspace string ,needsLogin bool ,openBrowser func (string ))* AppModel { 104if conn == nil { 105panic ("tui.NewAppModel requires a daemon connection" ) 106 } 107m := & AppModel { 108Conn :conn , 109Profiles :bootstrap .Settings .Profiles , 110ActiveSession :initialSession , 111Workspace :workspace , 112openBrowser :openBrowser , 113 } 114 115// Seed the shared owner before constructing a screen that reads preferences. 116m .SessionPicker = NewSessionViewer (workspace ) 117m .SessionPicker .Fetch = func (id string ) tea.Cmd { 118current ,_ := m .session (id ) 119return sessionPreviewCmd (conn ,id ,current .ETag ) 120 } 121m .SettingsETags = maps .Clone (bootstrap .Settings .ETags ) 122m .applySessionList (bootstrap .Sessions ) 123m .ApplyUI (bootstrap .Settings .UI ) 124if needsLogin { 125m .State = AppStateLogin 126m .Login = NewLoginModel (conn ,"" ,openBrowser ) 127 }else if initialSession != nil { 128m .State = AppStateChat 129m .Chat = m .newChatModel (m .ActiveSession ) 130 }else { 131m .State = AppStateSessionPicker 132 } 133 134return m 135} 136 137// NewLoginAppModel starts the standalone login flow, which exits when finished. 138func NewLoginAppModel (conn * daemon.Connection ,bootstrap Bootstrap ,workspace ,nameHint string ,openBrowser func (string ))* AppModel { 139m := NewAppModel (conn ,bootstrap ,nil ,workspace ,false ,openBrowser ) 140m .State = AppStateLogin 141m .StandaloneLogin = true 142m .Login = NewLoginModel (conn ,nameHint ,openBrowser ) 143return m 144} 145 146func (m * AppModel )Init () tea.Cmd { 147return m .initScreen () 148} 149 150func (m * AppModel )AddNotice (message string ) { 151if m .ActiveSession != nil && m .Chat .History != nil { 152m .Chat .AddNotice (message ) 153return 154 } 155m .Notices .AddNotice (message ) 156} 157 158func (m * AppModel )AddError (message string ) { 159if m .ActiveSession != nil && m .Chat .History != nil { 160m .Chat .AddError (message ) 161return 162 } 163m .Notices .AddError (message ) 164} 165 166func (m * AppModel )ClearNotices () { 167m .Notices .Clear () 168if m .ActiveSession != nil && m .Chat .History != nil { 169m .Chat .ClearNotices () 170 } 171} 172 173func (m * AppModel )Update (msg tea.Msg ) (tea.Model , tea.Cmd ) { 174if cmd ,handled := m .handleCommandInvocation (msg );handled { 175return m ,cmd 176 } 177if handled ,cmd := m .handleLoginOutcome (msg );handled { 178return m ,cmd 179 } 180if cmd ,handled := m .handleChatLifecycle (msg );handled { 181return m ,cmd 182 } 183 184if cmd ,handled := m .handlePreferences (msg );handled { 185return m ,cmd 186 } 187if cmd ,handled := m .handleSessionResults (msg );handled { 188return m ,cmd 189 } 190if cmd ,handled := m .handleCommandResults (msg );handled { 191return m ,cmd 192 } 193 194switch msg := msg .(type ) { 195 196case tea.WindowSizeMsg : 197m .Width = msg .Width 198m .Height = msg .Height 199if m .ActiveSession != nil && m .Chat .History != nil { 200m .Chat .SetSize (msg .Width ,msg .Height ) 201 } 202m .SessionPicker .SetSize (msg .Width ,msg .Height ) 203m .ModelPicker .SetSize (msg .Width ,msg .Height ) 204m .ExtensionPicker .SetSize (msg .Width ,msg .Height ) 205m .TreePicker .SetSize (msg .Width ,msg .Height ) 206m .ContextInspector .SetSize (msg .Width ,msg .Height ) 207m .PageView .SetSize (msg .Width ,msg .Height ) 208m .CapabilityPage .SetSize (msg .Width ,msg .Height ) 209m .WebhooksPage .SetSize (msg .Width ,msg .Height ) 210m .Agents .SetSize (msg .Width ,msg .Height ) 211m .FolderPicker .SetSize (msg .Width ,msg .Height ) 212m .Login .SetSize (msg .Width ,msg .Height ) 213m .Quota .SetSize (msg .Width ,msg .Height ) 214return m ,nil 215 216case tea.ModeReportMsg : 217// the reply Bubble Tea switches to grapheme widths on 218if msg .Mode == ansi .ModeUnicodeCore { 219switch msg .Value { 220case ansi .ModeReset ,ansi .ModeSet ,ansi .ModePermanentlySet : 221m .Graphemes = true 222m .Chat .graphemes = true 223 } 224 } 225return m ,nil 226 227case SessionDeleteMsg : 228if m .State != AppStateSessionPicker || ! m .SessionPicker .ArchiveView || m .ActiveSession != nil && m .ActiveSession .ID == msg .ID { 229return m ,nil 230 } 231return m ,m .deleteSessionCmd (msg .ID ,msg .Condition ) 232 233case commandCatalogLoadedMsg : 234if msg .Gen != m .CatalogGen { 235return m ,nil 236 } 237if msg .Err != nil { 238if _ ,ok := errors.AsType [* daemon.UpgradeRequiredError ](msg .Err );ok { 239m .AddError ("Could not load commands: " + msg .Err .Error ()) 240 } 241return m ,nil 242 } 243m .CommandCatalog = msg .Commands 244m .Chat .CommandMenu .Catalog = msg .Commands 245return m ,nil 246 247case ChatQuitMsg : 248m .Chat .Close () 249return m ,tea .Quit 250 251case ChatBackToSessionsMsg : 252return m ,m .openSessions () 253 254case ChatNewSessionMsg : 255return m ,m .newSessionCmd () 256 257case ChatOpenModelPickerMsg : 258return m ,m .openModelPicker () 259 260case ModelPickerSelectMsg : 261m .ModelPicker .Close () 262m .ModelPicker .Saving = true 263m .ModelPicker .Error = "" 264m .ModelGen ++ 265return m ,m .changeModelCmd (msg .Model ,msg .Provider ,msg .Effort ,msg .RaiseCap ,m .ModelGen ,msg .CapKey ) 266 267case ChatOpenExtensionPickerMsg : 268return m ,m .openExtensionPicker () 269 270case ChatOpenWebhooksPageMsg : 271if m .ActiveSession != nil { 272m .WebhooksPage = NewWebhooksPageModel (m .Conn ,m .ActiveSession .ID ) 273return m ,m .openScreen (AppStateWebhooksPage ,& m .WebhooksPage ) 274 } 275 276case ChatOpenAgentsMsg : 277if m .ActiveSession != nil { 278m .Agents .Close () 279pending := m .Agents .pendingOperations 280draft := m .Agents .input .Value () 281m .Agents = NewAgentsViewModel (m .Conn ,m .ActiveSession .ID ) 282m .Agents .pendingOperations = pending 283m .Agents .input .SetValue (draft ) 284return m ,m .openScreen (AppStateAgents ,& m .Agents ) 285 } 286case agentsSentMsg : 287if m .State != AppStateAgents && msg .Handle != nil { 288var cmd tea.Cmd 289m .Agents ,cmd = m .Agents .Update (msg ) 290return m ,cmd 291 } 292 293case ChatOpenFolderPickerMsg : 294return m ,m .openFolderPicker (msg .Retry ) 295 296case FolderMovedMsg : 297return m ,m .moved (msg ) 298 299case FolderPickerCancelMsg : 300m .FolderPicker .Close () 301m .State = m .folderReturn 302return m ,nil 303 304case SessionFoldersMsg : 305return m ,m .openFolderBrowser (msg .Query ) 306 307case FolderOpenSessionMsg : 308m .FolderPicker .Close () 309return m ,m .openSession (msg .Session ) 310 311case FolderNewSessionMsg : 312m .FolderPicker .Close () 313m .SessionGen ++ 314return m ,m .createSessionCmd (m .SessionGen ,msg .Workspace ) 315 316case AgentsDoneMsg : 317m .Agents .Close () 318m .State = AppStateChat 319return m ,nil 320 321case AgentsAttachMsg : 322m .Agents .Close () 323session := msg .Session 324if i := slices .IndexFunc (m .Sessions ,func (s daemon.Session )bool {return s .ID == session .ID });i >= 0 { 325session = m .Sessions [i ] 326 } 327return m ,m .openSession (session ) 328 329case ModelPickerCancelMsg : 330m .ModelPicker .Close () 331m .State = AppStateChat 332return m ,nil 333 334case WebhooksPageDoneMsg ,ExtensionPickerDoneMsg , 335TreeCancelMsg ,ContextDoneMsg ,CapabilityPageDoneMsg ,PageCancelMsg , 336QuotaCancelMsg : 337m .State = AppStateChat 338return m ,nil 339 340case ExtensionPickerChangedMsg : 341m .CatalogGen ++ 342return m ,m .loadCommandCatalogCmd (m .CatalogGen ) 343 344case CapabilityPageChangedMsg : 345m .CatalogGen ++ 346return m ,m .loadCommandCatalogCmd (m .CatalogGen ) 347 348case ChatOpenTreePickerMsg : 349return m ,m .openTreePicker () 350 351case ChatOpenQuotaMsg : 352return m ,m .openQuota () 353 354case TreeForkSuccessMsg : 355if m .State != AppStateTreePicker || msg .Generation != m .TreePicker .Generation { 356return m ,nil 357 } 358return m ,m .setChatSession (msg .Session ,true ) 359 360case ChatOpenContextInspectorMsg : 361return m ,m .openContextInspector () 362 363case ChatOpenCapabilityPageMsg : 364if m .ActiveSession != nil { 365m .CapabilityPage = NewCapabilityPageModel (m .Conn ,m .ActiveSession .ID ,msg .Kind ) 366return m ,m .openScreen (AppStateCapabilityPage ,& m .CapabilityPage ) 367 } 368 369case ChatOpenPageMsg : 370if m .ActiveSession != nil { 371m .PageView = NewPageViewModel (m .Conn ,m .ActiveSession .ID ,msg .Command ) 372return m ,m .openScreen (AppStatePageView ,& m .PageView ) 373 } 374 375case PageViewChangedMsg : 376m .Chat .statusRevision ++ 377return m ,m .Chat .statusCmd () 378 379case ChatOpenLoginMsg : 380return m ,m .openLogin (msg .Name ) 381 382case LoginCancelMsg : 383cleanup := m .Login .Close () 384m .ProfileGen ++ 385// /login may have removed providers even when it ends without a choice. 386 387if m .StandaloneLogin { 388return m ,tea .Batch (cleanup ,tea .Quit ) 389 } 390if m .ActiveSession != nil { 391return m ,tea .Batch (cleanup ,m .returnToChat (),m .loadProfilesCmd ("" ,m .ProfileGen )) 392 } 393m .State = AppStateSessionPicker 394m .updateSessionPickerItems () 395m .SessionGen ++ 396return m ,tea .Batch (cleanup ,m .SessionPicker .Init (),m .loadSessionsCmd (m .SessionGen )) 397 398case LoginDoneMsg : 399if m .State != AppStateLogin || msg .Gen != m .Login .Generation { 400return m ,nil 401 } 402cleanup := m .Login .Close () 403m .ProfileGen ++ 404return m ,tea .Batch (cleanup ,m .loadProfilesCmd (msg .Name ,m .ProfileGen )) 405 406case PickerSelectMsg : 407if m .State == AppStateSessionPicker { 408switch msg .ID { 409case "new" : 410return m ,m .newSessionCmd () 411case "archive" : 412m .SessionPicker .OpenArchive () 413return m ,nil 414case "login" : 415return m ,m .openLogin ("" ) 416default : 417// The active session may not yet appear in the daemon's recent list. 418listed := m .Sessions 419if m .ActiveSession != nil && ! slices .ContainsFunc (m .Sessions ,func (s daemon.Session )bool {return s .ID == m .ActiveSession .ID }) { 420listed = append ([]daemon.Session {* m .ActiveSession },listed ... ) 421 } 422if i := slices .IndexFunc (listed ,func (s daemon.Session )bool {return s .ID == msg .ID });i >= 0 { 423return m ,m .openSession (listed [i ]) 424 } 425 } 426 } 427 428case PickerCancelMsg : 429if m .State == AppStateSessionPicker { 430if m .SessionPicker .ArchiveView { 431m .SessionPicker .CloseArchive () 432return m ,nil 433 } 434if m .ActiveSession != nil { 435m .State = AppStateChat 436return m ,nil 437 } 438return m ,tea .Quit 439 } 440 } 441 442// Route to active state model 443var cmd tea.Cmd 444switch m .State { 445case AppStateChat : 446beforePending := len (m .Chat .pendingUsers ) 447before := m .Chat .Flags 448m .Chat ,cmd = m .Chat .Update (msg ) 449if len (m .Chat .pendingUsers )> beforePending { 450m .ClearNotices () 451 } 452if m .Chat .Flags .Thinking != before .Thinking || m .Chat .Flags .Tools != before .Tools { 453draft := m .Chat .Flags 454m .Chat .Flags = before 455if ! m .UISaving { 456m .UISaving = true 457m .SettingsGen ++ 458patch := daemon.UIPreferencesPatch {} 459if draft .Thinking != before .Thinking { 460patch .Thinking = & draft .Thinking 461 } 462if draft .Tools != before .Tools { 463patch .Tools = & draft .Tools 464 } 465cmd = tea .Batch (cmd ,m .patchUICmd ("" ,patch ,m .SettingsGen )) 466 } 467 } 468case AppStateSessionPicker : 469m .SessionPicker ,cmd = m .SessionPicker .Update (msg ) 470case AppStateModelPicker : 471m .ModelPicker ,cmd = m .ModelPicker .Update (msg ) 472case AppStateExtensionPicker : 473m .ExtensionPicker ,cmd = m .ExtensionPicker .Update (msg ) 474case AppStateTreePicker : 475m .TreePicker ,cmd = m .TreePicker .Update (msg ) 476case AppStateContextInspector : 477m .ContextInspector ,cmd = m .ContextInspector .Update (msg ) 478case AppStatePageView : 479m .PageView ,cmd = m .PageView .Update (msg ) 480case AppStateCapabilityPage : 481m .CapabilityPage ,cmd = m .CapabilityPage .Update (msg ) 482case AppStateWebhooksPage : 483m .WebhooksPage ,cmd = m .WebhooksPage .Update (msg ) 484case AppStateAgents : 485m .Agents ,cmd = m .Agents .Update (msg ) 486case AppStateFolderPicker : 487m .FolderPicker ,cmd = m .FolderPicker .Update (msg ) 488case AppStateLogin : 489m .Login ,cmd = m .Login .Update (msg ) 490case AppStateQuota : 491m .Quota ,cmd = m .Quota .Update (msg ) 492 } 493 494return m ,cmd 495}