Files
paperclip-harness/tui/agents.go

634 lines
15 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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"
"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
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
cursor int
scroll int
showHelp bool
width, height int
}
var agentTabs = []string{"roster", "runs", "log"}
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))
}
func (m agentsModel) load() tea.Cmd {
c := m.client
return func() tea.Msg {
agents, err := c.Agents()
if err != nil {
return agentsMsg{err: err}
}
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() tea.Cmd {
c := m.client
return func() tea.Msg {
runs, err := c.CompanyRuns()
if err != nil {
return runsMsg{err: err}
}
if len(runs) > 200 {
runs = runs[:200]
}
return runsMsg{runs: runs}
}
}
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.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()
case 2:
return m, m.loadLog(m.logRunID)
}
return m, m.load()
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()
}
}
return m, nil
case "up", "k":
m.scroll--
return m, nil
case "down", "j":
m.scroll++
return m, nil
case "g":
m.scroll = 0
return m, nil
case "G":
m.scroll = 1 << 30
return m, nil
case "enter":
if m.tab == 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 runsMsg:
if msg.runs != nil {
m.runs = msg.runs
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
}
// chosenRun is the run the roster 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]
}
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]
}
}
}
if len(m.agents) > 0 {
return &m.agents[0]
}
return nil
}
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)
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)
footer := hintLine([]string{
keyHint("[ ]", "roster, runs, log"),
keyHint("enter", "open a run"),
keyHint("p P w", "pause, resume, wake"),
keyHint("r", "read again"),
keyHint("?", "keys"),
}, 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 _, 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
}
}
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))
}
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 r.Snapshot.IssueID != "" {
return short(r.Snapshot.IssueID)
}
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 _, 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
}
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
}