okami.mom/albedo

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

dawntui: tree and context inspector on listView, shared confirm for agent delete78011da

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