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" "os" "path/filepath" "sort" "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 issues map[string]string err error } type pickerMsg struct { skills []skillItem exts []extensionItem note string 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 rosterAt int runAt int settingAt int scroll int skills []skillItem skillsAt int exts []extensionItem extsAt int picking bool pickerNote string 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", "settings", "skills", "extensions"} 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), tea.WindowSize()) } func (m agentsModel) load() tea.Cmd { c := m.client return func() tea.Msg { agents, err := c.Agents() 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 } 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(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} } 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: filtered, issues: mine} } } // loadPicker reads the three lists the picker needs: the person's own skills, // what Paperclip knows, and what this agent has. func (m agentsModel) loadPicker() tea.Cmd { c := m.client agent := m.chosenAgent() if agent == nil { return func() tea.Msg { return pickerMsg{err: fmt.Errorf("no agent chosen")} } } id := agent.ID return func() tea.Msg { company, err := c.CompanySkills() if err != nil { return pickerMsg{err: err} } have, err := c.AgentSkills(id) if err != nil { return pickerMsg{err: err} } bySlug := map[string]string{} for _, s := range company { bySlug[s.Slug] = s.Key } on := map[string]bool{} for _, k := range have.DesiredSkills { on[k] = true } var skills []skillItem seen := map[string]bool{} for _, name := range localSkills() { item := skillItem{slug: name, name: name, global: true} if key, ok := bySlug[name]; ok { item.key, item.inCompany = key, true item.onAgent = on[key] } skills = append(skills, item) seen[name] = true } // Paperclip-only skills, which the person never put in ~/.agents. for _, s := range company { if seen[s.Slug] { continue } skills = append(skills, skillItem{ slug: s.Slug, name: s.Name, key: s.Key, inCompany: true, onAgent: on[s.Key], }) } wrapped := wrapperExtensions(id) var exts []extensionItem for _, e := range localExtensions() { e.on = wrapped[e.name] exts = append(exts, e) } return pickerMsg{skills: skills, exts: exts} } } // toggleSkill applies one skill change to the chosen agent: import it if // Paperclip does not know it, then add or remove it for that agent alone. func (m agentsModel) toggleSkill(item skillItem) tea.Cmd { c, company := m.client, m.client.Company agent := m.chosenAgent() if agent == nil { return func() tea.Msg { return pickerMsg{err: fmt.Errorf("no agent chosen")} } } return func() tea.Msg { key := item.key if item.onAgent { if err := c.SyncSkills(agent.ID, "remove", []string{key}); err != nil { return pickerMsg{err: err, note: "could not remove " + item.slug} } return pickerMsg{note: item.slug + " removed"} } if key == "" { // Local only: copy into the company's managed folder, then import. dst := filepath.Join(managedSkillsDir(company), item.slug) if err := copyTree(filepath.Join(agentsHome(), "skills", item.slug), dst); err != nil { return pickerMsg{err: err, note: "could not copy " + item.slug} } k, err := c.ImportSkill(dst) if err != nil { return pickerMsg{err: err, note: "could not import " + item.slug} } key = k } if err := c.SyncSkills(agent.ID, "add", []string{key}); err != nil { return pickerMsg{err: err, note: "could not add " + item.slug} } return pickerMsg{note: item.slug + " added"} } } // toggleExtension flips one extension for the chosen agent and rewrites its // wrapper. Paperclip has no extension model of its own, so the wrapper is it. func (m agentsModel) toggleExtension(i int) tea.Cmd { agent := m.chosenAgent() exts := append([]extensionItem(nil), m.exts...) if agent == nil || i >= len(exts) { return func() tea.Msg { return pickerMsg{} } } exts[i].on = !exts[i].on var chosen []extensionItem for _, e := range exts { if e.on { chosen = append(chosen, e) } } p := &agentPicker{client: m.client} return func() tea.Msg { if err := writeWrapper(p, agent.ID, chosen); err != nil { return pickerMsg{err: err, note: "could not write the wrapper"} } verb := "added" if !exts[i].on { verb = "removed" } return pickerMsg{note: exts[i].name + " " + verb} } } // copyTree copies a skill folder into the company's managed directory. func copyTree(src, dst string) error { return filepath.Walk(src, func(path string, info os.FileInfo, err error) error { if err != nil { return err } rel, err := filepath.Rel(src, path) if err != nil { return err } target := filepath.Join(dst, rel) if info.IsDir() { return os.MkdirAll(target, 0o755) } data, err := os.ReadFile(path) if err != nil { return err } return os.WriteFile(target, data, info.Mode()) }) } 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.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 } 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(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 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(m.showAll) } if m.tab == 4 || m.tab == 5 { return m, m.loadPicker() } } return m, nil case "up", "k": return m.move(-1), nil case "down", "j": return m.move(1), nil case "g": if m.tab == 2 { m.scroll = 0 } else { m.moveTo(0) } return m, nil case "G": if m.tab == 2 { m.scroll = 1 << 30 } else { m.moveTo(m.listLen() - 1) } return m, nil case " ": switch m.tab { case 4: if m.skillsAt < len(m.skills) { return m, m.toggleSkill(m.skills[m.skillsAt]) } case 5: return m, m.toggleExtension(m.extsAt) } return m, nil case "enter": switch m.tab { case 4: if m.skillsAt < len(m.skills) { return m, m.toggleSkill(m.skills[m.skillsAt]) } return m, nil case 5: return m, m.toggleExtension(m.extsAt) 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 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 pickerMsg: if msg.skills != nil { m.skills = msg.skills m.skillsAt = min(m.skillsAt, max(0, len(m.skills)-1)) } if msg.exts != nil { m.exts = msg.exts m.extsAt = min(m.extsAt, max(0, len(m.exts)-1)) } m.pickerNote = msg.note if msg.err != nil { m.err = msg.err } else { m.err = nil } // After a change the lists are stale; read them again. if msg.note != "" && msg.err == nil { return m, m.loadPicker() } return m, nil 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 { 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 } // 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) case 4: return len(m.skills) case 5: return len(m.exts) } 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)) case 4: m.skillsAt = max(0, min(m.skillsAt+delta, len(m.skills)-1)) case 5: m.extsAt = max(0, min(m.extsAt+delta, len(m.exts)-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.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 { // 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 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…" } 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) case 3: body = m.settingLines(m.width - 4) case 4: body = m.skillLines(m.width - 4) case 5: body = m.extensionLines(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) hints := []string{ keyHint("( )", "tab"), keyHint("enter", "open"), keyHint("p P w", "pause/resume/wake"), keyHint("r", "refresh"), keyHint("?", "keys"), } if m.tab == 4 || m.tab == 5 { hints = []string{ keyHint("( )", "tab"), keyHint("space", "toggle"), keyHint("↑↓", "move"), keyHint("r", "refresh"), } } footer := hintLine(hints, 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 i, 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 } } 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.")) return out } func (m agentsModel) taskLabel(r paperclip.AgentRun) string { 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) } 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 i, 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 } 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)), 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 } // skillLines is the skills tab: every skill the person has, what Paperclip // knows about it, and whether this agent has it. Space or enter toggles. func (m agentsModel) skillLines(width int) []string { agent := m.chosenAgent() name := "no agent chosen" if agent != nil { name = agent.Name } out := []string{"", dimStyle().Render(" " + name + " · space toggles the chosen skill")} if len(m.skills) == 0 { return append(out, mutedStyle2().Render(" reading the skill lists…")) } for i, s := range m.skills { state := "not in Paperclip" colour := active.Warning if s.onAgent { state = "on this agent" colour = active.Success } else if s.inCompany { state = "in Paperclip" colour = active.Muted } line := fmt.Sprintf("%-26s %s", truncateVisible(s.slug, 26), lipgloss.NewStyle().Foreground(lipgloss.Color(colour)).Render(state)) if i == m.skillsAt { out = append(out, selectedStyle2().Render(padTo("▸ "+line, width))) continue } out = append(out, " "+line) } if m.pickerNote != "" { out = append(out, "", " "+lipgloss.NewStyle().Foreground(lipgloss.Color(active.Success)).Render(m.pickerNote)) } out = append(out, "", dimStyle().Render(" a skill only this agent has is the point of having agents.")) return out } // extensionLines is the extensions tab. An extension is turned on for one agent // by writing a wrapper and pointing that agent's command at it. func (m agentsModel) extensionLines(width int) []string { agent := m.chosenAgent() if agent == nil { return []string{"", mutedStyle2().Render(" no agent chosen")} } out := []string{"", dimStyle().Render(" " + agent.Name + " · space toggles the chosen extension")} if len(m.exts) == 0 { return append(out, mutedStyle2().Render(" nothing in ~/.agents/extensions")) } for i, e := range m.exts { mark := "off" colour := active.Muted if e.on { mark, colour = "on", active.Success } line := fmt.Sprintf("%-26s %s", truncateVisible(e.name, 26), lipgloss.NewStyle().Foreground(lipgloss.Color(colour)).Render(mark)) if i == m.extsAt { out = append(out, selectedStyle2().Render(padTo("▸ "+line, width))) continue } out = append(out, " "+line) } if m.pickerNote != "" { out = append(out, "", " "+lipgloss.NewStyle().Foreground(lipgloss.Color(active.Success)).Render(m.pickerNote)) } out = append(out, "", dimStyle().Render(" written as a wrapper at ~/.local/share/paperclip-harness/wrappers/")) out = append(out, dimStyle().Render(" and passed to Paperclip as this agent's command.")) return out }