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