Agents pane: project-scoped runs with selection, roster cursor, editable settings

This commit is contained in:
Sam
2026-11-10 17:23:32 +11:00
parent b15216aa7b
commit ec7e907b2e
7 changed files with 634 additions and 39 deletions

View File

@@ -385,3 +385,84 @@ Tell me which of those you want first, and whether the icons appear after reopen
- session: [[packs/paperclip-harness/80-sessions/paperclip-harness-tui-icon-font-fix-help-filter-unblock-safe|paperclip-harness TUI: icon font fix, help/filter/unblock, safe rebuild (commit 09e32da)]]
- session: [[packs/paperclip-harness/80-sessions/rich-discovery-config-2026-11-10-05-00-01|Rich discovery: .config 2026-11-10 05:00:01]]
- context: [[packs/paperclip-harness/20-context/tmp|.config]]
---
## Update (2026-11-10 05:59:07)
## Rich discovery update (2026-11-10 05:59:07)
The full list, at the end of the project, with what exists today marked.
## Workspaces and panes
| Workspace | Pane | Shows | Now |
|---|---|---|---|
| **Shell** | shell | a prompt in the project folder | built |
| **Board** | board | tasks by group · chosen task · needs you | built |
| **Work** | work | runs · change (diff) · facts | built |
| **Work** | review | the last ocr verdict and its blocking findings | **missing** |
| **Agents** | agents | roster · runs · log — who is working, on what | built today |
| **Agents** | settings | role, title, reports-to, model, heartbeat, budget | **missing** |
| **Reply** | reply | the task · the thread · the reply box | built |
| **New task** | new | the form · guidance · preview | built |
| **Project** | project | facts · costs · settings and limits | built |
| **Repair** | repair | health · layout · rebuild log | built |
| **any** | tool | Lazygit, Hunk, Yazi, Neovim, fzf, shell, ocr — opened with `t`, closed with `esc` | **not wired** |
So: 8 workspaces, 10 named panes, 21 panels built, 2 panels and the tool panes still to do.
## What the Agents pane does now
Workspace `Agents`, pane `agents`, three tabs:
**roster** — every agent with its state, spend and budget:
```
researcher idle 0 c 2000 c
Builder idle 10 c 5000 c
```
**runs** — the company's runs, newest first, with agent, state, what woke it, duration and task:
```
15:56:27 Builder succeeded automation 32s bb4dc72b
12:41:47 Builder succeeded assignment 1m27s bb4dc72b
```
**log** — `enter` on a run shows its output. This is watching Pi work: the stored log is a wall of JSON and token-by-token deltas, so it is gathered back into readable paragraphs:
```
15:56:41 session
15:56:42 user: ## Paperclip Resume Delta
15:56:43 The user posted "Here is a test reply" — a test comment…
15:56:43 Let me verify the file still exists and commit present…
```
`p`, `P` and `w` pause, resume and wake the selected agent. I tested the pause/resume calls directly against Paperclip on `Documenter`: `idle → paused → idle`.
One bug worth knowing: the company run list is 1.1 MB and takes 8 seconds, while the roster is instant. Reading them together made the whole pane appear empty. They are now separate reads — the roster on the timer, the runs when you open that tab.
## Still missing, in order
1. **Agents>settings** — role, title, reports-to, model, heartbeat, budget. All of it is a single `PATCH /api/agents/{id}`, which accepts exactly those fields.
2. **Work>review** — the ocr verdict beside the diff. ocr is installed and already refused a merge on `AUD-165`.
3. **Tool panes** — `t` opens Lazygit, Hunk, Yazi, Neovim, fzf or ocr in the current workspace; `esc` closes it. This is the answer to "any pane can work with any terminal tool" and needs no reimplementation.
The layout is now eight workspaces, one pane each, and `R` rebuilds them. `docs/WORKSPACES.md` holds this table and is updated; committed as `b15216a`.
Say which of the three you want next, and I will build only that.
## Source paths observed
- `d4921013-e5da-4d88-8016-57d5746e2d03/403de1f7-645c-4d35-bc7e-19b01d77bacc/df306e7b-f56b-4cd8-bf68-bab0cbfb27fa.ndjson`
- `tmp/pc-sandbox-2026-11-10/.paperclip/worktrees/AUD-166-add-a-changelog-entry-for-the-harness`
## Evidence
- Confidence: 84%
- Reason: Update component/repository context with detailed discovered structure and configuration
## Related learned memories <!-- same-turn-2026-11-10 05:59:07 -->
- session: [[packs/paperclip-harness/80-sessions/rich-discovery-the-full-list-at-the-end-of-the-project-with-|Rich discovery: the-full-list-at-the-end-of-the-project-with-what-exists-tod 2026-11-10 05:59:07]]
- context: [[packs/paperclip-harness/20-context/tmp|the-full-list-at-the-end-of-the-project-with-what-exists-tod]]
- observation: [[packs/paperclip-harness/60-observations/tmp-repository-structure-and-configuration-patterns|the-full-list-at-the-end-of-the-project-with-what-exists-tod repository structure and configuration patterns]]

View File

@@ -898,3 +898,84 @@ Committed as `7f18c54`. Say which item you want first and I will build only that
- session: [[packs/paperclip-harness/80-sessions/paperclip-harness-reverted-pane-split-interface-gap-discover|Paperclip-harness — reverted pane split, interface gap discovery, and PLAN-INTERFACE roadmap]]
- session: [[packs/paperclip-harness/80-sessions/rich-discovery-home-2026-11-10-05-41-48|Rich discovery: home 2026-11-10 05:41:48]]
- context: [[packs/paperclip-harness/20-context/home|home]]
---
## Update (2026-11-10 05:59:07)
## Structural/configuration observations (2026-11-10 05:59:07)
The full list, at the end of the project, with what exists today marked.
## Workspaces and panes
| Workspace | Pane | Shows | Now |
|---|---|---|---|
| **Shell** | shell | a prompt in the project folder | built |
| **Board** | board | tasks by group · chosen task · needs you | built |
| **Work** | work | runs · change (diff) · facts | built |
| **Work** | review | the last ocr verdict and its blocking findings | **missing** |
| **Agents** | agents | roster · runs · log — who is working, on what | built today |
| **Agents** | settings | role, title, reports-to, model, heartbeat, budget | **missing** |
| **Reply** | reply | the task · the thread · the reply box | built |
| **New task** | new | the form · guidance · preview | built |
| **Project** | project | facts · costs · settings and limits | built |
| **Repair** | repair | health · layout · rebuild log | built |
| **any** | tool | Lazygit, Hunk, Yazi, Neovim, fzf, shell, ocr — opened with `t`, closed with `esc` | **not wired** |
So: 8 workspaces, 10 named panes, 21 panels built, 2 panels and the tool panes still to do.
## What the Agents pane does now
Workspace `Agents`, pane `agents`, three tabs:
**roster** — every agent with its state, spend and budget:
```
researcher idle 0 c 2000 c
Builder idle 10 c 5000 c
```
**runs** — the company's runs, newest first, with agent, state, what woke it, duration and task:
```
15:56:27 Builder succeeded automation 32s bb4dc72b
12:41:47 Builder succeeded assignment 1m27s bb4dc72b
```
**log** — `enter` on a run shows its output. This is watching Pi work: the stored log is a wall of JSON and token-by-token deltas, so it is gathered back into readable paragraphs:
```
15:56:41 session
15:56:42 user: ## Paperclip Resume Delta
15:56:43 The user posted "Here is a test reply" — a test comment…
15:56:43 Let me verify the file still exists and commit present…
```
`p`, `P` and `w` pause, resume and wake the selected agent. I tested the pause/resume calls directly against Paperclip on `Documenter`: `idle → paused → idle`.
One bug worth knowing: the company run list is 1.1 MB and takes 8 seconds, while the roster is instant. Reading them together made the whole pane appear empty. They are now separate reads — the roster on the timer, the runs when you open that tab.
## Still missing, in order
1. **Agents>settings** — role, title, reports-to, model, heartbeat, budget. All of it is a single `PATCH /api/agents/{id}`, which accepts exactly those fields.
2. **Work>review** — the ocr verdict beside the diff. ocr is installed and already refused a merge on `AUD-165`.
3. **Tool panes** — `t` opens Lazygit, Hunk, Yazi, Neovim, fzf or ocr in the current workspace; `esc` closes it. This is the answer to "any pane can work with any terminal tool" and needs no reimplementation.
The layout is now eight workspaces, one pane each, and `R` rebuilds them. `docs/WORKSPACES.md` holds this table and is updated; committed as `b15216a`.
Say which of the three you want next, and I will build only that.
## Source paths observed
- `d4921013-e5da-4d88-8016-57d5746e2d03/403de1f7-645c-4d35-bc7e-19b01d77bacc/df306e7b-f56b-4cd8-bf68-bab0cbfb27fa.ndjson`
- `tmp/pc-sandbox-2026-11-10/.paperclip/worktrees/AUD-166-add-a-changelog-entry-for-the-harness`
## Evidence
- Confidence: 82%
- Reason: Captured factual repo structure/configuration patterns from rich discovery
## Related learned memories <!-- same-turn-2026-11-10 05:59:07 -->
- session: [[packs/paperclip-harness/80-sessions/rich-discovery-the-full-list-at-the-end-of-the-project-with-|Rich discovery: the-full-list-at-the-end-of-the-project-with-what-exists-tod 2026-11-10 05:59:07]]
- context: [[packs/paperclip-harness/20-context/tmp|the-full-list-at-the-end-of-the-project-with-what-exists-tod]]
- observation: [[packs/paperclip-harness/60-observations/tmp-repository-structure-and-configuration-patterns|the-full-list-at-the-end-of-the-project-with-what-exists-tod repository structure and configuration patterns]]

View File

@@ -0,0 +1,100 @@
---
type: session
id: session.paperclip-harness.rich-discovery-the-full-list-at-the-end-of-the-project-with-
title: Rich discovery: the-full-list-at-the-end-of-the-project-with-what-exists-tod 2026-11-10 05:59:07
status: active
source_of_truth: false
freshness: current
last_reviewed: 2026-11-10
tags:
- pack/paperclip-harness
- agent-memory/session
- autolearn
- rich-discovery
- repo/the-full-list-at-the-end-of-the-project-with-what-exists-tod
---
# Rich discovery: the-full-list-at-the-end-of-the-project-with-what-exists-tod 2026-11-10 05:59:07
Sanitized rich discovery snapshot captured automatically from the completed turn.
## Source paths observed
- `d4921013-e5da-4d88-8016-57d5746e2d03/403de1f7-645c-4d35-bc7e-19b01d77bacc/df306e7b-f56b-4cd8-bf68-bab0cbfb27fa.ndjson`
- `tmp/pc-sandbox-2026-11-10/.paperclip/worktrees/AUD-166-add-a-changelog-entry-for-the-harness`
## Final answer snapshot
The full list, at the end of the project, with what exists today marked.
## Workspaces and panes
| Workspace | Pane | Shows | Now |
|---|---|---|---|
| **Shell** | shell | a prompt in the project folder | built |
| **Board** | board | tasks by group · chosen task · needs you | built |
| **Work** | work | runs · change (diff) · facts | built |
| **Work** | review | the last ocr verdict and its blocking findings | **missing** |
| **Agents** | agents | roster · runs · log — who is working, on what | built today |
| **Agents** | settings | role, title, reports-to, model, heartbeat, budget | **missing** |
| **Reply** | reply | the task · the thread · the reply box | built |
| **New task** | new | the form · guidance · preview | built |
| **Project** | project | facts · costs · settings and limits | built |
| **Repair** | repair | health · layout · rebuild log | built |
| **any** | tool | Lazygit, Hunk, Yazi, Neovim, fzf, shell, ocr — opened with `t`, closed with `esc` | **not wired** |
So: 8 workspaces, 10 named panes, 21 panels built, 2 panels and the tool panes still to do.
## What the Agents pane does now
Workspace `Agents`, pane `agents`, three tabs:
**roster** — every agent with its state, spend and budget:
```
researcher idle 0 c 2000 c
Builder idle 10 c 5000 c
```
**runs** — the company's runs, newest first, with agent, state, what woke it, duration and task:
```
15:56:27 Builder succeeded automation 32s bb4dc72b
12:41:47 Builder succeeded assignment 1m27s bb4dc72b
```
**log** — `enter` on a run shows its output. This is watching Pi work: the stored log is a wall of JSON and token-by-token deltas, so it is gathered back into readable paragraphs:
```
15:56:41 session
15:56:42 user: ## Paperclip Resume Delta
15:56:43 The user posted "Here is a test reply" — a test comment…
15:56:43 Let me verify the file still exists and commit present…
```
`p`, `P` and `w` pause, resume and wake the selected agent. I tested the pause/resume calls directly against Paperclip on `Documenter`: `idle → paused → idle`.
One bug worth knowing: the company run list is 1.1 MB and takes 8 seconds, while the roster is instant. Reading them together made the whole pane appear empty. They are now separate reads — the roster on the timer, the runs when you open that tab.
## Still missing, in order
1. **Agents>settings** — role, title, reports-to, model, heartbeat, budget. All of it is a single `PATCH /api/agents/{id}`, which accepts exactly those fields.
2. **Work>review** — the ocr verdict beside the diff. ocr is installed and already refused a merge on `AUD-165`.
3. **Tool panes** — `t` opens Lazygit, Hunk, Yazi, Neovim, fzf or ocr in the current workspace; `esc` closes it. This is the answer to "any pane can work with any terminal tool" and needs no reimplementation.
The layout is now eight workspaces, one pane each, and `R` rebuilds them. `docs/WORKSPACES.md` holds this table and is updated; committed as `b15216a`.
Say which of the three you want next, and I will build only that.
## Evidence
- Confidence: 86%
- Reason: Preserve full sanitized discovery detail from a rich investigation turn
## Related
- [[packs/paperclip-harness/00-system/pi-agent/memory-manifest|Memory Manifest]]
## Related learned memories <!-- same-turn-2026-11-10 05:59:07 -->
- session: [[packs/paperclip-harness/80-sessions/rich-discovery-the-full-list-at-the-end-of-the-project-with-|Rich discovery: the-full-list-at-the-end-of-the-project-with-what-exists-tod 2026-11-10 05:59:07]]
- context: [[packs/paperclip-harness/20-context/tmp|the-full-list-at-the-end-of-the-project-with-what-exists-tod]]
- observation: [[packs/paperclip-harness/60-observations/tmp-repository-structure-and-configuration-patterns|the-full-list-at-the-end-of-the-project-with-what-exists-tod repository structure and configuration patterns]]

Binary file not shown.

View File

@@ -52,7 +52,7 @@ written yet.
| `Agents>roster` | every agent: name, state, spend this month, budget | built |
| `Agents>runs` | the company's runs, newest first: agent, task, state, what woke it, duration | built |
| `Agents>log` | the chosen run's output, deltas gathered back into paragraphs — this is watching Pi work | built |
| `Agents>settings` | role, job title, who it reports to, model, heartbeat, monthly budget | **missing** |
| `Agents>settings` | role, job title, model, monthly budget, heartbeat — each one editable, with pause and resume | built |
## 7. Project
@@ -88,8 +88,8 @@ Yazi, fzf, Neovim, Lazygit, Hunk and anything else fits without being rebuilt by
## Count
- **21 panels built**, across 8 workspaces.
- **2 panels missing**: `Work>review` and `Agents>settings`.
- **22 panels built**, across 8 workspaces.
- **1 panel missing**: `Work>review`.
- **6 tool panes** to wire: Lazygit, Hunk, Yazi, Neovim, fzf, ocr.
Nothing in the missing list changes the layout: each is one more panel inside a pane that

View File

@@ -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)),

View File

@@ -441,3 +441,20 @@ func (c *Client) WakeAgent(agentID string) error {
return c.do("POST", "/api/agents/"+agentID+"/wakeup",
map[string]any{"source": "on_demand", "triggerDetail": "manual", "forceFreshSession": false}, nil)
}
// UpdateAgent changes an agent's own record. Paperclip owns it, so this is a
// PATCH with only the fields that changed; a field left out is not touched.
func (c *Client) UpdateAgent(agentID string, body map[string]any) error {
if !c.UsingUserKey() {
return errors.New("refusing to write with the agent key: set PAPERCLIP_BOARD_KEY")
}
return c.do("PATCH", "/api/agents/"+agentID, body, nil)
}
// AgentRaw reads one agent's full record, including the configuration blocks,
// so an edit can change one field and leave the rest exactly as it was.
func (c *Client) AgentRaw(agentID string) (map[string]any, error) {
var out map[string]any
err := c.do("GET", "/api/agents/"+agentID, nil, &out)
return out, err
}