From 87fb06d2f96940c832aa34af9c65bb127ca92624 Mon Sep 17 00:00:00 2001 From: yetone Date: Sat, 26 Sep 2026 19:40:58 +0800 Subject: [PATCH] =?UTF-8?q?tui:=20a=20library=20page=20(5)=20=E2=80=94=20t?= =?UTF-8?q?he=20shared=20instructions,=20MCP=20servers=20and=20skills=20ma?= =?UTF-8?q?gpie=20gives=20the=20agents,=20and=20RTK:=20=E2=86=B5=20lists?= =?UTF-8?q?=20the=20agents=20that=20can=20have=20one,=20each=20switched=20?= =?UTF-8?q?on=20or=20off=20in=20place;=20e=20edits=20the=20instructions=20?= =?UTF-8?q?in=20$EDITOR;=20the=20servers=20and=20skills=20found=20in=20the?= =?UTF-8?q?=20agents=20are=20listed=20too,=20i=20brings=20one=20into=20the?= =?UTF-8?q?=20library;=20d=20twice=20takes=20one=20out.=20The=20same=20dat?= =?UTF-8?q?a=20as=20the=20app's=20Library.=20Asked=20on=20Discord?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/tui/library.go | 429 ++++++++++++++++++++++++++++++++++++++++ internal/tui/pages.go | 3 +- internal/tui/tui.go | 66 ++++--- 3 files changed, 475 insertions(+), 23 deletions(-) create mode 100644 internal/tui/library.go diff --git a/internal/tui/library.go b/internal/tui/library.go new file mode 100644 index 00000000..ca627890 --- /dev/null +++ b/internal/tui/library.go @@ -0,0 +1,429 @@ +package tui + +// The library page: the instructions, MCP servers and skills magpie gives +// the agents, which agents get each, what the agents have that the library +// doesn't, and RTK's hook in each — what the app's Library view does, in +// the terminal. + +import ( + "fmt" + "os" + "os/exec" + "runtime" + "slices" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "github.com/yetone/magpie/internal/agent" + "github.com/yetone/magpie/internal/library" +) + +// libRow is one line of the page. +type libRow struct { + kind string // instructions, mcp, skill, found-mcp, found-skill, rtk + name string + about string // what it runs, or what it is + on []string // the agents that get it (or have it, when found) + own bool // a server the agent's app puts in itself: nothing to bring in + bad int // agents it couldn't be given to +} + +// libKinds are what the library can give, by row kind. +var libKinds = map[string]string{"instructions": "instructions", "mcp": "mcp", "skill": "skills"} + +func (m *model) reloadLibrary() { + m.lib, m.libAgents, m.libErr = nil, nil, "" + v, err := library.Read(nil) + if err != nil { + m.libErr = err.Error() + return + } + m.libView = v + m.libAgents = v.Agents + ins := libRow{kind: "instructions", name: "shared instructions"} + if lines := strings.TrimRight(v.Instructions.Shared, "\n"); strings.TrimSpace(lines) == "" { + ins.about = "none yet · e writes them" + } else { + ins.about = fmt.Sprintf("%d line%s", strings.Count(lines, "\n")+1, plural(strings.Count(lines, "\n")+1)) + } + for _, a := range v.Instructions.Agents { + if a.On { + ins.on = append(ins.on, a.Agent) + } + if a.Edited { + ins.bad++ + } + } + m.lib = append(m.lib, ins) + for _, s := range v.Servers { + what := s.URL + if s.Command != "" { + what = strings.TrimSpace(s.Command + " " + strings.Join(s.Args, " ")) + } + m.lib = append(m.lib, libRow{kind: "mcp", name: s.Name, about: what, on: s.Agents, bad: len(s.Problems)}) + } + for _, s := range v.Skills { + r := libRow{kind: "skill", name: s.Name, about: s.Description, on: s.Agents, bad: len(s.Problems)} + if s.Missing { + r.about, r.bad = "its folder is gone", r.bad+1 + } + m.lib = append(m.lib, r) + } + for _, f := range v.FoundServers { + m.lib = append(m.lib, libRow{kind: "found-mcp", name: f.Server.Name, on: f.Server.Agents, own: f.Own}) + } + for _, f := range v.FoundSkills { + m.lib = append(m.lib, libRow{kind: "found-skill", name: f.Name, about: f.Description, on: f.Agents}) + } + if rv := library.ReadRTK(); rv.Path != "" || len(rv.Agents) > 0 { + r := libRow{kind: "rtk", name: "RTK"} + switch { + case rv.Path == "": + r.about = "not installed · " + rv.URL + case rv.Gain != nil: + r.about = fmt.Sprintf("%s · %s tokens saved", rv.Version, fmtTokens(int(rv.Gain.Saved))) + default: + r.about = rv.Version + } + for _, a := range rv.Agents { + if a.On { + r.on = append(r.on, a.ID) + } + } + m.lib = append(m.lib, r) + } + m.lrow = clamp(m.lrow, len(m.lib)) +} + +// libName is an agent's name, by its id. +func (m model) libName(id string) string { + for _, a := range m.libAgents { + if a.ID == id { + return a.Name + } + } + return id +} + +func (m model) updateLibrary(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + n := len(m.lib) + key := msg.String() + if key != "d" { + m.confirm = "" + } + switch key { + case "j", "down": + if n > 0 { + m.lrow = (m.lrow + 1) % n + } + return m, nil + case "k", "up": + if n > 0 { + m.lrow = (m.lrow + n - 1) % n + } + return m, nil + } + if n == 0 { + return m, nil + } + r := m.lib[m.lrow] + switch key { + case "enter", " ": + switch r.kind { + case "instructions", "mcp", "skill", "rtk": + m.openLibAgents(r) + case "found-mcp", "found-skill": + m.flash, m.flashOK = "i brings "+r.name+" into the library, for magpie to give it to other agents too", true + } + case "e": + if r.kind != "instructions" { + return m, nil + } + return m, editInstructions() + case "i": + if r.own { + m.flash, m.flashOK = r.name+" is put there by the agent's app itself, each time it starts: it stays as it is", false + return m, nil + } + var do func(string) (*library.Result, error) + switch r.kind { + case "found-mcp": + do = library.ImportServer + case "found-skill": + do = library.ImportSkill + default: + return m, nil + } + return m, libCmd(func() (*library.Result, error) { return do(r.name) }, "brought "+r.name+" into the library") + case "d": + var do func(string) (*library.Result, error) + switch r.kind { + case "mcp": + do = library.RemoveServer + case "skill": + do = library.RemoveSkill + default: + return m, nil + } + if m.confirm != "library/"+r.kind+"/"+r.name { + m.confirm = "library/" + r.kind + "/" + r.name + m.flash, m.flashOK = "press d again to take "+r.name+" out of the library and every agent", false + return m, nil + } + m.confirm = "" + return m, libCmd(func() (*library.Result, error) { return do(r.name) }, "removed "+r.name) + } + return m, nil +} + +// libCmd runs a library change, saying what it did to the agents. +func libCmd(do func() (*library.Result, error), done string) tea.Cmd { + return func() tea.Msg { + res, err := do() + if err != nil { + return flashMsg{text: err.Error()} + } + text, ok := libSaid(res, done) + return flashMsg{text: text, ok: ok} + } +} + +// libSaid is a change's result in a line. +func libSaid(res *library.Result, done string) (string, bool) { + if len(res.Changed) > 0 { + done += " · written into " + strings.Join(res.Changed, ", ") + } + for _, p := range res.Problems { + done += " · " + p.Agent + ": " + p.Error + } + return done, len(res.Problems) == 0 +} + +// libOptions are the agents that can get a row's kind, those that get it +// marked. +func (m model) libOptions(r libRow) []agent.Option { + var out []agent.Option + add := func(id, name string) { + note := "○ off" + if slices.Contains(r.on, id) { + note = "● on" + } + out = append(out, agent.Option{Value: id, Note: note + " " + name}) + } + if r.kind == "rtk" { + for _, a := range library.ReadRTK().Agents { + add(a.ID, a.Name) + } + return out + } + for _, a := range m.libAgents { + has := map[string]bool{"instructions": a.Instructions != "", "mcp": a.MCP != "", "skill": a.Skills != ""}[r.kind] + if has { + add(a.ID, a.Name) + } + } + return out +} + +// openLibAgents lists the agents a row can go to; enter gives it to one or +// takes it away, and the list stays open. +func (m *model) openLibAgents(r libRow) { + crumbs := []string{"library", r.name, "agents"} + m.pk = picker{ + crumbs: crumbs, + input: newInput("filter agents"), + items: m.libOptions(r), + empty: "no agent here has a place for it", + toggle: func(id string) ([]agent.Option, string, bool) { + on := !slices.Contains(r.on, id) + var ( + text string + ok = true + ) + if r.kind == "rtk" { + v, err := library.SetRTK(id, on) + if err != nil { + return nil, err.Error(), false + } + r.on = nil + for _, a := range v.Agents { + if a.On { + r.on = append(r.on, a.ID) + } + } + text = "RTK " + map[bool]string{true: "on", false: "off"}[on] + " for " + m.libName(id) + if len(v.Restart) > 0 { + text += " · restart " + strings.Join(v.Restart, ", ") + " for it to take effect" + } + } else { + agents := slices.Clone(r.on) + if on { + agents = append(agents, id) + } else { + agents = slices.DeleteFunc(agents, func(a string) bool { return a == id }) + } + var ( + res *library.Result + err error + ) + switch r.kind { + case "instructions": + res, err = library.SaveInstructions(library.InstructionsChange{Agents: agents}) + case "mcp": + res, err = library.ServerAgents(r.name, agents) + case "skill": + res, err = library.SkillAgents(r.name, agents) + } + if err != nil { + return nil, err.Error(), false + } + r.on = agents + verb := "given to " + if !on { + verb = "taken from " + } + text, ok = libSaid(&library.Result{Problems: res.Problems}, r.name+" "+verb+m.libName(id)) + } + return m.libOptions(r), text, ok + }, + } + m.pk.refilter() + m.mode = modePick + m.back = modeList +} + +// editedMsg is the shared instructions as the editor left them. +type editedMsg struct { + text string + err error +} + +// editInstructions opens the shared instructions in $VISUAL or $EDITOR +// and saves what it leaves, which writes it into the agents that get it. +func editInstructions() tea.Cmd { + v, err := library.ReadInstructions() + if err != nil { + return func() tea.Msg { return flashMsg{text: err.Error()} } + } + f, err := os.CreateTemp("", "magpie-instructions-*.md") + if err != nil { + return func() tea.Msg { return flashMsg{text: err.Error()} } + } + f.WriteString(v.Shared) + f.Close() + ed := os.Getenv("VISUAL") + if ed == "" { + ed = os.Getenv("EDITOR") + } + if ed == "" { + ed = "vi" + if runtime.GOOS == "windows" { + ed = "notepad" + } + } + // the editor may come with flags: code -w + words := strings.Fields(ed) + c := exec.Command(words[0], append(words[1:], f.Name())...) + return tea.ExecProcess(c, func(err error) tea.Msg { + defer os.Remove(f.Name()) + if err != nil { + return editedMsg{err: err} + } + b, err := os.ReadFile(f.Name()) + if err != nil { + return editedMsg{err: err} + } + if string(b) == v.Shared { + return flashMsg{text: "the instructions are as they were", ok: true} + } + return editedMsg{text: string(b)} + }) +} + +func saveInstructions(text string) tea.Cmd { + return libCmd(func() (*library.Result, error) { + return library.SaveInstructions(library.InstructionsChange{Shared: &text}) + }, "instructions saved") +} + +func (m model) viewLibrary() string { + var b strings.Builder + b.WriteString(m.header()) + b.WriteString("\n\n") + if m.libErr != "" { + b.WriteString(pad + " " + sBad.Render(m.libErr)) + return b.String() + } + nameW := 0 + for _, r := range m.lib { + nameW = max(nameW, len([]rune(r.name))) + } + nameW = min(nameW, 32) + heads := map[string]string{ + "instructions": "instructions · what every agent reads before a conversation", + "mcp": "mcp servers · magpie library mcp add adds one", + "skill": "skills · added from the app's market, or a folder magpie keeps", + "found-mcp": "mcp servers in your agents, not in the library", + "found-skill": "skills in your agents, not in the library", + "rtk": "rtk · its hook in each agent, by RTK's own installer", + } + // the rows around the cursor, as many as fit + visible := max(4, m.h-6) + start := 0 + if m.lrow >= visible-2 { + start = m.lrow - visible + 3 + } + last, lines := "", 0 + for i := start; i < len(m.lib) && lines < visible; i++ { + r := m.lib[i] + if r.kind != last { + if last != "" || start > 0 { + b.WriteString("\n") + lines++ + } + b.WriteString(pad + " " + sFaint.Render(heads[r.kind]) + "\n") + lines++ + last = r.kind + } + marker, name := " ", sText.Render(padRight(trunc(r.name, nameW), nameW)) + if i == m.lrow { + marker, name = sCursor.Render("▸ "), sNameOn.Render(padRight(trunc(r.name, nameW), nameW)) + } + var agents []string + for _, id := range r.on { + agents = append(agents, m.libName(id)) + } + on := sMuted.Render(strings.Join(agents, ", ")) + switch { + case r.own: + on = sFaint.Render(strings.Join(agents, ", ") + "'s own") + case len(agents) == 0 && strings.HasPrefix(r.kind, "found-"): + case len(agents) == 0: + on = sFaint.Render("no agent") + } + line := pad + marker + name + " " + on + if r.bad > 0 { + line += " " + sBad.Render(fmt.Sprintf("! %d", r.bad)) + } + if r.about != "" { + if room := m.w - lipgloss.Width(line) - 4; room > 8 { + line += " " + sFaint.Render(trunc(r.about, room)) + } + } + b.WriteString(line + "\n") + lines++ + } + b.WriteString("\n" + pad + " " + sFaint.Render("kept in "+tilde(m.libView.Dir))) + return b.String() +} + +// trunc cuts s to n runes, with an ellipsis. +func trunc(s string, n int) string { + s = strings.Join(strings.Fields(s), " ") + if r := []rune(s); len(r) > n { + return string(r[:max(1, n-1)]) + "…" + } + return s +} diff --git a/internal/tui/pages.go b/internal/tui/pages.go index f88bf3c2..3bcd0405 100644 --- a/internal/tui/pages.go +++ b/internal/tui/pages.go @@ -28,9 +28,10 @@ const ( pageProviders pageGroups pageUsage + pageLibrary ) -var pageNames = []string{"agents", "providers", "groups", "usage"} +var pageNames = []string{"agents", "providers", "groups", "usage", "library"} // ask is a line to type: a key, a family, a group's name. type ask struct { diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 7b25f3a2..fd723f8c 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -1,6 +1,7 @@ // Package tui is magpie in the terminal: one row per agent, arrow keys to pick // a field, enter to change it; and pages for providers, routing groups and -// usage beside it (pages.go), 1–4 to go between them. +// usage beside it (pages.go), the library (library.go), 1–5 to go between +// them. package tui import ( @@ -18,6 +19,7 @@ import ( "github.com/yetone/magpie/internal/agent" "github.com/yetone/magpie/internal/catalog" + "github.com/yetone/magpie/internal/library" "github.com/yetone/magpie/internal/profile" "github.com/yetone/magpie/internal/provider" "github.com/yetone/magpie/internal/settings" @@ -94,20 +96,25 @@ type model struct { w, h int syncing bool - page page - back mode // where esc goes from a picker or a line to type - ask ask - confirm string // what a second d removes - provs []provider.Provider - prow int - bal map[string]string - asked bool // balances were asked for - groups []provider.Group - grow int - gid string // the group open - gsel int // its member picked - period usage.Period - sum usage.Summary + page page + back mode // where esc goes from a picker or a line to type + ask ask + confirm string // what a second d removes + provs []provider.Provider + prow int + bal map[string]string + asked bool // balances were asked for + groups []provider.Group + grow int + gid string // the group open + gsel int // its member picked + period usage.Period + sum usage.Summary + lib []libRow + lrow int + libView *library.View + libAgents []library.AgentView + libErr string } type flashMsg struct { @@ -153,6 +160,8 @@ func (m *model) reload() { } case pageUsage: m.sum = usage.Summarize(m.period) + case pageLibrary: + m.reloadLibrary() } } @@ -204,6 +213,12 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case pickMsg: m.openMemberPicker(msg) return m, nil + case editedMsg: + if msg.err != nil { + m.flash, m.flashOK = msg.err.Error(), false + return m, nil + } + return m, saveInstructions(msg.text) case syncedMsg: m.syncing = false if msg.err != nil { @@ -223,7 +238,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, m.goTo(p) } switch m.page { - case pageProviders, pageGroups, pageUsage: + case pageProviders, pageGroups, pageUsage, pageLibrary: if s := msg.String(); s == "q" || s == "esc" { return m, tea.Quit } @@ -242,6 +257,9 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m.updateGroups(msg) case pageUsage: return m.updateUsage(msg) + case pageLibrary: + m.flash = "" + return m.updateLibrary(msg) } return m.updateList(msg) case modeAsk: @@ -258,12 +276,12 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } -// pageKey is the page a key goes to: 1–4, or ] and [ for the next and +// pageKey is the page a key goes to: 1–5, or ] and [ for the next and // the one before. func (m model) pageKey(k string) (page, bool) { n := page(len(pageNames)) switch k { - case "1", "2", "3", "4": + case "1", "2", "3", "4", "5": return page(k[0] - '1'), true case "]": return (m.page + 1) % n, true @@ -386,6 +404,7 @@ func (m model) updatePicker(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.String() { case "esc": m.mode = m.back + m.reload() return m, nil case "down", "ctrl+n": if len(m.pk.match) > 0 { @@ -588,16 +607,19 @@ func (m model) View() string { switch m.page { case pageProviders: body = m.viewProviders() - footer = hints("↑↓", "provider", "↵", "models", "e", "key", "a", "add", "f", "family", "u", "list/groups only", "t", "test", "b", "balances", "d", "remove", "1–4", "pages") + footer = hints("↑↓", "provider", "↵", "models", "e", "key", "a", "add", "f", "family", "u", "list/groups only", "t", "test", "b", "balances", "d", "remove", "1–5", "pages") case pageGroups: body = m.viewGroups() - footer = hints("↑↓", "group", "↵", "open", "n", "new", "o", "routing", "d", "remove", "1–4", "pages", "q", "quit") + footer = hints("↑↓", "group", "↵", "open", "n", "new", "o", "routing", "d", "remove", "1–5", "pages", "q", "quit") + case pageLibrary: + body = m.viewLibrary() + footer = hints("↑↓", "item", "↵", "agents", "e", "edit instructions", "i", "bring in", "d", "remove", "r", "reload", "1–5", "pages", "q", "quit") case pageUsage: body = m.viewUsage() - footer = hints("←→", "period", "t w m A", "today · 7 days · 30 days · all", "r", "reload", "1–4", "pages", "q", "quit") + footer = hints("←→", "period", "t w m A", "today · 7 days · 30 days · all", "r", "reload", "1–5", "pages", "q", "quit") default: body = m.viewList() - footer = hints("↑↓", "agent", "←→", "field", "↵", "change", "s", "save profile", "p", "profiles", "1–4", "pages", "q", "quit") + footer = hints("↑↓", "agent", "←→", "field", "↵", "change", "s", "save profile", "p", "profiles", "1–5", "pages", "q", "quit") } case modePick, modeProfiles: body = m.viewPicker()