cat / 012
clone

a spreadsheet for the terminal: lotus 1-2-3 looks, google sheets keys and formulas, one go binary

cat/module-path
012 / internal / ui / agents.go
agents.go 10.4K
1package ui23import (4 "fmt"5 "os/user"6 "path/filepath"7 "strings"89 tea "charm.land/bubbletea/v2"1011 "src.rest/cat/012/internal/cowork"12 "src.rest/cat/012/internal/room"13 "src.rest/cat/012/internal/sheet"14 "src.rest/cat/012/internal/ui/picker"15 "src.rest/cat/012/internal/ui/review"16)1718// Live mode (docs/agents/live.md): the agent works in this session as a19// participant of its room. A local session invited to (012 --listen,20// File > Invite the agent) puts its workbook in a room of its own, with21// its person the first seat, and listens on a socket for 012 mcp22// --attach (package cowork); 012 serve's rooms are joined the same way.23// The agent's changes wait on the room's board as suggestions, marked24// in the grid (agentdraw.go) and settled in the review panel (package25// review); its questions and requests show on the context line26// (agentask.go).2728// agentState is the session's part in live mode.29type agentState struct {30 listener *cowork.Listener // the session's socket, nil when not listening31 scope cowork.Scope // what the person chose, for the board once the room is joined32 asking *cowork.Ask // the agent's question on the context line33 frame agentFrame // what the frame draws of suggestions: agentdraw.go34}3536func init() {37 register(38 &command{id: "agent.invite", macro: macroNever, title: "Invite an agent",39 desc: "Let an agent on this computer work in this workbook with you (012 mcp --attach): pick what it may change",40 hidden: func(m *Model) bool { return m.share.reg != nil && !m.share.local }, run: (*Model).openInvite},41 &command{id: "agent.review", macro: macroNever, title: "Review suggestions",42 desc: "List the agents' suggestions to accept or reject, whole or cell by cell",43 hidden: noRoom, enabled: func(m *Model) bool { return len(m.board().Pending()) > 0 }, run: (*Model).openReview},44 &command{id: "agent.direct", macro: macroNever, title: "Let agents edit directly",45 desc: "Make agents' changes at once, as their own steps, rather than as suggestions to accept",46 hidden: noRoom, checked: func(m *Model) bool { return m.board().Direct }, run: (*Model).toggleDirect},47 &command{id: "agent.stop", macro: macroNever, title: "Stop inviting agents",48 desc: "Close this session's socket: agents attached leave, their suggestions stay",49 hidden: func(m *Model) bool { return m.agents.listener == nil }, run: func(m *Model) tea.Cmd { m.StopListening(); return nil }},50 )51 keymap["ctrl+alt+a"] = "agent.review"52}5354// noRoom hides what needs a room: live mode's commands before the agent55// is invited.56func noRoom(m *Model) bool { return m.share.seat == nil }5758// board is the room's board of agents; nil outside a room. Call it in a59// turn.60func (m *Model) board() *cowork.Board {61 if m.share.seat == nil {62 return &cowork.Board{}63 }64 return cowork.BoardOf(m.share.seat)65}6667// openInvite asks what the agent may change.68func (m *Model) openInvite() tea.Cmd {69 whole := picker.Item{Title: "The whole workbook", Desc: "It may change anything, suggested for you to accept unless you let agents edit directly",70 Pick: m.inviting(cowork.Scope{Kind: cowork.Workbook})}71 items := []picker.Item{whole}72 if !m.sheet.IsNotebook() {73 r := m.selection()74 items = append(items,75 picker.Item{Title: "This sheet", Detail: m.sheet.Name(), Desc: "It may change " + m.sheet.Name() + " only",76 Pick: m.inviting(cowork.Scope{Kind: cowork.OneSheet, Sheet: m.sheet})},77 picker.Item{Title: "The selection", Detail: r.String(), Desc: "It may change the cells of " + r.String() + " only",78 Pick: m.inviting(cowork.Scope{Kind: cowork.OneRange, Sheet: m.sheet, Range: r})})79 }80 items = append(items, picker.Item{Title: "Read only", Desc: "It reads the workbook, points and asks you, and changes nothing",81 Pick: m.inviting(cowork.Scope{Kind: cowork.ReadOnly})})82 for i := range items {83 items[i].Name = len(items[i].Title)84 }85 pk := m.newPicker("Invite an agent", "what it may change", 56, items)86 pk.Action = "invite"87 m.openOverlay(pk)88 return nil89}9091// inviting is a picker's choice of scope.92func (m *Model) inviting(s cowork.Scope) func() tea.Cmd {93 return func() tea.Cmd {94 m.closeOverlay()95 m.invite(s)96 return nil97 }98}99100// invite lets agents in with scope s: at once when the session already101// listens, else once it has joined its room (enterLocal).102func (m *Model) invite(s cowork.Scope) {103 m.agents.scope = s104 if m.share.seat != nil {105 m.board().Scope = s106 m.share.seat.Touch()107 if m.agents.listener == nil && m.share.local {108 m.startListening(m.share.seat.Key())109 }110 m.note = "Agents may change " + s.String()111 return112 }113 m.share.reg = room.NewRegistry(room.Edit)114 m.share.link = &peerLink{name: userName(), kick: make(chan struct{}, 1)}115 m.share.local = true116 key := "@local"117 if m.filename != "" {118 if k, ok := m.roomKey(m.filename); ok {119 key = k120 }121 }122 m.share.want = &joinReq{key: key, name: m.filename, book: m.book(), local: true}123}124125// Listen has the session listen for agents from the start, letting126// them suggest changes to the whole workbook: 012 --listen.127func (m *Model) Listen() { m.invite(cowork.Scope{Kind: cowork.Workbook}) }128129// StopListening closes the session's socket; agents attached leave.130func (m *Model) StopListening() {131 if m.agents.listener == nil {132 return133 }134 m.agents.listener.Close()135 m.agents.listener = nil136 m.note = "Stopped inviting agents"137}138139// userName is who the person is to agents.140func userName() string {141 if u, err := user.Current(); err == nil && u.Username != "" {142 return filepath.Base(u.Username) // DOMAIN\name on Windows143 }144 return "you"145}146147// enterLocal takes the local session's own workbook into its room, as148// it is: nothing reopens, unsaved changes stay unsaved, and the steps149// made so far stay the person's to undo.150func (m *Model) enterLocal(seat *room.Seat, req *joinReq) tea.Cmd {151 m.share.seat, m.share.seen = seat, seat.Last()152 seat.Book().Adopt(seat.ID())153 runs := m.nb.runs154 m.nb.runs = seat.Value("nb", func() any { return runs }).(*nbRuns)155 src := m.src.run156 if src == nil {157 src = &sourceHost{}158 }159 m.src.run = seat.Value("sources", func() any { return src }).(*sourceHost)160 saved := room.Saved{State: m.saved, Outputs: m.nb.saved, Name: m.filename, Stamp: m.disk}161 if m.filename != "" {162 saved.Key = req.key163 }164 seat.SetSaved(saved)165 b := cowork.BoardOf(seat)166 b.Host, b.Scope = seat.ID(), m.agents.scope167 m.startListening(req.key)168 return m.waitRoom()169}170171// startListening opens the session's socket for agents joining the room172// with key.173func (m *Model) startListening(key string) {174 name := strings.TrimSuffix(filepath.Base(m.filename), filepath.Ext(m.filename))175 if m.filename == "" {176 name = "Untitled"177 }178 l, err := cowork.Listen(cowork.Options{Registry: m.share.reg, Room: func(string) (string, error) { return key, nil },179 Kind: "session", Workbook: name, Version: m.prefs.Version})180 if err != nil {181 m.warn = "Can't listen for agents: " + err.Error()182 return183 }184 m.agents.listener = l185 m.note = fmt.Sprintf("Agents may change %s: attach with 012 mcp --attach %s", m.agents.scope, name)186}187188// waitRoom waits for the room to change, in a local session, where no189// server sends the program its roomMsg.190func (m *Model) waitRoom() tea.Cmd {191 if !m.share.local || m.share.still || m.share.waiting {192 return nil193 }194 m.share.waiting = true195 kick := m.share.link.kick196 return func() tea.Msg {197 <-kick198 return roomMsg{}199 }200}201202// roomKicked catches up with the room, and waits for it again.203func (m *Model) roomKicked() tea.Cmd {204 m.share.waiting = false205 return tea.Batch(m.roomChanged(), m.waitRoom())206}207208// leftLocal stops listening when the session leaves its room: it opened209// another file, and agents work on the one they were invited to.210func (m *Model) leftLocal() {211 if m.agents.listener != nil {212 m.StopListening()213 m.note = "Stopped inviting agents: they work on the workbook they were invited to"214 }215}216217// toggleDirect turns direct edits by agents on or off.218func (m *Model) toggleDirect() tea.Cmd {219 if m.share.seat == nil {220 return nil221 }222 b := m.board()223 b.Direct = !b.Direct224 m.share.seat.Touch()225 if b.Direct {226 m.note = "Agents' changes are made at once, as their own steps: their undo takes them back"227 } else {228 m.note = "Agents' changes are suggestions for you to accept"229 }230 return nil231}232233// openReview opens the suggestions panel.234func (m *Model) openReview() tea.Cmd {235 m.openOverlay(review.New(m.host()))236 return nil237}238239// Suggestions are the board's, for the panel.240func (h host) Suggestions() []review.Item {241 var out []review.Item242 for _, s := range h.m.board().Pending() {243 it := review.Item{ID: s.ID, Agent: s.Agent, Color: s.Color, Label: s.Label, Message: s.Message, Whole: s.Whole(),244 Other: s.Other, Total: len(s.Cells), Failed: s.Failed}245 for i, c := range s.Cells {246 if s.Whole() || s.States[i] == cowork.CellPending {247 it.Cells = append(it.Cells, review.Cell{Index: i, Sheet: c.Sheet, At: c.At, Was: c.Was, Now: c.Now, Look: c.Look})248 }249 }250 out = append(out, it)251 }252 return out253}254255func (h host) Accept(id int, cells []int) { h.m.settle(id, cells, true) }256func (h host) Reject(id int, cells []int) { h.m.settle(id, cells, false) }257258// Reveal shows the cell a suggestion sets.259func (h host) Reveal(name string, a sheet.Addr) {260 if s := h.m.book().Lookup(name); s != nil && !s.Hidden() {261 h.m.showSheet(s)262 h.m.cur = a263 h.m.clearSelection()264 }265}266267// settle accepts or rejects cells of suggestion id (nil: all it has268// waiting). What's accepted is one step, the agent's.269func (m *Model) settle(id int, cells []int, accept bool) {270 b, seat := m.board(), m.share.seat271 s := b.Get(id)272 if s == nil || seat == nil {273 return274 }275 what := "all of"276 if cells != nil && !s.Whole() {277 what = fmt.Sprintf("%d of %d cells of", len(cells), len(s.Cells))278 }279 if !accept {280 b.Reject(id, cells)281 seat.Touch()282 m.note = "Rejected " + what + " " + s.Agent + "'s suggestion: " + s.Label283 return284 }285 if !m.mayEdit() {286 return287 }288 if err := b.Accept(m.book(), id, cells); err != nil {289 m.warn = err.Error()290 return291 }292 seat.Touch()293 m.note = "Accepted " + what + " " + s.Agent + "'s suggestion: " + s.Label + " (" + s.Agent + "'s to undo)"294}295296// agentRunCell runs the notebook cell the agent asked for, once allowed297// (cowork.Agent.RunCell), as Run does.298func (m *Model) agentRunCell(rc cowork.RunCell) tea.Cmd {299 s := m.book().Lookup(rc.Notebook)300 if s == nil || !s.IsNotebook() || rc.Cell >= len(s.NotebookCells()) {301 return nil302 }303 m.note = fmt.Sprintf("%s runs cell %d of %s", rc.Agent, rc.Cell+1, rc.Notebook)304 return m.runCells(s, rc.Cell, s.NotebookCells())305}