okami.mom/albedo

git clone https://git.nkp.pet/okami.mom/albedo

dawntui: clear stopping state when session becomes idlebb7b485

main
8.2 KiB275 linesraw
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() {
12	m.settledToolLabel = ""
13	m.progressByCallID = nil
14	m.progressOrder = nil
15	m.ThoughtProgressText = ""
16}
17
18func (m ChatModel) latestProgress() *daemon.ToolProgress {
19	if len(m.progressOrder) == 0 {
20		return nil
21	}
22	return 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 {
27	if m.transcript.activeKind != StreamKindNone || m.ThoughtProgressText != "" {
28		return ""
29	}
30	if progress := m.latestProgress(); progress != nil {
31		return m.liveActionLabel(progress)
32	}
33	return m.settledToolLabel
34}
35
36func (m ChatModel) liveActionLabel(progress *daemon.ToolProgress) string {
37	if progress.Name == "python" && progress.Phase == "running" {
38		if command := m.runningJobCommand(); command != "" {
39			return "running " + command
40		}
41	}
42	return 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 {
49	for _, job := range m.Status.RunningJobs {
50		if command := oneLine(job.Command); command != "" {
51			return command
52		}
53	}
54	return ""
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 {
60	for _, job := range m.agedJobs() {
61		if command := oneLine(job.Command); command != "" {
62			return command
63		}
64	}
65	return ""
66}
67
68func (m *ChatModel) rememberProgress(progress *daemon.ToolProgress) {
69	if m.progressByCallID == nil {
70		m.progressByCallID = make(map[string]*daemon.ToolProgress)
71	}
72	if _, exists := m.progressByCallID[progress.CallID]; exists {
73		m.progressOrder = slices.DeleteFunc(m.progressOrder, func(callID string) bool { return callID == progress.CallID })
74	}
75	m.progressByCallID[progress.CallID] = progress
76	m.progressOrder = append(m.progressOrder, progress.CallID)
77	m.settledToolLabel = ""
78}
79
80func (m *ChatModel) forgetProgress(callID string) *daemon.ToolProgress {
81	progress := m.progressByCallID[callID]
82	delete(m.progressByCallID, callID)
83	m.progressOrder = slices.DeleteFunc(m.progressOrder, func(candidate string) bool { return candidate == callID })
84	return progress
85}
86
87func (m *ChatModel) handleStreamEvent(evt daemon.StreamEvent) {
88	defer m.reseedMood()
89	if evt.EntryID != "" {
90		if index := m.History.Find(evt.EntryID); index >= 0 {
91			entry := m.History.Entries()[index]
92			switch evt.Type {
93			case daemon.EventUser:
94				entry.Speaker, entry.Source = inputSpeaker(evt), evt.Source
95				entry.MailKind, entry.SenderSessionID = evt.MailKind, evt.SenderSessionID
96				entry.Text = evt.Text
97			case daemon.EventMessage, daemon.EventThinking:
98				entry.Text = evt.Text
99				entry.ElapsedMs = evt.ElapsedMs
100			case daemon.EventNote:
101				entry.Text = evt.Text
102			case daemon.EventTool:
103				entry.ToolArgs, entry.ToolResult, entry.ToolTrace = evt.ToolArgs, evt.ToolResult, evt.ToolTrace
104			case daemon.EventCompacted:
105				entry.Text = evt.Summary
106			}
107			if evt.Timestamp != nil {
108				entry.Timestamp = *evt.Timestamp
109			}
110			entry.Seq = evt.Position
111			entry.facts = nil
112			if Compact(entry, m.Flags) {
113				facts := factsOf(entry)
114				entry.facts = &facts
115			}
116			m.History.Replace(entry)
117			m.burstEpoch++
118			m.rebuildSettledLines()
119			return
120		}
121	}
122	retireAll := evt.Type == daemon.EventRetry || evt.Type == daemon.EventError || evt.Type == daemon.EventInterrupted || evt.Type == daemon.EventTurnCompleted && evt.Source != ""
123	if len(evt.ReplacesLiveIDs) > 0 || retireAll || evt.ReplacesAllLive {
124		if m.History.retireLive(evt.ReplacesLiveIDs, retireAll || evt.ReplacesAllLive) {
125			m.burstEpoch++
126			m.rebuildSettledLines()
127		}
128		if retireAll {
129			m.transcript.resetStream()
130		}
131	}
132	if evt.Replayed {
133		// Replayed events do not describe current activity; preserve live status.
134		defer func(live daemon.AgentStatus) { m.Status = live }(m.Status)
135	}
136	watchedThought := !m.transcript.thinkingSince.IsZero()
137	entries := m.transcript.apply(evt, m.AgentName)
138	for _, entry := range entries {
139		m.appendSettledEntry(entry)
140	}
141
142	switch evt.Type {
143	case daemon.EventReset:
144		if evt.Snapshot != nil {
145			m.Status = evt.Snapshot.Status
146			m.Glances = evt.Snapshot.Glances
147			if m.sidebarWidth() != m.layoutSidebar {
148				m.SetSize(m.Width, m.Height)
149			} else {
150				m.syncViewportHeight()
151			}
152		}
153		m.History.Clear()
154		m.burstEpoch++
155		m.settledLines = nil
156		m.settledLinesBytes, m.droppedSettledLines = 0, 0
157		m.clearAction()
158		m.Usage = nil
159		m.ClearNotices()
160		m.TurnFailed, m.Stopping, m.Stopped = false, false, false
161		m.Follow, m.scrollOffset = true, 0
162		m.olderBefore, m.olderMore, m.loadingOlder = evt.Before, evt.More, false
163	case "status":
164		if evt.Status != nil {
165			m.Status.Phase = evt.Status.Phase
166			m.Status.Running = evt.Status.Running
167			m.Status.Idle = evt.Status.Idle
168			m.Status.RunID = evt.Status.RunID
169			m.reconcileStopped()
170		}
171	case "input", daemon.EventTurnMembership:
172		if evt.Receipt != nil {
173			m.applyInputReceipt(*evt.Receipt)
174		}
175	case daemon.EventCommitted:
176		m.History.Stamp(evt.Seq)
177	case daemon.EventRetry:
178		m.clearAction()
179	case daemon.EventUser:
180		m.Stopped, m.TurnFailed = false, false
181		m.clearAction()
182		if !evt.Replayed {
183			m.ClearNotices()
184		}
185		if evt.OperationID != "" {
186			if i := slices.IndexFunc(m.pendingUsers, func(p PendingUserTurn) bool { return p.OperationID == evt.OperationID }); i >= 0 {
187				m.pendingUsers = slices.Delete(m.pendingUsers, i, i+1)
188			}
189		}
190	case daemon.EventText, daemon.EventThinking:
191		if evt.Text != "" {
192			m.settledToolLabel, m.ThoughtProgressText = "", ""
193			m.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.
196			if evt.Type == daemon.EventThinking && m.transcript.activeText == "" && !m.transcript.thinkingSince.IsZero() {
197				for _, entry := range entries {
198					if entry.Kind == EntryThinking {
199						m.ThoughtProgressText = cmp.Or(thinkingLine(entry.Text), "thinking")
200					}
201				}
202			}
203		}
204	case daemon.EventToolProgress:
205		if evt.Replayed {
206			break // A progress snapshot is not an action happening now.
207		}
208		if evt.Progress == nil {
209			m.clearAction()
210			break
211		}
212		m.ThoughtProgressText = ""
213		m.Status.Running, m.Status.Idle, m.Status.Phase = true, false, &phaseTool
214		m.rememberProgress(evt.Progress)
215	case daemon.EventTool:
216		m.ThoughtProgressText = ""
217		m.Status.Running, m.Status.Idle = true, false
218		if !evt.Replayed {
219			progress := m.forgetProgress(evt.ProgressCallID)
220			for i := len(entries) - 1; i >= 0; i-- {
221				if entries[i].Kind == EntryTool {
222					m.settledToolLabel = actionLabel(progress, &entries[i])
223					break
224				}
225			}
226		}
227	case daemon.EventMessage, daemon.EventTurnCompleted:
228		m.clearAction()
229		if evt.Type == daemon.EventTurnCompleted {
230			m.Stopping = false
231			if evt.Source == "interrupted" {
232				m.Stopped = true
233			}
234			if evt.Source == "failed" {
235				m.TurnFailed = true
236			}
237		}
238	case daemon.EventError:
239		m.TurnFailed = true
240		m.clearAction()
241		m.Status.Running, m.Status.Idle, m.Status.Phase = false, true, &phaseResting
242	case daemon.EventUsage:
243		m.Usage = evt.Usage
244		if watchedThought {
245			for _, entry := range entries {
246				if entry.Kind == EntryThinking {
247					m.ThoughtProgressText = cmp.Or(thinkingLine(entry.Text), "thinking")
248				}
249			}
250		}
251	case daemon.EventInterrupted:
252		m.Stopping, m.Stopped, m.TurnFailed = false, true, false
253		m.clearAction()
254		m.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 {
261	mood mood
262	call 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() {
268	now := stretch{mood: m.phaseMood()}
269	if progress := m.latestProgress(); progress != nil {
270		now.call = progress.CallID
271	}
272	if now != m.stretch {
273		m.stretch, m.moodSeed = now, rand.Int64()
274	}
275}