Something went wrong. Try again.
small gleam coding and persistent agent daemon with a detachable cli and a canvas ui forked from okami.mom/albedo
Something went wrong. Try again.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430package tui
import ( "cmp" "context" "errors" "fmt" "hash/fnv" "math" "net/http" "net/url" "slices" "strings" "time" "unicode/utf8"
"albedo/cli/internal/daemon"
"charm.land/bubbles/v2/textinput" tea "charm.land/bubbletea/v2" "github.com/charmbracelet/x/ansi")
// The orchestrator view: every agent in the active session's tree as a dot,// drawn in terminal cells. Running agents jam on their token rate, and mail// travels the edges as a packet of blocks. The daemon's agents stream feeds// it; the selected agent's tail sits beside it, and what you type goes to// that agent as you.
type ChatOpenAgentsMsg struct{}type AgentsDoneMsg struct{}
// AgentsAttachMsg opens the agent's session in the chat screen.type AgentsAttachMsg struct{ Session daemon.Session }
type agentsSnapshotMsg struct { Err error Root string Nodes []agentWire Gen int}
type agentsEventsMsg struct { Events []map[string]any Gen int}
type agentsStreamClosedMsg struct{ Gen int }
// agentsSeedMsg is an agent's recent history, so its tail starts from where// it is rather than empty.type agentsSeedMsg struct { ID string Items []struct { Type string `json:"type"` Preview string `json:"preview"` } Gen int}type agentsSeedErrMsg struct { Err error ID string Gen int}
type agentsFrameMsg struct{ Gen int }type agentsSentMsg struct { Err error Action string Notice string Gen int}type agentsReconnectMsg struct{ Gen int }
type agentsGenMsg interface{ gen() int }
func (m agentsSnapshotMsg) gen() int { return m.Gen }func (m agentsSeedMsg) gen() int { return m.Gen }func (m agentsSeedErrMsg) gen() int { return m.Gen }func (m agentsEventsMsg) gen() int { return m.Gen }func (m agentsStreamClosedMsg) gen() int { return m.Gen }func (m agentsReconnectMsg) gen() int { return m.Gen }func (m agentsFrameMsg) gen() int { return m.Gen }func (m agentsSentMsg) gen() int { return m.Gen }
type agentWire struct { Parent *string `json:"parent"` Address *string `json:"address"` Session daemon.Session `json:"session"` Name string `json:"name"` Depth int `json:"depth"` Running bool `json:"running"` Closed bool `json:"closed"`}
type agentMail struct { who string kind string incoming bool}
type agentNode struct { session daemon.Session id, parent, name, model string address string // how its family mails it; a rename leaves it call string // the tool call whose arguments are streaming mail []agentMail tail agentTail preview agentPreview revision uint64 hue rgb rate float64 flash float64 phase float64 depth int chars int x, y int lineKind tailKind running, closed bool peer bool // reached by mail, outside the tree seeded bool}
// What a tail line is, which decides how it is drawn.type tailKind byte
const ( tailText tailKind = iota // the agent's reply, as it streams tailThinking // reasoning, faint tailCode // a tool call's code as the model writes it tailOutput // what a tool printed tailMeta // ▸ tool, ← letter, » progress, ✕ error)
type tailLine struct { text string kind tailKind}
type agentPacket struct { to string edge string path [][2]int hue rgb pos float64 blocks int tokens int}
type agentFloat struct { text string hue rgb x, y int t float64}
type AgentsViewModel struct { last time.Time err error heat map[string]float64 events chan []map[string]any cancel context.CancelFunc Conn *daemon.Connection nodes map[string]*agentNode SessionID string root string selected string
notice string confirm string // the agent waiting for y to delete it packets []agentPacket floats []agentFloat order []string input textinput.Model rename renameField tailCache agentTailCache Width int clock float64 Height int Gen int noticeT float64 ticking bool // a frame tick is in flight}
const ( agentsYou = "you" agentsFrame = 70 * time.Millisecond agentsSlotW = 16 agentsLevelGp = 2)
var agentsBars = []rune("▁▂▃▄▅▆▇█")
func NewAgentsViewModel(conn *daemon.Connection, sessionID string) AgentsViewModel { input := newField() input.CharLimit = 4000 input.Focus() return AgentsViewModel{ Conn: conn, SessionID: sessionID, nodes: map[string]*agentNode{}, heat: map[string]float64{}, selected: sessionID, input: input, last: time.Now(), }}
func (m *AgentsViewModel) SetSize(w, h int) { m.Width = w m.Height = h m.layout() m.refreshTail()}
func (m *AgentsViewModel) Init() tea.Cmd { m.Gen++ m.ticking = true return tea.Batch(m.snapshotCmd(m.Gen), m.startStream(m.Gen), m.frameCmd(m.Gen), textinput.Blink)}
// Close stops the stream and invalidates batches, completions, and timers// already queued for this view.func (m *AgentsViewModel) Close() { if m.cancel != nil { m.cancel() m.cancel = nil } m.Gen++}
func (m AgentsViewModel) snapshotCmd(gen int) tea.Cmd { conn, id := m.Conn, m.SessionID return func() tea.Msg { if conn == nil { return agentsSnapshotMsg{Gen: gen, Err: errors.New("daemon connection unavailable")} } tree, err := daemon.Request[struct { Root string `json:"root"` Nodes []agentWire `json:"nodes"` }](context.Background(), conn, "/agents?session="+url.QueryEscape(id), nil) return agentsSnapshotMsg{Gen: gen, Root: tree.Root, Nodes: tree.Nodes, Err: err} }}
func (m *AgentsViewModel) startStream(gen int) tea.Cmd { if m.Conn == nil { return nil } if m.cancel != nil { m.cancel() } ctx, cancel := context.WithCancel(context.Background()) m.cancel = cancel events := make(chan []map[string]any, 64) m.events = events conn := m.Conn go func() { defer close(events) // EOF and failures both reconnect through queue closure, so the view // deliberately discards the transport error here. _ = daemon.StreamAgents(ctx, conn, func(batch []map[string]any) error { // Leaving the view stops its consumer. Cancellation must release a // producer blocked on a full queue so it can close the HTTP body. select { case events <- batch: return nil case <-ctx.Done(): return ctx.Err() } }) }() return waitAgents(events, gen)}
func waitAgents(events <-chan []map[string]any, gen int) tea.Cmd { return func() tea.Msg { batch, ok := <-events if !ok { return agentsStreamClosedMsg{Gen: gen} } return agentsEventsMsg{Gen: gen, Events: batch} }}
func (m AgentsViewModel) frameCmd(gen int) tea.Cmd { return tea.Tick(agentsFrame, func(time.Time) tea.Msg { return agentsFrameMsg{Gen: gen} })}
// Update handles msg, then starts the frame tick again if something began to// move while it was stopped.func (m AgentsViewModel) Update(msg tea.Msg) (AgentsViewModel, tea.Cmd) { m, cmd := m.update(msg) m.refreshTail() if m.ticking || !m.moving() { return m, cmd } m.ticking, m.last = true, time.Now() return m, tea.Batch(cmd, m.frameCmd(m.Gen))}
func (m AgentsViewModel) update(msg tea.Msg) (AgentsViewModel, tea.Cmd) { if g, ok := msg.(agentsGenMsg); ok && g.gen() != m.Gen { return m, nil } switch msg := msg.(type) { case agentsSnapshotMsg: if msg.Err != nil { m.err = msg.Err return m, nil } m.err, m.root = nil, msg.Root for _, wire := range msg.Nodes { n := m.node(wire.Session.ID, wire.Name) n.session = wire.Session n.model = wire.Session.Model n.depth = wire.Depth n.running = wire.Running n.closed = wire.Closed n.peer = false if wire.Address != nil { n.address = *wire.Address } if wire.Parent != nil { n.parent = *wire.Parent } } m.layout() return m, m.seedCmd()
case agentsSeedMsg: n := m.nodes[msg.ID] if n == nil { return m, nil } var seeded agentTail for _, item := range msg.Items { switch item.Type { case "user", "tool": prefix := "← " if item.Type == "tool" { prefix = "▸ " } seeded.push(tailLine{kind: tailMeta, text: prefix + firstLine(item.Preview)}) default: for line := range strings.SplitSeq(item.Preview, "\n") { seeded.push(tailLine{kind: tailText, text: line}) } } } existing := slices.Collect(n.tail.newest(false)) for _, line := range slices.Backward(existing) { seeded.push(line) } seeded.omitted = seeded.omitted || n.tail.omitted n.tail = seeded n.revision++ return m, nil
case agentsSeedErrMsg: // The seed died; unseed its node so the next selection tries again. if n := m.nodes[msg.ID]; n != nil { n.seeded = false m.say("Could not load recent messages for " + m.label(msg.ID, "this agent") + ". Select it again to retry: " + msg.Err.Error()) } return m, nil
case agentsEventsMsg: relayout := false for _, event := range msg.Events { relayout = m.apply(event) || relayout } if relayout { m.layout() } return m, waitAgents(m.events, m.Gen)
case agentsStreamClosedMsg: // The daemon restarted or the connection dropped: reconnect shortly. return m, tea.Tick(time.Second, func(time.Time) tea.Msg { return agentsReconnectMsg(msg) })
case agentsReconnectMsg: return m, tea.Batch(m.snapshotCmd(m.Gen), m.startStream(m.Gen))
case agentsFrameMsg: m.step() if m.ticking = m.moving(); !m.ticking { return m, nil } return m, m.frameCmd(m.Gen)
case agentsSentMsg: if msg.Err != nil { m.say("Could not " + msg.Action + ": " + msg.Err.Error()) } else if msg.Notice != "" { m.say(msg.Notice) } return m, nil
case sessionRenamedMsg: switch { case msg.Err != nil: m.say("Could not rename the session. Try again: " + msg.Err.Error()) case msg.Name == "": m.say("name cleared") default: m.say("renamed to " + msg.Name) } return m, nil
case tea.KeyPressMsg: return m.key(msg) } var cmd tea.Cmd m.input, cmd = m.input.Update(msg) return m, cmd}
func (m AgentsViewModel) key(msg tea.KeyPressMsg) (AgentsViewModel, tea.Cmd) { s, empty := msg.String(), m.input.Value() == "" if m.rename.active() { return m, m.rename.key(msg) } if m.confirm != "" { id := m.confirm m.confirm = "" if s == "y" { m.say("deleting " + m.label(id, "") + "…") return m, m.deleteCmd(id) } m.say("kept") return m, nil } switch { case s == "ctrl+r": if n := m.nodes[m.selected]; n != nil && n.id != agentsYou { m.rename.open(n.id, n.name, "name this agent") } return m, nil case s == "ctrl+x": switch n := m.nodes[m.selected]; { case n == nil || n.id == agentsYou: case n.id == m.SessionID: m.say("this is the session you opened the view from; delete it from the session browser") default: m.confirm = n.id } return m, nil case s == "esc" || s == "ctrl+c" || s == "ctrl+o": if !empty && s == "esc" { m.input.SetValue("") return m, nil } return m, func() tea.Msg { return AgentsDoneMsg{} } case s == "tab" || (empty && (s == "right" || s == "down")): m.cycle(1) return m, m.seedCmd() case s == "shift+tab" || (empty && (s == "left" || s == "up")): m.cycle(-1) return m, m.seedCmd() case s == "enter": n := m.nodes[m.selected] if n == nil { return m, nil } text := strings.TrimSpace(m.input.Value()) if text == "" { if n.session.ID == "" { n.session = daemon.Session{ID: n.id, Title: n.name, Model: n.model} } sess := n.session return m, func() tea.Msg { return AgentsAttachMsg{Session: sess} } } m.input.SetValue("") if rest, ok := strings.CutPrefix(text, "/spawn "); ok { name, task, _ := strings.Cut(strings.TrimSpace(rest), " ") return m, m.spawnCmd(n.id, name, strings.TrimSpace(task)) } return m, m.sendCmd(n.id, text) } var cmd tea.Cmd m.input, cmd = m.input.Update(msg) return m, cmd}
// seedCmd loads the selected agent's recent history once; failures retry.func (m *AgentsViewModel) seedCmd() tea.Cmd { n := m.nodes[m.selected] if n == nil || n.seeded || n.id == agentsYou || m.Conn == nil { return nil } n.seeded = true conn, gen, id := m.Conn, m.Gen, n.id return func() tea.Msg { path := fmt.Sprintf("/sessions/%s/preview?limit=10", url.PathEscape(id)) res, err := daemon.Request[agentsSeedMsg](context.Background(), conn, path, nil) if err != nil { return agentsSeedErrMsg{Gen: gen, ID: id, Err: err} } res.Gen, res.ID = gen, id return res }}
// below counts the agents under id.func (m AgentsViewModel) below(id string) int { count := 0 for _, n := range m.nodes { for p := n.parent; p != ""; { if p == id { count++ break } if m.nodes[p] == nil { break } p = m.nodes[p].parent } } return count}
func (m AgentsViewModel) deleteCmd(id string) tea.Cmd { conn, gen, name := m.Conn, m.Gen, m.label(id, "") return func() tea.Msg { path := fmt.Sprintf("/sessions/%s?tree=1", url.PathEscape(id)) res, err := daemon.RequestMethod[struct { Deleted int `json:"deleted"` }](context.Background(), conn, http.MethodDelete, path, nil) if err != nil { return agentsSentMsg{Gen: gen, Action: "delete " + name, Err: err} } return agentsSentMsg{Gen: gen, Notice: fmt.Sprintf("deleted %s (%d sessions)", name, res.Deleted)} }}
func (m AgentsViewModel) sendCmd(id, text string) tea.Cmd { conn, gen, target := m.Conn, m.Gen, m.label(id, "agent") return func() tea.Msg { path := fmt.Sprintf("/sessions/%s/events", url.PathEscape(id)) _, err := daemon.Request[map[string]any](context.Background(), conn, path, map[string]any{"content": text}) return agentsSentMsg{Gen: gen, Action: "send a message to " + target, Err: err} }}
func (m AgentsViewModel) spawnCmd(parent, name, task string) tea.Cmd { conn, gen := m.Conn, m.Gen return func() tea.Msg { if name == "" || task == "" { return agentsSentMsg{Gen: gen, Action: "start an agent", Err: errors.New("use /spawn <name> <task>")} } path := fmt.Sprintf("/sessions/%s/children", url.PathEscape(parent)) _, err := daemon.Request[map[string]any](context.Background(), conn, path, map[string]any{"name": name, "task": task}) if err != nil { return agentsSentMsg{Gen: gen, Action: "start " + name, Err: err} } return agentsSentMsg{Gen: gen, Notice: "spawned " + name} }}
// renameBlank is what an agent is called once its given name is cleared.func renameBlank(n *agentNode) string { return cmp.Or(n.address, "its latest message's title")}
func (m *AgentsViewModel) say(text string) { m.notice = text m.noticeT = 3}
func (m *AgentsViewModel) cycle(d int) { if n := len(m.order); n > 0 { i := max(0, slices.Index(m.order, m.selected)) m.selected = m.order[(i+d+n)%n] }}
// node finds or makes the dot for a session.func (m *AgentsViewModel) node(id, name string) *agentNode { if n, ok := m.nodes[id]; ok { if name != "" { n.name = name } return n } if name == "" { name = shortID(id) } n := &agentNode{id: id, name: name, hue: hueFor(id), phase: float64(hashOf(id)%628) / 100} m.nodes[id] = n return n}
func shortID(id string) string { return id[:min(len(id), 8)]}
func hashOf(s string) uint32 { h := fnv.New32a() _, _ = h.Write([]byte(s)) return h.Sum32()}
func hueFor(id string) rgb { hues := agentColors().hues return hues[hashOf(id)%uint32(len(hues))]}
func str(event map[string]any, key string) string { v, _ := event[key].(string) return v}
// apply folds one bus event into the view; true when the tree changed shape.func (m *AgentsViewModel) apply(event map[string]any) bool { kind := str(event, "type") id := str(event, "session") switch kind { case "spawn": parent := str(event, "parent") if _, ok := m.nodes[parent]; !ok { return false } n := m.node(id, str(event, "name")) n.parent, n.model, n.depth = parent, str(event, "model"), int(num(event, "depth")) n.peer, n.flash = false, 1 return true case "gone": if _, ok := m.nodes[id]; !ok { return false } delete(m.nodes, id) if m.rename.id == id { m.rename = renameField{} } if m.confirm == id { m.confirm = "" } if m.selected == id { m.selected = m.root } return true case "mail": from, to := str(event, "from"), str(event, "to") fromName := str(event, "fromName") changed := false ensure := func(nodeID, name string) { if _, ok := m.nodes[nodeID]; !ok { m.node(nodeID, name).peer = true changed = true } } ensure(to, "") source := agentsYou if from != "" { ensure(from, fromName) source = from } if changed { m.layout() } bytes, label := int(num(event, "bytes")), str(event, "kind") addMail := func(nodeID string, incoming bool, who string) { if n := m.nodes[nodeID]; n != nil { n.mail = capped(append(n.mail, agentMail{incoming: incoming, who: who, kind: label}), 12) } } addMail(to, true, m.label(source, fromName)) addMail(source, false, m.label(to, "")) m.send(source, to, max(1, bytes/4)) return false } n, ok := m.nodes[id] if !ok { return false } switch kind { case "running": if n.running, _ = event["running"].(bool); !n.running { m.flushLine(n) } case "text", "thinking": text := str(event, "text") n.chars += utf8.RuneCountInString(text) n.rate += float64(len(text)) tail := tailText if kind == "thinking" { tail = tailThinking } m.stream(n, tail, text) case "arguments_delta": text := str(event, "text") n.chars += utf8.RuneCountInString(text) n.rate += float64(len(text)) if call := str(event, "callId"); call != n.call || n.lineKind != tailCode { m.flushLine(n) n.call = call } n.lineKind = tailCode if n.preview.appendArguments(text) { n.revision++ } case "tool_progress": progress, _ := event["progress"].(map[string]any) if str(progress, "phase") == "running" { m.flushLine(n) m.pushTail(n, tailMeta, "▸ "+str(progress, "name")) } case "tool": m.flushLine(n) for line := range strings.SplitSeq(str(event, "output"), "\n") { if strings.TrimSpace(line) != "" { m.pushTail(n, tailOutput, line) } } case "user": text := str(event, "text") m.flushLine(n) m.pushTail(n, tailMeta, "← "+firstLine(text)) // Mail already travelled as a packet; a person typing is new. if str(event, "source") == "chat" { m.send(agentsYou, id, max(1, len(text)/4)) } case "message": // A root's answer goes back to you. if n.parent == "" && !n.peer { m.send(id, agentsYou, max(1, len(str(event, "text"))/4)) } case "error": m.pushTail(n, tailMeta, "✕ "+str(event, "text")) case "interrupted": m.flushLine(n) m.pushTail(n, tailMeta, "· interrupted") case "progress": m.flushLine(n) m.pushTail(n, tailMeta, "» "+str(event, "text")) n.flash = 0.6 case "renamed": n.name = str(event, "name") case "closed": n.closed, n.running = true, false } return false}
func (m *AgentsViewModel) label(id, fallback string) string { if id == agentsYou { return agentsYou } if n := m.nodes[id]; n != nil { return n.name } return cmp.Or(fallback, shortID(id))}
// capped keeps only the newest keep entries of a growing slice.func capped[S ~[]E, E any](list S, keep int) S { if len(list) > keep { return list[len(list)-keep:] } return list}
func num(event map[string]any, key string) float64 { v, _ := event[key].(float64) return v}
// stream appends streamed text of one kind, closing a line at each newline.func (m *AgentsViewModel) stream(n *agentNode, kind tailKind, text string) { if n.lineKind != kind { m.flushLine(n) n.lineKind = kind } for { head, rest, found := strings.Cut(text, "\n") n.preview.lines.write(head, kind) n.revision++ if !found { return } m.flushLine(n) n.lineKind = kind text = rest }}
// flushLine settles whatever is streaming into the tail: a partial line, or// the code of a finished tool call.func (m *AgentsViewModel) flushLine(n *agentNode) { if n.lineKind == tailCode { n.preview.finish() lines := slices.Collect(n.preview.lines.newest(true)) for _, line := range slices.Backward(lines) { m.pushTail(n, tailCode, line.text) } } else { for line := range n.preview.lines.newest(false) { if strings.TrimSpace(line.text) != "" { m.pushTail(n, n.lineKind, line.text) } } } n.tail.omitted = n.tail.omitted || n.preview.lines.omitted n.preview = agentPreview{} n.call = "" n.lineKind = tailText n.revision++}
func (m *AgentsViewModel) pushTail(n *agentNode, kind tailKind, line string) { n.tail.push(tailLine{kind: kind, text: line}) n.revision++}
// ─── layout ───
func (m *AgentsViewModel) dagWidth() int { w := m.paneWidth() if w > 0 { return m.Width - w - 1 } return m.Width}
func (m *AgentsViewModel) paneWidth() int { if m.Width < 96 { return 0 } return min(46, max(34, m.Width/3))}
// layout places dots by depth, each depth wrapped into rows that fit the// pane, parents before their children so a family stays together.func (m *AgentsViewModel) layout() { // The app sizes every screen, including this one before it first opens. if m.nodes == nil { return } you := m.node(agentsYou, agentsYou) you.x, you.y = 2, 2 children := map[string][]string{} var roots, peers []string for id, n := range m.nodes { switch { case id == agentsYou: case n.peer: peers = append(peers, id) case n.parent == "" || m.nodes[n.parent] == nil: roots = append(roots, id) default: children[n.parent] = append(children[n.parent], id) } } for _, list := range children { slices.Sort(list) } slices.Sort(roots) slices.Sort(peers) levels := [][]string{append(roots, peers...)} for { var next []string for _, id := range levels[len(levels)-1] { next = append(next, children[id]...) } if len(next) == 0 { break } levels = append(levels, next) } width := max(agentsSlotW, m.dagWidth()-4) perRow := max(1, width/agentsSlotW) y := 5 m.order = m.order[:0] for _, level := range levels { for start := 0; start < len(level); start += perRow { row := level[start:min(len(level), start+perRow)] used := len(row) * agentsSlotW x := 2 + max(0, (width-used)/2) for i, id := range row { n := m.nodes[id] n.x, n.y = x+i*agentsSlotW, y m.order = append(m.order, id) } y += 3 } y += agentsLevelGp } if m.nodes[m.selected] == nil && len(m.order) > 0 { m.selected = m.order[0] }}
// route runs from a to b: down from a, across the row above b, into b. A// route upward is the same route reversed.func route(a, b *agentNode) [][2]int { if b.y < a.y { cells := route(b, a) slices.Reverse(cells) return cells } var cells [][2]int channel := b.y - 1 if b.y == a.y { channel = a.y - 1 cells = append(cells, [2]int{a.x, a.y - 1}) } for r := a.y + 1; r <= channel; r++ { cells = append(cells, [2]int{a.x, r}) } step := 1 if b.x < a.x { step = -1 } for c := a.x + step; c != b.x+step; c += step { cells = append(cells, [2]int{c, channel}) } return cells}
func edgeKey(a, b string) string { return a + ">" + b }
func (m *AgentsViewModel) send(from, to string, tokens int) { a, b := m.nodes[from], m.nodes[to] if a == nil || b == nil || from == to { return } path := route(a, b) if len(path) == 0 { return } blocks := max(1, min(5, int(math.Round(math.Log2(float64(tokens)/200+1))))) var key string if a.parent == to { key = edgeKey(to, from) } else { key = edgeKey(from, to) } m.packets = append(m.packets, agentPacket{path: path, pos: -float64(blocks), blocks: blocks, hue: a.hue, to: to, tokens: tokens, edge: key}) m.heat[key] = 1}
// step advances one frame of animation.func (m *AgentsViewModel) step() { now := time.Now() dt := math.Min(0.2, now.Sub(m.last).Seconds()) m.last = now m.clock += dt m.noticeT = math.Max(0, m.noticeT-dt) live := m.packets[:0] for _, p := range m.packets { p.pos += 30 * dt m.heat[p.edge] = 1 if p.pos-float64(p.blocks) >= float64(len(p.path)-1) { if n := m.nodes[p.to]; n != nil { n.flash = 1 m.floats = append(m.floats, agentFloat{x: n.x + 2, y: n.y - 1, text: "▴" + compactCount(p.tokens), hue: p.hue}) } continue } live = append(live, p) } m.packets = live for key, h := range m.heat { if slices.ContainsFunc(m.packets, func(p agentPacket) bool { return p.edge == key }) { continue } h -= dt * 0.8 if h <= 0 { delete(m.heat, key) continue } m.heat[key] = h } floats := m.floats[:0] for _, f := range m.floats { f.t += dt if f.t < 1.4 { floats = append(floats, f) } } m.floats = floats for _, n := range m.nodes { n.flash = math.Max(0, n.flash-dt*2.5) n.rate *= math.Exp(-dt * 1.5) }}
// moving is whether the next frame can differ from this one: running agents// pulse, and packets, heat, floats, flashes, and notices play out.func (m AgentsViewModel) moving() bool { if len(m.packets) > 0 || len(m.heat) > 0 || len(m.floats) > 0 || m.noticeT > 0 { return true } for _, n := range m.nodes { if n.running || n.flash > 0 { return true } } return false}
func compactCount(n int) string { if n >= 1000 { return fmt.Sprintf("%.1fk", float64(n)/1000) } return fmt.Sprintf("%d", n)}
// ─── drawing ───
type agentCell struct { hue *rgb r rune}
type agentCanvas struct { cells [][]agentCell w, h int}
func newCanvas(w, h int) *agentCanvas { c := &agentCanvas{w: w, h: h, cells: make([][]agentCell, h)} for y := range c.cells { c.cells[y] = make([]agentCell, w) for x := range c.cells[y] { c.cells[y][x] = agentCell{r: ' '} } } return c}
func (c *agentCanvas) put(x, y int, s string, hue rgb) { h := hue for _, r := range s { if x >= 0 && x < c.w && y >= 0 && y < c.h { c.cells[y][x] = agentCell{r: r, hue: &h} } x++ }}
var sgrCache = map[string]string{}
// sgrCached memoizes the escape sequence for one hex color.func sgrCached(hex string) string { seq, ok := sgrCache[hex] if !ok { seq = sgr(hex, "") sgrCache[hex] = seq } return seq}
func (c *agentCanvas) line(y int) string { var b strings.Builder current := "" for _, cell := range c.cells[y] { seq := "" if cell.hue != nil && cell.r != ' ' { seq = sgrCached(cell.hue.hex()) } if seq != current { if current != "" { b.WriteString(ansiReset) } b.WriteString(seq) current = seq } b.WriteRune(cell.r) } if current != "" { b.WriteString(ansiReset) } return b.String()}
var agentGlyphs = map[int]rune{5: '│', 10: '─', 6: '╭', 12: '╮', 3: '╰', 9: '╯', 7: '├', 13: '┤', 14: '┬', 11: '┴', 15: '┼', 1: '│', 4: '│', 2: '─', 8: '─'}
func (m *AgentsViewModel) drawEdges(c *agentCanvas) { type mark struct { mask int heat float64 hue rgb dotted bool } marks := map[[2]int]*mark{} // dir is one bit per compass direction; a cell's mask names the arms meeting in it. dir := func(dx, dy int) int { switch { case dy < 0: return 1 case dy > 0: return 4 case dx > 0: return 2 default: return 8 } } draw := func(from, to *agentNode, heat float64, dotted bool) { cells := append([][2]int{{from.x, from.y}}, route(from, to)...) for i := 1; i < len(cells); i++ { cell, prev := cells[i], cells[i-1] mk := marks[cell] if mk == nil { mk = &mark{hue: from.hue} marks[cell] = mk } next := [2]int{to.x, to.y} if i+1 < len(cells) { next = cells[i+1] } mk.mask |= dir(prev[0]-cell[0], prev[1]-cell[1]) | dir(next[0]-cell[0], next[1]-cell[1]) if heat >= mk.heat { mk.heat, mk.hue = heat, from.hue } mk.dotted = mk.dotted || dotted } } crowded := len(m.nodes) > 24 for _, n := range m.nodes { parent := m.nodes[n.parent] if parent == nil || n.peer { continue } heat := m.heat[edgeKey(n.parent, n.id)] if crowded && heat == 0 { continue } draw(parent, n, heat, false) } if root := m.nodes[m.root]; root != nil { draw(m.nodes[agentsYou], root, m.heat[edgeKey(agentsYou, m.root)]+m.heat[edgeKey(m.root, agentsYou)], false) } for key, heat := range m.heat { from, to, _ := strings.Cut(key, ">") a, b := m.nodes[from], m.nodes[to] if a == nil || b == nil || b.parent == from || (from == agentsYou && to == m.root) || (to == agentsYou && from == m.root) { continue } draw(a, b, heat, true) } decor := agentColors().decor for cell, mk := range marks { g := agentGlyphs[mk.mask] if g == 0 { g = '·' } if mk.dotted { switch { case mk.mask&5 != 0 && mk.mask&10 == 0: g = '┆' case mk.mask&10 != 0 && mk.mask&5 == 0: g = '┄' } } c.put(cell[0], cell[1], string(g), decor.mix(mk.hue, math.Min(1, mk.heat*1.1))) }}
func (m *AgentsViewModel) drawNodes(c *agentCanvas) { t, colors := m.clock, agentColors() for id, n := range m.nodes { glyph := "●" var hue rgb switch { case id == agentsYou: glyph, hue = "◆", colors.you case n.running: p := 0.5 + 0.5*math.Sin(t*5+n.phase) if p > 0.55 { glyph = "◉" } hue = n.hue.mix(colors.faint, 0.2).mix(n.hue, p) case n.closed: glyph, hue = "✓", n.hue.mix(colors.faint, 0.5) default: glyph = "○" if n.peer { glyph = "◇" } hue = n.hue.mix(colors.faint, 0.3) } if n.flash > 0 { hue = hue.mix(colors.hi, n.flash*0.7) } c.put(n.x, n.y, glyph, hue) name := n.name if w := agentsSlotW - 4; utf8.RuneCountInString(name) > w { name = string([]rune(name)[:w-1]) + "…" } label := n.hue.mix(colors.hi, 0.35) if id == m.selected { label = colors.hi c.put(n.x-1, n.y, "[", n.hue) c.put(n.x+2+utf8.RuneCountInString(name), n.y, "]", n.hue) } c.put(n.x+2, n.y, name, label) if id == agentsYou { continue } // The jam: five bars riding the token rate. var bars strings.Builder level := math.Min(1, n.rate/400) for i := range 5 { v := 0.0 if n.running { v = 0.15 + level*(0.5+0.35*math.Sin(t*(6+float64(i)*1.7)+n.phase*float64(i+1))) + 0.15*math.Sin(t*2.3+float64(i)) } bars.WriteRune(agentsBars[max(0, min(7, int(math.Round(v*7))))]) } barHue := colors.decor if n.running { barHue = n.hue } c.put(n.x+2, n.y+1, bars.String(), barHue) if n.chars > 0 { c.put(n.x+8, n.y+1, compactCount(n.chars/4), colors.faint) } } for _, p := range m.packets { head := int(math.Floor(p.pos)) for i := range p.blocks { idx := head - i if idx < 0 || idx >= len(p.path) { continue } glyph, hue := "▪", p.hue if i == 0 { glyph, hue = "■", p.hue.mix(colors.hi, 0.35) } c.put(p.path[idx][0], p.path[idx][1], glyph, hue) } } for _, f := range m.floats { rise := min(2, int(f.t*2.2)) c.put(f.x, f.y-rise, f.text, f.hue.mix(colors.decor, math.Min(1, f.t/1.4))) }}
func (m AgentsViewModel) paneHeader() []string { // One column goes to the space after the divider. width := m.paneWidth() - 1 rows := make([]string, 0, 12) n := m.nodes[m.selected] if n == nil || width <= 0 { return rows } colors := agentColors() styled := func(hue rgb, s string) string { return sgr(hue.hex(), "") + s + ansiReset } state := "idle" switch { case n.id == agentsYou: state = "that's you" case n.running: state = "running" case n.closed: state = "closed" case n.peer: state = "outside this tree" } rows = append(rows, styled(n.hue, "● ")+DefaultStyles.Bold.Render(n.name)+" "+DefaultStyles.Faint.Render(state)) var meta []string if n.model != "" { meta = append(meta, n.model) } if n.id != agentsYou { meta = append(meta, fmt.Sprintf("depth %d", n.depth)) } if parent := m.nodes[n.parent]; parent != nil { meta = append(meta, "parent "+parent.name) } rows = append(rows, DefaultStyles.Faint.Render(ansi.Truncate(strings.Join(meta, " · "), width, "…")), "") if len(n.mail) > 0 { rows = append(rows, DefaultStyles.Muted.Render("mail")) for _, mail := range n.mail[max(0, len(n.mail)-5):] { var arrow string if mail.incoming { arrow = styled(colors.mail, "← ") } else { arrow = DefaultStyles.Faint.Render("→ ") } rows = append(rows, ansi.Truncate(arrow+mail.who+" "+DefaultStyles.Faint.Render(mail.kind), width, "…")) } rows = append(rows, "") } live := DefaultStyles.Muted.Render("tail") if n.running { live += " " + styled(n.hue, "●") + DefaultStyles.Faint.Render(" live") } if n.tail.omitted || n.preview.lines.omitted { live += DefaultStyles.Faint.Render(" · earlier output omitted") } return append(rows, live)}
func (m AgentsViewModel) pane(height int) []string { rows := m.paneHeader() room := max(0, height-len(rows)) if room == 0 || len(rows) == 0 { return rows } wrapped := m.tailCache.rows if len(wrapped) == 0 { return append(rows, DefaultStyles.Faint.Render("nothing yet")) } return append(rows, wrapped[max(0, len(wrapped)-room):]...)}
// drawTail wraps one tail line to width and styles it by kind.func drawTail(line tailLine, width, limit int) []string { gutter, inner := "", width format := func(s ...string) string { return strings.Join(s, "") } switch line.kind { case tailThinking: format = func(s ...string) string { return DefaultStyles.Faint.Render(ansiItalic + s[0]) } case tailCode: gutter, inner, format = DefaultStyles.Decor.Render("│ "), width-2, DefaultStyles.Muted.Render case tailOutput: gutter, inner, format = DefaultStyles.Decor.Render("⎿ "), width-2, DefaultStyles.Faint.Render case tailMeta: format = DefaultStyles.Faint.Render } wrapped := ansi.Hardwrap(line.text, max(8, inner), true) start := len(wrapped) for range limit { index := strings.LastIndexByte(wrapped[:start], '\n') if index < 0 { start = 0 break } start = index } if start > 0 { start++ } var out []string for part := range strings.SplitSeq(wrapped[start:], "\n") { // A cached row must not retain the rest of a long wrapped line. out = append(out, gutter+format(strings.Clone(part))) } return out}
func (m AgentsViewModel) View() string { if m.Width <= 0 || m.Height <= 0 { return "" } live, tokens := 0, 0 for id, n := range m.nodes { if id == agentsYou { continue } if n.running { live++ } tokens += n.chars / 4 } right := DefaultStyles.Faint.Render(fmt.Sprintf("%d agents · %d running · ▴%s tok", len(m.nodes)-1, live, compactCount(tokens))) out := []string{titleRule(m.Width, brand("albedo")+" "+DefaultStyles.Muted.Render("/agents"), right)}
var confirmRows []string if m.confirm != "" { what := m.label(m.confirm, "") if below := m.below(m.confirm); below == 1 { what += " and the agent below it" } else if below > 1 { what += fmt.Sprintf(" and the %d agents below it", below) } for line := range strings.SplitSeq(ansi.Wrap("Their transcripts and work will also be deleted. Delete "+what+"?", max(1, m.Width), " "), "\n") { confirmRows = append(confirmRows, DefaultStyles.Warning.Render(line)) } confirmRows = append(confirmRows, keyHints(hint{"y", "delete"}, hint{"any key", "keep"})) } bodyH := max(1, m.Height-3-max(1, len(confirmRows))) dagW := m.dagWidth() // Scroll so the selected dot stays in view on a tall swarm. offset := 0 if n := m.nodes[m.selected]; n != nil && n.y+3 > bodyH { offset = n.y + 3 - bodyH } canvas := newCanvas(dagW, bodyH+offset) m.drawEdges(canvas) m.drawNodes(canvas) pane := m.pane(bodyH) divider := DefaultStyles.Decor.Render("│") pw := m.paneWidth() for y := range bodyH { row := canvas.line(y + offset) if pw > 0 { side := "" if y < len(pane) { side = pane[y] } row += divider + " " + ansi.Truncate(side, pw-1, "…") } out = append(out, row) }
status := "" switch { case m.rename.active(): status = DefaultStyles.Muted.Render("rename ") + DefaultStyles.Bold.Render(m.label(m.rename.id, "")) if n := m.nodes[m.rename.id]; n != nil && n.address != "" { status += DefaultStyles.Faint.Render(" · its family can still mail it at ") + DefaultStyles.Muted.Render(n.address) } case m.err != nil: status = DefaultStyles.Error.Render(m.err.Error()) case m.noticeT > 0: status = DefaultStyles.Muted.Render(m.notice) } if len(confirmRows) > 0 { out = append(out, confirmRows...) } else { out = append(out, status) } target := m.label(m.selected, "agent") input := m.input.View() if m.input.Value() == "" { input = DefaultStyles.Faint.Render("message " + target + "… or /spawn <name> <task>") } if n := m.nodes[m.rename.id]; n != nil && m.rename.active() { out = append(out, DefaultStyles.Prompt.Render("✎ ")+m.rename.view(m.Width-promptMarkWidth), renameHints("restores "+renameBlank(n))) } else { 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"})) } return strings.Join(out, "\n")}