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 }