1242 lines
32 KiB
Go
1242 lines
32 KiB
Go
package main
|
||
|
||
// The Agents workspace: who is working, on what, and for how much.
|
||
//
|
||
// This is the view the project was missing. Paperclip already records every
|
||
// agent, every run and every run's output; nothing here is invented, and
|
||
// nothing here is stored. Three panels in one pane, switched with tabs:
|
||
//
|
||
// roster every agent, its state, its spend and its budget
|
||
// runs every run in the company, newest first
|
||
// log the output of the chosen run — watching Pi work
|
||
//
|
||
// Pause, resume and wake are the only writes, and each one is a single call to
|
||
// Paperclip, which owns the agent.
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"os"
|
||
"path/filepath"
|
||
"sort"
|
||
"strings"
|
||
"time"
|
||
|
||
tea "github.com/charmbracelet/bubbletea"
|
||
"github.com/charmbracelet/lipgloss"
|
||
|
||
"paperclip-harness/tui/paperclip"
|
||
)
|
||
|
||
type agentsMsg struct {
|
||
agents []paperclip.Agent
|
||
names map[string]string
|
||
err error
|
||
}
|
||
|
||
// runsMsg is separate from agentsMsg on purpose: the company's run list is over
|
||
// a megabyte and takes eight seconds, while the roster is small and instant.
|
||
// Reading them together made the whole screen wait for the slow one.
|
||
type runsMsg struct {
|
||
runs []paperclip.AgentRun
|
||
issues map[string]string
|
||
err error
|
||
}
|
||
|
||
type pickerMsg struct {
|
||
skills []skillItem
|
||
exts []extensionItem
|
||
note string
|
||
err error
|
||
}
|
||
|
||
type runLogMsg struct {
|
||
runID string
|
||
text string
|
||
err error
|
||
}
|
||
|
||
type agentsModel struct {
|
||
client *paperclip.Client
|
||
folder string
|
||
|
||
agents []paperclip.Agent
|
||
runs []paperclip.AgentRun
|
||
names map[string]string
|
||
logRunID string
|
||
logText string
|
||
err error
|
||
notice string
|
||
readAt time.Time
|
||
|
||
tab int
|
||
rosterAt int
|
||
runAt int
|
||
settingAt int
|
||
scroll int
|
||
|
||
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.
|
||
issues map[string]string // issue id -> identifier, for this project
|
||
showAll bool
|
||
editing string // the field being typed into, empty when not editing
|
||
editBuf string
|
||
|
||
width, height int
|
||
}
|
||
|
||
var agentTabs = []string{"roster", "runs", "log", "settings", "skills", "extensions"}
|
||
|
||
func newAgentsModel(c *paperclip.Client, folder string) agentsModel {
|
||
return agentsModel{client: c, folder: folder}
|
||
}
|
||
|
||
func (m agentsModel) Init() tea.Cmd {
|
||
return tea.Batch(m.load(), tickEvery(5*time.Second), tea.WindowSize())
|
||
}
|
||
|
||
func (m agentsModel) load() tea.Cmd {
|
||
c := m.client
|
||
return func() tea.Msg {
|
||
agents, err := c.Agents()
|
||
if err != nil {
|
||
return agentsMsg{err: err}
|
||
}
|
||
// A stable order, so the cursor stays on the agent it was on after a
|
||
// reload rather than following whatever order the server returned.
|
||
sort.Slice(agents, func(i, j int) bool { return agents[i].Name < agents[j].Name })
|
||
names := map[string]string{}
|
||
for _, a := range agents {
|
||
names[a.ID] = a.Name
|
||
}
|
||
return agentsMsg{agents: agents, names: names}
|
||
}
|
||
}
|
||
|
||
// loadRuns reads the company's runs. Called when the runs tab is opened and
|
||
// when it is asked for, never on the timer: it is a megabyte and eight seconds.
|
||
func (m agentsModel) loadRuns(showAll bool) tea.Cmd {
|
||
c, folder := m.client, m.folder
|
||
return func() tea.Msg {
|
||
issues, _ := c.Issues()
|
||
proj := ""
|
||
if cfg, err := readHarnessFile(folder); err == nil {
|
||
proj = cfg["paperclip.project"]
|
||
}
|
||
mine := map[string]string{}
|
||
for _, i := range issues {
|
||
if i.ProjectID == proj {
|
||
mine[i.ID] = i.Identifier
|
||
}
|
||
}
|
||
runs, err := c.CompanyRuns()
|
||
if err != nil {
|
||
return runsMsg{err: err}
|
||
}
|
||
filtered := make([]paperclip.AgentRun, 0, len(runs))
|
||
for _, r := range runs {
|
||
if showAll || mine[r.Snapshot.IssueID] != "" {
|
||
filtered = append(filtered, r)
|
||
}
|
||
if len(filtered) >= 400 {
|
||
break
|
||
}
|
||
}
|
||
return runsMsg{runs: filtered, issues: mine}
|
||
}
|
||
}
|
||
|
||
// 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 {
|
||
if runID == "" {
|
||
return runLogMsg{}
|
||
}
|
||
// The tail, not the whole thing: a run log here is tens of thousands of
|
||
// lines and only the end says what is happening now.
|
||
// Read the whole log: the stored form is one JSON envelope, and a
|
||
// partial read cuts the envelope in half and makes it unreadable.
|
||
text, err := c.RunLog(runID, 8<<20)
|
||
return runLogMsg{runID: runID, text: text, err: err}
|
||
}
|
||
}
|
||
|
||
func (m agentsModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||
switch msg := msg.(type) {
|
||
case tea.WindowSizeMsg:
|
||
m.width, m.height = msg.Width, msg.Height
|
||
return m, nil
|
||
|
||
case tickMsg:
|
||
return m, tea.Batch(m.load(), tickEvery(5*time.Second))
|
||
|
||
case tea.KeyMsg:
|
||
if m.editing != "" {
|
||
switch msg.Type {
|
||
case tea.KeyEsc:
|
||
m.editing = ""
|
||
m.editBuf = ""
|
||
return m, nil
|
||
case tea.KeyEnter:
|
||
field, value, agent := m.editing, m.editBuf, m.chosenAgent()
|
||
m.editing = ""
|
||
m.editBuf = ""
|
||
if agent == nil {
|
||
return m, nil
|
||
}
|
||
return m, m.saveSetting(agent.ID, field, value)
|
||
case tea.KeyBackspace:
|
||
r := []rune(m.editBuf)
|
||
if len(r) > 0 {
|
||
m.editBuf = string(r[:len(r)-1])
|
||
}
|
||
return m, nil
|
||
case tea.KeySpace:
|
||
m.editBuf += " "
|
||
return m, nil
|
||
case tea.KeyRunes:
|
||
m.editBuf += string(msg.Runes)
|
||
return m, nil
|
||
}
|
||
return m, nil
|
||
}
|
||
if m.showHelp {
|
||
m.showHelp = false
|
||
return m, nil
|
||
}
|
||
switch msg.String() {
|
||
case "?":
|
||
m.showHelp = true
|
||
return m, nil
|
||
case "q", "ctrl+c":
|
||
return m, tea.Quit
|
||
case "r":
|
||
switch m.tab {
|
||
case 1:
|
||
return m, m.loadRuns(m.showAll)
|
||
case 2:
|
||
return m, m.loadLog(m.logRunID)
|
||
case 3:
|
||
return m, m.load()
|
||
}
|
||
return m, m.load()
|
||
case "A":
|
||
m.showAll = !m.showAll
|
||
return m, m.loadRuns(m.showAll)
|
||
case "R":
|
||
if err := startRebuild(m.folder); err != nil {
|
||
m.err = err
|
||
return m, nil
|
||
}
|
||
return m, tea.Quit
|
||
case "[", "h":
|
||
if m.tab > 0 {
|
||
m.tab--
|
||
m.scroll = 0
|
||
}
|
||
return m, nil
|
||
case "]", "l", "tab":
|
||
if m.tab < len(agentTabs)-1 {
|
||
m.tab++
|
||
m.scroll = 0
|
||
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":
|
||
return m.move(-1), nil
|
||
case "down", "j":
|
||
return m.move(1), nil
|
||
case "g":
|
||
if m.tab == 2 {
|
||
m.scroll = 0
|
||
} else {
|
||
m.moveTo(0)
|
||
}
|
||
return m, nil
|
||
case "G":
|
||
if m.tab == 2 {
|
||
m.scroll = 1 << 30
|
||
} else {
|
||
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]
|
||
if f.key == "heartbeat" {
|
||
agent := m.chosenAgent()
|
||
if agent == nil {
|
||
return m, nil
|
||
}
|
||
return m, m.saveSetting(agent.ID, "heartbeat", "toggle")
|
||
}
|
||
m.editing = f.key
|
||
m.editBuf = m.currentAgentSetting(f.key)
|
||
return m, nil
|
||
}
|
||
case 1:
|
||
if r := m.chosenRun(); r != nil {
|
||
m.logRunID = r.ID
|
||
m.tab = 2
|
||
m.scroll = 1 << 30
|
||
return m, m.loadLog(r.ID)
|
||
}
|
||
}
|
||
return m, nil
|
||
case "p", "P", "w":
|
||
agent := m.chosenAgent()
|
||
if agent == nil {
|
||
return m, nil
|
||
}
|
||
c := m.client
|
||
action := msg.String()
|
||
return m, func() tea.Msg {
|
||
var err error
|
||
switch action {
|
||
case "p":
|
||
err = c.PauseAgent(agent.ID)
|
||
case "P":
|
||
err = c.ResumeAgent(agent.ID)
|
||
case "w":
|
||
err = c.WakeAgent(agent.ID)
|
||
}
|
||
if err != nil {
|
||
return errMsg{err}
|
||
}
|
||
agents, err := c.Agents()
|
||
if err != nil {
|
||
return errMsg{err}
|
||
}
|
||
return agentsMsg{agents: agents}
|
||
}
|
||
case "esc":
|
||
return m, nil
|
||
}
|
||
|
||
case agentsMsg:
|
||
if msg.agents != nil {
|
||
m.agents = msg.agents
|
||
}
|
||
m.names = msg.names
|
||
if msg.err != nil {
|
||
m.err = msg.err
|
||
} else {
|
||
m.err = nil
|
||
}
|
||
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
|
||
m.issues = msg.issues
|
||
m.runAt = min(m.runAt, max(0, len(m.runs)-1))
|
||
m.readAt = time.Now()
|
||
}
|
||
if msg.err != nil {
|
||
m.err = msg.err
|
||
} else {
|
||
m.err = nil
|
||
}
|
||
return m, nil
|
||
|
||
case runLogMsg:
|
||
if msg.runID != "" {
|
||
m.logRunID = msg.runID
|
||
}
|
||
m.logText = msg.text
|
||
if msg.err != nil {
|
||
m.err = msg.err
|
||
}
|
||
return m, nil
|
||
|
||
case errMsg:
|
||
m.err = msg.err
|
||
return m, nil
|
||
}
|
||
return m, nil
|
||
}
|
||
|
||
// listLen is how many things the current tab can select. The log tab scrolls
|
||
// instead of selecting, so it has none.
|
||
func (m agentsModel) listLen() int {
|
||
switch m.tab {
|
||
case 0:
|
||
return len(m.agents)
|
||
case 1:
|
||
return len(m.runs)
|
||
case 4:
|
||
return len(m.skills)
|
||
case 5:
|
||
return len(m.exts)
|
||
}
|
||
return 0
|
||
}
|
||
|
||
// move keeps one cursor per tab, so switching tabs does not forget where you
|
||
// were, and keeps the list scrolled to wherever the cursor is.
|
||
func (m agentsModel) move(delta int) agentsModel {
|
||
if m.tab == 2 {
|
||
m.scroll += delta
|
||
return m
|
||
}
|
||
switch m.tab {
|
||
case 0:
|
||
m.rosterAt = max(0, min(m.rosterAt+delta, len(m.agents)-1))
|
||
case 1:
|
||
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()
|
||
}
|
||
|
||
func (m agentsModel) moveTo(i int) agentsModel {
|
||
if m.tab == 0 && len(m.agents) > 0 {
|
||
m.rosterAt = max(0, min(i, len(m.agents)-1))
|
||
}
|
||
if m.tab == 1 && len(m.runs) > 0 {
|
||
m.runAt = max(0, min(i, len(m.runs)-1))
|
||
}
|
||
return m.follow()
|
||
}
|
||
|
||
// follow scrolls so the selected row is on screen, with two rows of context.
|
||
func (m agentsModel) follow() agentsModel {
|
||
var at int
|
||
switch m.tab {
|
||
case 0:
|
||
at = m.rosterAt
|
||
case 1:
|
||
at = m.runAt
|
||
default:
|
||
return m
|
||
}
|
||
visible := max(3, m.height-7)
|
||
if at < m.scroll+2 {
|
||
m.scroll = max(0, at-2)
|
||
}
|
||
if at > m.scroll+visible-3 {
|
||
m.scroll = at - visible + 3
|
||
}
|
||
if m.scroll < 0 {
|
||
m.scroll = 0
|
||
}
|
||
return m
|
||
}
|
||
|
||
// saveSetting writes one field of one agent to Paperclip, merging into the
|
||
// blocks it belongs to so nothing else is lost.
|
||
func (m agentsModel) saveSetting(agentID, field, value string) tea.Cmd {
|
||
c := m.client
|
||
return func() tea.Msg {
|
||
full, err := c.AgentRaw(agentID)
|
||
if err != nil {
|
||
return errMsg{err}
|
||
}
|
||
body := map[string]any{}
|
||
switch field {
|
||
case "role":
|
||
body["role"] = value
|
||
case "title":
|
||
body["title"] = value
|
||
case "model":
|
||
cfg, _ := full["adapterConfig"].(map[string]any)
|
||
merged := map[string]any{}
|
||
for k, v := range cfg {
|
||
merged[k] = v
|
||
}
|
||
merged["model"] = value
|
||
body["adapterConfig"] = merged
|
||
case "budget":
|
||
var n int
|
||
if _, err := fmt.Sscanf(value, "%d", &n); err != nil || n < 0 {
|
||
return errMsg{fmt.Errorf("a budget is a whole number of cents, not %q", value)}
|
||
}
|
||
body["budgetMonthlyCents"] = n
|
||
case "heartbeat":
|
||
rc, _ := full["runtimeConfig"].(map[string]any)
|
||
merged := map[string]any{}
|
||
for k, v := range rc {
|
||
merged[k] = v
|
||
}
|
||
hb, _ := merged["heartbeat"].(map[string]any)
|
||
next := map[string]any{}
|
||
for k, v := range hb {
|
||
next[k] = v
|
||
}
|
||
on, _ := next["enabled"].(bool)
|
||
if value == "toggle" {
|
||
next["enabled"] = !on
|
||
} else {
|
||
next["enabled"] = value == "true"
|
||
}
|
||
merged["heartbeat"] = next
|
||
body["runtimeConfig"] = merged
|
||
}
|
||
if err := c.UpdateAgent(agentID, body); err != nil {
|
||
return errMsg{err}
|
||
}
|
||
agents, err := c.Agents()
|
||
if err != nil {
|
||
return errMsg{err}
|
||
}
|
||
names := map[string]string{}
|
||
for _, a := range agents {
|
||
names[a.ID] = a.Name
|
||
}
|
||
return agentsMsg{agents: agents, names: names}
|
||
}
|
||
}
|
||
|
||
// settingLines is the settings tab: one field per line, the chosen one marked.
|
||
func (m agentsModel) settingLines(width int) []string {
|
||
agent := m.chosenAgent()
|
||
if agent == nil {
|
||
return []string{"", mutedStyle2().Render("no agent chosen")}
|
||
}
|
||
out := []string{"", dimStyle().Render(" " + agent.Name + " · press enter to change a field")}
|
||
for i, f := range settingFields {
|
||
value := m.currentAgentSetting(f.key)
|
||
if value == "" {
|
||
value = "—"
|
||
}
|
||
if m.editing == f.key {
|
||
out = append(out, " ▸ "+lipgloss.NewStyle().Foreground(lipgloss.Color(active.Accent)).Bold(true).Render(f.label)+
|
||
": "+lipgloss.NewStyle().Foreground(lipgloss.Color(active.Warning)).Render(m.editBuf+"▌"))
|
||
out = append(out, " "+dimStyle().Render("enter saves, esc cancels · "+f.hint))
|
||
continue
|
||
}
|
||
style := lipgloss.NewStyle().Foreground(lipgloss.Color(active.Text))
|
||
mark := " "
|
||
if i == m.settingAt {
|
||
mark = " ▸ "
|
||
style = selectedStyle2()
|
||
value = f.label + ": " + value
|
||
}
|
||
out = append(out, mark+style.Render(fmt.Sprintf("%-14s %s", f.label, value)))
|
||
}
|
||
out = append(out, "")
|
||
out = append(out, dimStyle().Render(" a model change applies to that agent's next run, never to one in flight."))
|
||
out = append(out, dimStyle().Render(" p pause · P resume · w wake: they act on the agent chosen here."))
|
||
return out
|
||
}
|
||
|
||
// chosenRun is the run the cursor is on, in the runs tab.
|
||
func (m agentsModel) chosenRun() *paperclip.AgentRun {
|
||
if m.runAt >= 0 && m.runAt < len(m.runs) {
|
||
return &m.runs[m.runAt]
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// chosenAgent is the agent whose name matches the chosen run, or the first one.
|
||
func (m agentsModel) chosenAgent() *paperclip.Agent {
|
||
// On the roster the cursor picks the agent. On the runs tab it is the agent
|
||
// who ran the chosen run. On settings it is the roster's choice.
|
||
if m.tab == 1 {
|
||
if r := m.chosenRun(); r != nil {
|
||
for i := range m.agents {
|
||
if m.agents[i].ID == r.AgentID {
|
||
return &m.agents[i]
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if m.rosterAt >= 0 && m.rosterAt < len(m.agents) {
|
||
return &m.agents[m.rosterAt]
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// settingField is one editable thing about an agent. Only fields Paperclip
|
||
// accepts on a PATCH are here, and each one sends only itself.
|
||
type settingField struct {
|
||
key string
|
||
label string
|
||
hint string
|
||
}
|
||
|
||
var settingFields = []settingField{
|
||
{"role", "role", "what this agent is for"},
|
||
{"title", "job title", "the name a person sees"},
|
||
{"model", "model", "the one used for its next run"},
|
||
{"budget", "monthly budget", "in cents"},
|
||
{"heartbeat", "heartbeat", "whether it wakes on a timer"},
|
||
}
|
||
|
||
func (m agentsModel) currentAgentSetting(key string) string {
|
||
a := m.chosenAgent()
|
||
if a == nil {
|
||
return ""
|
||
}
|
||
full, err := m.client.AgentRaw(a.ID)
|
||
if err != nil {
|
||
return ""
|
||
}
|
||
switch key {
|
||
case "role":
|
||
return fmt.Sprint(full["role"])
|
||
case "title":
|
||
return fmt.Sprint(full["title"])
|
||
case "model":
|
||
if cfg, ok := full["adapterConfig"].(map[string]any); ok {
|
||
return fmt.Sprint(cfg["model"])
|
||
}
|
||
case "budget":
|
||
return fmt.Sprint(int(full["budgetMonthlyCents"].(float64)))
|
||
case "heartbeat":
|
||
if rc, ok := full["runtimeConfig"].(map[string]any); ok {
|
||
if hb, ok := rc["heartbeat"].(map[string]any); ok {
|
||
return fmt.Sprintf("%v", hb["enabled"])
|
||
}
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func (m agentsModel) View() string {
|
||
if m.width == 0 || m.height == 0 {
|
||
return "reading…"
|
||
}
|
||
if m.width < 80 || m.height < 24 {
|
||
return lipgloss.NewStyle().Width(m.width).Height(m.height).Render(
|
||
fmt.Sprintf("This workspace needs 80 × 24.\nThis panel is %d × %d.", m.width, m.height))
|
||
}
|
||
|
||
if m.showHelp {
|
||
return helpOverlay("agents", [][2]string{
|
||
{"[ ] h l", "roster, runs, log"},
|
||
{"enter", "open the chosen run's log"},
|
||
{"p / P", "pause / resume the agent"},
|
||
{"w", "wake the agent now"},
|
||
{"↑ ↓ j k", "scroll"},
|
||
{"r / R", "read again / rebuild"},
|
||
{"q", "leave"},
|
||
}, m.width, m.height)
|
||
}
|
||
|
||
head := titleStyle.Render("Agents") + mutedStyle2().Render(" · who is working, on what, for how much")
|
||
state := "read " + m.readAt.Format("15:04:05")
|
||
if m.readAt.IsZero() {
|
||
state = "reading…"
|
||
}
|
||
|
||
var body []string
|
||
switch m.tab {
|
||
case 0:
|
||
body = m.rosterLines(m.width - 4)
|
||
case 1:
|
||
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)
|
||
}
|
||
|
||
p := panel{title: fmt.Sprintf("agents · %d agent(s), %d run(s)", len(m.agents), len(m.runs)),
|
||
width: m.width, height: m.height - 2, tabs: agentTabs, active: m.tab, focused: true}
|
||
visible := p.bodyHeight()
|
||
maxScroll := max(0, len(body)-visible)
|
||
if m.scroll > maxScroll {
|
||
m.scroll = maxScroll
|
||
}
|
||
if m.scroll < 0 {
|
||
m.scroll = 0
|
||
}
|
||
from := m.scroll
|
||
to := min(len(body), from+visible)
|
||
|
||
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"),
|
||
}
|
||
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)
|
||
}
|
||
if m.err != nil {
|
||
footer = lipgloss.NewStyle().Foreground(lipgloss.Color(active.Error)).Render(truncateVisible("error: "+m.err.Error(), m.width))
|
||
}
|
||
|
||
gap := max(2, m.width-lipgloss.Width(head)-lipgloss.Width(state)-1)
|
||
return strings.Join([]string{
|
||
head + strings.Repeat(" ", gap) + mutedStyle2().Render(state),
|
||
p.render(body[from:to]),
|
||
footer,
|
||
}, "\n")
|
||
}
|
||
|
||
func (m agentsModel) rosterLines(width int) []string {
|
||
out := []string{"", dimStyle().Render(fmt.Sprintf(" %-16s %-10s %-18s %8s %8s", "agent", "state", "working on", "spent", "budget"))}
|
||
for i, a := range m.agents {
|
||
mark := icons.Todo
|
||
if a.Status == "idle" {
|
||
mark = icons.Done
|
||
}
|
||
working := "—"
|
||
for _, r := range m.runs {
|
||
if r.AgentID == a.ID && r.Status == "running" {
|
||
working = m.taskLabel(r)
|
||
mark = icons.Running
|
||
break
|
||
}
|
||
}
|
||
line := fmt.Sprintf(" %s %-16s %-10s %-18s %6d c %6d c",
|
||
mark, a.Name, a.Status, truncateVisible(working, 18), a.SpentCents, a.BudgetCents)
|
||
if i == m.rosterAt {
|
||
line = "▸" + line[1:]
|
||
line = selectedStyle2().Render(padTo(line, width))
|
||
}
|
||
out = append(out, line)
|
||
}
|
||
out = append(out, "")
|
||
out = append(out, dimStyle().Render(" c means cents. Budget is set in Paperclip; change it there, not here."))
|
||
return out
|
||
}
|
||
|
||
func (m agentsModel) taskLabel(r paperclip.AgentRun) string {
|
||
if id := r.Snapshot.IssueID; id != "" {
|
||
if name, ok := m.issues[id]; ok {
|
||
return name
|
||
}
|
||
return short(id)
|
||
}
|
||
if r.Snapshot.TaskKey != "" {
|
||
return short(r.Snapshot.TaskKey)
|
||
}
|
||
return "—"
|
||
}
|
||
|
||
func (m agentsModel) runLines(width int) []string {
|
||
if len(m.runs) == 0 {
|
||
return []string{"", mutedStyle2().Render("no runs recorded")}
|
||
}
|
||
out := []string{"", dimStyle().Render(fmt.Sprintf(" %-10s %-11s %-9s %-12s %-8s %s",
|
||
"when", "agent", "state", "woken by", "took", "task"))}
|
||
for i, r := range m.runs {
|
||
when := "-"
|
||
if t, err := time.Parse(time.RFC3339, r.StartedAt); err == nil {
|
||
when = t.Local().Format("15:04:05")
|
||
}
|
||
took := "-"
|
||
if r.FinishedAt != "" {
|
||
if s, err := time.Parse(time.RFC3339, r.StartedAt); err == nil {
|
||
if f, err := time.Parse(time.RFC3339, r.FinishedAt); err == nil {
|
||
took = f.Sub(s).Round(time.Second).String()
|
||
}
|
||
}
|
||
} else if r.Status == "running" {
|
||
took = "running"
|
||
}
|
||
colour := active.Text
|
||
switch r.Status {
|
||
case "running":
|
||
colour = active.Info
|
||
case "succeeded":
|
||
colour = active.Success
|
||
case "failed":
|
||
colour = active.Error
|
||
}
|
||
line := fmt.Sprintf(" %-10s %-11s %-9s %-12s %-8s %s",
|
||
when, truncateVisible(m.names[r.AgentID], 11),
|
||
fmt.Sprintf("%-9s", r.Status), r.Source, took, m.taskLabel(r))
|
||
if i == m.runAt {
|
||
out = append(out, selectedStyle2().Render(padTo("▸"+line[1:], width)))
|
||
continue
|
||
}
|
||
out = append(out, fmt.Sprintf(" %-10s %-11s %-9s %-12s %-8s %s",
|
||
when, truncateVisible(m.names[r.AgentID], 11),
|
||
lipgloss.NewStyle().Foreground(lipgloss.Color(colour)).Render(fmt.Sprintf("%-9s", r.Status)),
|
||
r.Source, took, m.taskLabel(r)))
|
||
}
|
||
return out
|
||
}
|
||
|
||
// logEntry is one readable line out of a run log.
|
||
type logEntry struct {
|
||
when string
|
||
stream string
|
||
line string
|
||
}
|
||
|
||
// decodeRunLog turns what Paperclip stores into something a person can read.
|
||
//
|
||
// A run log is not lines of prose: it is an envelope around newline-separated
|
||
// JSON, and most of those are token-by-token streaming events. Printed raw they
|
||
// are unreadable and they redraw the panel. So deltas are gathered back into
|
||
// paragraphs, and everything else becomes one labelled line.
|
||
func decodeRunLog(raw string) []logEntry {
|
||
var envelope struct {
|
||
Content string `json:"content"`
|
||
}
|
||
content := raw
|
||
if json.Unmarshal([]byte(raw), &envelope) == nil && envelope.Content != "" {
|
||
content = envelope.Content
|
||
}
|
||
|
||
var out []logEntry
|
||
var buf strings.Builder
|
||
bufWhen := ""
|
||
flush := func() {
|
||
text := strings.TrimSpace(cleanText(buf.String()))
|
||
buf.Reset()
|
||
if text == "" {
|
||
return
|
||
}
|
||
for _, para := range strings.Split(text, "\n") {
|
||
para = strings.TrimSpace(para)
|
||
if para != "" {
|
||
out = append(out, logEntry{when: bufWhen, stream: "thinking", line: para})
|
||
}
|
||
}
|
||
}
|
||
|
||
for _, line := range strings.Split(content, "\n") {
|
||
line = strings.TrimSpace(line)
|
||
if line == "" {
|
||
continue
|
||
}
|
||
var rec struct {
|
||
TS string `json:"ts"`
|
||
Stream string `json:"stream"`
|
||
Chunk string `json:"chunk"`
|
||
}
|
||
text := line
|
||
when := ""
|
||
if json.Unmarshal([]byte(line), &rec) == nil && rec.Chunk != "" {
|
||
text, when = rec.Chunk, shortTime(rec.TS)
|
||
}
|
||
|
||
var ev struct {
|
||
Type string `json:"type"`
|
||
Delta string `json:"delta"`
|
||
Message struct {
|
||
Role string `json:"role"`
|
||
Content []struct {
|
||
Type string `json:"type"`
|
||
Text string `json:"text"`
|
||
} `json:"content"`
|
||
} `json:"message"`
|
||
AssistantMessageEvent struct {
|
||
Type string `json:"type"`
|
||
Delta string `json:"delta"`
|
||
} `json:"assistantMessageEvent"`
|
||
}
|
||
|
||
if json.Unmarshal([]byte(text), &ev) != nil {
|
||
// Not JSON we know: show it as a line, trimmed.
|
||
if t := strings.TrimSpace(cleanText(text)); t != "" {
|
||
out = append(out, logEntry{when: when, line: t})
|
||
}
|
||
continue
|
||
}
|
||
|
||
kind := ev.AssistantMessageEvent.Type
|
||
if ev.Type == "message_update" && strings.HasSuffix(kind, "_delta") {
|
||
if bufWhen == "" {
|
||
bufWhen = when
|
||
}
|
||
buf.WriteString(ev.AssistantMessageEvent.Delta)
|
||
continue
|
||
}
|
||
|
||
// Anything else ends the paragraph that was being gathered.
|
||
flush()
|
||
bufWhen = when
|
||
|
||
switch ev.Type {
|
||
case "message_start", "message_end":
|
||
role := ev.Message.Role
|
||
if role == "" {
|
||
continue
|
||
}
|
||
for _, c := range ev.Message.Content {
|
||
t := strings.TrimSpace(cleanText(c.Text))
|
||
if t == "" {
|
||
continue
|
||
}
|
||
if i := strings.Index(t, "\n"); i > 0 {
|
||
t = t[:i]
|
||
}
|
||
out = append(out, logEntry{when: when, stream: "prompt", line: role + ": " + t})
|
||
break
|
||
}
|
||
case "tool_call", "tool_result", "tool_use":
|
||
out = append(out, logEntry{when: when, stream: "tool", line: ev.Type})
|
||
case "turn_start", "turn_end", "session", "agent_start", "agent_end":
|
||
out = append(out, logEntry{when: when, stream: "event", line: ev.Type})
|
||
default:
|
||
if kind != "" {
|
||
out = append(out, logEntry{when: when, stream: "event", line: kind})
|
||
}
|
||
}
|
||
}
|
||
flush()
|
||
|
||
if len(out) == 0 {
|
||
for _, line := range strings.Split(strings.TrimRight(content, "\n"), "\n") {
|
||
if t := strings.TrimSpace(cleanText(line)); t != "" {
|
||
out = append(out, logEntry{line: t})
|
||
}
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// shortTime is the clock time of a log line, in the reader's timezone.
|
||
func shortTime(ts string) string {
|
||
if t, err := time.Parse(time.RFC3339Nano, ts); err == nil {
|
||
return t.Local().Format("15:04:05")
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// cleanText removes escape sequences and control characters, so a log cannot
|
||
// repaint the panel it is drawn in.
|
||
func cleanText(s string) string {
|
||
var b strings.Builder
|
||
skip := false
|
||
for _, r := range s {
|
||
if r == 0x1b {
|
||
skip = true
|
||
continue
|
||
}
|
||
if skip {
|
||
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') {
|
||
skip = false
|
||
}
|
||
continue
|
||
}
|
||
if r == '\t' {
|
||
b.WriteString(" ")
|
||
continue
|
||
}
|
||
if r < 0x20 && r != '\n' {
|
||
continue
|
||
}
|
||
b.WriteRune(r)
|
||
}
|
||
return b.String()
|
||
}
|
||
|
||
func (m agentsModel) logLines(width int) []string {
|
||
if m.logRunID == "" {
|
||
return []string{"", mutedStyle2().Render("choose a run on the runs tab, then press enter")}
|
||
}
|
||
if m.logText == "" {
|
||
return []string{"", mutedStyle2().Render("no output recorded for this run")}
|
||
}
|
||
var out []string
|
||
out = append(out, "", dimStyle().Render(" run "+short(m.logRunID)))
|
||
entries := decodeRunLog(m.logText)
|
||
if len(entries) > 2000 {
|
||
entries = entries[len(entries)-2000:]
|
||
}
|
||
for _, e := range entries {
|
||
if lipgloss.Width(e.line) > width {
|
||
e.line = truncateVisible(e.line, width)
|
||
}
|
||
colour := active.Text
|
||
switch e.stream {
|
||
case "thinking":
|
||
colour = active.Muted
|
||
case "prompt":
|
||
colour = active.Info
|
||
case "tool":
|
||
colour = active.Warning
|
||
case "event":
|
||
colour = active.Overlay
|
||
}
|
||
out = append(out, " "+dimStyle().Render(e.when)+" "+
|
||
lipgloss.NewStyle().Foreground(lipgloss.Color(colour)).Render(e.line))
|
||
}
|
||
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
|
||
}
|