package main // Screen 3: what the task is doing, and what it changed. // // The log half comes from Paperclip: the task's runs and its activity. The diff // half comes from the task's own copy of the code, which Paperclip made as a git // worktree; the program only reads it with git, in a command, never while // drawing. import ( "context" "fmt" "os" "os/exec" "strings" "time" tea "github.com/charmbracelet/bubbletea" "paperclip-harness/tui/paperclip" ) type logMsg struct { task paperclip.Issue runs []paperclip.Run activity []paperclip.Activity diff string diffErr error } type tickMsg time.Time type logModel struct { client *paperclip.Client issue string task paperclip.Issue runs []paperclip.Run activity []paperclip.Activity diff string diffErr error err error scroll int width, height int } func newLogModel(c *paperclip.Client, issueID string) logModel { return logModel{client: c, issue: issueID} } // The screen re-reads on a timer so it is live while an agent works. The read // happens in a command, never in Update or View. func (m logModel) Init() tea.Cmd { return tea.Batch(m.load(), m.tick()) } func (m logModel) load() tea.Cmd { c, id := m.client, m.issue return func() tea.Msg { task, err := c.Issue(id) if err != nil { return errMsg{err} } runs, _ := c.Runs(id) act, _ := c.Activity(id) msg := logMsg{task: task, runs: runs, activity: act} if task.Workspace == nil || task.Workspace.CWD == "" { msg.diffErr = fmt.Errorf("this task has no worktree, so there is nothing to compare") return msg } msg.diff, msg.diffErr = worktreeDiff(task.Workspace.CWD, task.Workspace.BaseRef) return msg } } // worktreeDiff reads what the task's branch changed. It runs git, so it is only // ever called from a command. func worktreeDiff(dir, base string) (string, error) { if base == "" { base = "main" } if st, err := os.Stat(dir); err != nil || !st.IsDir() { return "", fmt.Errorf("the task's folder %s no longer exists, so there is nothing to compare", dir) } ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() run := func(args ...string) (string, error) { cmd := exec.CommandContext(ctx, "git", append([]string{"-C", dir}, args...)...) out, err := cmd.CombinedOutput() if err != nil { return string(out), fmt.Errorf("git %s: %v", strings.Join(args, " "), err) } return string(out), nil } var b strings.Builder head, err := run("rev-parse", "--abbrev-ref", "HEAD") if err != nil { return "", err } b.WriteString("worktree: " + dir + "\n") b.WriteString("branch: " + strings.TrimSpace(head) + " base: " + base + "\n\n") commits, err := run("log", "--oneline", base+"..HEAD") if err != nil { commits = "" } if strings.TrimSpace(commits) == "" { b.WriteString("no commits on this branch yet\n") } else { b.WriteString("commits\n") b.WriteString(indent(commits, " ", 20)) b.WriteString("\n") } stat, err := run("diff", "--stat", base+"...HEAD") if err == nil && strings.TrimSpace(stat) != "" { b.WriteString("changed\n") b.WriteString(indent(stat, " ", 40)) b.WriteString("\n") } d, err := run("diff", base+"...HEAD") if err == nil && strings.TrimSpace(d) != "" { b.WriteString("diff\n") b.WriteString(indent(d, " ", 400)) } // Work the agent has not committed is worth more than the commit log. dirty, _ := run("status", "--porcelain") if strings.TrimSpace(dirty) != "" { b.WriteString("\nuncommitted\n") b.WriteString(indent(dirty, " ", 40)) ud, _ := run("diff") if strings.TrimSpace(ud) != "" { b.WriteString(indent(ud, " ", 200)) } } return b.String(), nil } // indent prefixes lines and stops after max, saying how many were left out. func indent(s, prefix string, max int) string { lines := strings.Split(strings.TrimRight(s, "\n"), "\n") var b strings.Builder for i, ln := range lines { if i >= max { fmt.Fprintf(&b, "%s… %d more lines\n", prefix, len(lines)-max) break } b.WriteString(prefix + ln + "\n") } return b.String() } func (m logModel) tick() tea.Cmd { return tea.Tick(5*time.Second, func(t time.Time) tea.Msg { return tickMsg(t) }) } func (m logModel) 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 tea.KeyMsg: switch msg.String() { case "q", "ctrl+c": return m, tea.Quit case "r": return m, m.load() case "up": m.scroll-- return m, nil case "down": m.scroll++ return m, nil case "g": m.scroll = 0 return m, nil case "G": m.scroll = 1 << 30 return m, nil case "esc": return m, nil } case tickMsg: return m, tea.Batch(m.load(), m.tick()) case logMsg: m.task = msg.task m.runs = msg.runs m.activity = msg.activity m.diff = msg.diff m.diffErr = msg.diffErr m.err = nil return m, nil case errMsg: m.err = msg.err return m, m.tick() } return m, nil } func (m logModel) running() bool { for _, r := range m.runs { if r.Status == "running" { return true } } return false } func (m logModel) View() string { if m.width == 0 || m.height == 0 { return "loading…" } if m.width < minWidth || m.height < minHeight { return fmt.Sprintf("Window is too small.\nNeed %d×%d, have %d×%d.", minWidth, minHeight, m.width, m.height) } var lines []string head := fmt.Sprintf("%s %s · log and diff", m.task.Identifier, m.task.Status) lines = append(lines, titleStyle.Render(truncate(head, m.width))) if m.err != nil { lines = append(lines, blockedStyle.Render(truncate("error: "+m.err.Error(), m.width))) } lines = append(lines, "") lines = append(lines, groupStyle.Render("runs")) if len(m.runs) == 0 { lines = append(lines, mutedStyle.Render(" never run")) } for _, r := range m.runs { when := r.StartedAt if r.FinishedAt != "" { when += " → " + r.FinishedAt } line := fmt.Sprintf(" %-10s %-9s %s", r.Status, r.Source, when) if r.ErrorCode != "" { line += " " + r.ErrorCode } lines = append(lines, truncate(line, m.width)) } lines = append(lines, "") lines = append(lines, groupStyle.Render("activity")) if len(m.activity) == 0 { lines = append(lines, mutedStyle.Render(" nothing recorded")) } for i, a := range m.activity { if i >= 12 { lines = append(lines, mutedStyle.Render(fmt.Sprintf(" … %d more", len(m.activity)-12))) break } lines = append(lines, truncate(fmt.Sprintf(" %s %-16s %s", a.CreatedAt, a.ActorType, a.Action), m.width)) } lines = append(lines, "") lines = append(lines, groupStyle.Render("diff and worktree")) switch { case m.diffErr != nil: lines = append(lines, mutedStyle.Render(" "+truncate(m.diffErr.Error(), m.width-2))) case m.diff == "": lines = append(lines, mutedStyle.Render(" nothing yet")) default: for _, ln := range strings.Split(strings.TrimRight(m.diff, "\n"), "\n") { lines = append(lines, truncate(ln, m.width)) } } // Scrolling, with the cursor pinned to the bottom until moved up. visible := max(4, m.height-1) maxScroll := max(0, len(lines)-visible) if m.scroll > maxScroll { m.scroll = maxScroll } if m.scroll < 0 { m.scroll = 0 } from := m.scroll to := min(len(lines), from+visible+1) var b strings.Builder for _, ln := range lines[from:to] { b.WriteString(ln) b.WriteString("\n") } return b.String() }