okami.mom/albedo
git clone https://git.nkp.pet/okami.mom/albedo
78011da
main
1package tui 2 3import ( 4"hash/fnv" 5"slices" 6"strings" 7"unicode/utf8" 8 9"albedo/cli/internal/daemon" 10) 11 12// node finds or makes the dot for a session. 13func (m * AgentsViewModel )node (id ,name string )* agentNode { 14if n ,ok := m .nodes [id ];ok { 15if name != "" { 16n .name = name 17 } 18return n 19 } 20if name == "" { 21name = shortID (id ) 22 } 23n := & agentNode {id :id ,name :name ,hue :hueFor (id ),phase :float64 (hashOf (id )% 628 )/ 100 } 24m .nodes [id ]= n 25return n 26} 27 28func shortID (id string )string { 29return id [:min (len (id ),8 )] 30} 31 32func hashOf (s string )uint32 { 33h := fnv .New32a () 34_ ,_ = h .Write ([]byte (s )) 35return h .Sum32 () 36} 37 38func hueFor (id string )rgb { 39hues := agentColors ().hues 40return hues [hashOf (id )% uint32 (len (hues ))] 41} 42 43// apply folds one bus event into the view; true when the tree changed shape. 44func (m * AgentsViewModel )apply (event daemon.AgentEvent )bool { 45kind := event .Type 46id := event .Session 47switch kind { 48case "spawn" : 49parent := event .Parent 50if _ ,ok := m .nodes [parent ];! ok { 51return false 52 } 53n := m .node (id ,event .Name ) 54n .parent ,n .model ,n .depth = parent ,event .Model ,event .Depth 55n .peer ,n .flash = false ,1 56return true 57case "gone" : 58if _ ,ok := m .nodes [id ];! ok { 59return false 60 } 61delete (m .nodes ,id ) 62if m .rename .id == id { 63m .rename = renameField {} 64 } 65if m .confirm .target == id { 66m .confirm .dismiss () 67 } 68if m .selected == id { 69m .selected = m .root 70 } 71return true 72case "mail" : 73if event .MailID != "" { 74if slices .Contains (m .seenMail ,event .MailID ) { 75return false 76 } 77m .seenMail = capped (append (m .seenMail ,event .MailID ),256 ) 78 } 79from ,to := event .From ,event .To 80fromName := event .FromName 81changed := false 82ensure := func (nodeID ,name string ) { 83if _ ,ok := m .nodes [nodeID ];! ok { 84m .node (nodeID ,name ).peer = true 85changed = true 86 } 87 } 88ensure (to ,"" ) 89source := agentsYou 90if from != "" { 91ensure (from ,fromName ) 92source = from 93 } 94if changed { 95m .layout () 96 } 97bytes ,label := event .Bytes ,event .Kind 98addMail := func (nodeID string ,incoming bool ,who string ) { 99if n := m .nodes [nodeID ];n != nil { 100n .= capped (append (n .agentMail {incoming :incoming ,who :who ,kind :label }),12 ) 101 } 102 } 103addMail (to ,true ,m .label (source ,fromName )) 104addMail (source ,false ,m .label (to ,"" )) 105m .send (source ,to ,max (1 ,bytes / 4 )) 106return false 107 } 108n ,ok := m .nodes [id ] 109if ! ok { 110return false 111 } 112switch kind { 113case "activity" : 114if event .Activity == nil { 115return false 116 } 117if event .Cursor != nil && n .cursor != nil && event .Cursor .Generation == n .cursor .Generation && event .Cursor .Sequence <= n .cursor .Sequence { 118return false 119 } 120n .currentRequest ,n .latestProgress = "" ,"" 121if event .Activity .CurrentRequest != nil { 122n .currentRequest = event .Activity .CurrentRequest .Text 123 } 124if event .Activity .LatestProgress != nil { 125n .latestProgress = * event .Activity .LatestProgress 126 } 127n .cursor = event .Cursor 128n .running = event .Running 129if event .Status != nil { 130n .session .Status = * event .Status 131 } 132delta := max (0 ,int (event .Activity .OutputScalars )- n .chars ) 133n .rate += float64 (delta ) 134n .chars = int (event .Activity .OutputScalars ) 135n .tail ,n .preview = agentTail {},agentTail {} 136n .lineKind = tailText 137n .progressByCallID = nil 138n .progressOrder = nil 139n .previewCallID = "" 140for _ ,line := range event .Activity .Lines { 141kind := tailText 142switch line .Kind { 143case "thinking" : 144kind = tailThinking 145case "tool" ,"input" ,"note" ,"error" : 146kind = tailMeta 147 } 148n .tail .push (tailLine {kind :kind ,text :line .Text }) 149 } 150for i := range event .CurrentProgress { 151progress := event .CurrentProgress [i ] 152m .rememberProgress (n ,& progress ) 153if progress .Phase == "generating" { 154n .preview .setCode (progress .Code ) 155n .previewCallID = progress .CallID 156n .lineKind = tailCode 157 } 158 } 159if input := event .Activity .LatestInput ;input != nil && input .InputID != n .latestInput { 160if n .latestInput != "" && input .Source == "chat" { 161m .send (agentsYou ,id ,max (1 ,int (input .Bytes )/ 4 )) 162 } 163n .latestInput = input .InputID 164 } 165if answer := event .Activity .LatestAnswer ;answer != nil && answer .MessageID != n .latestAnswer { 166if n .latestAnswer != "" && n .parent == "" && ! n .peer { 167m .send (id ,agentsYou ,max (1 ,int (answer .Bytes )/ 4 )) 168 } 169n .latestAnswer = answer .MessageID 170 } 171n .revision ++ 172case "running" : 173n .running = event .Running 174if ! n .running { 175m .clearProgress (n ) 176 } 177case "text" ,"thinking" : 178text := event .Text 179n .chars += utf8 .RuneCountInString (text ) 180n .rate += float64 (len (text )) 181tail := tailText 182if kind == "thinking" { 183tail = tailThinking 184 } 185m .stream (n ,tail ,text ) 186case "tool_progress" : 187progress := event .Progress 188if progress == nil { 189m .clearProgress (n ) 190break 191 } 192m .rememberProgress (n ,progress ) 193if progress .Phase == "generating" { 194n .preview .setCode (progress .Code ) 195n .previewCallID = progress .CallID 196n .lineKind = tailCode 197n .revision ++ 198 }else { 199// A different call can still own the displayed code window. Hide it 200// without settling it into the tail; a later snapshot can restore it. 201if n .lineKind == tailCode { 202n .preview = agentTail {} 203n .previewCallID = "" 204n .lineKind = tailText 205n .revision ++ 206 }else { 207m .flushLine (n ) 208 } 209m .pushTail (n ,tailMeta ,"▸ " + progress .Name ) 210 } 211case "tool" : 212if n .lineKind != tailCode || n .previewCallID == event .ProgressCallID { 213m .flushLine (n ) 214 } 215m .removeProgress (n ,event .ProgressCallID ) 216for line := range strings .SplitSeq (event .Output ,"\n" ) { 217if strings .TrimSpace (line )!= "" { 218m .pushTail (n ,tailOutput ,line ) 219 } 220 } 221case "user" : 222text := event .Text 223m .flushLine (n ) 224m .pushTail (n ,tailMeta ,"← " + firstLine (text )) 225// Mail already travelled as a packet; a person typing is new. 226if event .Source == "chat" { 227m .send (agentsYou ,id ,max (1 ,len (text )/ 4 )) 228 } 229case "message" : 230// A root's answer goes back to you. 231if n .parent == "" && ! n .peer { 232m .send (id ,agentsYou ,max (1 ,len (event .Text )/ 4 )) 233 } 234case "error" : 235m .clearProgress (n ) 236n .running = false 237m .pushTail (n ,tailMeta ,"✕ " + event .Text ) 238case "interrupted" : 239m .clearProgress (n ) 240n .running = false 241m .pushTail (n ,tailMeta ,"· interrupted" ) 242case "progress" : 243m .flushLine (n ) 244m .pushTail (n ,tailMeta ,"» " + event .Text ) 245n .flash = 0.6 246case "renamed" : 247n .name = event .Name 248case "closed" : 249m .clearProgress (n ) 250n .closed ,n .running = true ,false 251 } 252return false 253} 254 255func (m * AgentsViewModel )clearProgress (node * agentNode ) { 256m .flushLine (node ) 257node .progressByCallID = nil 258node .progressOrder = nil 259} 260 261func (m * AgentsViewModel )rememberProgress (node * agentNode ,progress * daemon.ToolProgress ) { 262if node .progressByCallID == nil { 263node .progressByCallID = make (map [string ]* daemon.ToolProgress ) 264 } 265if _ ,exists := node .progressByCallID [progress .CallID ];exists { 266node .progressOrder = slices .DeleteFunc (node .progressOrder ,func (callID string )bool {return callID == progress .CallID }) 267 } 268node .progressByCallID [progress .CallID ]= progress 269node .progressOrder = append (node .progressOrder ,progress .CallID ) 270} 271 272func (m * AgentsViewModel )removeProgress (node * agentNode ,progressCallID string ) { 273if progressCallID == "" || node .progressByCallID == nil { 274return 275 } 276delete (node .progressByCallID ,progressCallID ) 277node .progressOrder = slices .DeleteFunc (node .progressOrder ,func (callID string )bool {return callID == progressCallID }) 278if len (node .progressOrder )== 0 { 279node .preview = agentTail {} 280node .previewCallID = "" 281node .lineKind = tailText 282node .revision ++ 283return 284 } 285latest := node .progressByCallID [node .progressOrder [len (node .progressOrder )- 1 ]] 286if latest .Phase == "generating" { 287node .preview .setCode (latest .Code ) 288node .previewCallID = latest .CallID 289node .lineKind = tailCode 290node .revision ++ 291 }else { 292node .preview = agentTail {} 293node .previewCallID = "" 294node .lineKind = tailText 295node .revision ++ 296 } 297} 298 299// capped keeps only the newest keep entries of a growing slice. 300func capped [S ~ []E ,E any ](list S ,keep int )S { 301if len (list )> keep { 302return list [len (list )- keep :] 303 } 304return list 305} 306 307// stream appends streamed text of one kind, closing a line at each newline. 308func (m * AgentsViewModel )stream (n * agentNode ,kind tailKind ,text string ) { 309if n .lineKind != kind { 310m .flushLine (n ) 311n .lineKind = kind 312 } 313for { 314head ,rest ,found := strings .Cut (text ,"\n" ) 315n .preview .write (head ,kind ) 316n .revision ++ 317if ! found { 318return 319 } 320m .flushLine (n ) 321n .lineKind = kind 322text = rest 323 } 324} 325 326// flushLine settles whatever is streaming into the tail: a partial line, or 327// the code of a finished tool call. 328func (m * AgentsViewModel )flushLine (n * agentNode ) { 329if n .lineKind == tailCode { 330lines := slices .Collect (n .preview .newest (true )) 331for _ ,line := range slices .Backward (lines ) { 332m .pushTail (n ,tailCode ,line .text ) 333 } 334 }else { 335for line := range n .preview .newest (false ) { 336if strings .TrimSpace (line .text )!= "" { 337m .pushTail (n ,n .lineKind ,line .text ) 338 } 339 } 340 } 341n .tail .omitted = n .tail .omitted || n .preview .omitted 342n .preview = agentTail {} 343n .previewCallID = "" 344n .lineKind = tailText 345n .revision ++ 346} 347 348func (m * AgentsViewModel )pushTail (n * agentNode ,kind tailKind ,line string ) { 349n .tail .push (tailLine {kind :kind ,text :line }) 350n .revision ++ 351}