810 lines
22 KiB
Go
810 lines
22 KiB
Go
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
|
||
}
|