Skill and extension picker for Paperclip agents; session notes and cleanup plan
This commit is contained in:
302
tui/agents.go
302
tui/agents.go
@@ -16,6 +16,8 @@ package main
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -41,6 +43,13 @@ type runsMsg struct {
|
||||
err error
|
||||
}
|
||||
|
||||
type pickerMsg struct {
|
||||
skills []skillItem
|
||||
exts []extensionItem
|
||||
note string
|
||||
err error
|
||||
}
|
||||
|
||||
type runLogMsg struct {
|
||||
runID string
|
||||
text string
|
||||
@@ -65,7 +74,14 @@ type agentsModel struct {
|
||||
runAt int
|
||||
settingAt int
|
||||
scroll int
|
||||
showHelp bool
|
||||
|
||||
skills []skillItem
|
||||
skillsAt int
|
||||
exts []extensionItem
|
||||
extsAt int
|
||||
picking bool
|
||||
pickerNote string
|
||||
showHelp bool
|
||||
|
||||
// runs are limited to this project by default; A shows every run in the
|
||||
// company, which is a different question and a much longer list.
|
||||
@@ -77,7 +93,7 @@ type agentsModel struct {
|
||||
width, height int
|
||||
}
|
||||
|
||||
var agentTabs = []string{"roster", "runs", "log", "settings"}
|
||||
var agentTabs = []string{"roster", "runs", "log", "settings", "skills", "extensions"}
|
||||
|
||||
func newAgentsModel(c *paperclip.Client, folder string) agentsModel {
|
||||
return agentsModel{client: c, folder: folder}
|
||||
@@ -138,6 +154,150 @@ func (m agentsModel) loadRuns(showAll bool) tea.Cmd {
|
||||
}
|
||||
}
|
||||
|
||||
// loadPicker reads the three lists the picker needs: the person's own skills,
|
||||
// what Paperclip knows, and what this agent has.
|
||||
func (m agentsModel) loadPicker() tea.Cmd {
|
||||
c := m.client
|
||||
agent := m.chosenAgent()
|
||||
if agent == nil {
|
||||
return func() tea.Msg { return pickerMsg{err: fmt.Errorf("no agent chosen")} }
|
||||
}
|
||||
id := agent.ID
|
||||
return func() tea.Msg {
|
||||
company, err := c.CompanySkills()
|
||||
if err != nil {
|
||||
return pickerMsg{err: err}
|
||||
}
|
||||
have, err := c.AgentSkills(id)
|
||||
if err != nil {
|
||||
return pickerMsg{err: err}
|
||||
}
|
||||
bySlug := map[string]string{}
|
||||
for _, s := range company {
|
||||
bySlug[s.Slug] = s.Key
|
||||
}
|
||||
on := map[string]bool{}
|
||||
for _, k := range have.DesiredSkills {
|
||||
on[k] = true
|
||||
}
|
||||
|
||||
var skills []skillItem
|
||||
seen := map[string]bool{}
|
||||
for _, name := range localSkills() {
|
||||
item := skillItem{slug: name, name: name, global: true}
|
||||
if key, ok := bySlug[name]; ok {
|
||||
item.key, item.inCompany = key, true
|
||||
item.onAgent = on[key]
|
||||
}
|
||||
skills = append(skills, item)
|
||||
seen[name] = true
|
||||
}
|
||||
// Paperclip-only skills, which the person never put in ~/.agents.
|
||||
for _, s := range company {
|
||||
if seen[s.Slug] {
|
||||
continue
|
||||
}
|
||||
skills = append(skills, skillItem{
|
||||
slug: s.Slug, name: s.Name, key: s.Key,
|
||||
inCompany: true, onAgent: on[s.Key],
|
||||
})
|
||||
}
|
||||
|
||||
wrapped := wrapperExtensions(id)
|
||||
var exts []extensionItem
|
||||
for _, e := range localExtensions() {
|
||||
e.on = wrapped[e.name]
|
||||
exts = append(exts, e)
|
||||
}
|
||||
return pickerMsg{skills: skills, exts: exts}
|
||||
}
|
||||
}
|
||||
|
||||
// toggleSkill applies one skill change to the chosen agent: import it if
|
||||
// Paperclip does not know it, then add or remove it for that agent alone.
|
||||
func (m agentsModel) toggleSkill(item skillItem) tea.Cmd {
|
||||
c, company := m.client, m.client.Company
|
||||
agent := m.chosenAgent()
|
||||
if agent == nil {
|
||||
return func() tea.Msg { return pickerMsg{err: fmt.Errorf("no agent chosen")} }
|
||||
}
|
||||
return func() tea.Msg {
|
||||
key := item.key
|
||||
if item.onAgent {
|
||||
if err := c.SyncSkills(agent.ID, "remove", []string{key}); err != nil {
|
||||
return pickerMsg{err: err, note: "could not remove " + item.slug}
|
||||
}
|
||||
return pickerMsg{note: item.slug + " removed"}
|
||||
}
|
||||
if key == "" {
|
||||
// Local only: copy into the company's managed folder, then import.
|
||||
dst := filepath.Join(managedSkillsDir(company), item.slug)
|
||||
if err := copyTree(filepath.Join(agentsHome(), "skills", item.slug), dst); err != nil {
|
||||
return pickerMsg{err: err, note: "could not copy " + item.slug}
|
||||
}
|
||||
k, err := c.ImportSkill(dst)
|
||||
if err != nil {
|
||||
return pickerMsg{err: err, note: "could not import " + item.slug}
|
||||
}
|
||||
key = k
|
||||
}
|
||||
if err := c.SyncSkills(agent.ID, "add", []string{key}); err != nil {
|
||||
return pickerMsg{err: err, note: "could not add " + item.slug}
|
||||
}
|
||||
return pickerMsg{note: item.slug + " added"}
|
||||
}
|
||||
}
|
||||
|
||||
// toggleExtension flips one extension for the chosen agent and rewrites its
|
||||
// wrapper. Paperclip has no extension model of its own, so the wrapper is it.
|
||||
func (m agentsModel) toggleExtension(i int) tea.Cmd {
|
||||
agent := m.chosenAgent()
|
||||
exts := append([]extensionItem(nil), m.exts...)
|
||||
if agent == nil || i >= len(exts) {
|
||||
return func() tea.Msg { return pickerMsg{} }
|
||||
}
|
||||
exts[i].on = !exts[i].on
|
||||
var chosen []extensionItem
|
||||
for _, e := range exts {
|
||||
if e.on {
|
||||
chosen = append(chosen, e)
|
||||
}
|
||||
}
|
||||
p := &agentPicker{client: m.client}
|
||||
return func() tea.Msg {
|
||||
if err := writeWrapper(p, agent.ID, chosen); err != nil {
|
||||
return pickerMsg{err: err, note: "could not write the wrapper"}
|
||||
}
|
||||
verb := "added"
|
||||
if !exts[i].on {
|
||||
verb = "removed"
|
||||
}
|
||||
return pickerMsg{note: exts[i].name + " " + verb}
|
||||
}
|
||||
}
|
||||
|
||||
// copyTree copies a skill folder into the company's managed directory.
|
||||
func copyTree(src, dst string) error {
|
||||
return filepath.Walk(src, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rel, err := filepath.Rel(src, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
target := filepath.Join(dst, rel)
|
||||
if info.IsDir() {
|
||||
return os.MkdirAll(target, 0o755)
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(target, data, info.Mode())
|
||||
})
|
||||
}
|
||||
|
||||
func (m agentsModel) loadLog(runID string) tea.Cmd {
|
||||
c := m.client
|
||||
return func() tea.Msg {
|
||||
@@ -234,6 +394,9 @@ func (m agentsModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if m.tab == 1 && len(m.runs) == 0 {
|
||||
return m, m.loadRuns(m.showAll)
|
||||
}
|
||||
if m.tab == 4 || m.tab == 5 {
|
||||
return m, m.loadPicker()
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
case "up", "k":
|
||||
@@ -254,8 +417,25 @@ func (m agentsModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.moveTo(m.listLen() - 1)
|
||||
}
|
||||
return m, nil
|
||||
case " ":
|
||||
switch m.tab {
|
||||
case 4:
|
||||
if m.skillsAt < len(m.skills) {
|
||||
return m, m.toggleSkill(m.skills[m.skillsAt])
|
||||
}
|
||||
case 5:
|
||||
return m, m.toggleExtension(m.extsAt)
|
||||
}
|
||||
return m, nil
|
||||
case "enter":
|
||||
switch m.tab {
|
||||
case 4:
|
||||
if m.skillsAt < len(m.skills) {
|
||||
return m, m.toggleSkill(m.skills[m.skillsAt])
|
||||
}
|
||||
return m, nil
|
||||
case 5:
|
||||
return m, m.toggleExtension(m.extsAt)
|
||||
case 3:
|
||||
if m.settingAt >= 0 && m.settingAt < len(settingFields) {
|
||||
f := settingFields[m.settingAt]
|
||||
@@ -322,6 +502,27 @@ func (m agentsModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.readAt = time.Now()
|
||||
return m, nil
|
||||
|
||||
case pickerMsg:
|
||||
if msg.skills != nil {
|
||||
m.skills = msg.skills
|
||||
m.skillsAt = min(m.skillsAt, max(0, len(m.skills)-1))
|
||||
}
|
||||
if msg.exts != nil {
|
||||
m.exts = msg.exts
|
||||
m.extsAt = min(m.extsAt, max(0, len(m.exts)-1))
|
||||
}
|
||||
m.pickerNote = msg.note
|
||||
if msg.err != nil {
|
||||
m.err = msg.err
|
||||
} else {
|
||||
m.err = nil
|
||||
}
|
||||
// After a change the lists are stale; read them again.
|
||||
if msg.note != "" && msg.err == nil {
|
||||
return m, m.loadPicker()
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case runsMsg:
|
||||
if msg.runs != nil {
|
||||
m.runs = msg.runs
|
||||
@@ -361,6 +562,10 @@ func (m agentsModel) listLen() int {
|
||||
return len(m.agents)
|
||||
case 1:
|
||||
return len(m.runs)
|
||||
case 4:
|
||||
return len(m.skills)
|
||||
case 5:
|
||||
return len(m.exts)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -379,6 +584,10 @@ func (m agentsModel) move(delta int) agentsModel {
|
||||
m.runAt = max(0, min(m.runAt+delta, len(m.runs)-1))
|
||||
case 3:
|
||||
m.settingAt = max(0, min(m.settingAt+delta, len(settingFields)-1))
|
||||
case 4:
|
||||
m.skillsAt = max(0, min(m.skillsAt+delta, len(m.skills)-1))
|
||||
case 5:
|
||||
m.extsAt = max(0, min(m.extsAt+delta, len(m.exts)-1))
|
||||
}
|
||||
return m.follow()
|
||||
}
|
||||
@@ -622,6 +831,10 @@ func (m agentsModel) View() string {
|
||||
body = m.runLines(m.width - 4)
|
||||
case 3:
|
||||
body = m.settingLines(m.width - 4)
|
||||
case 4:
|
||||
body = m.skillLines(m.width - 4)
|
||||
case 5:
|
||||
body = m.extensionLines(m.width - 4)
|
||||
default:
|
||||
body = m.logLines(m.width - 4)
|
||||
}
|
||||
@@ -639,13 +852,22 @@ func (m agentsModel) View() string {
|
||||
from := m.scroll
|
||||
to := min(len(body), from+visible)
|
||||
|
||||
footer := hintLine([]string{
|
||||
keyHint("[ ]", "roster, runs, log"),
|
||||
hints := []string{
|
||||
keyHint("[ ]", "roster, runs, log, settings, skills, extensions"),
|
||||
keyHint("enter", "open a run"),
|
||||
keyHint("p P w", "pause, resume, wake"),
|
||||
keyHint("r", "read again"),
|
||||
keyHint("?", "keys"),
|
||||
}, m.width)
|
||||
}
|
||||
if m.tab == 4 || m.tab == 5 {
|
||||
hints = []string{
|
||||
keyHint("[ ]", "switch tab"),
|
||||
keyHint("space", "turn this skill or extension on or off"),
|
||||
keyHint("↑↓ j k", "move"),
|
||||
keyHint("r", "read again"),
|
||||
}
|
||||
}
|
||||
footer := hintLine(hints, m.width)
|
||||
if m.notice != "" {
|
||||
footer = lipgloss.NewStyle().Foreground(lipgloss.Color(active.Success)).Render(m.notice)
|
||||
}
|
||||
@@ -947,3 +1169,73 @@ func (m agentsModel) logLines(width int) []string {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// skillLines is the skills tab: every skill the person has, what Paperclip
|
||||
// knows about it, and whether this agent has it. Space or enter toggles.
|
||||
func (m agentsModel) skillLines(width int) []string {
|
||||
agent := m.chosenAgent()
|
||||
name := "no agent chosen"
|
||||
if agent != nil {
|
||||
name = agent.Name
|
||||
}
|
||||
out := []string{"", dimStyle().Render(" " + name + " · space toggles the chosen skill")}
|
||||
if len(m.skills) == 0 {
|
||||
return append(out, mutedStyle2().Render(" reading the skill lists…"))
|
||||
}
|
||||
for i, s := range m.skills {
|
||||
state := "not in Paperclip"
|
||||
colour := active.Warning
|
||||
if s.onAgent {
|
||||
state = "on this agent"
|
||||
colour = active.Success
|
||||
} else if s.inCompany {
|
||||
state = "in Paperclip"
|
||||
colour = active.Muted
|
||||
}
|
||||
line := fmt.Sprintf("%-26s %s", truncateVisible(s.slug, 26),
|
||||
lipgloss.NewStyle().Foreground(lipgloss.Color(colour)).Render(state))
|
||||
if i == m.skillsAt {
|
||||
out = append(out, selectedStyle2().Render(padTo("▸ "+line, width)))
|
||||
continue
|
||||
}
|
||||
out = append(out, " "+line)
|
||||
}
|
||||
if m.pickerNote != "" {
|
||||
out = append(out, "", " "+lipgloss.NewStyle().Foreground(lipgloss.Color(active.Success)).Render(m.pickerNote))
|
||||
}
|
||||
out = append(out, "", dimStyle().Render(" a skill only this agent has is the point of having agents."))
|
||||
return out
|
||||
}
|
||||
|
||||
// extensionLines is the extensions tab. An extension is turned on for one agent
|
||||
// by writing a wrapper and pointing that agent's command at it.
|
||||
func (m agentsModel) extensionLines(width int) []string {
|
||||
agent := m.chosenAgent()
|
||||
if agent == nil {
|
||||
return []string{"", mutedStyle2().Render(" no agent chosen")}
|
||||
}
|
||||
out := []string{"", dimStyle().Render(" " + agent.Name + " · space toggles the chosen extension")}
|
||||
if len(m.exts) == 0 {
|
||||
return append(out, mutedStyle2().Render(" nothing in ~/.agents/extensions"))
|
||||
}
|
||||
for i, e := range m.exts {
|
||||
mark := "off"
|
||||
colour := active.Muted
|
||||
if e.on {
|
||||
mark, colour = "on", active.Success
|
||||
}
|
||||
line := fmt.Sprintf("%-26s %s", truncateVisible(e.name, 26),
|
||||
lipgloss.NewStyle().Foreground(lipgloss.Color(colour)).Render(mark))
|
||||
if i == m.extsAt {
|
||||
out = append(out, selectedStyle2().Render(padTo("▸ "+line, width)))
|
||||
continue
|
||||
}
|
||||
out = append(out, " "+line)
|
||||
}
|
||||
if m.pickerNote != "" {
|
||||
out = append(out, "", " "+lipgloss.NewStyle().Foreground(lipgloss.Color(active.Success)).Render(m.pickerNote))
|
||||
}
|
||||
out = append(out, "", dimStyle().Render(" written as a wrapper at ~/.local/share/paperclip-harness/wrappers/"))
|
||||
out = append(out, dimStyle().Render(" and passed to Paperclip as this agent's command."))
|
||||
return out
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user