Screens 2 and 3: new-task form, live log and diff; measured the merge gap they expose
This commit is contained in:
290
tui/logdiff.go
Normal file
290
tui/logdiff.go
Normal 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()
|
||||
}
|
||||
Reference in New Issue
Block a user