Screens 2 and 3: new-task form, live log and diff; measured the merge gap they expose

This commit is contained in:
Sam
2026-11-10 12:15:45 +11:00
parent 34f60872f6
commit 50fde87450
12 changed files with 801 additions and 44 deletions

290
tui/logdiff.go Normal file
View File

@@ -0,0 +1,290 @@
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/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"
}
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()
}