YangZhiqiang98 asked (#1449) that the Library find which skills have updates and update them. magpie already checked skills from GitHub (a market install among them) by hand and could update them, but an update deleted the library's folder, so a skill the user had changed (by hand, through an agent's link, or in an agent's copy not yet taken in) was lost without a word, and nothing checked unless the user pressed Check. - A skill is "changed here" when its library folder no longer hashes to what was fetched (Finder's .DS_Store aside), or an agent's copy was edited since its mark and not yet taken in (editState, skill_changed.go). The view says so (SkillView.edited) and the row shows "Changed here". - UpdateSkill refuses such a skill with *EditedError unless told to replace it. The row's Update asks in the page first (Keep mine / Update; Update sends replace). Update N, the group's Update N and the CLI's update-all leave it as it is and say so. The CLI takes `magpie library skill update <name> --replace-edits`. - The version an update replaces is moved to the backups when it was changed, or when it can't be told (a skill with no hash from before). - View.checkDue: no check since magpie started, or none for 12 hours. The page then runs the check once by itself as the skills open, and says only what there is to update or add. Not checked, by design: skills the skills CLI manages (`npx skills update` updates them; magpie only groups them), linked local folders, skills imported from an agent with no source, and From-only skills have no remote; CC Switch-installed skills can still be updated by hand. Verified: - go test -tags nogui ./... (sandbox HOME); new TestUpdateKeepsASkillChangedHere, TestUpdateKeepsACopyEditedInAnAgent, TestUpdateKeepsWhatItCantTell, TestCheckDue, TestCheckSkillsMarksItDone, all from httptest. With editState forced to "not changed" the first three fail. - gofmt -l, go vet, GOOS=linux and GOOS=windows go vet -tags nogui on ./internal/library ./internal/gui ./internal/tui . - GUI: new skill-update-edited.test.cjs (Chromium and WebKit, 860 and 400px, en and zh; no scroll on click; fails with the row's ask removed), plus the 22 other library skill tests, gui-ja, gui-de, gui-zh-tw, i18n-keys and click-scroll: 262 pass. - Real use in a sandbox HOME with github.com/anthropics/skills: installed pdf and docx, the real check said both current; an edit through Claude Code's link was refused by `skill update pdf`, skipped by `skill update` (docx updated), replaced by `--replace-edits` with the edit in backups/<time>/library/skills/pdf; docx edited later shows edited, a .DS_Store doesn't.
584 lines
16 KiB
Go
584 lines
16 KiB
Go
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"
|
|
"runtime"
|
|
"slices"
|
|
"strings"
|
|
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
"github.com/charmbracelet/lipgloss"
|
|
|
|
"github.com/yetone/magpie/internal/agent"
|
|
"github.com/yetone/magpie/internal/library"
|
|
"github.com/yetone/magpie/internal/proc"
|
|
)
|
|
|
|
// 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
|
|
case "a":
|
|
m.openLibAdd()
|
|
return m, nil
|
|
case "s":
|
|
m.flash, m.flashOK = "writing the library into the agents…", true
|
|
return m, libCmd(library.Sync, "synced")
|
|
}
|
|
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
|
|
if r.kind == "found-skill" {
|
|
m.flash += "; d takes it out of every agent"
|
|
}
|
|
}
|
|
case "e":
|
|
switch r.kind {
|
|
case "instructions":
|
|
return m, editInstructions()
|
|
case "mcp":
|
|
m.openServer(r.name)
|
|
}
|
|
case "u":
|
|
if r.kind != "skill" {
|
|
return m, nil
|
|
}
|
|
m.flash, m.flashOK = "fetching "+r.name+" from GitHub again…", true
|
|
return m, libCmd(func() (*library.Result, error) { return library.UpdateSkill(r.name, false) }, "updated "+r.name)
|
|
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
|
|
case "found-skill":
|
|
do = library.RemoveFoundSkill
|
|
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
|
|
if r.kind == "found-skill" {
|
|
m.flash = "press d again to take " + r.name + " out of every agent that has it, kept with the backups"
|
|
}
|
|
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) {
|
|
// an install of skills the library had already added none
|
|
if len(res.Installed) == 0 && len(res.Had) > 0 {
|
|
done = strings.Join(res.Had, ", ") + " in the library already"
|
|
}
|
|
for _, p := range res.Skipped {
|
|
done += " · " + strings.TrimPrefix(p.What, "skill:") + " skipped: " + p.Error
|
|
}
|
|
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 && len(res.Skipped) == 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
|
|
}
|
|
|
|
// openLibAdd picks what to add: an MCP server, or skills.
|
|
func (m *model) openLibAdd() {
|
|
m.pk = picker{
|
|
crumbs: []string{"library", "add"},
|
|
input: newInput("filter"),
|
|
items: []agent.Option{
|
|
{Value: "mcp server", Note: "a URL, or a command it runs"},
|
|
{Value: "skills", Note: "from a GitHub repository, or a folder on this machine"},
|
|
},
|
|
onPick: func(v string) tea.Cmd {
|
|
return func() tea.Msg {
|
|
if v == "skills" {
|
|
in := newInput("a GitHub link (owner/repo, or a folder in one) or a folder's full path")
|
|
in.CharLimit = 400
|
|
return askMsg{ask{crumbs: []string{"library", "add", "skills"}, input: in,
|
|
hint: "magpie looks there for skills, then you pick one",
|
|
onEnter: probeCmd}}
|
|
}
|
|
in := newInput("<name> <url | command args…>")
|
|
in.CharLimit = 400
|
|
return askMsg{ask{crumbs: []string{"library", "add", "mcp server"}, input: in,
|
|
hint: "e.g. context7 https://mcp.context7.com/mcp · fs npx -y @modelcontextprotocol/server-filesystem ~/code\nthen ↵ on it gives it to agents",
|
|
onEnter: func(line string) tea.Cmd { return saveServer(nil, line) }}}
|
|
}
|
|
},
|
|
}
|
|
m.pk.refilter()
|
|
m.mode = modePick
|
|
m.back = modeList
|
|
}
|
|
|
|
// openServer types a library server again: its name, then its URL or
|
|
// command; what else it has (env, headers, agents) stays.
|
|
func (m *model) openServer(name string) {
|
|
var old *library.Server
|
|
for _, s := range m.libView.Servers {
|
|
if s.Name == name {
|
|
c := *s.Server
|
|
old = &c
|
|
}
|
|
}
|
|
if old == nil {
|
|
return
|
|
}
|
|
line := old.Name + " " + old.URL
|
|
if old.Command != "" {
|
|
line = strings.Join(append([]string{old.Name, old.Command}, old.Args...), " ")
|
|
}
|
|
in := newInput("<name> <url | command args…>")
|
|
in.CharLimit = 400
|
|
in.SetValue(line)
|
|
in.CursorEnd()
|
|
m.openAsk(ask{crumbs: []string{"library", name, "edit"}, input: in,
|
|
hint: "its env, headers and agents stay as they are",
|
|
onEnter: func(line string) tea.Cmd { return saveServer(old, line) }})
|
|
}
|
|
|
|
// saveServer makes a server of a typed line, in place of old when it is
|
|
// one being changed.
|
|
func saveServer(old *library.Server, line string) tea.Cmd {
|
|
return func() tea.Msg {
|
|
words := strings.Fields(line)
|
|
if len(words) < 2 {
|
|
return flashMsg{text: "a name, then a URL or a command"}
|
|
}
|
|
s, err := library.ServerOf(words[0], words[1:])
|
|
if err != nil {
|
|
return flashMsg{text: err.Error()}
|
|
}
|
|
was, done := "", "added "+s.Name+" · ↵ on it gives it to agents"
|
|
if old != nil {
|
|
was, done = old.Name, "saved "+s.Name
|
|
s.Agents, s.Env, s.Headers = old.Agents, old.Env, old.Headers
|
|
}
|
|
return libCmd(func() (*library.Result, error) { return library.SaveServer(was, s) }, done)()
|
|
}
|
|
}
|
|
|
|
// probedMsg is what was found at a source typed for skills.
|
|
type probedMsg struct{ p *library.Probe }
|
|
|
|
func probeCmd(source string) tea.Cmd {
|
|
return func() tea.Msg {
|
|
p, err := library.ProbeSkills(source)
|
|
if err != nil {
|
|
return flashMsg{text: err.Error()}
|
|
}
|
|
return probedMsg{p}
|
|
}
|
|
}
|
|
|
|
// openProbe lists the skills found at a source; enter adds one to the
|
|
// library.
|
|
func (m *model) openProbe(p *library.Probe) {
|
|
var items []agent.Option
|
|
for _, c := range p.Candidates {
|
|
note := c.Description
|
|
if c.Have {
|
|
note = "in the library already · " + note
|
|
}
|
|
v := c.Path
|
|
if v == "" {
|
|
v = "."
|
|
}
|
|
items = append(items, agent.Option{Value: v, Note: c.Name + " " + trunc(note, 80)})
|
|
}
|
|
m.pk = picker{
|
|
crumbs: []string{"library", "add", "skills", p.Source},
|
|
input: newInput("filter skills"),
|
|
items: items,
|
|
empty: "no skills there: a skill is a folder with a SKILL.md",
|
|
onPick: func(path string) tea.Cmd {
|
|
if path == "." {
|
|
path = ""
|
|
}
|
|
return libCmd(func() (*library.Result, error) {
|
|
return library.InstallSkills(p.Source, []string{path}, []string{})
|
|
}, "added the skill · ↵ on it gives it to agents")
|
|
},
|
|
}
|
|
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 := proc.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 · a adds one, e changes it",
|
|
"skill": "skills · a adds some from GitHub or a folder, u fetches one from GitHub again",
|
|
"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
|
|
}
|