Files
paperclip-harness/tui/work.go

810 lines
22 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 Work workspace: what the chosen task is doing, and what it changed.
//
// One panel with three tabs, because the three things are read one at a time:
// the runs, the change, and the facts about the copy of the code the task is
// working in. The change is read with git in the task's own worktree, in a
// command, never while drawing.
import (
"fmt"
"os"
"strings"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"paperclip-harness/tui/paperclip"
)
type runLogMsg2 struct {
runID string
text string
err error
}
type pickTasksMsg struct {
issues []paperclip.Issue
err error
}
type workMsg struct {
task paperclip.Issue
tasks []paperclip.Issue
runs []paperclip.Run
activity []paperclip.Activity
cost paperclip.Cost
diff string
diffErr error
}
type workModel struct {
client *paperclip.Client
folder string
issue string
explicit bool // the task was named on the command line, so do not follow the file
task paperclip.Issue
runs []paperclip.Run
activity []paperclip.Activity
cost paperclip.Cost
diff string
diffErr error
err error
logRunID string
logText string
tab int
scroll int
runAt int
confirm bool
tools bool
toolAt int
// choosing a task here, so the screen does not depend on the Board
taskList []paperclip.Issue
picking bool
pickList []paperclip.Issue
pickNames map[string]string
pickAt int
readAt time.Time
showHelp bool
width, height int
}
var workTabs = []string{"runs", "change", "facts", "review", "log", "merge"}
func newWorkModel(c *paperclip.Client, folder, issueID string) workModel {
return workModel{client: c, folder: folder, issue: issueID, explicit: issueID != ""}
}
func (m workModel) Init() tea.Cmd {
return tea.Batch(m.load(), tickEvery(5*time.Second), tea.WindowSize())
}
// loadRunLog reads one run's output for the log tab.
func (m workModel) loadRunLog(runID string) tea.Cmd {
c := m.client
return func() tea.Msg {
text, err := c.RunLog(runID, 8<<20)
return runLogMsg2{runID: runID, text: text, err: err}
}
}
func (m workModel) load() tea.Cmd {
c, folder := m.client, m.folder
fixed, explicit := m.issue, m.explicit
return func() tea.Msg {
// Unless a task was named on the command line, follow the choice made
// on the Board: it is written in the project's own file, so this panel
// needs no message from any other program.
id := fixed
if !explicit {
id = watchedTask(folder)
}
if id == "" {
return errMsg{fmt.Errorf("no task chosen: pick one on the Board, then press Enter")}
}
task, err := c.Issue(id)
if err != nil {
return errMsg{err}
}
runs, _ := c.Runs(id)
var list []paperclip.Issue
if all, err := c.Issues(); err == nil {
cfg, _ := readHarnessFile(folder)
for _, i := range all {
if i.ProjectID == cfg["paperclip.project"] {
list = append(list, i)
}
}
}
act, _ := c.Activity(id)
cost, _ := c.CostSummary(id)
msg := workMsg{task: task, runs: runs, activity: act, cost: cost, tasks: list}
if ws := task.Workspace; ws != nil && ws.CWD != "" {
msg.diff, msg.diffErr = worktreeDiff(ws.CWD, ws.BaseRef)
} else {
msg.diffErr = fmt.Errorf("this task has no worktree on this machine, so there is nothing to compare")
}
return msg
}
}
// watchedTask reads the chosen task from the project's own harness file.
func watchedTask(folder string) string {
b, err := os.ReadFile(projectFilePath(folder))
if err != nil {
return ""
}
for _, line := range strings.Split(string(b), "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "watching") {
parts := strings.SplitN(line, "=", 2)
if len(parts) == 2 {
return strings.Trim(strings.TrimSpace(parts[1]), `"`)
}
}
}
return ""
}
func projectFilePath(folder string) string {
return folder + "/.harness/harness.toml"
}
func (m workModel) 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), tea.WindowSize())
case tea.KeyMsg:
if m.confirm {
switch msg.String() {
case "y":
m.confirm = false
if err := startMerge(m.folder, m.task.Identifier); err != nil {
m.err = err
return m, nil
}
m.tab = 5
m.scroll = 1 << 30
return m, m.load()
case "esc", "n", "q":
m.confirm = false
}
return m, nil
}
if m.picking {
switch msg.String() {
case "esc", "q", "o":
m.picking = false
return m, nil
case "up", "k":
m.pickAt = max(0, m.pickAt-1)
return m, nil
case "down", "j":
m.pickAt = min(len(m.pickList)-1, m.pickAt+1)
return m, nil
case "enter":
if m.pickAt < len(m.pickList) {
chosen := m.pickList[m.pickAt]
m.picking = false
if err := rememberTask(m.folder, chosen.Identifier); err != nil {
m.err = err
return m, nil
}
m.explicit = true
m.issue = chosen.ID
m.task = chosen
return m, m.load()
}
return m, nil
}
return m, nil
}
if m.tools {
switch msg.String() {
case "esc", "q", "t":
m.tools = false
return m, nil
case "up", "k":
m.toolAt = max(0, m.toolAt-1)
return m, nil
case "down", "j":
m.toolAt = min(len(tools)-1, m.toolAt+1)
return m, nil
case "g":
m.toolAt = 0
return m, nil
case "G":
m.toolAt = len(tools) - 1
return m, nil
case "enter":
t := tools[m.toolAt]
m.tools = false
if err := openTool(m.folder, t.name); err != nil {
m.err = err
}
return m, nil
}
return m, nil
}
if m.showHelp {
m.showHelp = false
return m, nil
}
switch msg.String() {
case "t":
m.tools = true
return m, nil
case "n", "p":
if len(m.taskList) == 0 {
return m, nil
}
at := -1
for i, t := range m.taskList {
if t.ID == m.task.ID {
at = i
}
}
if at < 0 {
at = 0
}
if msg.String() == "n" {
at = (at + 1) % len(m.taskList)
} else {
at = (at + len(m.taskList) - 1) % len(m.taskList)
}
chosen := m.taskList[at]
if err := rememberTask(m.folder, chosen.Identifier); err != nil {
m.err = err
return m, nil
}
m.explicit = true
m.issue = chosen.ID
m.task = chosen
m.tab = 0
m.runAt = 0
return m, m.load()
case "o":
c := m.client
folder := m.folder
return m, func() tea.Msg {
cfg, err := readHarnessFile(folder)
if err != nil {
return errMsg{err}
}
issues, err := c.Issues()
if err != nil {
return errMsg{err}
}
var mine []paperclip.Issue
for _, i := range issues {
if i.ProjectID == cfg["paperclip.project"] {
mine = append(mine, i)
}
}
return pickTasksMsg{issues: mine}
}
case "v":
// A review is cheap to start and posts its verdict on the task, so
// the record lives with the work.
if m.task.Identifier == "" {
return m, nil
}
if err := startReview(m.folder, m.task.Identifier); err != nil {
m.err = err
return m, nil
}
m.tab = 3
return m, m.load()
case "m":
// Merging is a decision, so it asks first and shows what it will do.
if m.task.Identifier == "" {
return m, nil
}
m.confirm = true
return m, nil
case "?":
m.showHelp = true
return m, nil
case "q", "ctrl+c":
return m, tea.Quit
case "r":
return m, m.load()
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(workTabs)-1 {
m.tab++
m.scroll = 0
}
return m, nil
case "up", "k":
if m.tab == 0 && m.runAt > 0 {
m.runAt--
}
m.scroll--
return m, nil
case "down", "j":
if m.tab == 0 && m.runAt < len(m.runs)-1 {
m.runAt++
}
m.scroll++
return m, nil
case "g":
m.scroll = 0
return m, nil
case "G":
m.scroll = 1 << 30
return m, nil
case "enter":
if m.tab == 0 && m.runAt < len(m.runs) {
m.tab = 4
m.scroll = 1 << 30
return m, m.loadRunLog(m.runs[m.runAt].ID)
}
return m, nil
case "esc":
return m, nil
}
case runLogMsg2:
m.logRunID = msg.runID
m.logText = msg.text
if msg.err != nil {
m.err = msg.err
}
return m, nil
case pickTasksMsg:
m.pickList = msg.issues
m.pickAt = 0
m.picking = true
if msg.err != nil {
m.err = msg.err
}
return m, nil
case workMsg:
if msg.task.ID != "" && msg.task.ID != m.task.ID {
m.runAt = 0
m.logRunID, m.logText = "", ""
if len(msg.runs) > 0 {
m.logRunID = msg.runs[0].ID
}
}
m.task = msg.task
if msg.tasks != nil {
m.taskList = msg.tasks
}
m.runs = msg.runs
m.activity = msg.activity
m.cost = msg.cost
m.diff = msg.diff
m.diffErr = msg.diffErr
m.err = nil
m.readAt = time.Now()
return m, nil
case errMsg:
m.err = msg.err
return m, nil
}
return m, nil
}
func (m workModel) 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("the work", [][2]string{
{"[ ] h l", "switch between runs, change and facts"},
{"↑ ↓ j k", "scroll"},
{"g / G", "top / bottom"},
{"r", "read again"},
{"R", "rebuild every pane"},
{"q", "leave"},
{"?", "this list"},
}, m.width, m.height)
}
head := titleStyle.Render("Work") + mutedStyle2().Render(" · ")
if m.task.Identifier != "" {
head += lipgloss.NewStyle().Foreground(lipgloss.Color(active.Text)).Render(m.task.Identifier)
head += mutedStyle2().Render(" " + truncateVisible(m.task.Title, max(10, m.width-60)))
for i, t := range m.taskList {
if t.ID == m.task.ID {
head += mutedStyle2().Render(fmt.Sprintf(" task %d of %d", i+1, len(m.taskList)))
break
}
}
}
state := "read " + m.readAt.Format("15:04:05")
if m.readAt.IsZero() {
state = "reading…"
}
gap := max(2, m.width-lipgloss.Width(head)-lipgloss.Width(state)-1)
var body []string
switch m.tab {
case 0:
body = m.runsLines(m.width - 4)
case 1:
body = m.diffLines(m.width - 4)
case 3:
body = m.reviewLines(m.width - 4)
case 4:
body = m.logLinesWork(m.width - 4)
case 5:
body = m.mergeLines(m.width - 4)
default:
body = m.factsLines(m.width - 4)
}
if m.confirm {
verify := "no verify command: the review decides"
if cmd := verifyFromDescription(m.task.Description); cmd != "" {
verify = "verify command: " + cmd
}
branch := "(no branch yet)"
if m.task.Workspace != nil && m.task.Workspace.Branch != "" {
branch = m.task.Workspace.Branch
}
return helpOverlay("merge "+m.task.Identifier+" into main?", [][2]string{
{"what is merged", branch + " → " + baseRefOf(m.task)},
{"what happens", "rebase the branch onto the base, then merge it"},
{"", verify},
{"", "ocr reviews the change; a critical finding refuses the merge"},
{"", "merge, then re-run the checks on the base branch"},
{"y", "do it"},
{"esc or n", "do nothing"},
}, m.width, m.height)
}
if m.picking {
entries := make([][2]string, 0, len(m.pickList))
for i, is := range m.pickList {
mark := " "
if i == m.pickAt {
mark = "▸ "
}
entries = append(entries, [2]string{mark + is.Identifier, is.Status + " " + is.Title})
}
return helpOverlay("choose a task to look at", entries, m.width, m.height)
}
if m.tools {
entries := make([][2]string, 0, len(tools))
for i, t := range tools {
mark := " "
if i == m.toolAt {
mark = "▸ "
}
entries = append(entries, [2]string{mark + t.name, t.about})
}
return helpOverlay("open a tool pane here", entries, m.width, m.height)
}
title := "the work"
if m.tab == 0 {
title = fmt.Sprintf("the work · %s", fmtCount(len(m.runs), "run", "runs"))
}
p := panel{
title: title, width: m.width, height: m.height - 2,
tabs: workTabs, active: m.tab, focused: true,
}
// Scroll inside the panel.
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("n p", "task"),
keyHint("( )", "tab"),
keyHint("↑↓", "scroll"),
keyHint("enter", "log"),
keyHint("o", "pick"),
keyHint("m", "merge"),
keyHint("v", "review"),
keyHint("t", "tools"),
keyHint("r", "refresh"),
keyHint("R", "rebuild"),
}, m.width)
return strings.Join([]string{
head + strings.Repeat(" ", gap) + mutedStyle2().Render(state),
p.render(body[from:to]),
footer,
}, "\n")
}
func (m workModel) runsLines(width int) []string {
if m.err != nil {
return []string{lipgloss.NewStyle().Foreground(lipgloss.Color(active.Error)).Render("error: " + m.err.Error())}
}
var out []string
out = append(out, "")
if len(m.runs) == 0 {
out = append(out, mutedStyle2().Render("this task has never run"))
} else {
out = append(out, dimStyle().Render(fmt.Sprintf("%-18s %-12s %-10s %-10s %s",
"state", "woken by", "started", "ended", "took")))
for i, r := range m.runs {
started, took := "-", "-"
if t, err := time.Parse(time.RFC3339, r.StartedAt); err == nil {
started = t.Local().Format("15:04:05")
}
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()
}
}
}
ended := "-"
if t, err := time.Parse(time.RFC3339, r.FinishedAt); err == nil {
ended = t.Local().Format("15:04:05")
}
colour := active.Text
switch r.Status {
case "running":
colour = active.Info
case "succeeded":
colour = active.Success
case "failed":
colour = active.Error
}
// Pad the plain text first, then colour it: styling first would
// make the escape codes count as characters and break the columns.
row := fmt.Sprintf("%-18s %-12s %-10s %-10s %s", r.Status, r.Source, started, ended, took)
if i == m.runAt {
out = append(out, selectedStyle2().Render(padTo("▸ "+row, width)))
continue
}
out = append(out, fmt.Sprintf("%-18s %-12s %-10s %-10s %s",
lipgloss.NewStyle().Foreground(lipgloss.Color(colour)).Render(fmt.Sprintf("%-18s", r.Status)),
fmt.Sprintf("%-12s", r.Source), started, ended, took))
}
}
out = append(out, "")
out = append(out, groupHeading("activity", len(m.activity), width))
for i, a := range m.activity {
if i >= 20 {
out = append(out, dimStyle().Render(fmt.Sprintf("… %d more", len(m.activity)-20)))
break
}
when := a.CreatedAt
if t, err := time.Parse(time.RFC3339, a.CreatedAt); err == nil {
when = t.Local().Format("15:04")
}
who := a.ActorType
if who == "user" {
who = "you"
}
out = append(out, fmt.Sprintf("%s %-7s %s", dimStyle().Render(when), mutedStyle2().Render(who),
lipgloss.NewStyle().Foreground(lipgloss.Color(active.Text)).Render(a.Action)))
}
return out
}
func (m workModel) diffLines(width int) []string {
if m.diffErr != nil {
return []string{"", mutedStyle2().Render(m.diffErr.Error())}
}
if m.diff == "" {
return []string{"", mutedStyle2().Render("nothing to compare yet")}
}
var out []string
for _, ln := range strings.Split(strings.TrimRight(m.diff, "\n"), "\n") {
style := lipgloss.NewStyle().Foreground(lipgloss.Color(active.Text))
switch {
case strings.HasPrefix(ln, "+++"), strings.HasPrefix(ln, "---"):
style = lipgloss.NewStyle().Foreground(lipgloss.Color(active.Muted))
case strings.HasPrefix(ln, "+"):
style = lipgloss.NewStyle().Foreground(lipgloss.Color(active.Success))
case strings.HasPrefix(ln, "-"):
style = lipgloss.NewStyle().Foreground(lipgloss.Color(active.Error))
case strings.HasPrefix(ln, "@@"):
style = lipgloss.NewStyle().Foreground(lipgloss.Color(active.Info))
case strings.HasPrefix(ln, "diff "), strings.HasPrefix(ln, "commit "), strings.HasPrefix(ln, "+"):
style = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(active.Text))
}
if lipgloss.Width(ln) > width {
ln = truncateVisible(ln, width)
}
out = append(out, style.Render(ln))
}
return out
}
// reviewLines shows the verdicts already recorded on the task. A review is
// written as a comment, so the record lives with the work and survives us.
func (m workModel) reviewLines(width int) []string {
out := []string{"", dimStyle().Render(" verdicts recorded on this task")}
if rl := reviewLogTailFor(m.task.Identifier); len(rl) > 0 {
out = append(out, "")
out = append(out, dimStyle().Render(" the last review started from here:"))
for _, ln := range rl {
out = append(out, " "+mutedStyle2().Render(truncateVisible(ln, width-2)))
}
}
if m.task.Identifier == "" {
return append(out, mutedStyle2().Render(" no task chosen"))
}
cs, err := m.client.Comments(m.task.ID)
if err != nil {
return append(out, lipgloss.NewStyle().Foreground(lipgloss.Color(active.Error)).Render(" "+err.Error()))
}
found := 0
for i := len(cs) - 1; i >= 0 && found < 3; i-- {
body := cs[i].Body
if !strings.HasPrefix(body, "## Review") &&
!strings.HasPrefix(body, "## Merge refused") &&
!strings.HasPrefix(body, "## Merged") &&
!strings.HasPrefix(body, "## Merge undone") {
continue
}
found++
blocking := strings.Contains(body, "critical") || strings.Contains(body, "**high/")
colour := active.Success
if strings.HasPrefix(body, "## Merge refused") || blocking {
colour = active.Error
}
for _, ln := range wrap(strings.ReplaceAll(body, "\n", " · "), width-2) {
out = append(out, " "+lipgloss.NewStyle().Foreground(lipgloss.Color(colour)).Render(ln))
}
out = append(out, "")
}
if found == 0 {
out = append(out, mutedStyle2().Render(" no review recorded for this task yet"))
out = append(out, "")
out = append(out, dimStyle().Render(" run one with: bin/review "+m.task.Identifier+" --post"))
}
return out
}
// mergeLines shows the last merge this screen started, read from the log the
// merge writes. Pressing m again is safe: the merge takes a lock.
// logLinesWork shows the output of the run chosen on the runs tab.
func (m workModel) logLinesWork(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")}
}
out := []string{"", dimStyle().Render(" run " + short(m.logRunID)), ""}
entries := decodeRunLog(m.logText)
if len(entries) > 2000 {
entries = entries[len(entries)-2000:]
}
for _, e := range entries {
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(truncateVisible(e.line, width-10)))
}
return out
}
func (m workModel) mergeLines(width int) []string {
tail := mergeLogTail(max(6, m.height-6))
joined := strings.Join(tail, "\n")
if m.task.Identifier != "" && !strings.Contains(joined, m.task.Identifier) {
// The log on disk belongs to another task. Say so instead of showing
// someone else's merge as if it were this one.
return []string{"",
" " + mutedStyle2().Render("no merge has been run from this screen for "+m.task.Identifier),
"",
" " + dimStyle().Render("the last merge recorded was for another task:"),
" " + dimStyle().Render(firstLineWith(tail, "merge of ")),
"",
" " + dimStyle().Render("press m to merge this task"),
}
}
out := []string{"", dimStyle().Render(" " + mergeLogPath()), ""}
for _, ln := range tail {
colour := active.Text
switch {
case strings.Contains(ln, "refused"), strings.Contains(ln, "stopped"):
colour = active.Error
case strings.Contains(ln, "merged into"), strings.Contains(ln, "finished"):
colour = active.Success
}
out = append(out, " "+lipgloss.NewStyle().Foreground(lipgloss.Color(colour)).Render(truncateVisible(ln, width-2)))
}
out = append(out, "", dimStyle().Render(" m runs the checks and merges this task; the log above is what it did"))
return out
}
func (m workModel) factsLines(width int) []string {
var out []string
out = append(out, "")
row := func(label, value string) {
out = append(out, mutedStyle2().Render(fmt.Sprintf("%-14s", label))+
lipgloss.NewStyle().Foreground(lipgloss.Color(active.Text)).Render(value))
}
if m.task.Identifier != "" {
row("task", m.task.Identifier+" "+m.task.Title)
row("status", m.task.Status)
}
if m.task.Workspace != nil {
row("worktree", m.task.Workspace.CWD)
row("branch", m.task.Workspace.Branch)
row("cut from", m.task.Workspace.BaseRef)
} else {
row("worktree", "none: this task has no copy of the code yet")
}
row("cost", fmt.Sprintf("%d cent(s), %d run(s), %d tokens out", m.cost.CostCents, m.cost.RunCount, m.cost.OutputTokens))
return out
}
// baseRefOf is the branch a task's work will be merged into.
func baseRefOf(t paperclip.Issue) string {
if t.Workspace != nil && t.Workspace.BaseRef != "" {
return t.Workspace.BaseRef
}
return "main"
}
// firstLineWith finds the first line mentioning a marker, for a one-line hint.
func firstLineWith(lines []string, marker string) string {
for _, l := range lines {
if strings.Contains(l, marker) {
return strings.TrimSpace(l)
}
}
return "none"
}
// reviewLogTailFor returns the last review log, but only when it was for this
// task: another task's review is not evidence about this one.
func reviewLogTailFor(identifier string) []string {
tail := reviewLogTail(8)
joined := strings.Join(tail, "\n")
if !strings.Contains(joined, identifier) {
return nil
}
return tail
}