A local session invited to takes its workbook, as it is, into a room of its own and listens for 012 mcp --attach; 012 serve's rooms listen too. Agents show with a ◆, cells their suggestions would set with a ◇ and the proposed value on the context line; Review suggestions (Ctrl+Alt+A) accepts or rejects them whole or by cell, each acceptance one step of the agent's. Invite picks the scope; Let agents edit directly skips suggestions. Agents' questions and requests take the context line.
- commit
- c7a0f1b1d65aaad0b383e13b3e7fb60a7d6b2805
- parent
- 8115302f5531080b5826d903c0f3c51ba108b436
- author
- Dami <dami.etoile@gmail.com>
- kind
- feat risk score 1.9 of 3, Jev's estimate
| M | cmd/ | 26 | ++- |
| M | cmd/ | 21 | ++- |
| M | cmd/ | 7 | + |
| M | docs/ | 12 | + |
| M | internal/ | 13 | + |
| A | internal/ | 44 | ++++ |
| A | internal/ | 148 | +++++++++++++ |
| A | internal/ | 139 | ++++++++++++ |
| A | internal/ | 300 | +++++++++++++++++++++++++ |
| A | internal/ | 288 | ++++++++++++++++++++++++ |
| M | internal/ | 2 | +- |
| M | internal/ | 2 | + |
| M | internal/ | 1 | + |
| M | internal/ | 5 | +- |
| M | internal/ | 2 | +- |
| M | internal/ | 2 | +- |
| M | internal/ | 2 | + |
| A | internal/ | 354 | ++++++++++++++++++++++++++++++ |
| A | internal/ | 122 | ++++++++++ |
| M | internal/ | 1 | + |
| M | internal/ | 24 | ++- |
| M | internal/ | 12 | +- |
| M | internal/ | 12 | +- |
| M | internal/ | 23 | ++ |
| M | internal/ | 2 | +- |
| M | internal/ | 7 | + |
| M | internal/ | 3 | + |
@@ -76,6 +76,7 @@ func usage() error {
7676 return errors.New("usage: 012 " + config.FlagUsage() + " [file]: a " + sheet.FileExt +7777 " sheet, or a .csv, .tsv, .json, .nuon, .xlsx, .sqlite, .parquet or .wk1 file to import\n" +7878 " 012 [flags] -: a table from standard input (NUON, JSON, CSV or TSV)\n" +79+ " 012 [flags] --listen [file]: let agents work in the session, attached with 012 mcp --attach (see docs/agents/live.md)\n" +7980 " 012 [flags] --pipe [--to nuon|json|csv|tsv] [--send ask|selection|sheet] [file]: on quitting, send the table to standard output\n" +8081 " 012 nu [flags] [file]: the workbook's nushell notebook (see docs/nushell/notebooks.md)\n" +8182 " 012 nu --module | --install-module [--force] [path]: the nushell module with sheet (see docs/nushell/README.md)\n" +@@ -83,6 +84,7 @@ func usage() error {
8384 " 012 get|set|recalc|export|describe file.012 ...: read and change a workbook without the screen (see docs/files/scripts.md)\n" +8485 " 012 diff a.012 b.012, 012 merge-driver base ours theirs: compare and merge workbooks (see docs/files/git.md)\n" +8586 " 012 mcp file.012 [--read-only] [--force] [--notebooks [--trust]] [--jev]: an MCP server on the workbook (see docs/agents/mcp.md)\n" +87+ " 012 mcp --attach [name]: an MCP server in a running session listening for agents (see docs/agents/live.md)\n" +8688 " 012 agent --skill | --install-skill [--force] [dir]: the Claude Code skill (see docs/agents/README.md)\n" +8789 " 012 config [path|edit|default|themes|set-key|delete-key]\n" +8890 " 012 version")@@ -115,6 +117,7 @@ func run(args []string, e env) error {
115117 if err != nil {116118 return usage()117119 }120+ listen, args := cutFlag(args, "--listen")118121 pipe, args, err := parsePipe(args)119122 if err != nil {120123 return err@@ -148,6 +151,7 @@ func run(args []string, e env) error {
148151 Notes: configNotes(cfg),149152 // Where a crash keeps unsaved work: crash.go.150153 RecoveryDir: recoveryDir(),154+ Version: buildVersion(),151155 }152156 client, notes := startJEV(cfg, e, settings.Keys)153157 settings.Notes = append(settings.Notes, notes...)@@ -161,13 +165,19 @@ func run(args []string, e env) error {
161165 if offersKept(args, pipe.stdin) {162166 m.OfferKept()163167 }168+ if listen {169+ m.Listen() // live mode: agents attach with 012 mcp --attach170+ }164171 setPipe(m, pipe, e.stdin)165172 opts, closeTTY, err := tuiOptions(pipe, e)166173 if err != nil {167174 return err168175 }169176 g := ui.Guard(m)170- err = e.runTUI(g, opts...)177+ // In rooms, so the session can share its workbook with agents178+ // (live mode, docs/agents/live.md).179+ err = e.runTUI(ui.InRooms(g), opts...)180+ m.StopListening()171181 closeTTY()172182 if c := g.Finish(err); c != nil {173183 return crashed(g, c)@@ -178,6 +188,20 @@ func run(args []string, e env) error {
178188 return sendPiped(m, pipe, e.stdout)179189 }180190 191+// cutFlag takes flag out of args, reporting whether it was there.192+func cutFlag(args []string, flag string) (bool, []string) {193+ var rest []string194+ found := false195+ for _, a := range args {196+ if a == flag {197+ found = true198+ continue199+ }200+ rest = append(rest, a)201+ }202+ return found, rest203+}204+181205 // loadConfig reads the config file with the environment and flags.182206 func loadConfig(e env, flags map[string]string) (*config.Config, error) {183207 path, err := config.DefaultPath()@@ -12,6 +12,7 @@ import (
1212 sdk "github.com/modelcontextprotocol/go-sdk/mcp"1313 1414 "github.com/FelineStateMachine/012/internal/config"15+ "github.com/FelineStateMachine/012/internal/cowork"1516 "github.com/FelineStateMachine/012/internal/headless"1617 "github.com/FelineStateMachine/012/internal/jev"1718 "github.com/FelineStateMachine/012/internal/mcp"@@ -24,19 +25,21 @@ import (
2425 // working directory. A file named is the default of tools called2526 // without a path.2627 27-const mcpUsage = "usage: 012 mcp [file.012] [--root dir]... [--read-only] [--force] [--notebooks [--trust]] [--jev]"28+const mcpUsage = "usage: 012 mcp [file.012] [--root dir]... [--read-only] [--force] [--notebooks [--trust]] [--jev]\n 012 mcp --attach [name|socket]: join a running 012 session listening for agents (see docs/agents/live.md)"2829 2930 // runMCP is 012 mcp: it serves until the client closes standard input.3031 // Nothing runs a program or reaches the network unless its flag says:3132 // --notebooks offers run_notebook_cell, --jev answers JEV functions.3233 func runMCP(args []string, e env) error {33- a, err := parseArgs(args, []string{"root"}, []string{"read-only", "force", "notebooks", "trust", "jev", "help"})34+ a, err := parseArgs(args, []string{"root"}, []string{"read-only", "force", "notebooks", "trust", "jev", "help", "attach"})3435 switch {3536 case err != nil:3637 return usageError(err.Error(), mcpUsage)3738 case a.has("help"):3839 fmt.Fprintln(e.stdout, mcpUsage)3940 return nil41+ case a.has("attach"):42+ return attach(a, e)4043 case len(a.pos) > 1:4144 return usageError("", mcpUsage)4245 case a.has("trust") && !a.has("notebooks"):@@ -75,6 +78,20 @@ func runMCP(args []string, e env) error {
7578 return mcp.New(o).Run(ctx, t)7679 }7780 81+// attach is 012 mcp --attach [name|socket]: live mode, the agent a82+// participant of a running session, whose person decides what it may83+// do, so no other flag goes with it.84+func attach(a cliArgs, e env) error {85+ if len(a.flags) > 1 || len(a.pos) > 1 {86+ return usageError("--attach takes a session's name or socket and no other flag: the session decides what the agent may do", mcpUsage)87+ }88+ target := ""89+ if len(a.pos) == 1 {90+ target = a.pos[0]91+ }92+ return cowork.Attach(target, e.stdin, e.stdout)93+}94+7895 // rootDirs are the folders --root gave, else the working directory,7996 // else, when that's the file system's root (where some hosts start8097 // servers), the home folder.@@ -121,6 +121,13 @@ func runServe(args []string, e env) error {
121121 }122122 fmt.Fprintf(e.stderr, "012 serving %s on %s\nhost key %s\nconnect: ssh -p %s %s\n",123123 srv.Dir(), l.Addr(), gossh.FingerprintSHA256(srv.HostKey()), portOf(l.Addr()), hostOf(l.Addr()))124+ // Agents on this machine join files people have open: live mode.125+ if agents, err := srv.ListenForAgents(buildVersion()); err == nil {126+ defer agents.Close()127+ fmt.Fprintln(e.stderr, "agents: 012 mcp --attach <file someone has open>")128+ } else if o.Share != "off" && o.Share != "" {129+ log.Warn("agents can't join: " + err.Error())130+ }124131 125132 ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)126133 defer stop()@@ -133,6 +133,18 @@ palette) lists the others and goes to one's cell, and **File > Hand over
133133 writing** passes writing on in a one-writer room; neither has a key of134134 its own ([Sharing a workbook](../terminal/ssh.md#sharing-a-workbook)).135135 136+With an agent working in the session ([Live mode](../agents/live.md)):137+138+| Key | Action |139+|---|---|140+| Ctrl+Alt+A | Review suggestions: the agents' changes waiting for you |141+| Up, Down (in the panel) | Go from suggestion to cell; the grid shows the cell |142+| Enter, A (in the panel) | Accept the suggestion, or the cell, highlighted; click ✓ |143+| R, Del (in the panel) | Reject it; click ✗ |144+| Shift+A, Shift+R (in the panel) | Accept or reject every suggestion waiting |145+| Enter, N, Esc (an agent's question) | Yes, no, cancel; a choice's number, or a value typed and Enter |146+| Enter, A, Esc (an agent's request) | Allow once, allow for the session, deny |147+136148 ## Macros137149 138150 | Key | Action |@@ -281,6 +281,19 @@ func (s *Seat) Peer(id int) (Peer, bool) {
281281 // Others is how many others are in the room. Call it in a turn.282282 func (s *Seat) Others() int { return len(s.room.seats) - 1 }283283 284+// People is how many other people are in the room, agents left out:285+// those who keep the workbook open when this one leaves. Call it in a286+// turn.287+func (s *Seat) People() int {288+ n := 0289+ for _, o := range s.room.seats {290+ if o != s && !o.agent {291+ n++292+ }293+ }294+ return n295+}296+284297 // Marks are the operations since seq, oldest first, whoever made them.285298 // Call it in a turn.286299 func (s *Seat) Marks(since uint64) []Mark {@@ -0,0 +1,44 @@
1+package serve2+3+import (4+ "errors"5+ "fmt"6+ "path/filepath"7+ "strings"8+9+ "github.com/FelineStateMachine/012/internal/cowork"10+)11+12+// ListenForAgents lets agents on the server's machine, run by the13+// server's user, join the files people have open (live mode,14+// docs/agents/live.md): 012 mcp --attach names a file in the served15+// folder, or a named room (@name), and the agent sits in its room with16+// the people there. It needs sharing on.17+func (s *Server) ListenForAgents(version string) (*cowork.Listener, error) {18+ if s.rooms == nil {19+ return nil, errors.New("sharing is off (--share off): agents join shared workbooks")20+ }21+ return cowork.Listen(cowork.Options{Registry: s.rooms, Room: s.agentRoom, Kind: "serve", Workbook: s.Dir(), Version: version})22+}23+24+// agentRoom is the room of the file an agent names, which someone must25+// have open.26+func (s *Server) agentRoom(name string) (string, error) {27+ if name == "" {28+ return "", errors.New("name the file to join: 012 mcp --attach budget.012")29+ }30+ key := name31+ if !strings.HasPrefix(name, "@") {32+ p, err := s.root.Resolve(name)33+ if err != nil {34+ return "", fmt.Errorf("%s: %w", name, err)35+ }36+ if key, err = filepath.Abs(p); err != nil {37+ return "", err38+ }39+ }40+ if !s.rooms.Has(key) {41+ return "", fmt.Errorf("nobody has %s open in 012 serve: an agent joins a file someone has open", name)42+ }43+ return key, nil44+}@@ -0,0 +1,148 @@
1+package ui2+3+import (4+ "strconv"5+6+ tea "charm.land/bubbletea/v2"7+8+ "github.com/FelineStateMachine/012/internal/cowork"9+)10+11+// An agent's questions (the ask tool) and requests (to run notebook12+// cells, to enter JEV formulas directly) wait on the room's board until13+// the person it asks is free: in READY, nothing open. Then the question14+// takes the context line, a field at a time: a yes or no, a choice, or15+// a value typed as a prompt. Esc cancels; the answer goes back to the16+// agent as an elicitation's result.17+18+// agentAsks opens the next question waiting for this session, when the19+// person is free, and lets go of one the agent withdrew.20+func (m *Model) agentAsks() tea.Cmd {21+ seat := m.share.seat22+ if seat == nil {23+ return nil24+ }25+ if q := m.agents.asking; q != nil {26+ if q.Done() {27+ m.agents.asking = nil28+ m.closeAsk()29+ m.note = q.Agent + " stopped waiting for an answer"30+ }31+ return nil32+ }33+ if m.mode != modeReady || m.overlay != nil {34+ return nil35+ }36+ b := m.board()37+ q := b.NextAsk(seat.ID(), func(id int) bool {38+ _, here := seat.Peer(id)39+ return here || id == seat.ID()40+ })41+ if q == nil {42+ return nil43+ }44+ m.agents.asking = q45+ if q.Kind == cowork.Grant {46+ m.askGrant(q)47+ } else {48+ m.askField(q, 0, map[string]any{})49+ }50+ return nil51+}52+53+// closeAsk closes the question on the context line.54+func (m *Model) closeAsk() {55+ switch {56+ case m.mode == modePrompt:57+ m.closePrompt()58+ case m.overlay != nil:59+ m.closeOverlay()60+ }61+}62+63+// answerAsk sends the person's answer to the agent.64+func (m *Model) answerAsk(q *cowork.Ask, ans cowork.Answer) {65+ m.agents.asking = nil66+ if m.share.seat != nil {67+ m.board().Answer(q, ans)68+ }69+ switch ans.Action {70+ case "accept":71+ m.note = "Answered " + q.Agent72+ case "decline":73+ m.note = "Told " + q.Agent + " no"74+ default:75+ m.note = "Didn't answer " + q.Agent76+ }77+}78+79+// askHead is how a question starts on the context line.80+func askHead(q *cowork.Ask) string { return "◆ " + q.Agent + " asks: " + q.Message }81+82+// askField asks field i of q, the answers so far in content; past the83+// last, the answer goes back.84+func (m *Model) askField(q *cowork.Ask, i int, content map[string]any) {85+ cancel := func(m *Model) tea.Cmd { m.answerAsk(q, cowork.Answer{Action: "cancel"}); return nil }86+ if len(q.Fields) == 0 {87+ m.ask(question{msg: askHead(q), choices: []choice{88+ {key: "enter", label: "Yes", run: func(m *Model) tea.Cmd { m.answerAsk(q, cowork.Answer{Action: "accept"}); return nil }},89+ {key: "n", label: "No", run: func(m *Model) tea.Cmd { m.answerAsk(q, cowork.Answer{Action: "decline"}); return nil }},90+ {key: "esc", label: "Cancel", run: cancel},91+ }})92+ return93+ }94+ if i == len(q.Fields) {95+ m.answerAsk(q, cowork.Answer{Action: "accept", Content: content})96+ return97+ }98+ f := q.Fields[i]99+ head := askHead(q) + " " + f.Label()100+ if i > 0 {101+ head = "◆ " + q.Agent + " asks: " + f.Label()102+ }103+ next := func(v any) func(m *Model) tea.Cmd {104+ return func(m *Model) tea.Cmd {105+ content[f.Name] = v106+ m.askField(q, i+1, content)107+ return nil108+ }109+ }110+ switch f.Kind {111+ case cowork.Boolean:112+ m.ask(question{msg: head + "?", desc: f.Description, choices: []choice{113+ {key: "y", label: "Yes", run: next(true)}, {key: "n", label: "No", run: next(false)}, {key: "esc", label: "Cancel", run: cancel}}})114+ case cowork.Choice:115+ var chs []choice116+ for j := range f.Choices {117+ chs = append(chs, choice{key: strconv.Itoa(j + 1), label: f.ChoiceLabel(j), run: next(f.Choices[j])})118+ }119+ m.ask(question{msg: head, desc: f.Description, choices: append(chs, choice{key: "esc", label: "Cancel", run: cancel})})120+ default:121+ m.askText(head+":", "", func(text string) {122+ v, err := f.Parse(text)123+ if err != nil {124+ m.fail(err.Error())125+ m.askField(q, i, content)126+ return127+ }128+ next(v)(m)129+ }, func() { cancel(m) })130+ }131+}132+133+// askGrant asks leave for what an agent wants to do beyond changing134+// cells, once or for the session.135+func (m *Model) askGrant(q *cowork.Ask) {136+ answer := func(ans cowork.Answer) func(m *Model) tea.Cmd {137+ return func(m *Model) tea.Cmd { m.answerAsk(q, ans); return nil }138+ }139+ desc := "Notebook cells run with nu as you, with your files, as an untrusted macro would"140+ if q.Grant == "jev" {141+ desc = "JEV formulas send cells' values to TypeSafe's model with your API key"142+ }143+ m.ask(question{msg: "◆ " + q.Message + ".", warn: true, desc: desc, choices: []choice{144+ {key: "enter", label: "Allow once", run: answer(cowork.Answer{Action: "accept"})},145+ {key: "a", label: "Allow for this session", run: answer(cowork.Answer{Action: "accept", Always: true})},146+ {key: "esc", label: "Deny", run: answer(cowork.Answer{Action: "decline"})},147+ }})148+}@@ -0,0 +1,139 @@
1+package ui2+3+import (4+ "strconv"5+ "strings"6+7+ "charm.land/lipgloss/v2"8+ "github.com/charmbracelet/x/ansi"9+10+ "github.com/FelineStateMachine/012/internal/cowork"11+ "github.com/FelineStateMachine/012/internal/sheet"12+ "github.com/FelineStateMachine/012/internal/ui/theme"13+)14+15+// Drawing agents' suggestions: each cell a suggestion waiting would set16+// has a ◇ in its top-left corner (theme.Suggested), the context line17+// says what the agent would put there and why when the pointer is on18+// one, and the status line counts those waiting. Agents themselves are19+// drawn as the others are (sharedraw.go), their initial a ◆.20+21+// agentFrame is what a frame draws of suggestions, worked out once as22+// it starts: the cells of the sheet shown that suggestions waiting set,23+// and how many suggestions wait.24+type agentFrame struct {25+ cells map[sheet.Addr]suggestedCell26+ waiting int27+}28+29+// suggestedCell is a cell of a suggestion.30+type suggestedCell struct {31+ s *cowork.Suggestion32+ i int33+}34+35+// frameAgents works out the suggestions for the frame about to be36+// drawn.37+func (m *Model) frameAgents() {38+ f := &m.agents.frame39+ clear(f.cells)40+ f.waiting = 041+ if m.share.seat == nil {42+ return43+ }44+ pending := m.board().Pending()45+ f.waiting = len(pending)46+ name := m.sheet.Name()47+ for _, s := range pending {48+ for i, c := range s.Cells {49+ if c.Sheet != name || !s.Whole() && s.States[i] != cowork.CellPending {50+ continue51+ }52+ if f.cells == nil {53+ f.cells = map[sheet.Addr]suggestedCell{}54+ }55+ f.cells[c.At] = suggestedCell{s, i}56+ }57+ }58+}59+60+// suggestedMark draws the ◇ of a cell a suggestion would set over the61+// first column of its text.62+func (m *Model) suggestedMark(text string, a sheet.Addr, base lipgloss.Style, colored bool) string {63+ if _, ok := m.agents.frame.cells[a]; !ok || m.sheet.ColWidth(a.Col) < 2 {64+ return text65+ }66+ mark := m.th.Suggested67+ if colored {68+ mark = base.Bold(true)69+ }70+ return mark.Render("◇") + ansi.Cut(text, 1, m.sheet.ColWidth(a.Col))71+}72+73+// agentLine is the context line's word on a cell a suggestion would74+// set: whose, what it would put there, what's there now and why.75+func (m *Model) agentLine() string {76+ sc, ok := m.agents.frame.cells[m.cur]77+ if !ok {78+ return ""79+ }80+ s, c := sc.s, sc.s.Cells[sc.i]81+ what := shownInput(c.Now)82+ if c.Look && c.Was == c.Now {83+ what = "a new format"84+ }85+ line := m.th.Suggested.Render("◇ ") + m.th.Peer[s.Color%theme.Peers].Render(" ◆ "+s.Agent+" ") +86+ m.th.Muted.Render(" suggests ") + m.th.Key.Render(what) + m.th.Muted.Render(", now "+shownInput(c.Was))87+ if s.Message != "" {88+ line += m.th.Muted.Render(": " + s.Message)89+ }90+ return line + " " + m.th.KeyHints(keyLabel(keyFor("agent.review")), "review")91+}92+93+// keyFor is the first key bound to a command.94+func keyFor(id string) string {95+ if keys := keysFor(id, false); len(keys) > 0 {96+ return keys[0]97+ }98+ return ""99+}100+101+// shownInput is an input as live mode shows it: blank for none.102+func shownInput(in string) string {103+ if in == "" {104+ return "blank"105+ }106+ return in107+}108+109+// agentsStatus is the status line's count of suggestions waiting.110+func (m *Model) agentsStatus() string {111+ n := m.agents.frame.waiting112+ if n == 0 {113+ return ""114+ }115+ what := strconv.Itoa(n) + " suggestions"116+ if n == 1 {117+ what = "1 suggestion"118+ }119+ return " " + m.th.Suggested.Render("◇ "+what)120+}121+122+// peerInitial is what stands for a participant on a row header: an123+// agent's ◆, a person's initial.124+func peerInitial(name string, agent bool) string {125+ if agent {126+ return "◆"127+ }128+ return initial(name)129+}130+131+// peerLabel is a participant's name as the status line shows it, an132+// agent's after its ◆.133+func peerLabel(name string, agent bool) string {134+ name = ansi.Truncate(name, 10, "…")135+ if agent {136+ return "◆ " + name137+ }138+ return strings.TrimSpace(name)139+}@@ -0,0 +1,300 @@
1+package ui2+3+import (4+ "fmt"5+ "os/user"6+ "path/filepath"7+ "strings"8+9+ tea "charm.land/bubbletea/v2"10+11+ "github.com/FelineStateMachine/012/internal/cowork"12+ "github.com/FelineStateMachine/012/internal/room"13+ "github.com/FelineStateMachine/012/internal/sheet"14+ "github.com/FelineStateMachine/012/internal/ui/picker"15+ "github.com/FelineStateMachine/012/internal/ui/review"16+)17+18+// Live mode (docs/agents/live.md): an agent works in this session as a19+// participant of its room. A local session invited to (012 --listen,20+// File > Invite an 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).27+28+// agentState is the session's part in live mode.29+type 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+}35+36+func 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+}53+54+// noRoom hides what needs a room: live mode's commands before an agent55+// is invited.56+func noRoom(m *Model) bool { return m.share.seat == nil }57+58+// board is the room's board of agents; nil outside a room. Call it in a59+// turn.60+func (m *Model) board() *cowork.Board {61+ if m.share.seat == nil {62+ return &cowork.Board{}63+ }64+ return cowork.BoardOf(m.share.seat)65+}66+67+// openInvite asks what the agent may change.68+func (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+}90+91+// inviting is a picker's choice of scope.92+func (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+}99+100+// invite lets agents in with scope s: at once when the session already101+// listens, else once it has joined its room (enterLocal).102+func (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+}124+125+// Listen has the session listen for agents from the start, letting126+// them suggest changes to the whole workbook: 012 --listen.127+func (m *Model) Listen() { m.invite(cowork.Scope{Kind: cowork.Workbook}) }128+129+// StopListening closes the session's socket; agents attached leave.130+func (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+}138+139+// userName is who the person is to agents.140+func 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+}146+147+// 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.150+func (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+ saved := room.Saved{State: m.saved, Outputs: m.nb.saved, Name: m.filename, Stamp: m.disk}156+ if m.filename != "" {157+ saved.Key = req.key158+ }159+ seat.SetSaved(saved)160+ b := cowork.BoardOf(seat)161+ b.Host, b.Scope = seat.ID(), m.agents.scope162+ m.startListening(req.key)163+ return m.waitRoom()164+}165+166+// startListening opens the session's socket for agents joining the room167+// with key.168+func (m *Model) startListening(key string) {169+ name := strings.TrimSuffix(filepath.Base(m.filename), filepath.Ext(m.filename))170+ if m.filename == "" {171+ name = "Untitled"172+ }173+ l, err := cowork.Listen(cowork.Options{Registry: m.share.reg, Room: func(string) (string, error) { return key, nil },174+ Kind: "session", Workbook: name, Version: m.prefs.Version})175+ if err != nil {176+ m.warn = "Can't listen for agents: " + err.Error()177+ return178+ }179+ m.agents.listener = l180+ m.note = fmt.Sprintf("Agents may change %s: attach with 012 mcp --attach %s", m.agents.scope, name)181+}182+183+// waitRoom waits for the room to change, in a local session, where no184+// server sends the program its roomMsg.185+func (m *Model) waitRoom() tea.Cmd {186+ if !m.share.local || m.share.still || m.share.waiting {187+ return nil188+ }189+ m.share.waiting = true190+ kick := m.share.link.kick191+ return func() tea.Msg {192+ <-kick193+ return roomMsg{}194+ }195+}196+197+// roomKicked catches up with the room, and waits for it again.198+func (m *Model) roomKicked() tea.Cmd {199+ m.share.waiting = false200+ return tea.Batch(m.roomChanged(), m.waitRoom())201+}202+203+// leftLocal stops listening when the session leaves its room: it opened204+// another file, and agents work on the one they were invited to.205+func (m *Model) leftLocal() {206+ if m.agents.listener != nil {207+ m.StopListening()208+ m.note = "Stopped inviting agents: they work on the workbook they were invited to"209+ }210+}211+212+// toggleDirect turns direct edits by agents on or off.213+func (m *Model) toggleDirect() tea.Cmd {214+ if m.share.seat == nil {215+ return nil216+ }217+ b := m.board()218+ b.Direct = !b.Direct219+ m.share.seat.Touch()220+ if b.Direct {221+ m.note = "Agents' changes are made at once, as their own steps: their undo takes them back"222+ } else {223+ m.note = "Agents' changes are suggestions for you to accept"224+ }225+ return nil226+}227+228+// openReview opens the suggestions panel.229+func (m *Model) openReview() tea.Cmd {230+ m.openOverlay(review.New(m.host()))231+ return nil232+}233+234+// Suggestions are the board's, for the panel.235+func (h host) Suggestions() []review.Item {236+ var out []review.Item237+ for _, s := range h.m.board().Pending() {238+ it := review.Item{ID: s.ID, Agent: s.Agent, Color: s.Color, Label: s.Label, Message: s.Message, Whole: s.Whole(),239+ Other: s.Other, Total: len(s.Cells), Failed: s.Failed}240+ for i, c := range s.Cells {241+ if s.Whole() || s.States[i] == cowork.CellPending {242+ it.Cells = append(it.Cells, review.Cell{Index: i, Sheet: c.Sheet, At: c.At, Was: c.Was, Now: c.Now, Look: c.Look})243+ }244+ }245+ out = append(out, it)246+ }247+ return out248+}249+250+func (h host) Accept(id int, cells []int) { h.m.settle(id, cells, true) }251+func (h host) Reject(id int, cells []int) { h.m.settle(id, cells, false) }252+253+// Reveal shows the cell a suggestion sets.254+func (h host) Reveal(name string, a sheet.Addr) {255+ if s := h.m.book().Lookup(name); s != nil && !s.Hidden() {256+ h.m.showSheet(s)257+ h.m.cur = a258+ h.m.clearSelection()259+ }260+}261+262+// settle accepts or rejects cells of suggestion id (nil: all it has263+// waiting). What's accepted is one step, the agent's.264+func (m *Model) settle(id int, cells []int, accept bool) {265+ b, seat := m.board(), m.share.seat266+ s := b.Get(id)267+ if s == nil || seat == nil {268+ return269+ }270+ what := "all of"271+ if cells != nil && !s.Whole() {272+ what = fmt.Sprintf("%d of %d cells of", len(cells), len(s.Cells))273+ }274+ if !accept {275+ b.Reject(id, cells)276+ seat.Touch()277+ m.note = "Rejected " + what + " " + s.Agent + "'s suggestion: " + s.Label278+ return279+ }280+ if !m.mayEdit() {281+ return282+ }283+ if err := b.Accept(m.book(), id, cells); err != nil {284+ m.warn = err.Error()285+ return286+ }287+ seat.Touch()288+ m.note = "Accepted " + what + " " + s.Agent + "'s suggestion: " + s.Label + " (" + s.Agent + "'s to undo)"289+}290+291+// agentRunCell runs the notebook cell an agent asked for, once allowed292+// (cowork.Agent.RunCell), as Run does.293+func (m *Model) agentRunCell(rc cowork.RunCell) tea.Cmd {294+ s := m.book().Lookup(rc.Notebook)295+ if s == nil || !s.IsNotebook() || rc.Cell >= len(s.NotebookCells()) {296+ return nil297+ }298+ m.note = fmt.Sprintf("%s runs cell %d of %s", rc.Agent, rc.Cell+1, rc.Notebook)299+ return m.runCells(s, rc.Cell, s.NotebookCells())300+}@@ -0,0 +1,288 @@
1+package ui2+3+import (4+ "context"5+ "os"6+ "path/filepath"7+ "strings"8+ "testing"9+ "time"10+11+ tea "charm.land/bubbletea/v2"12+ sdk "github.com/modelcontextprotocol/go-sdk/mcp"13+14+ "github.com/FelineStateMachine/012/internal/cowork"15+ "github.com/FelineStateMachine/012/internal/headless"16+ "github.com/FelineStateMachine/012/internal/mcp"17+ "github.com/FelineStateMachine/012/internal/sheet"18+)19+20+// liveSession is a local session listening for agents, and an agent in21+// its room, as 012 --listen and 012 mcp --attach make them.22+type liveSession struct {23+ *seshion24+ agent *cowork.Agent25+}26+27+// testAgent is an agent in the room without a connection.28+type testAgent struct{ kick chan struct{} }29+30+func (a *testAgent) Name() string { return "claude" }31+func (a *testAgent) Agent() {}32+func (a *testAgent) Notify() {33+ select {34+ case a.kick <- struct{}{}:35+ default:36+ }37+}38+39+// startLive opens a session on a workbook of 40, 2 and their sum in40+// A1:A3, listening for agents, with an agent attached.41+func startLive(t *testing.T) *liveSession {42+ t.Helper()43+ run, err := os.MkdirTemp("", "o12")44+ if err != nil {45+ t.Fatal(err)46+ }47+ t.Cleanup(func() { os.RemoveAll(run) })48+ t.Setenv("XDG_RUNTIME_DIR", run)49+ s := sheet.New()50+ for i, in := range []string{"40", "2", "=A1+A2"} {51+ s.Set(sheet.Addr{Row: i}, in)52+ }53+ s.Book().ClearHistory()54+ path := filepath.Join(t.TempDir(), "budget.012")55+ m := New(s, path)56+ m.SetMachine("here")57+ m.nb.still = true58+ sh := InRooms(Guard(m))59+ ls := &liveSession{seshion: &seshion{t: t, sh: sh, m: m}}60+ m.Listen()61+ m.share.still = true62+ ls.run(sh.Init())63+ ls.send(tea.WindowSizeMsg{Width: 80, Height: 20})64+ t.Cleanup(m.StopListening)65+ if m.share.seat == nil || m.agents.listener == nil {66+ t.Fatalf("not listening: %s", m.warn)67+ }68+ seat, ok := m.share.reg.JoinOpen(m.share.seat.Key(), &testAgent{kick: make(chan struct{}, 1)})69+ if !ok {70+ t.Fatal("the agent couldn't join")71+ }72+ ls.agent = cowork.NewAgent(seat)73+ ls.sync()74+ return ls75+}76+77+// sync hands the session the room's changes.78+func (ls *liveSession) sync() {79+ for range 10 {80+ select {81+ case <-ls.m.share.link.kick:82+ ls.send(roomMsg{})83+ default:84+ return85+ }86+ }87+}88+89+// suggest has the agent write entries, pairs of a reference and an90+// input, as write_cells does.91+func (ls *liveSession) suggest(message string, entries ...string) mcp.Made {92+ ls.t.Helper()93+ var es []headless.Entry94+ for i := 0; i+1 < len(entries); i += 2 {95+ es = append(es, headless.Entry{Ref: entries[i], Input: entries[i+1]})96+ }97+ made, err := ls.agent.Make(context.Background(), mcp.Making{Label: "set", Message: message, Fn: func(w *sheet.Workbook) error {98+ _, err := headless.Set(w, es, headless.SetOptions{})99+ return err100+ }})101+ if err != nil {102+ ls.t.Fatal(err)103+ }104+ ls.sync()105+ return made106+}107+108+func TestListenSharesTheWorkbookAsItIs(t *testing.T) {109+ ls := startLive(t)110+ ls.press("<down>", "<down>", "<down>", "7", "<enter>")111+ if !ls.m.book().CanUndo() {112+ t.Fatal("the person can't undo their step")113+ }114+ if !ls.m.changed || ls.m.filename == "" {115+ t.Fatal("the session lost its file")116+ }117+ var peers string118+ _, peers = ls.m.Others()119+ if peers != "claude" {120+ t.Fatalf("others: %q", peers)121+ }122+ if got := line(ls.m, ls.m.height-1); !strings.Contains(got, "◆ claude") {123+ t.Errorf("status line %q", got)124+ }125+ eps, _ := cowork.Endpoints(filepath.Join(os.Getenv("XDG_RUNTIME_DIR"), "012"))126+ if len(eps) != 1 || eps[0].Workbook != "budget" {127+ t.Fatalf("endpoints %+v", eps)128+ }129+}130+131+func TestSuggestionMarkedAndAccepted(t *testing.T) {132+ ls := startLive(t)133+ made := ls.suggest("keep the total at 50", "A1", "48")134+ if made.Suggestion != 1 || made.Applied {135+ t.Fatalf("made %+v", made)136+ }137+ if got := ls.m.sheet.Value(addr("A3")).Num; got != 42 {138+ t.Fatalf("the suggestion was made: A3 = %v", got)139+ }140+ if !strings.Contains(ls.screen(), "◇40") && !strings.Contains(ls.screen(), "◇ ") {141+ t.Errorf("no mark on A1:\n%s", ls.screen())142+ }143+ ls.press("<down>", "<up>")144+ ctx := line(ls.m, contextLine)145+ if !strings.Contains(ctx, "◆ claude") || !strings.Contains(ctx, "suggests 48, now 40: keep the total at 50") {146+ t.Errorf("context line %q", ctx)147+ }148+ if got := line(ls.m, ls.m.height-1); !strings.Contains(got, "◇ 1 suggestion") {149+ t.Errorf("status line %q", got)150+ }151+ ls.press("<ctrl+alt+a>")152+ if !strings.Contains(ls.screen(), "Suggestions") || !strings.Contains(ls.screen(), "A1 40 → 48") {153+ t.Fatalf("the panel:\n%s", ls.screen())154+ }155+ ls.press("<enter>")156+ if got := ls.m.sheet.Value(addr("A3")).Num; got != 50 {157+ t.Fatalf("A3 = %v after accepting", got)158+ }159+ if ls.m.overlay != nil {160+ t.Error("the panel stayed open with nothing waiting")161+ }162+ if !strings.Contains(ls.m.note, "Accepted all of claude's suggestion") {163+ t.Errorf("note %q", ls.m.note)164+ }165+ if ls.m.book().CanUndo() {166+ t.Error("the person's undo takes back the agent's step")167+ }168+ if label, err := ls.agent.Undo(context.Background()); err != nil || label != "set" {169+ t.Fatalf("the agent's undo: %q %v", label, err)170+ }171+ ls.sync()172+ if got := ls.m.sheet.Value(addr("A3")).Num; got != 42 {173+ t.Fatalf("A3 = %v after the agent's undo", got)174+ }175+}176+177+func TestSuggestionByCellInThePanel(t *testing.T) {178+ ls := startLive(t)179+ ls.suggest("", "A1", "1", "A2", "2000")180+ ls.press("<ctrl+alt+a>", "<down>", "<down>", "r")181+ if got := input(ls.m, "A2"); got != "2" {182+ t.Fatalf("A2 = %q after rejecting it", got)183+ }184+ ls.press("<enter>") // the heading again: the rest185+ if got := input(ls.m, "A1"); got != "1" {186+ t.Fatalf("A1 = %q after accepting the rest", got)187+ }188+ list, _ := ls.agent.Suggestions(context.Background())189+ if list[0].State != "accepted in part" {190+ t.Fatalf("suggestions %+v", list)191+ }192+}193+194+func TestSuggestionRejectedTellsTheAgent(t *testing.T) {195+ ls := startLive(t)196+ ls.suggest("", "B1", "x")197+ ls.press("<ctrl+alt+a>", "r")198+ made := ls.suggest("", "B2", "y")199+ if len(made.Notices) != 1 || !strings.Contains(made.Notices[0], "was rejected") {200+ t.Fatalf("notices %v", made.Notices)201+ }202+}203+204+func TestInviteChoosesTheScope(t *testing.T) {205+ ls := startLive(t)206+ ls.m.runCommand("agent.invite")207+ ls.press("Read", "<enter>")208+ ls.sync()209+ if !strings.Contains(ls.m.note, "Agents may change read only") {210+ t.Errorf("note %q", ls.m.note)211+ }212+ _, err := ls.agent.Make(context.Background(), mcp.Making{Label: "set", Fn: func(w *sheet.Workbook) error {213+ return w.Sheet(0).Set(sheet.Addr{}, "1")214+ }})215+ if err == nil || !strings.Contains(err.Error(), "read only") {216+ t.Fatalf("a read-only agent wrote: %v", err)217+ }218+}219+220+func TestDirectEditsSetting(t *testing.T) {221+ ls := startLive(t)222+ ls.m.runCommand("agent.direct")223+ ls.sync()224+ made := ls.suggest("", "A1", "8")225+ if !made.Applied || input(ls.m, "A1") != "8" {226+ t.Fatalf("made %+v, A1 %q", made, input(ls.m, "A1"))227+ }228+}229+230+// ask waits for the agent's question to reach the session and shows it.231+func (ls *liveSession) waitAsk() {232+ ls.t.Helper()233+ for deadline := time.Now().Add(5 * time.Second); time.Now().Before(deadline); time.Sleep(5 * time.Millisecond) {234+ ls.sync()235+ ls.send(nil)236+ if ls.m.agents.asking != nil {237+ return238+ }239+ }240+ ls.t.Fatal("the question didn't come")241+}242+243+func TestAgentAsksOnTheContextLine(t *testing.T) {244+ ls := startLive(t)245+ got := make(chan *sdk.ElicitResult, 1)246+ go func() {247+ res, err := ls.agent.Ask(context.Background(), &sdk.ElicitParams{Message: "Overwrite B2:B40?"})248+ if err != nil {249+ t.Error(err)250+ }251+ got <- res252+ }()253+ ls.waitAsk()254+ if ctx := line(ls.m, contextLine); !strings.Contains(ctx, "◆ claude asks: Overwrite B2:B40?") || !strings.Contains(ctx, "Yes") {255+ t.Fatalf("context line %q", ctx)256+ }257+ ls.press("n")258+ if res := <-got; res.Action != "decline" {259+ t.Fatalf("answer %+v", res)260+ }261+}262+263+func TestAgentAsksForValues(t *testing.T) {264+ ls := startLive(t)265+ got := make(chan *sdk.ElicitResult, 1)266+ go func() {267+ res, _ := ls.agent.Ask(context.Background(), &sdk.ElicitParams{Message: "Which quarter?", RequestedSchema: map[string]any{268+ "type": "object", "required": []string{"quarter", "target"},269+ "properties": map[string]any{270+ "quarter": map[string]any{"type": "string", "enum": []string{"Q1", "Q2", "Q3"}},271+ "target": map[string]any{"type": "number", "title": "Target"}}}})272+ got <- res273+ }()274+ ls.waitAsk()275+ ls.press("3")276+ if ctx := line(ls.m, contextLine); !strings.Contains(ctx, "Target:") {277+ t.Fatalf("context line %q", ctx)278+ }279+ ls.press("lots", "<enter>")280+ if !strings.Contains(ls.m.errMsg+ls.m.warn, "takes a number") {281+ t.Errorf("no complaint about lots: %q %q", ls.m.errMsg, ls.m.warn)282+ }283+ ls.press("1200", "<enter>")284+ res := <-got285+ if res.Action != "accept" || res.Content["quarter"] != "Q3" || res.Content["target"] != 1200.0 {286+ t.Fatalf("answer %+v", res)287+ }288+}@@ -131,7 +131,7 @@ func (m *Model) reset(s *sheet.Sheet, filename string) {
131131 }132132 nb := nbState{runner: m.nb.runner, runs: &nbRuns{}, served: m.nb.served, words: m.nb.words, asked: m.nb.asked, lang: m.nb.lang}133133 defer func() { m.nb = nb; m.bookOpened() }()134- *m = Model{grid: grid{sheet: s, width: m.width, height: m.height}, filename: filename, th: m.th, term: m.term, jev: m.jev, root: m.root, charts: chartState{last: -1}, prefs: m.prefs, session: m.session, pipe: m.pipe, share: m.share,134+ *m = Model{grid: grid{sheet: s, width: m.width, height: m.height}, filename: filename, th: m.th, term: m.term, jev: m.jev, root: m.root, charts: chartState{last: -1}, prefs: m.prefs, session: m.session, pipe: m.pipe, share: m.share, agents: m.agents,135135 macros: macroState{machine: m.macros.machine, editor: m.macros.editor}, spans: m.spans}136136 s.Book().SetTrace(m.spans)137137 if m.jev != nil {@@ -12,6 +12,7 @@ import (
1212 "github.com/FelineStateMachine/012/internal/ui/findbar"1313 "github.com/FelineStateMachine/012/internal/ui/lineedit"1414 "github.com/FelineStateMachine/012/internal/ui/picker"15+ "github.com/FelineStateMachine/012/internal/ui/review"1516 "github.com/FelineStateMachine/012/internal/ui/rules"1617 "github.com/FelineStateMachine/012/internal/ui/shortcuts"1718 "github.com/FelineStateMachine/012/internal/ui/sortbar"@@ -39,6 +40,7 @@ var (
3940 _ filterpick.Host = host{}4041 _ shortcuts.Host = host{}4142 _ suggest.Host = host{}43+ _ review.Host = host{}4244 )4345 4446 // The in-package hosts the model implements itself.@@ -35,6 +35,7 @@ var menuBar = []menuDef{
3535 {cmd: "file.new"}, {cmd: "file.open"}, {cmd: "file.import"}, sep,3636 {cmd: "file.save"}, {cmd: "file.saveas"}, {title: "Download", items: downloadItems()}, sep,3737 {cmd: "share.who"}, {cmd: "share.hand"}, sep,38+ {cmd: "agent.invite"}, {cmd: "agent.review"}, {cmd: "agent.direct"}, {cmd: "agent.stop"}, sep,3839 {title: "Settings", items: []menuItem{3940 {cmd: "settings.decimal"}, {cmd: "settings.locale"}, {cmd: "settings.vim"}, sep,4041 {cmd: "settings.theme"}, {cmd: "settings.jev_key"}, sep,@@ -129,6 +129,7 @@ type Model struct {
129129 prefs prefs // the settings in effect and the theme chosen: prefs.go130130 session session // what outlasts the file open: the : history, the keyboard: keyboard.go131131 share shareState // the room the workbook is shared in, in 012 serve: share.go132+ agents agentState // agents working here, live mode: agents.go132133 133134 vim vimState // a vim key sequence in progress: vim.go134135 rec *recorder // a macro being recorded: macrorec.go@@ -236,7 +237,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
236237 case followTickMsg, followMsg:237238 cmd = m.handleFollow(msg)238239 case roomMsg:239- cmd = m.roomChanged()240+ cmd = m.roomKicked()240241 case shareTickMsg:241242 m.share.ticking = false242243 case macroCallMsg:@@ -271,7 +272,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
271272 // Any edit may have queued JEV questions.272273 // Chart images follow any change, see graphics.go.273274 // Linked regions may have come, gone or changed: follow.go.274- return m, tea.Batch(cmd, m.jev.send(m.spans.Parent()), m.term.syncImages(m.sheet, m.displayCharts, &m.th, m.spans), m.syncSixel(msg), m.syncFollowers(), m.notebookSync(), m.shareTick())275+ return m, tea.Batch(cmd, m.jev.send(m.spans.Parent()), m.term.syncImages(m.sheet, m.displayCharts, &m.th, m.spans), m.syncSixel(msg), m.syncFollowers(), m.notebookSync(), m.shareTick(), m.agentAsks())275276 }276277 277278 // beginUpdate prepares for an input event and returns the sheet's state@@ -140,7 +140,7 @@ func (m *Model) displayName() string {
140140 if m.filename == "" && m.xfer.Source != "" {141141 return filepath.Base(m.xfer.Source)142142 }143- if m.filename == "" && m.share.seat != nil && strings.HasPrefix(m.share.seat.Key(), "@") {143+ if m.filename == "" && m.share.seat != nil && !m.share.local && strings.HasPrefix(m.share.seat.Key(), "@") {144144 return m.share.seat.Key() // a named room not saved yet145145 }146146 if m.filename == "" {@@ -173,7 +173,7 @@ func TestPipeCommands(t *testing.T) {
173173 return out174174 }175175 m := newModel()176- if slices.Contains(titles(m), "Quit and send sheet") || len(m.applicable(menuBar[0].items)) != len(menuBar[0].items)-5 { // the send commands, and sharing outside 012 serve176+ if slices.Contains(titles(m), "Quit and send sheet") || len(m.applicable(menuBar[0].items)) != len(menuBar[0].items)-8 { // the send commands, sharing outside 012 serve, and agents before one is invited177177 t.Error("send commands outside a pipeline")178178 }179179 m = stdinModel(t, filesNUON, true, 0)@@ -35,6 +35,8 @@ type Settings struct {
3535 // it stops on an internal error, and finds it again: recovery.go.3636 // "" keeps none; 012 serve keeps them in the served directory.3737 RecoveryDir string38+ // Version is 012's, which live mode's MCP server reports to agents.39+ Version string3840 }3941 4042 // prefs is the settings in effect: the component behind File >@@ -0,0 +1,354 @@
1+// Package review is live mode's suggestions panel (docs/agents/live.md):2+// a box at the right of the grid listing the agents' suggestions waiting3+// for the person, each by agent and message with the cells it would4+// set, to accept or reject whole or cell by cell, with keys or the5+// mouse. The grid beside it shows the cell of the row highlighted. It6+// knows the UI only through Host.7+package review8+9+import (10+ "fmt"11+ "strings"12+13+ tea "charm.land/bubbletea/v2"14+ "github.com/charmbracelet/x/ansi"15+16+ "github.com/FelineStateMachine/012/internal/sheet"17+ "github.com/FelineStateMachine/012/internal/ui/overlay"18+ "github.com/FelineStateMachine/012/internal/ui/theme"19+)20+21+// Host is what the panel needs of the UI.22+type Host interface {23+ Theme() *theme.Theme24+ Size() (width, height int)25+ // Close closes the open overlay.26+ Close()27+ // Suggestions are those waiting, oldest first.28+ Suggestions() []Item29+ // Accept and Reject settle the cells of suggestion id picked, by30+ // their index in it; nil is all it has waiting.31+ Accept(id int, cells []int)32+ Reject(id int, cells []int)33+ // Reveal puts the pointer on a cell a suggestion sets.34+ Reveal(sheet string, at sheet.Addr)35+}36+37+// Item is a suggestion as the panel lists it.38+type Item struct {39+ ID int40+ Agent string41+ Color int42+ Label string43+ Message string44+ Whole bool // it can only be accepted whole45+ Other []string // what it does besides setting cells46+ Cells []Cell // the cells still waiting47+ Total int // the cells it sets, waiting or not48+ Failed string // why accepting it failed49+}50+51+// Cell is one cell a suggestion sets.52+type Cell struct {53+ Index int // in the suggestion54+ Sheet string55+ At sheet.Addr56+ Was, Now string57+ Look bool // its format, style or note change too58+}59+60+// ID identifies the panel's box in mouse events.61+const ID = "review"62+63+// row is a line of the list: a suggestion's heading (cell -1), one of64+// its cells, or what else it does (other).65+type row struct {66+ item int67+ cell int68+ other string69+}70+71+// Panel is the open suggestions panel.72+type Panel struct {73+ h Host74+ sel, top int75+ items []Item76+ rows []row77+ box overlay.Box78+}79+80+// New opens the panel on the first suggestion.81+func New(h Host) *Panel {82+ p := &Panel{h: h}83+ p.refresh()84+ p.show()85+ return p86+}87+88+func (p *Panel) Indicator() string { return "REVIEW" }89+90+// refresh lists the suggestions as they are now.91+func (p *Panel) refresh() {92+ p.items = p.h.Suggestions()93+ p.rows = p.rows[:0]94+ for i, it := range p.items {95+ p.rows = append(p.rows, row{item: i, cell: -1})96+ for _, o := range it.Other {97+ p.rows = append(p.rows, row{item: i, cell: -1, other: o})98+ }99+ for j := range it.Cells {100+ p.rows = append(p.rows, row{item: i, cell: j})101+ }102+ }103+ p.sel = min(max(p.sel, 0), max(len(p.rows)-1, 0))104+ for p.sel > 0 && p.rows[p.sel].other != "" {105+ p.sel--106+ }107+}108+109+// show puts the grid's pointer on the highlighted row's cell.110+func (p *Panel) show() {111+ if len(p.rows) == 0 {112+ return113+ }114+ r := p.rows[p.sel]115+ it := p.items[r.item]116+ c := r.cell117+ if c < 0 {118+ c = 0119+ }120+ if c < len(it.Cells) {121+ p.h.Reveal(it.Cells[c].Sheet, it.Cells[c].At)122+ }123+}124+125+// visible is how many rows fit.126+func (p *Panel) visible() int {127+ _, height := p.h.Size()128+ return max(min(len(p.rows), height-overlay.GridTop-3), 1)129+}130+131+func (p *Panel) Layout() []overlay.Box {132+ p.refresh()133+ th := p.h.Theme()134+ width, _ := p.h.Size()135+ inner := max(min(58, width-4), 20)136+ n := p.visible()137+ p.top = min(max(p.top, p.sel-n+1, 0), p.sel)138+ lines := make([]string, n)139+ for i := range lines {140+ j := p.top + i141+ if j >= len(p.rows) {142+ lines[i] = strings.Repeat(" ", inner)143+ continue144+ }145+ lines[i] = p.line(th, p.rows[j], inner, j == p.sel)146+ }147+ if len(p.rows) == 0 {148+ lines[0] = theme.Cells(th.Muted, " No suggestions waiting", inner)149+ }150+ footer := fmt.Sprintf("%d waiting", len(p.items))151+ b := th.Frame(inner, "Suggestions", footer, lines)152+ p.box = overlay.Box{ID: ID, X: max(width-ansi.StringWidth(b[0]), 0), Y: overlay.GridTop, Lines: b}153+ return []overlay.Box{p.box}154+}155+156+// line draws a row in inner columns: a suggestion's agent, message and157+// size, or a cell's address and its value then and now, with the158+// accept and reject chips at the right of the one highlighted.159+func (p *Panel) line(th *theme.Theme, r row, inner int, sel bool) string {160+ it := p.items[r.item]161+ base := th.MenuBar162+ if sel {163+ base = th.MenuSelected164+ }165+ chips := ""166+ if sel && r.other == "" {167+ chips = " " + th.Chip("✓") + " " + th.Chip("✗")168+ }169+ room := inner - ansi.StringWidth(chips)170+ var text string171+ switch {172+ case r.other != "":173+ return theme.Cells(th.Muted, " also "+r.other, inner)174+ case r.cell < 0:175+ what := it.Message176+ if what == "" {177+ what = it.Label178+ }179+ size := fmt.Sprintf("%d cells", len(it.Cells))180+ if it.Whole {181+ size = "whole"182+ } else if len(it.Cells) == 1 {183+ size = "1 cell"184+ }185+ text = " " + agentMark(it.Agent) + ": " + what + " (" + size + ")"186+ if it.Failed != "" {187+ text += " ! " + it.Failed188+ }189+ if !sel {190+ return th.Peer[it.Color%theme.Peers].Render(" ◆ ") + theme.Cells(base, strings.TrimPrefix(text, " ◆"), room-3) + chips191+ }192+ default:193+ c := it.Cells[r.cell]194+ text = " ◇ " + cellName(it, c) + " " + shown(c.Was) + " → " + shown(c.Now)195+ if c.Look && c.Was == c.Now {196+ text = " ◇ " + cellName(it, c) + " format"197+ }198+ }199+ return theme.Cells(base, text, room) + chips200+}201+202+// agentMark is an agent's name with the mark agents have.203+func agentMark(name string) string { return "◆ " + name }204+205+// cellName is a cell's address, with its sheet when the suggestion sets206+// cells on more than one.207+func cellName(it Item, c Cell) string {208+ for _, o := range it.Cells {209+ if o.Sheet != c.Sheet {210+ return sheet.Qualified(c.Sheet, sheet.Rect{From: c.At, To: c.At})211+ }212+ }213+ return c.At.String()214+}215+216+// shown is an input as the panel shows it: blank for none.217+func shown(in string) string {218+ if in == "" {219+ return "blank"220+ }221+ return in222+}223+224+// target is what the highlighted row settles: a suggestion and the225+// cells picked, nil for all it has waiting.226+func (p *Panel) target() (int, []int, bool) {227+ if len(p.rows) == 0 {228+ return 0, nil, false229+ }230+ r := p.rows[p.sel]231+ it := p.items[r.item]232+ if r.cell < 0 || it.Whole {233+ return it.ID, nil, true234+ }235+ return it.ID, []int{it.Cells[r.cell].Index}, true236+}237+238+func (p *Panel) Key(k tea.KeyPressMsg) tea.Cmd {239+ switch k.String() {240+ case "up", "k":241+ p.move(-1)242+ case "down", "j":243+ p.move(1)244+ case "enter", "a", "y":245+ p.settle(true)246+ case "r", "n", "delete", "backspace":247+ p.settle(false)248+ case "A":249+ p.all(true)250+ case "R":251+ p.all(false)252+ case "esc", "q":253+ p.h.Close()254+ }255+ return nil256+}257+258+// move highlights the row d away that settles something.259+func (p *Panel) move(d int) {260+ for i := p.sel + d; i >= 0 && i < len(p.rows); i += d {261+ if p.rows[i].other == "" {262+ p.sel = i263+ break264+ }265+ }266+ p.show()267+}268+269+// settle accepts or rejects what the highlighted row stands for.270+func (p *Panel) settle(accept bool) {271+ id, cells, ok := p.target()272+ if !ok {273+ return274+ }275+ if accept {276+ p.h.Accept(id, cells)277+ } else {278+ p.h.Reject(id, cells)279+ }280+ p.after()281+}282+283+// all accepts or rejects every suggestion waiting.284+func (p *Panel) all(accept bool) {285+ for _, it := range p.items {286+ if accept {287+ p.h.Accept(it.ID, nil)288+ } else {289+ p.h.Reject(it.ID, nil)290+ }291+ }292+ p.after()293+}294+295+// after lists what's left, closing when nothing is.296+func (p *Panel) after() {297+ p.refresh()298+ if len(p.rows) == 0 {299+ p.h.Close()300+ return301+ }302+ p.show()303+}304+305+func (p *Panel) Mouse(e overlay.MouseEvent) tea.Cmd {306+ switch {307+ case e.Kind == overlay.MouseWheel && e.Button == tea.MouseWheelUp:308+ p.move(-1)309+ case e.Kind == overlay.MouseWheel && e.Button == tea.MouseWheelDown:310+ p.move(1)311+ case e.Kind != overlay.MousePress:312+ case e.Box != ID:313+ p.h.Close()314+ default:315+ p.click(e.Col, e.Row)316+ }317+ return nil318+}319+320+// click highlights the row clicked, and on its chips accepts or rejects321+// it: the chips are the last eight columns inside the frame.322+func (p *Panel) click(col, line int) {323+ i := p.top + line - 1 // the frame's top border324+ if line < 1 || i >= len(p.rows) || p.rows[i].other != "" {325+ return326+ }327+ was := p.sel328+ p.sel = i329+ inner := p.box.Width() - 2330+ switch {331+ case i != was:332+ p.show()333+ case col > inner-4 && col <= inner:334+ p.settle(false)335+ case col > inner-8 && col <= inner-4:336+ p.settle(true)337+ default:338+ p.show()339+ }340+}341+342+func (p *Panel) Status() (string, string) {343+ th := p.h.Theme()344+ keys := th.KeyHints("Enter", "accept", "R", "reject", "Shift+A", "all", "Esc", "close")345+ if len(p.rows) == 0 {346+ return "", keys347+ }348+ it := p.items[p.rows[p.sel].item]349+ desc := it.Agent + " suggests: " + it.Label350+ if it.Message != "" {351+ desc = it.Agent + ": " + it.Message352+ }353+ return desc, keys354+}@@ -0,0 +1,122 @@
1+package review2+3+import (4+ "slices"5+ "strings"6+ "testing"7+8+ tea "charm.land/bubbletea/v2"9+ "github.com/charmbracelet/x/ansi"10+11+ "github.com/FelineStateMachine/012/internal/sheet"12+ "github.com/FelineStateMachine/012/internal/ui/overlay"13+ "github.com/FelineStateMachine/012/internal/ui/theme"14+)15+16+// fakeHost holds suggestions and settles them as the model would.17+type fakeHost struct {18+ th theme.Theme19+ items []Item20+ closed int21+ shown []string22+}23+24+func (f *fakeHost) Theme() *theme.Theme { return &f.th }25+func (f *fakeHost) Size() (width, height int) { return 80, 24 }26+func (f *fakeHost) Close() { f.closed++ }27+func (f *fakeHost) Suggestions() []Item { return slices.Clone(f.items) }28+func (f *fakeHost) Reveal(s string, a sheet.Addr) {29+ f.shown = append(f.shown, sheet.Qualified(s, sheet.Rect{From: a, To: a}))30+}31+32+func (f *fakeHost) settle(id int, cells []int) {33+ for i, it := range f.items {34+ if it.ID != id {35+ continue36+ }37+ if cells == nil {38+ f.items = slices.Delete(f.items, i, i+1)39+ return40+ }41+ it.Cells = slices.DeleteFunc(slices.Clone(it.Cells), func(c Cell) bool { return slices.Contains(cells, c.Index) })42+ f.items[i] = it43+ if len(it.Cells) == 0 {44+ f.items = slices.Delete(f.items, i, i+1)45+ }46+ return47+ }48+}49+50+func (f *fakeHost) Accept(id int, cells []int) { f.settle(id, cells) }51+func (f *fakeHost) Reject(id int, cells []int) { f.settle(id, cells) }52+53+func twoSuggestions() *fakeHost {54+ return &fakeHost{th: theme.New(true), items: []Item{55+ {ID: 1, Agent: "claude", Label: "set", Message: "fix totals", Total: 2, Cells: []Cell{56+ {Index: 0, Sheet: "Sheet1", At: sheet.Addr{Row: 1}, Was: "40", Now: "42"},57+ {Index: 1, Sheet: "Sheet1", At: sheet.Addr{Row: 2}, Was: "", Now: "=A1+A2"}}},58+ {ID: 2, Agent: "claude", Label: "insert 1 row", Whole: true, Other: []string{"inserts or deletes rows or columns on Sheet1"}},59+ }}60+}61+62+func text(p *Panel) string {63+ var b strings.Builder64+ for _, l := range p.Layout()[0].Lines {65+ b.WriteString(ansi.Strip(l) + "\n")66+ }67+ return b.String()68+}69+70+func TestPanelLists(t *testing.T) {71+ h := twoSuggestions()72+ p := New(h)73+ got := text(p)74+ for _, want := range []string{"Suggestions", "◆ claude: fix totals (2 cells)", "◇ A2 40 → 42", "◇ A3 blank → =A1+A2",75+ "claude: insert 1 row (whole)", "also inserts or deletes rows", "2 waiting"} {76+ if !strings.Contains(got, want) {77+ t.Errorf("no %q in\n%s", want, got)78+ }79+ }80+ if len(h.shown) != 1 || h.shown[0] != "Sheet1!A2" {81+ t.Errorf("shown %v", h.shown)82+ }83+}84+85+func TestPanelSettlesByCellAndWhole(t *testing.T) {86+ h := twoSuggestions()87+ p := New(h)88+ p.Key(tea.KeyPressMsg{Code: tea.KeyDown})89+ p.Key(tea.KeyPressMsg{Code: 'r', Text: "r"}) // A2 rejected90+ if len(h.items[0].Cells) != 1 || h.items[0].Cells[0].Index != 1 {91+ t.Fatalf("items %+v", h.items)92+ }93+ p.Key(tea.KeyPressMsg{Code: tea.KeyUp})94+ p.Key(tea.KeyPressMsg{Code: tea.KeyEnter}) // the rest of the first95+ if len(h.items) != 1 || h.items[0].ID != 2 {96+ t.Fatalf("items %+v", h.items)97+ }98+ p.Key(tea.KeyPressMsg{Code: 'A', Text: "A"})99+ if len(h.items) != 0 || h.closed != 1 {100+ t.Fatalf("accepting all left %+v, closed %d", h.items, h.closed)101+ }102+}103+104+func TestPanelChipsClick(t *testing.T) {105+ h := twoSuggestions()106+ p := New(h)107+ p.Layout()108+ inner := p.box.Width() - 2109+ p.Mouse(overlay.MouseEvent{Kind: overlay.MousePress, Box: ID, Col: inner - 2, Row: 1}) // ✗ on the first heading110+ if len(h.items) != 1 || h.items[0].ID != 2 {111+ t.Fatalf("clicking ✗: %+v", h.items)112+ }113+ p.Layout()114+ p.Mouse(overlay.MouseEvent{Kind: overlay.MousePress, Box: ID, Col: inner - 6, Row: 1}) // ✓115+ if len(h.items) != 0 {116+ t.Fatalf("clicking ✓: %+v", h.items)117+ }118+ desc, keys := p.Status()119+ if desc != "" || !strings.Contains(ansi.Strip(keys), "accept") {120+ t.Errorf("status %q %q", desc, keys)121+ }122+}@@ -175,6 +175,7 @@ func (s *Shared) between() tea.Cmd {
175175 // one out stops what the room ran; otherwise it goes on without this176176 // model.177177 func (m *Model) leftRoom(last bool) {178+ m.leftLocal()178179 if last {179180 m.stopCells()180181 return@@ -7,6 +7,7 @@ import (
77 88 tea "charm.land/bubbletea/v2"99 10+ "github.com/FelineStateMachine/012/internal/cowork"1011 "github.com/FelineStateMachine/012/internal/room"1112 "github.com/FelineStateMachine/012/internal/sheet"1213 )@@ -44,6 +45,10 @@ type shareState struct {
4445 // still stops the ticks, so tests that run commands in line don't4546 // wait on them.4647 still bool48+ // local is set for a local session sharing its workbook with agents49+ // (live mode: agents.go), which waits for its room's changes itself50+ // (waiting while it does), as no server sends them.51+ local, waiting bool4752 }4853 4954 // joinReq is a room to join: its key, the file's name as typed, and@@ -56,6 +61,9 @@ type joinReq struct {
5661 // restored is the recovery file book was read from: the room opens5762 // again on it, as unsaved changes.5863 restored string64+ // local is a local session's own workbook, taken into a room of its65+ // own as it is (enterLocal).66+ local bool5967 }6068 6169 // roomMsg tells the model its room changed.@@ -68,8 +76,13 @@ func (m *Model) ShareRooms(reg *room.Registry, user string) {
6876 m.share.link = &peerLink{name: user, kick: make(chan struct{}, 1)}6977 }7078 71-// shared reports whether the workbook is shown in a room with others.72-func (m *Model) shared() bool { return m.share.seat != nil && m.share.seat.Others() > 0 }79+// shared reports whether the workbook is shown in a room with other80+// people, who keep it open when this session leaves; agents don't.81+func (m *Model) shared() bool { return m.share.seat != nil && m.share.seat.People() > 0 }82+83+// withOthers reports whether anyone else, people or agents, is in the84+// workbook's room.85+func (m *Model) withOthers() bool { return m.share.seat != nil && m.share.seat.Others() > 0 }7386 7487 // roomKey is the room of the file named name: its path on disk, or the7588 // name itself for a named room (@name).@@ -122,6 +135,9 @@ func (m *Model) loadedShared(msg loadedMsg) {
122135 // enterRoom shows the room's workbook, as opening a file does. It runs123136 // in a turn of the new seat.124137 func (m *Model) enterRoom(seat *room.Seat, created bool, req *joinReq) tea.Cmd {138+ if req.local {139+ return m.enterLocal(seat, req)140+ }125141 w := seat.Book()126142 s := w.Sheets()[w.Active()]127143 if !s.Live() || s.Hidden() {@@ -192,6 +208,10 @@ func (m *Model) roomChanged() tea.Cmd {
192208 }193209 var cmds []tea.Cmd194210 for _, msg := range seat.Take() {211+ if rc, ok := msg.(cowork.RunCell); ok {212+ cmds = append(cmds, m.agentRunCell(rc))213+ continue214+ }195215 _, cmd := m.Update(msg)196216 cmds = append(cmds, cmd)197217 }@@ -18,7 +18,7 @@ func init() {
1818 unshared := func(m *Model) bool { return m.share.reg == nil }1919 register(2020 &command{id: "share.who", macro: macroNever, title: "Who's here", desc: "List who shares this workbook and where each is; pick one to go to their cell",21- hidden: unshared, enabled: func(m *Model) bool { return m.shared() }, run: (*Model).openWho},21+ hidden: unshared, enabled: (*Model).withOthers, run: (*Model).openWho},2222 &command{id: "share.hand", macro: macroNever, title: "Hand over writing", desc: "Let someone else edit the shared workbook, and follow them",2323 hidden: unshared, enabled: func(m *Model) bool {2424 seat := m.share.seat@@ -47,14 +47,17 @@ func (m *Model) openWho() tea.Cmd {
4747 for _, p := range m.share.seat.Peers() {4848 where := m.peerWhere(p)4949 desc := "Go to " + p.Name + "'s cell"50- detail := where50+ detail, title := where, p.Name51+ if p.Agent {52+ detail, title = "agent, "+where, peerLabel(p.Name, true)53+ }5154 switch {5255 case p.Writing && m.share.seat.Mode() == room.View:5356 detail += ", writing"5457 case p.Presence.Editing:5558 detail += ", typing"5659 }57- items = append(items, picker.Item{Title: p.Name, Name: len(p.Name), Detail: detail, Desc: desc,60+ items = append(items, picker.Item{Title: title, Name: len(title), Detail: detail, Desc: desc,5861 Pick: func() tea.Cmd { m.closeOverlay(); m.goToPeer(p); return nil }})5962 }6063 pk := m.newPicker("Who's here", "a name", 50, items)@@ -66,6 +69,9 @@ func (m *Model) openWho() tea.Cmd {
6669 // peerWhere is where p is, Sheet2!B3, or the notebook they're on.6770 func (m *Model) peerWhere(p room.Peer) string {6871 s := p.Presence.Sheet72+ if s == nil && p.Agent {73+ return "reading"74+ }6975 if s == nil || !s.Live() {7076 return "arriving"7177 }@@ -39,6 +39,7 @@ type shareTickMsg struct{}
3939 func (m *Model) frameShare() {4040 f := &m.share.frame4141 f.peers, f.here, f.fresh = nil, f.here[:0], f.fresh[:0]42+ m.frameAgents()4243 seat := m.share.seat4344 if seat == nil {4445 return@@ -111,7 +112,7 @@ func (m *Model) rowTags(row int) (string, int) {
111112 if p.Presence.Cursor.Row != row || n >= 2 {112113 continue113114 }114- b.WriteString(m.th.Peer[p.Color%theme.Peers].Render(initial(p.Name)))115+ b.WriteString(m.th.Peer[p.Color%theme.Peers].Render(peerInitial(p.Name, p.Agent)))115116 n++116117 }117118 return b.String(), n@@ -130,7 +131,7 @@ func initial(name string) string {
130131 func (m *Model) peersStatus() string {131132 peers := m.share.frame.peers132133 if len(peers) == 0 {133- return ""134+ return m.agentsStatus()134135 }135136 var b strings.Builder136137 b.WriteString(" ")@@ -142,7 +143,7 @@ func (m *Model) peersStatus() string {
142143 if i > 0 {143144 b.WriteString(" ")144145 }145- b.WriteString(m.th.Peer[p.Color%theme.Peers].Render(" " + ansi.Truncate(p.Name, 10, "…") + " "))146+ b.WriteString(m.th.Peer[p.Color%theme.Peers].Render(" " + peerLabel(p.Name, p.Agent) + " "))146147 }147148 if seat := m.share.seat; seat.Mode() == room.View {148149 if seat.Writing() {@@ -151,7 +152,7 @@ func (m *Model) peersStatus() string {
151152 b.WriteString(m.th.Muted.Render(" " + seat.Writer() + " writes"))152153 }153154 }154- return b.String()155+ return b.String() + m.agentsStatus()155156 }156157 157158 // shareLine is the context line's word on the active cell: who else is@@ -160,6 +161,9 @@ func (m *Model) shareLine() string {
160161 if m.share.seat == nil {161162 return ""162163 }164+ if line := m.agentLine(); line != "" {165+ return line166+ }163167 if p, ok := m.peerAt(m.cur); ok {164168 what := " is here"165169 if p.Presence.Editing {@@ -1,6 +1,7 @@
11 package ui22 33 import (4+ "context"45 "strings"56 "testing"67 @@ -34,3 +35,25 @@ func TestMonochromePeers(t *testing.T) {
3435 }3536 })3637 }38+39+// Agents read without color too: their ◆ on the row header and before40+// their name, and a ◇ on each cell a suggestion of theirs would set.41+func TestMonochromeAgents(t *testing.T) {42+ ls := startLive(t)43+ ls.suggest("", "A1", "48")44+ ls.agent.Focus(context.Background(), "B2")45+ ls.sync()46+ ls.sh.turn(func() {47+ m := ls.m48+ m.frameShare()49+ if row := cellText(monoLine(m, gridTop)); !strings.Contains(row, "◇") {50+ t.Errorf("row 1 %q", row)51+ }52+ if row := cellText(monoLine(m, gridTop+1)); !strings.HasPrefix(row, "◆") {53+ t.Errorf("row 2's header %q", row)54+ }55+ if status := cellText(monoLine(m, m.height-1)); !strings.Contains(status, "◆ claude") || !strings.Contains(status, "◇ 1 suggestion") {56+ t.Errorf("status line %q", status)57+ }58+ })59+}@@ -123,7 +123,7 @@ func minContrast(role string, lv levels) float64 {
123123 switch role {124124 case "Disabled":125125 return lv.disabled126- case "Hint", "Muted", "ChartLabel", "Match", "Dropdown", "NoteMark", "CellHead", "Stale", "PeerMark":126+ case "Hint", "Muted", "ChartLabel", "Match", "Dropdown", "NoteMark", "CellHead", "Stale", "PeerMark", "Suggested":127127 return lv.secondary128128 case "Border", "FrozenLine", "ChartFrame", "ChartAxis", "Progress", "ProgressTodo", "Copied", "Series", "CellBorder", "CellBar", "CellBarEdit":129129 return lv.lines@@ -183,6 +183,10 @@ type Theme struct {
183183 // its top-left corner in their color (PeerMark).184184 Peer [Peers]lipgloss.Style185185 PeerMark [Peers]lipgloss.Style186+ // Suggested marks a cell an agent's suggestion would set, waiting187+ // for the person to accept or reject it (live mode): a ◇ in the188+ // cell's top-left corner, so it reads without color.189+ Suggested lipgloss.Style186190 187191 // levels are the contrast minimums the roles meet: WCAG AA, or AAA188192 // for a high-contrast scheme.@@ -288,6 +292,7 @@ func roles(dark bool) Theme {
288292 bar := lipgloss.Cyan289293 filterFg := lipgloss.Yellow290294 noteFg := lipgloss.Yellow295+ suggested := lipgloss.BrightMagenta291296 borderFg := lipgloss.White292297 // Bands are the header's gray, lightest on a light terminal.293298 bandBg, bandFg := lipgloss.BrightBlack, lipgloss.BrightWhite@@ -295,6 +300,7 @@ func roles(dark bool) Theme {
295300 bandBg, bandFg = lipgloss.White, lipgloss.Black296301 borderFg = lipgloss.Black297302 noteFg = lipgloss.Magenta303+ suggested = lipgloss.Magenta298304 headerBg, headerFg = lipgloss.White, lipgloss.Black299305 selFg, muted, match = lipgloss.BrightWhite, lipgloss.Black, lipgloss.Blue300306 filterFg = lipgloss.Blue@@ -381,6 +387,7 @@ func roles(dark bool) Theme {
381387 ruleRoles(&t, dark)382388 codeRoles(&t, dark)383389 peerRoles(&t, dark)390+ t.Suggested = lipgloss.NewStyle().Foreground(suggested).Bold(true)384391 for i, c := range series {385392 t.Series[i] = lipgloss.NewStyle().Foreground(c)386393 t.SeriesBg[i] = lipgloss.NewStyle().Background(c)@@ -287,6 +287,9 @@ func (m *Model) cellMarks(lc *lineCtx, a sheet.Addr, text string, look *sheet.Lo
287287 if lc.ln.K == 0 && len(m.share.frame.fresh) > 0 {288288 text = m.changedMark(text, a, *base, colored)289289 }290+ if lc.ln.K == 0 && len(m.agents.frame.cells) > 0 {291+ text = m.suggestedMark(text, a, *base, colored)292+ }290293 if lc.shaped {291294 text = m.leftEdge(a, text)292295 }