Files
paperclip-harness/tui/agents.go

950 lines
24 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"
"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 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
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"}
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}
}
// 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}
}
}
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)
}
}
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 "enter":
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
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.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)
}
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.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)
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 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
}