Agents pane: project-scoped runs with selection, roster cursor, editable settings
This commit is contained in:
388
tui/agents.go
388
tui/agents.go
@@ -16,6 +16,7 @@ package main
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -35,8 +36,9 @@ type agentsMsg struct {
|
||||
// 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
|
||||
err error
|
||||
runs []paperclip.AgentRun
|
||||
issues map[string]string
|
||||
err error
|
||||
}
|
||||
|
||||
type runLogMsg struct {
|
||||
@@ -58,15 +60,24 @@ type agentsModel struct {
|
||||
notice string
|
||||
readAt time.Time
|
||||
|
||||
tab int
|
||||
cursor int
|
||||
scroll int
|
||||
showHelp bool
|
||||
tab int
|
||||
rosterAt int
|
||||
runAt int
|
||||
settingAt int
|
||||
scroll int
|
||||
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"}
|
||||
var agentTabs = []string{"roster", "runs", "log", "settings"}
|
||||
|
||||
func newAgentsModel(c *paperclip.Client, folder string) agentsModel {
|
||||
return agentsModel{client: c, folder: folder}
|
||||
@@ -83,6 +94,9 @@ func (m agentsModel) load() tea.Cmd {
|
||||
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
|
||||
@@ -93,17 +107,34 @@ func (m agentsModel) load() tea.Cmd {
|
||||
|
||||
// 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() tea.Cmd {
|
||||
c := m.client
|
||||
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}
|
||||
}
|
||||
if len(runs) > 200 {
|
||||
runs = runs[:200]
|
||||
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: runs}
|
||||
return runsMsg{runs: filtered, issues: mine}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,6 +163,35 @@ func (m agentsModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
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
|
||||
@@ -145,11 +205,16 @@ func (m agentsModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
case "r":
|
||||
switch m.tab {
|
||||
case 1:
|
||||
return m, m.loadRuns()
|
||||
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
|
||||
@@ -167,24 +232,45 @@ func (m agentsModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.tab++
|
||||
m.scroll = 0
|
||||
if m.tab == 1 && len(m.runs) == 0 {
|
||||
return m, m.loadRuns()
|
||||
return m, m.loadRuns(m.showAll)
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
case "up", "k":
|
||||
m.scroll--
|
||||
return m, nil
|
||||
return m.move(-1), nil
|
||||
case "down", "j":
|
||||
m.scroll++
|
||||
return m, nil
|
||||
return m.move(1), nil
|
||||
case "g":
|
||||
m.scroll = 0
|
||||
if m.tab == 2 {
|
||||
m.scroll = 0
|
||||
} else {
|
||||
m.moveTo(0)
|
||||
}
|
||||
return m, nil
|
||||
case "G":
|
||||
m.scroll = 1 << 30
|
||||
if m.tab == 2 {
|
||||
m.scroll = 1 << 30
|
||||
} else {
|
||||
m.moveTo(m.listLen() - 1)
|
||||
}
|
||||
return m, nil
|
||||
case "enter":
|
||||
if m.tab == 1 {
|
||||
switch m.tab {
|
||||
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
|
||||
@@ -239,6 +325,8 @@ func (m agentsModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
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 {
|
||||
@@ -265,29 +353,240 @@ func (m agentsModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// chosenRun is the run the roster cursor is on, in the runs tab.
|
||||
// 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)
|
||||
}
|
||||
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))
|
||||
}
|
||||
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.cursor >= 0 && m.cursor < len(m.runs) {
|
||||
return &m.runs[m.cursor]
|
||||
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 {
|
||||
if r := m.chosenRun(); r != nil {
|
||||
for i := range m.agents {
|
||||
if m.agents[i].ID == r.AgentID {
|
||||
return &m.agents[i]
|
||||
// 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 len(m.agents) > 0 {
|
||||
return &m.agents[0]
|
||||
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…"
|
||||
@@ -321,6 +620,8 @@ func (m agentsModel) View() string {
|
||||
body = m.rosterLines(m.width - 4)
|
||||
case 1:
|
||||
body = m.runLines(m.width - 4)
|
||||
case 3:
|
||||
body = m.settingLines(m.width - 4)
|
||||
default:
|
||||
body = m.logLines(m.width - 4)
|
||||
}
|
||||
@@ -362,7 +663,7 @@ func (m agentsModel) View() string {
|
||||
|
||||
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 _, a := range m.agents {
|
||||
for i, a := range m.agents {
|
||||
mark := icons.Todo
|
||||
if a.Status == "idle" {
|
||||
mark = icons.Done
|
||||
@@ -375,8 +676,13 @@ func (m agentsModel) rosterLines(width int) []string {
|
||||
break
|
||||
}
|
||||
}
|
||||
out = append(out, fmt.Sprintf(" %s %-16s %-10s %-18s %6d c %6d c",
|
||||
mark, a.Name, a.Status, truncateVisible(working, 18), a.SpentCents, a.BudgetCents))
|
||||
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."))
|
||||
@@ -384,8 +690,11 @@ func (m agentsModel) rosterLines(width int) []string {
|
||||
}
|
||||
|
||||
func (m agentsModel) taskLabel(r paperclip.AgentRun) string {
|
||||
if r.Snapshot.IssueID != "" {
|
||||
return short(r.Snapshot.IssueID)
|
||||
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)
|
||||
@@ -399,7 +708,7 @@ func (m agentsModel) runLines(width int) []string {
|
||||
}
|
||||
out := []string{"", dimStyle().Render(fmt.Sprintf(" %-10s %-11s %-9s %-12s %-8s %s",
|
||||
"when", "agent", "state", "woken by", "took", "task"))}
|
||||
for _, r := range m.runs {
|
||||
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")
|
||||
@@ -423,6 +732,13 @@ func (m agentsModel) runLines(width int) []string {
|
||||
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)),
|
||||
|
||||
Reference in New Issue
Block a user