tui: a library page (5) — the shared instructions, MCP servers and skills magpie gives the agents, and RTK: ↵ lists the agents that can have one, each switched on or off in place; e edits the instructions in $EDITOR; the servers and skills found in the agents are listed too, i brings one into the library; d twice takes one out. The same data as the app's Library. Asked on Discord

This commit is contained in:
yetone committed 2026-09-26 19:40:58 +08:00
1 parent 631e4c3096
commit 87fb06d2f9
3 files changed
+475 -23

No files matched your search

+429
View File
@@ -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 <name> <url | command> 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
}
+2 -1
View File
@@ -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 {
+44 -22
View File
@@ -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()