Agents workspace: roster, runs, and a readable run log; layout now eight workspaces
This commit is contained in:
633
tui/agents.go
Normal file
633
tui/agents.go
Normal file
@@ -0,0 +1,633 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user