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
7.0 KiB240 linesraw
1package tui
2
3import (
4	"albedo/cli/internal/daemon"
5	"fmt"
6	"maps"
7	"slices"
8	"strings"
9
10	"github.com/charmbracelet/x/ansi"
11)
12
13func (m AgentsViewModel) paneHeader() []string {
14	// One column goes to the space after the divider.
15	width := m.paneWidth() - 1
16	if width <= 0 {
17		width = max(1, m.Width-2)
18	}
19	rows := make([]string, 0, 12)
20	n := m.nodes[m.selected]
21	if n == nil || width <= 0 {
22		return rows
23	}
24	colors := agentColors()
25	styled := func(hue rgb, s string) string { return sgr(hue.hex(), "") + s + ansiReset }
26	state := "idle"
27	switch {
28	case n.id == agentsYou:
29		state = "that's you"
30	case n.closed:
31		state = "closed"
32	case n.session.Status.Phase != nil:
33		switch *n.session.Status.Phase {
34		case daemon.PhasePreparing:
35			state = "preparing"
36		case daemon.PhaseInterrupted:
37			state = "interrupting"
38		case daemon.PhaseIdle, daemon.PhaseResting:
39			state = "idle"
40		default:
41			state = "running"
42		}
43	case n.running:
44		state = "running"
45	case n.peer:
46		state = "outside this tree"
47	}
48	rows = append(rows, styled(n.hue, "● ")+DefaultStyles.Bold.Render(n.name)+"  "+DefaultStyles.Faint.Render(state))
49	var meta []string
50	if n.model != "" {
51		meta = append(meta, n.model)
52	}
53	if n.id != agentsYou {
54		meta = append(meta, fmt.Sprintf("depth %d", n.depth))
55	}
56	if parent := m.nodes[n.parent]; parent != nil {
57		meta = append(meta, "parent "+parent.name)
58	}
59	rows = append(rows, DefaultStyles.Faint.Render(ansi.Truncate(strings.Join(meta, " · "), width, "…")), "")
60	for _, field := range []struct{ label, text string }{{"request", n.currentRequest}, {"latest", n.latestProgress}} {
61		if field.text == "" {
62			continue
63		}
64		rows = append(rows, DefaultStyles.Muted.Render(field.label))
65		wrapped := strings.Split(ansi.Wrap(field.text, max(1, width), " "), "\n")
66		if len(wrapped) > 3 {
67			wrapped = wrapped[:3]
68			wrapped[2] = ansi.Truncate(wrapped[2], max(1, width-1), "") + "…"
69		}
70		rows = append(rows, wrapped...)
71	}
72	if len(n.mail) > 0 {
73		rows = append(rows, DefaultStyles.Muted.Render("mail"))
74		for _, mail := range n.mail[max(0, len(n.mail)-5):] {
75			var arrow string
76			if mail.incoming {
77				arrow = styled(colors.mail, "← ")
78			} else {
79				arrow = DefaultStyles.Faint.Render("→ ")
80			}
81			rows = append(rows, ansi.Truncate(arrow+mail.who+" "+DefaultStyles.Faint.Render(mail.kind), width, "…"))
82		}
83		rows = append(rows, "")
84	}
85	live := DefaultStyles.Muted.Render("tail")
86	if n.running {
87		live += " " + styled(n.hue, "●") + DefaultStyles.Faint.Render(" live")
88	}
89	if n.tail.omitted || n.preview.omitted {
90		live += DefaultStyles.Faint.Render(" · earlier output omitted")
91	}
92	return append(rows, live)
93}
94
95func (m AgentsViewModel) pane(height int) []string {
96	rows := m.paneHeader()
97	room := max(0, height-len(rows))
98	if room == 0 || len(rows) == 0 {
99		return rows
100	}
101	wrapped := m.tailCache.rows
102	if len(wrapped) == 0 {
103		return append(rows, DefaultStyles.Faint.Render("nothing yet"))
104	}
105	return append(rows, wrapped[max(0, len(wrapped)-room):]...)
106}
107
108// drawTail wraps one tail line to width and styles it by kind.
109func drawTail(line tailLine, width, limit int) []string {
110	gutter, inner := "", width
111	format := func(s ...string) string { return strings.Join(s, "") }
112	switch line.kind {
113	case tailThinking:
114		format = func(s ...string) string { return DefaultStyles.Faint.Render(ansiItalic + s[0]) }
115	case tailCode:
116		gutter, inner, format = DefaultStyles.Decor.Render("│ "), width-2, DefaultStyles.Muted.Render
117	case tailOutput:
118		gutter, inner, format = DefaultStyles.Decor.Render("⎿ "), width-2, DefaultStyles.Faint.Render
119	case tailMeta:
120		format = DefaultStyles.Faint.Render
121	}
122	wrapped := ansi.Hardwrap(line.text, max(8, inner), true)
123	start := len(wrapped)
124	for range limit {
125		index := strings.LastIndexByte(wrapped[:start], '\n')
126		if index < 0 {
127			start = 0
128			break
129		}
130		start = index
131	}
132	if start > 0 {
133		start++
134	}
135	var out []string
136	for part := range strings.SplitSeq(wrapped[start:], "\n") {
137		// A cached row must not retain the rest of a long wrapped line.
138		out = append(out, gutter+format(strings.Clone(part)))
139	}
140	return out
141}
142
143func (m AgentsViewModel) View() string {
144	if m.Width <= 0 || m.Height <= 0 {
145		return ""
146	}
147	live, tokens := 0, 0
148	for id, n := range m.nodes {
149		if id == agentsYou {
150			continue
151		}
152		if n.running {
153			live++
154		}
155		tokens += n.chars / 4
156	}
157	right := DefaultStyles.Faint.Render(fmt.Sprintf("%d agents · %d running · ▴%s tok", len(m.nodes)-1, live, compactCount(tokens)))
158	out := []string{titleRule(m.Width, brand("albedo")+" "+DefaultStyles.Muted.Render("/agents"), right)}
159
160	var confirmRows []string
161	if m.confirm.asking() {
162		confirmRows = strings.Split(m.confirm.footer(max(1, m.Width), ""), "\n")
163	}
164	bodyH := max(1, m.Height-3-max(1, len(confirmRows)))
165	dagW := m.dagWidth()
166	// Scroll so the selected dot stays in view on a tall swarm.
167	offset := 0
168	if n := m.nodes[m.selected]; n != nil && n.y+3 > bodyH {
169		offset = n.y + 3 - bodyH
170	}
171	pw := m.paneWidth()
172	stacked := 0
173	if pw == 0 {
174		stacked = min(len(m.paneHeader()), max(1, bodyH/2))
175		bodyH = max(1, bodyH-stacked)
176		offset = 0
177		if n := m.nodes[m.selected]; n != nil && n.y+3 > bodyH {
178			offset = n.y + 3 - bodyH
179		}
180	}
181	canvas := newCanvas(dagW, bodyH+offset)
182	m.drawEdges(canvas)
183	m.drawNodes(canvas)
184	pane := m.pane(bodyH)
185	divider := DefaultStyles.Decor.Render("│")
186	for y := range bodyH {
187		row := canvas.line(y + offset)
188		if pw > 0 {
189			side := ""
190			if y < len(pane) {
191				side = pane[y]
192			}
193			row += divider + " " + ansi.Truncate(side, pw-1, "…")
194		}
195		out = append(out, row)
196	}
197
198	if stacked > 0 {
199		rows := m.paneHeader()
200		for i := range stacked {
201			row := ""
202			if i < len(rows) {
203				row = rows[i]
204			}
205			out = append(out, ansi.Truncate(row, max(1, m.Width), "…"))
206		}
207	}
208
209	status := ""
210	switch {
211	case m.rename.active():
212		status = DefaultStyles.Muted.Render("rename ") + DefaultStyles.Bold.Render(m.label(m.rename.id, ""))
213		if n := m.nodes[m.rename.id]; n != nil && n.address != "" {
214			status += DefaultStyles.Faint.Render(" · its family can still mail it at ") + DefaultStyles.Muted.Render(n.address)
215		}
216	case m.err != nil:
217		status = DefaultStyles.Error.Render(m.err.Error() + " · ctrl+l retries")
218	case len(m.pendingOperations) > 0:
219		ids := slices.Sorted(maps.Keys(m.pendingOperations))
220		status = DefaultStyles.Error.Render("Unresolved request: " + strings.Join(ids, ", "))
221	case m.noticeT > 0:
222		status = DefaultStyles.Muted.Render(m.notice)
223	}
224	if len(confirmRows) > 0 {
225		out = append(out, confirmRows...)
226	} else {
227		out = append(out, status)
228	}
229	target := m.label(m.selected, "agent")
230	input := m.input.View()
231	if m.input.Value() == "" {
232		input = DefaultStyles.Faint.Render("message " + target + "…  or /spawn <name> <task>")
233	}
234	if n := m.nodes[m.rename.id]; n != nil && m.rename.active() {
235		out = append(out, DefaultStyles.Prompt.Render("✎ ")+m.rename.view(m.Width-promptMarkWidth), renameHints("restores "+renameBlank(n)))
236	} else {
237		out = append(out, promptLead()+input, keyHints(hint{"tab", "next agent"}, hint{"enter", "open or send"}, hint{"ctrl+r", "rename"}, hint{"ctrl+x", "delete"}, hint{"esc", "back"}))
238	}
239	return strings.Join(out, "\n")
240}