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
6.2 KiB211 linesraw
1package tui
2
3import (
4	"cmp"
5	"context"
6	"errors"
7	"fmt"
8	"slices"
9	"strings"
10	"time"
11
12	"albedo/cli/internal/daemon"
13
14	tea "charm.land/bubbletea/v2"
15)
16
17// below counts the agents under id.
18func (m AgentsViewModel) below(id string) int {
19	count := 0
20	for _, n := range m.nodes {
21		for p := n.parent; p != ""; {
22			if p == id {
23				count++
24				break
25			}
26			if m.nodes[p] == nil {
27				break
28			}
29			p = m.nodes[p].parent
30		}
31	}
32	return count
33}
34
35func (m AgentsViewModel) deleteCmd(id string) tea.Cmd {
36	conn, gen, name := m.Conn, m.viewGen, m.label(id, "")
37	condition := daemon.SessionCondition{}
38	if n := m.nodes[id]; n != nil {
39		condition = daemon.SessionCondition{ETag: n.session.ETag, FamilyRevision: n.session.FamilyRevision}
40	}
41	return func() tea.Msg {
42		res, err := daemon.DeleteSession(context.Background(), conn, id, true, condition)
43		if err != nil {
44			return agentsSentMsg{Gen: gen, Action: "delete " + name, Err: err}
45		}
46		return agentsSentMsg{Gen: gen, Notice: name + ": " + res.Message}
47	}
48}
49
50func (m *AgentsViewModel) sendCmd(id, text string) tea.Cmd {
51	conn, gen, target := m.Conn, m.viewGen, m.label(id, "agent")
52	handle, err := daemon.NewSubmission(id, daemon.SubmissionRequest{Content: &text})
53	if err == nil {
54		m.pendingOperations[handle.ID()] = agentPendingOperation{Handle: handle, Action: "send a message to " + target, Draft: text, InFlight: true}
55	}
56	return func() tea.Msg {
57		result := agentsSentMsg{Gen: gen, Action: "send a message to " + target, Handle: handle, Draft: text, Err: err}
58		if err == nil {
59			_, result.Err = daemon.SubmitOperation(context.Background(), conn, handle)
60		}
61		return result
62	}
63}
64
65func (m *AgentsViewModel) spawnCmd(parent, name, task string) tea.Cmd {
66	conn, gen := m.Conn, m.viewGen
67	draft := "/spawn " + name + " " + task
68	if name == "" || task == "" {
69		return func() tea.Msg {
70			return agentsSentMsg{Gen: gen, Action: "start an agent", Draft: draft, Err: errors.New("use /spawn <name> <task>")}
71		}
72	}
73	handle, err := daemon.NewCreation(daemon.CreateSessionRequest{Kind: "child", ParentID: parent, Address: name, Name: name, Task: task})
74	if err == nil {
75		m.pendingOperations[handle.ID()] = agentPendingOperation{Handle: handle, Action: "start " + name, Draft: draft, InFlight: true}
76	}
77	return func() tea.Msg {
78		result := agentsSentMsg{Gen: gen, Action: "start " + name, Notice: "spawned " + name, Handle: handle, Draft: draft, Err: err}
79		if err == nil {
80			_, result.Err = daemon.CreateSessionOperation(context.Background(), conn, handle)
81		}
82		return result
83	}
84}
85
86func agentOperationTick(id string, gen int) tea.Cmd {
87	return tea.Tick(300*time.Millisecond, func(time.Time) tea.Msg { return agentsOperationPollMsg{ID: id, Gen: gen} })
88}
89
90func (m AgentsViewModel) resolveAgentOperation(pending agentPendingOperation) tea.Cmd {
91	conn, gen := m.Conn, m.viewGen
92	return func() tea.Msg {
93		result := agentsSentMsg{Gen: gen, Action: pending.Action, Handle: pending.Handle, Draft: pending.Draft, Recovery: true, Notice: "Confirmed: " + pending.Action}
94		if pending.Handle.IsCreation() {
95			_, result.Err = daemon.ResolveCreation(context.Background(), conn, pending.Handle)
96		} else {
97			receipt, err := daemon.ResolveOperation(context.Background(), conn, pending.Handle)
98			result.Err = err
99			if err == nil {
100				result.Err = receipt.Rejection()
101				if receipt.Delivery != nil && *receipt.Delivery == "cancelled" {
102					result.Notice = "The pending input was cancelled."
103				}
104			}
105		}
106		return result
107	}
108}
109
110// askDelete asks before deleting an agent, and says what goes with it.
111func (m *AgentsViewModel) askDelete(id string) {
112	what := m.label(id, "")
113	switch below := m.below(id); {
114	case below == 1:
115		what += " and the agent below it"
116	case below > 1:
117		what += fmt.Sprintf(" and the %d agents below it", below)
118	}
119	m.confirm.ask("delete", id, "delete", "Their transcripts and work will also be deleted. Delete "+what+"?")
120}
121
122func (m AgentsViewModel) key(msg tea.KeyPressMsg) (AgentsViewModel, tea.Cmd) {
123	s, empty := msg.String(), m.input.Value() == ""
124	if m.rename.active() {
125		return m, m.rename.key(msg)
126	}
127	if m.confirm.asking() {
128		id := m.confirm.target
129		if m.confirm.key(msg) {
130			m.confirm.dismiss()
131			m.say("deleting " + m.label(id, "") + "…")
132			return m, m.deleteCmd(id)
133		}
134		if !m.confirm.asking() {
135			m.say("kept")
136		}
137		return m, nil
138	}
139	switch {
140	case s == "ctrl+l":
141		return m, m.restartStream()
142	case s == "ctrl+r":
143		if n := m.nodes[m.selected]; n != nil && n.id != agentsYou {
144			m.rename.open(n.id, n.name, "name this agent")
145			if n := m.nodes[m.rename.id]; n != nil {
146				m.rename.etag = n.session.ETag
147			}
148		}
149		return m, nil
150	case s == "ctrl+x":
151		switch n := m.nodes[m.selected]; {
152		case n == nil || n.id == agentsYou:
153		case n.id == m.SessionID:
154			m.say("this is the session you opened the view from; delete it from the session browser")
155		default:
156			m.askDelete(n.id)
157		}
158		return m, nil
159	case s == "esc" || s == "ctrl+c" || s == "ctrl+o":
160		if !empty && s == "esc" {
161			m.input.SetValue("")
162			return m, nil
163		}
164		return m, func() tea.Msg { return AgentsDoneMsg{} }
165	case s == "tab" || (empty && (s == "right" || s == "down")):
166		m.cycle(1)
167		return m, m.seedCmd()
168	case s == "shift+tab" || (empty && (s == "left" || s == "up")):
169		m.cycle(-1)
170		return m, m.seedCmd()
171	case s == "enter":
172		n := m.nodes[m.selected]
173		if n == nil {
174			return m, nil
175		}
176		text := strings.TrimSpace(m.input.Value())
177		if text == "" {
178			if n.session.ID == "" {
179				n.session = daemon.Session{ID: n.id, Title: n.name, Model: n.model}
180			}
181			sess := n.session
182			return m, func() tea.Msg { return AgentsAttachMsg{Session: sess} }
183		}
184		m.input.SetValue("")
185		if rest, ok := strings.CutPrefix(text, "/spawn "); ok {
186			name, task, _ := strings.Cut(strings.TrimSpace(rest), " ")
187			return m, m.spawnCmd(n.id, name, strings.TrimSpace(task))
188		}
189		return m, m.sendCmd(n.id, text)
190	}
191	var cmd tea.Cmd
192	m.input, cmd = m.input.Update(msg)
193	return m, cmd
194}
195
196// renameBlank is what an agent is called once its given name is cleared.
197func renameBlank(n *agentNode) string {
198	return cmp.Or(n.address, "its latest message's title")
199}
200
201func (m *AgentsViewModel) say(text string) {
202	m.notice = text
203	m.noticeT = 3
204}
205
206func (m *AgentsViewModel) cycle(d int) {
207	if n := len(m.order); n > 0 {
208		i := max(0, slices.Index(m.order, m.selected))
209		m.selected = m.order[(i+d+n)%n]
210	}
211}