okami.mom/albedo
git clone https://git.nkp.pet/okami.mom/albedo
bb7b485
main
1package tui 2 3import ( 4"albedo/cli/internal/daemon" 5"cmp" 6"math/rand/v2" 7"slices" 8) 9 10// clearAction drops the live action row; the next action starts a new one. 11func (m * ChatModel )clearAction () { 12m .settledToolLabel = "" 13m .progressByCallID = nil 14m .progressOrder = nil 15m .ThoughtProgressText = "" 16} 17 18func (m ChatModel )latestProgress ()* daemon.ToolProgress { 19if len (m .progressOrder )== 0 { 20return nil 21 } 22return m .progressByCallID [m .progressOrder [len (m .progressOrder )- 1 ]] 23} 24 25// Prose and thoughts occupy the action slot without discarding live calls. 26func (m ChatModel )toolLabel ()string { 27if m .transcript .activeKind != StreamKindNone || m .ThoughtProgressText != "" { 28return "" 29 } 30if progress := m .latestProgress ();progress != nil { 31return m .liveActionLabel (progress ) 32 } 33return m .settledToolLabel 34} 35 36func (m ChatModel )liveActionLabel (progress * daemon.ToolProgress )string { 37if progress .Name == "python" && progress .Phase == "running" { 38if command := m .runningJobCommand ();command != "" { 39return "running " + command 40 } 41 } 42return actionLabel (progress ,nil ) 43} 44 45// runningJobCommand is the first live job's command, folded onto one row. 46// It names any job, even inside its grace, because the action row shows the 47// work a running cell is doing, not background work. 48func (m ChatModel )runningJobCommand ()string { 49for _ ,job := range m .Status .RunningJobs { 50if command := oneLine (job .Command );command != "" { 51return command 52 } 53 } 54return "" 55} 56 57// agedJobCommand is the idle status line's example: the first job past its 58// grace, matching the count it sits beside. 59func (m ChatModel )agedJobCommand ()string { 60for _ ,job := range m .agedJobs () { 61if command := oneLine (job .Command );command != "" { 62return command 63 } 64 } 65return "" 66} 67 68func (m * ChatModel )rememberProgress (progress * daemon.ToolProgress ) { 69if m .progressByCallID == nil { 70m .progressByCallID = make (map [string ]* daemon.ToolProgress ) 71 } 72if _ ,exists := m .progressByCallID [progress .CallID ];exists { 73m .progressOrder = slices .DeleteFunc (m .progressOrder ,func (callID string )bool {return callID == progress .CallID }) 74 } 75m .progressByCallID [progress .CallID ]= progress 76m .progressOrder = append (m .progressOrder ,progress .CallID ) 77m .settledToolLabel = "" 78} 79 80func (m * ChatModel )forgetProgress (callID string )* daemon.ToolProgress { 81progress := m .progressByCallID [callID ] 82delete (m .progressByCallID ,callID ) 83m .progressOrder = slices .DeleteFunc (m .progressOrder ,func (candidate string )bool {return candidate == callID }) 84return progress 85} 86 87func (m * ChatModel )handleStreamEvent (evt daemon.StreamEvent ) { 88defer m .reseedMood () 89if evt .EntryID != "" { 90if index := m .History .Find (evt .EntryID );index >= 0 { 91entry := m .History .Entries ()[index ] 92switch evt .Type { 93case daemon .EventUser : 94entry .Speaker ,entry .Source = inputSpeaker (evt ),evt .Source 95entry .MailKind ,entry .SenderSessionID = evt .MailKind ,evt .SenderSessionID 96entry .Text = evt .Text 97case daemon .EventMessage ,daemon .EventThinking : 98entry .Text = evt .Text 99entry .ElapsedMs = evt .ElapsedMs 100case daemon .EventNote : 101entry .Text = evt .Text 102case daemon .EventTool : 103entry .ToolArgs ,entry .ToolResult ,entry .ToolTrace = evt .ToolArgs ,evt .ToolResult ,evt .ToolTrace 104case daemon .EventCompacted : 105entry .Text = evt .Summary 106 } 107if evt .Timestamp != nil { 108entry .Timestamp = * evt .Timestamp 109 } 110entry .Seq = evt .Position 111entry .facts = nil 112if Compact (entry ,m .Flags ) { 113facts := factsOf (entry ) 114entry .facts = & facts 115 } 116m .History .Replace (entry ) 117m .burstEpoch ++ 118m .rebuildSettledLines () 119return 120 } 121 } 122retireAll := evt .Type == daemon .EventRetry || evt .Type == daemon .EventError || evt .Type == daemon .EventInterrupted || evt .Type == daemon .EventTurnCompleted && evt .Source != "" 123if len (evt .ReplacesLiveIDs )> 0 || retireAll || evt .ReplacesAllLive { 124if m .History .retireLive (evt .ReplacesLiveIDs ,retireAll || evt .ReplacesAllLive ) { 125m .burstEpoch ++ 126m .rebuildSettledLines () 127 } 128if retireAll { 129m .transcript .resetStream () 130 } 131 } 132if evt .Replayed { 133// Replayed events do not describe current activity; preserve live status. 134defer func (live daemon.AgentStatus ) {m .Status = live }(m .Status ) 135 } 136watchedThought := ! m .transcript .thinkingSince .IsZero () 137entries := m .transcript .apply (evt ,m .AgentName ) 138for _ ,entry := range entries { 139m .appendSettledEntry (entry ) 140 } 141 142switch evt .Type { 143case daemon .EventReset : 144if evt .Snapshot != nil { 145m .Status = evt .Snapshot .Status 146m .Glances = evt .Snapshot .Glances 147if m .sidebarWidth ()!= m .layoutSidebar { 148m .SetSize (m .Width ,m .Height ) 149 }else { 150m .syncViewportHeight () 151 } 152 } 153m .History .Clear () 154m .burstEpoch ++ 155m .settledLines = nil 156m .settledLinesBytes ,m .droppedSettledLines = 0 ,0 157m .clearAction () 158m .Usage = nil 159m .ClearNotices () 160m .TurnFailed ,m .Stopping ,m .Stopped = false ,false ,false 161m .Follow ,m .scrollOffset = true ,0 162m .olderBefore ,m .olderMore ,m .loadingOlder = evt .Before ,evt .More ,false 163case "status" : 164if evt .Status != nil { 165m .Status .Phase = evt .Status .Phase 166m .Status .Running = evt .Status .Running 167m .Status .Idle = evt .Status .Idle 168m .Status .RunID = evt .Status .RunID 169m .reconcileStopped () 170 } 171case "input" ,daemon .EventTurnMembership : 172if evt .Receipt != nil { 173m .applyInputReceipt (* evt .Receipt ) 174 } 175case daemon .EventCommitted : 176m .History .Stamp (evt .Seq ) 177case daemon .EventRetry : 178m .clearAction () 179case daemon .EventUser : 180m .Stopped ,m .TurnFailed = false ,false 181m .clearAction () 182if ! evt .Replayed { 183m .ClearNotices () 184 } 185if evt .OperationID != "" { 186if i := slices .IndexFunc (m .pendingUsers ,func (p PendingUserTurn )bool {return p .OperationID == evt .OperationID });i >= 0 { 187m .pendingUsers = slices .Delete (m .pendingUsers ,i ,i + 1 ) 188 } 189 } 190case daemon .EventText ,daemon .EventThinking : 191if evt .Text != "" { 192m .settledToolLabel ,m .ThoughtProgressText = "" ,"" 193m .Status .Running ,m .Status .Idle ,m .Status .Phase = true ,false ,& phaseReasoning 194// A thought flushed at the buffer cap keeps its last compact line 195// until the next delta, just like an explicitly settled thought. 196if evt .Type == daemon .EventThinking && m .transcript .activeText == "" && ! m .transcript .thinkingSince .IsZero () { 197for _ ,entry := range entries { 198if entry .Kind == EntryThinking { 199m .ThoughtProgressText = cmp .Or (thinkingLine (entry .Text ),"thinking" ) 200 } 201 } 202 } 203 } 204case daemon .EventToolProgress : 205if evt .Replayed { 206break // A progress snapshot is not an action happening now. 207 } 208if evt .Progress == nil { 209m .clearAction () 210break 211 } 212m .ThoughtProgressText = "" 213m .Status .Running ,m .Status .Idle ,m .Status .Phase = true ,false ,& phaseTool 214m .rememberProgress (evt .Progress ) 215case daemon .EventTool : 216m .ThoughtProgressText = "" 217m .Status .Running ,m .Status .Idle = true ,false 218if ! evt .Replayed { 219progress := m .forgetProgress (evt .ProgressCallID ) 220for i := len (entries )- 1 ;i >= 0 ;i -- { 221if entries [i ].Kind == EntryTool { 222m .settledToolLabel = actionLabel (progress ,& entries [i ]) 223break 224 } 225 } 226 } 227case daemon .EventMessage ,daemon .EventTurnCompleted : 228m .clearAction () 229if evt .Type == daemon .EventTurnCompleted { 230m .Stopping = false 231if evt .Source == "interrupted" { 232m .Stopped = true 233 } 234if evt .Source == "failed" { 235m .TurnFailed = true 236 } 237 } 238case daemon .EventError : 239m .TurnFailed = true 240m .clearAction () 241m .Status .Running ,m .Status .Idle ,m .Status .Phase = false ,true ,& phaseResting 242case daemon .EventUsage : 243m .Usage = evt .Usage 244if watchedThought { 245for _ ,entry := range entries { 246if entry .Kind == EntryThinking { 247m .ThoughtProgressText = cmp .Or (thinkingLine (entry .Text ),"thinking" ) 248 } 249 } 250 } 251case daemon .EventInterrupted : 252m .Stopping ,m .Stopped ,m .TurnFailed = false ,true ,false 253m .clearAction () 254m .Status .Running ,m .Status .Idle ,m .Status .Phase = false ,true ,& phaseResting 255 } 256} 257 258// stretch is one run of a phase: a thinking or replying spell, or one tool 259// call, which keeps a single animation from start to end. 260type stretch struct { 261mood mood 262call string 263} 264 265// reseedMood draws a new animation when a new stretch starts, at random, so 266// the same one may well come up twice. 267func (m * ChatModel )reseedMood () { 268now := stretch {mood :m .phaseMood ()} 269if progress := m .latestProgress ();progress != nil { 270now .call = progress .CallID 271 } 272if now != m .stretch { 273m .stretch ,m .moodSeed = now ,rand .Int64 () 274 } 275}