okami.mom/albedo
git clone https://git.nkp.pet/okami.mom/albedo
78011da
main
1package tui 2 3import ( 4"cmp" 5"context" 6"errors" 7"strings" 8"time" 9 10"albedo/cli/internal/daemon" 11 12"charm.land/bubbles/v2/textinput" 13 tea"charm.land/bubbletea/v2" 14) 15 16// The orchestrator view: every agent in the active session's tree as a dot, 17// drawn in terminal cells. Running agents jam on their token rate, and mail 18// travels the edges as a packet of blocks. The daemon's agents stream feeds 19// it; the selected agent's tail sits beside it, and what you type goes to 20// that agent as you. 21 22type ChatOpenAgentsMsg struct {} 23type AgentsDoneMsg struct {} 24 25// AgentsAttachMsg opens the agent's session in the chat screen. 26type AgentsAttachMsg struct {Session daemon.Session } 27 28type agentsSnapshotMsg struct { 29Err error 30Root string 31Nodes []daemon.AgentNode 32Gen int 33} 34 35type agentsEventsMsg struct { 36Events []daemon.AgentEvent 37Gen int 38} 39 40type agentsStreamClosedMsg struct { 41Gen int 42Err error 43} 44type agentsSnapshotRetryMsg struct {Gen int } 45 46// agentsSeedMsg is an agent's recent history, so its tail starts from where 47// it is rather than empty. 48type agentsSeedMsg struct { 49Snapshot int 50ID string 51Items []daemon.PreviewItem 52Session * daemon.Session 53Gen int 54} 55type agentsSeedErrMsg struct { 56Snapshot int 57Err error 58ID string 59Gen int 60} 61 62type agentsFrameMsg struct {Gen int } 63type agentsSentMsg struct { 64Handle * daemon.OperationHandle 65Draft string 66Recovery bool 67Err error 68Action string 69Notice string 70Gen int 71} 72type agentsOperationPollMsg struct { 73ID string 74Gen int 75} 76type agentPendingOperation struct { 77Handle * daemon.OperationHandle 78Action ,Draft string 79Expired bool 80InFlight bool 81} 82type agentsReconnectMsg struct {Gen int } 83 84type agentsGenMsg interface {gen ()int } 85 86func (m agentsSnapshotRetryMsg )gen ()int {return m .Gen } 87func (m agentsSnapshotMsg )gen ()int {return m .Gen } 88func (m agentsSeedMsg )gen ()int {return m .Gen } 89func (m agentsSeedErrMsg )gen ()int {return m .Gen } 90func (m agentsEventsMsg )gen ()int {return m .Gen } 91func (m agentsStreamClosedMsg )gen ()int {return m .Gen } 92func (m agentsReconnectMsg )gen ()int {return m .Gen } 93func (m agentsFrameMsg )gen ()int {return m .Gen } 94 95type agentMail struct { 96who string 97kind string 98incoming bool 99} 100 101type agentNode struct { 102session daemon.Session 103cursor * daemon.Cursor 104latestInput ,latestAnswer string 105currentRequest ,latestProgress string 106id ,parent ,name ,model string 107address string // how its family mails it; a rename leaves it 108progressByCallID map [string ]* daemon.ToolProgress 109progressOrder []string 110previewCallID string 111agentMail 112tail agentTail 113preview agentTail 114revision uint64 115hue rgb 116rate float64 117flash float64 118phase float64 119depth int 120chars int 121x ,y int 122lineKind tailKind 123running ,closed bool 124peer bool // reached by mail, outside the tree 125seeded bool 126} 127 128// What a tail line is, which decides how it is drawn. 129type tailKind byte 130 131const ( 132tailText tailKind = iota // the agent's reply, as it streams 133tailThinking // reasoning, faint 134tailCode // a tool call's code as the model writes it 135tailOutput // what a tool printed 136tailMeta // ▸ tool, ← letter, » progress, ✕ error 137) 138 139type tailLine struct { 140text string 141kind tailKind 142} 143 144type AgentsViewModel struct { 145seenMail []string 146pendingOperations map [string ]agentPendingOperation 147bufferedActivity map [string ]daemon.AgentEvent 148last time.Time 149err error 150heat map [string ]float64 151events chan tea.Msg 152streamCtx context.Context 153streamReady bool 154snapshotInFlight bool 155snapshotDirty bool 156snapshotRevision int 157cancel context.CancelFunc 158Conn * daemon.Connection 159nodes map [string ]* agentNode 160SessionID string 161root string 162selected string 163 164notice string 165confirm confirm // the delete waiting for enter; esc keeps the agent 166packets []agentPacket 167floats []agentFloat 168order []string 169input textinput.Model 170rename renameField 171tailCache agentTailCache 172Width int 173clock float64 174Height int 175Gen int 176viewGen int // User operation outcomes survive stream replacement. 177noticeT float64 178ticking bool // a frame tick is in flight 179} 180 181const ( 182agentsYou = "you" 183agentsFrame = 70 * time .Millisecond 184agentsSlotW = 16 185agentsLevelGp = 2 186) 187 188var agentsBars = []rune ("▁▂▃▄▅▆▇█" ) 189 190func NewAgentsViewModel (conn * daemon.Connection ,sessionID string )AgentsViewModel { 191input := newField () 192input .CharLimit = 4000 193input .Focus () 194return AgentsViewModel { 195Conn :conn , 196pendingOperations :map [string ]agentPendingOperation {}, 197SessionID :sessionID , 198nodes :map [string ]* agentNode {}, 199heat :map [string ]float64 {}, 200selected :sessionID , 201input :input , 202last :time .Now (), 203 } 204} 205 206func (m * AgentsViewModel )SetSize (w ,h int ) { 207m .Width = w 208m .Height = h 209m .layout () 210m .refreshTail () 211} 212 213func (m * AgentsViewModel )Init () tea.Cmd { 214m .viewGen ++ 215m .Gen ++ 216m .ticking = true 217cmds := []tea.Cmd {m .startStream (m .Gen ),m .frameCmd (m .Gen ),textinput .Blink } 218for id ,pending := range m .pendingOperations { 219if ! pending .Expired && ! pending .InFlight { 220cmds = append (cmds ,agentOperationTick (id ,m .viewGen )) 221 } 222 } 223return tea .Batch (cmds ... ) 224} 225 226func (m AgentsViewModel )frameCmd (gen int ) tea.Cmd { 227return tea .Tick (agentsFrame ,func (time.Time ) tea.Msg {return agentsFrameMsg {Gen :gen } }) 228} 229 230// Update handles msg, then starts the frame tick again if something began to 231// move while it was stopped. 232func (m AgentsViewModel )Update (msg tea.Msg ) (AgentsViewModel , tea.Cmd ) { 233m ,cmd := m .update (msg ) 234m .refreshTail () 235if m .ticking || ! m .moving () { 236return m ,cmd 237 } 238m .ticking ,m .last = true ,time .Now () 239return m ,tea .Batch (cmd ,m .frameCmd (m .Gen )) 240} 241 242func (m AgentsViewModel )update (msg tea.Msg ) (AgentsViewModel , tea.Cmd ) { 243if g ,ok := msg .(agentsGenMsg );ok && g .gen ()!= m .Gen { 244return m ,nil 245 } 246switch msg := msg .(type ) { 247case agentsSnapshotMsg : 248m .snapshotInFlight = false 249if msg .Err != nil { 250m .snapshotDirty = false 251m .err = msg .Err 252m .say ("Could not refresh agents; retrying: " + msg .Err .Error ()) 253return m ,tea .Tick (time .Second ,func (time.Time ) tea.Msg {return agentsSnapshotRetryMsg {Gen :m .Gen } }) 254 } 255if m .snapshotDirty { 256m .snapshotDirty = false 257m .snapshotInFlight = true 258return m ,m .snapshotCmd (m .Gen ) 259 } 260m .err ,m .root = nil ,msg .Root 261m .snapshotRevision ++ 262previous := m .nodes 263m .nodes = make (map [string ]* agentNode ,len (msg .Nodes )) 264for _ ,wire := range msg .Nodes { 265if n := previous [wire .Session .ID ];n != nil { 266m .nodes [wire .Session .ID ]= n 267 } 268n := m .node (wire .Session .ID ,wire .Name ) 269n .name = wire .Name 270n .parent ,n .address = "" ,"" 271n .seeded = false 272n .session = wire .Session 273n .cursor = nil 274m .apply (daemon.AgentEvent {Type :"activity" ,Session :wire .Session .ID ,Cursor :wire .Cursor ,Activity :& wire .Activity ,CurrentProgress :wire .CurrentProgress ,Running :wire .Running }) 275n .model = wire .Session .Model 276n .depth = wire .Depth 277n .running = wire .Running 278n .closed = wire .Closed 279n .peer = false 280if wire .Address != nil { 281n .address = * wire .Address 282 } 283if wire .Parent != nil { 284n .parent = * wire .Parent 285 } 286 } 287if m .nodes [m .selected ]== nil { 288m .selected = m .root 289 } 290if m .nodes [m .confirm .target ]== nil { 291m .confirm .dismiss () 292 } 293if m .nodes [m .rename .id ]== nil { 294m .rename = renameField {} 295 } 296for _ ,event := range m .bufferedActivity { 297m .apply (event ) 298 } 299m .bufferedActivity = nil 300m .layout () 301return m ,m .seedCmd () 302 303case agentsSeedMsg : 304if msg .Snapshot != m .snapshotRevision { 305return m ,nil 306 } 307n := m .nodes [msg .ID ] 308if n == nil { 309return m ,nil 310 } 311var seeded agentTail 312for _ ,item := range msg .Items { 313switch item .Type { 314case "user" ,"tool" : 315prefix := "← " 316if item .Type == "tool" { 317prefix = "▸ " 318 } 319seeded .push (tailLine {kind :tailMeta ,text :prefix + firstLine (item .Preview )}) 320default : 321for line := range strings .SplitSeq (item .Preview ,"\n" ) { 322seeded .push (tailLine {kind :tailText ,text :line }) 323 } 324 } 325 } 326if msg .Session == nil || msg .Session .Cursor == nil || n .cursor == nil || msg .Session .Cursor .Generation == n .cursor .Generation && msg .Session .Cursor .Sequence >= n .cursor .Sequence { 327n .tail = seeded 328if msg .Session != nil { 329n .session = * msg .Session 330n .cursor = msg .Session .Cursor 331 } 332 } 333n .revision ++ 334return m ,nil 335 336case agentsSeedErrMsg : 337if msg .Snapshot != m .snapshotRevision { 338return m ,nil 339 } 340// The seed died; unseed its node so the next selection tries again. 341if n := m .nodes [msg .ID ];n != nil { 342n .seeded = false 343m .say ("Could not load recent messages for " + m .label (msg .ID ,"this agent" )+ ". Select it again to retry: " + msg .Err .Error ()) 344 } 345return m ,nil 346 347case agentsEventsMsg : 348if len (msg .Events )== 1 && msg .Events [0 ].Type == "overflow" { 349return m ,m .restartStream () 350 } 351var snapshot tea.Cmd 352if ! m .streamReady { 353m .streamReady = true 354m .snapshotInFlight = true 355snapshot = m .snapshotCmd (m .Gen ) 356 } 357relayout := false 358for _ ,event := range msg .Events { 359if m .snapshotInFlight { 360switch event .Type { 361case "invalidate" ,"spawn" ,"gone" ,"running" ,"renamed" ,"closed" : 362m .snapshotDirty = true 363 } 364 } 365if event .Type == "activity" && m .snapshotInFlight { 366if m .bufferedActivity == nil { 367m .bufferedActivity = map [string ]daemon.AgentEvent {} 368 } 369m .bufferedActivity [event .Session ]= event 370 } 371if event .Type == "invalidate" && ! m .snapshotInFlight { 372m .snapshotInFlight = true 373snapshot = m .snapshotCmd (m .Gen ) 374 } 375relayout = m .apply (event )|| relayout 376 } 377if relayout { 378m .layout () 379 } 380return m ,tea .Batch (snapshot ,waitAgents (m .events ,m .Gen )) 381 382case agentsStreamClosedMsg : 383if msg .Err != nil { 384m .err = msg .Err 385m .say ("Agent stream failed; ctrl+l retries: " + msg .Err .Error ()) 386if failure ,ok := errors.AsType [* daemon.StreamError ](msg .Err );ok && failure .Kind != daemon .StreamTransient { 387m .stopStream () 388m .ticking = false 389return m ,nil 390 } 391 } 392return m ,tea .Tick (time .Second ,func (time.Time ) tea.Msg {return agentsReconnectMsg {Gen :m .Gen } }) 393 394case agentsReconnectMsg : 395return m ,m .restartStream () 396 397case agentsSnapshotRetryMsg : 398if ! m .streamReady || m .snapshotInFlight { 399return m ,nil 400 } 401m .snapshotInFlight = true 402return m ,m .snapshotCmd (m .Gen ) 403 404case agentsFrameMsg : 405m .step () 406if m .ticking = m .moving ();! m .ticking { 407return m ,nil 408 } 409return m ,m .frameCmd (m .Gen ) 410 411case agentsOperationPollMsg : 412pending ,ok := m .pendingOperations [msg .ID ] 413if ! ok || pending .Expired || msg .Gen != m .viewGen { 414return m ,nil 415 } 416return m ,m .resolveAgentOperation (pending ) 417case agentsSentMsg : 418if msg .Gen != m .viewGen { 419if msg .Handle == nil { 420return m ,nil 421 } 422if _ ,retained := m .pendingOperations [msg .Handle .ID ()];! retained { 423return m ,nil 424 } 425 } 426if msg .Err != nil { 427_ ,uncertain := errors.AsType [* daemon.UncertainOutcomeError ](msg .Err ) 428if msg .Handle != nil && (uncertain || msg .Recovery ) { 429if m .pendingOperations == nil { 430m .pendingOperations = map [string ]agentPendingOperation {} 431 } 432pending := agentPendingOperation {Handle :msg .Handle ,Action :msg .Action ,Draft :msg .Draft ,Expired :daemon .IsOperationExpired (msg .Err )} 433if api ,ok := errors.AsType [* daemon.APIError ](msg .Err );ok && api .StatusCode >= 400 && api .StatusCode < 500 && api .StatusCode != 404 && ! pending .Expired && ! uncertain { 434delete (m .pendingOperations ,msg .Handle .ID ()) 435if m .input .Value ()== "" { 436m .input .SetValue (msg .Draft ) 437 } 438m .say (msg .Err .Error ()) 439return m ,nil 440 } 441m .pendingOperations [msg .Handle .ID ()]= pending 442m .say (operationError (msg .Err ,"Could not " + msg .Action + ": " ,"The outcome for " + msg .Handle .ID ()+ " remains unresolved." )) 443if pending .Expired { 444return m ,nil 445 } 446return m ,agentOperationTick (msg .Handle .ID (),m .viewGen ) 447 } 448if msg .Draft != "" && m .input .Value ()== "" { 449m .input .SetValue (msg .Draft ) 450 } 451if msg .Handle != nil { 452delete (m .pendingOperations ,msg .Handle .ID ()) 453 } 454m .say (operationError (msg .Err ,"Could not " + msg .Action + ": " ,"The request may have been accepted; refresh before trying again." )) 455 }else { 456if msg .Handle != nil { 457delete (m .pendingOperations ,msg .Handle .ID ()) 458 } 459if msg .Notice != "" { 460m .say (msg .Notice ) 461 } 462 } 463return m ,nil 464 465case sessionRenamedMsg : 466switch { 467case msg .Err != nil : 468m .say (operationError (msg .Err ,"Could not rename the session. Try again: " ,"Session may have been renamed; refresh before trying again." )) 469case msg .Name == "" : 470m .say ("name cleared" ) 471default : 472m .say ("renamed to " + msg .Name ) 473 } 474return m ,nil 475 476case tea.KeyPressMsg : 477return m .key (msg ) 478 } 479var cmd tea.Cmd 480m .input ,cmd = m .input .Update (msg ) 481return m ,cmd 482} 483 484func (m * AgentsViewModel )label (id ,fallback string )string { 485if id == agentsYou { 486return agentsYou 487 } 488if n := m .nodes [id ];n != nil { 489return n .name 490 } 491return cmp .Or (fallback ,shortID (id )) 492} 493 494// ─── layout ─── 495 496// ─── drawing ───