Files
paperclip-harness/tui/board.go

569 lines
15 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 Board workspace: the state of the work, and what needs a person.
//
// Three panels on one page: the task tree, the summary of the chosen task, and
// the list of things waiting for a decision. Choosing a task changes what the
// other panels show, here and in every other workspace, because the choice is
// written to the project's own .harness file.
import (
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"paperclip-harness/tui/paperclip"
)
type boardDetailMsg struct {
issueID string
runs []paperclip.Run
cost paperclip.Cost
activity []paperclip.Activity
err error
}
type boardModel struct {
client *paperclip.Client
project paperclip.Project
folder string
rows []Row
cursor int
focus int // 0 tree, 1 summary, 2 needs-you
loading bool
readAt time.Time
notice string
err error
detailID string
runs []paperclip.Run
cost paperclip.Cost
activity []paperclip.Activity
needsTab int
width, height int
}
const (
focusTree = iota
focusSummary
focusNeeds
focusCount
)
func newBoardModel(c *paperclip.Client, proj paperclip.Project, folder string) boardModel {
return boardModel{client: c, project: proj, folder: folder, loading: true}
}
func (m boardModel) Init() tea.Cmd {
return tea.Batch(m.reload(), tickEvery(5*time.Second))
}
func (m boardModel) reload() tea.Cmd {
c, proj, want := m.client, m.project, m.selectedID()
return func() tea.Msg {
rows, err := loadRows(c, proj)
if err != nil {
return errMsg{err}
}
_ = want
return rowsMsg(rows)
}
}
func (m boardModel) loadDetail(id string) tea.Cmd {
c := m.client
return func() tea.Msg {
if id == "" {
return boardDetailMsg{}
}
runs, err := c.Runs(id)
if err != nil {
return boardDetailMsg{issueID: id, err: err}
}
cost, _ := c.CostSummary(id)
act, _ := c.Activity(id)
return boardDetailMsg{issueID: id, runs: runs, cost: cost, activity: act}
}
}
func (m boardModel) selectedID() string {
if m.cursor >= 0 && m.cursor < len(m.rows) {
return m.rows[m.cursor].Issue.ID
}
return ""
}
func (m boardModel) selectedTask() *Row {
if m.cursor >= 0 && m.cursor < len(m.rows) {
return &m.rows[m.cursor]
}
return nil
}
func (m boardModel) 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.reload(), tickEvery(5*time.Second))
case tea.KeyMsg:
switch msg.String() {
case "q", "ctrl+c":
return m, tea.Quit
case "tab":
m.focus = (m.focus + 1) % focusCount
return m, nil
case "shift+tab":
m.focus = (m.focus + focusCount - 1) % focusCount
return m, nil
case "r":
m.loading = true
return m, m.reload()
case "up":
if m.cursor > 0 {
m.cursor--
return m, m.loadDetail(m.selectedID())
}
return m, nil
case "down":
if m.cursor < len(m.rows)-1 {
m.cursor++
return m, m.loadDetail(m.selectedID())
}
return m, nil
case "a":
if r := m.selectedTask(); r != nil && r.Issue.AssigneeAgent == "" {
c, id, agent := m.client, r.Issue.ID, defaultAgent
return m, func() tea.Msg {
if _, err := c.Assign(id, agent); err != nil {
return errMsg{err}
}
rows, err := loadRows(c, m.project)
if err != nil {
return errMsg{err}
}
return rowsMsg(rows)
}
}
return m, nil
case "enter":
if r := m.selectedTask(); r != nil {
if err := rememberTask(m.folder, r.Issue.Identifier); err != nil {
m.err = err
return m, nil
}
m.notice = "showing " + r.Issue.Identifier + " in every workspace"
}
return m, nil
case "[":
if m.focus == focusNeeds && m.needsTab > 0 {
m.needsTab--
}
return m, nil
case "]":
if m.focus == focusNeeds && m.needsTab < 1 {
m.needsTab++
}
return m, nil
case "esc":
m.notice = ""
return m, nil
}
case rowsMsg:
m.rows = msg
m.loading = false
m.readAt = time.Now()
m.err = nil
if m.cursor >= len(m.rows) {
m.cursor = max(0, len(m.rows)-1)
}
if id := m.selectedID(); id != "" && id != m.detailID {
return m, m.loadDetail(id)
}
return m, nil
case boardDetailMsg:
m.detailID = msg.issueID
m.runs = msg.runs
m.cost = msg.cost
m.activity = msg.activity
if msg.err != nil {
m.err = msg.err
}
return m, nil
case errMsg:
m.err = msg.err
m.loading = false
return m, nil
}
return m, nil
}
func tickEvery(d time.Duration) tea.Cmd {
return tea.Tick(d, func(t time.Time) tea.Msg { return tickMsg(t) })
}
func (m boardModel) 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.\n\nMake it larger, or use Tab and zoom.",
m.width, m.height))
}
header := m.headerLine()
footer := m.footerLine()
bodyHeight := m.height - 2
leftWidth := max(34, m.width*40/100)
rightWidth := max(30, m.width-leftWidth-1)
// Left: the task tree.
tree := panel{title: "tasks", width: leftWidth, height: bodyHeight, focused: m.focus == focusTree}
treeLines := m.treeLines(leftWidth - 2)
left := tree.render(treeLines)
// Right, upper: the summary of the chosen task.
sumHeight := max(10, bodyHeight*58/100)
sum := panel{title: "the chosen task", width: rightWidth, height: sumHeight, focused: m.focus == focusSummary}
// Right, lower: what needs a person.
needsHeight := bodyHeight - sumHeight
needs := panel{
title: "needs you", width: rightWidth, height: needsHeight,
tabs: []string{"decisions", "activity"}, active: m.needsTab, focused: m.focus == focusNeeds,
}
needsBody := m.needsLines(rightWidth-2, needsHeight-2)
if m.needsTab == 1 {
needsBody = m.activityLines(rightWidth-2, needsHeight-2)
}
right := lipgloss.JoinVertical(lipgloss.Left,
sum.render(m.summaryLines(rightWidth-2)),
needs.render(needsBody),
)
page := lipgloss.JoinHorizontal(lipgloss.Top, left, " ", right)
return strings.Join([]string{header, page, footer}, "\n")
}
func (m boardModel) headerLine() string {
left := panelTitleStyle().Render("Board") + mutedStyle2().Render(" · "+m.project.Name)
state := "reading…"
if !m.readAt.IsZero() {
state = "read " + m.readAt.Format("15:04:05")
}
if m.loading {
state = "reading…"
}
if m.err != nil {
state = "last read failed"
}
right := mutedStyle2().Render(state)
gap := max(2, m.width-lipgloss.Width(left)-lipgloss.Width(right)-2)
if m.notice != "" {
right = lipgloss.NewStyle().Foreground(lipgloss.Color(active.Success)).Render(m.notice)
gap = max(2, m.width-lipgloss.Width(left)-lipgloss.Width(right)-2)
}
return left + strings.Repeat(" ", gap) + right
}
func (m boardModel) footerLine() string {
switch m.focus {
case focusTree:
return hintLine([]string{
keyHint("↑↓", "choose a task"),
keyHint("enter", "show it everywhere"),
keyHint("a", "assign"),
keyHint("tab", "next panel"),
keyHint("r", "read again"),
keyHint("q", "leave"),
}, m.width)
case focusSummary:
return hintLine([]string{
keyHint("↑↓", "choose a task"),
keyHint("tab", "next panel"),
keyHint("r", "read again"),
keyHint("q", "leave"),
}, m.width)
default:
return hintLine([]string{
keyHint("tab", "next panel"),
keyHint("[ ]", "switch tab"),
keyHint("enter", "show this task everywhere"),
keyHint("r", "read again"),
keyHint("q", "leave"),
}, m.width)
}
}
// treeLines draws the grouped task list.
func (m boardModel) treeLines(width int) []string {
var out []string
if len(m.rows) == 0 && !m.loading {
return []string{mutedStyle2().Render(" no tasks in this project")}
}
index := 0
for _, g := range groupOrder {
var group []Row
for _, r := range m.rows {
if r.Group == g {
group = append(group, r)
}
}
if len(group) == 0 {
continue
}
out = append(out, groupHeading(fmt.Sprintf("%s %d", strings.ToUpper(g), len(group))))
for _, r := range group {
out = append(out, m.taskLine(r, index, width)...)
index++
}
out = append(out, "")
}
return out
}
func groupHeading(s string) string {
return lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(active.Mauve)).Render(s)
}
func (m boardModel) taskLine(r Row, index, width int) []string {
chosen := index == m.cursor
cursor := " "
firstStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(active.Text))
secondStyle := mutedStyle2()
if chosen {
cursor = "▸ "
if m.focus == focusTree {
firstStyle = selectedStyle2()
secondStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(active.Base))
} else {
firstStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(active.Accent)).Bold(true)
secondStyle = mutedStyle2()
}
}
status, _ := shortStatus(r.Issue.Status)
who := r.Who
if who == "" || who == "—" {
who = "unassigned"
}
first := fmt.Sprintf("%s%-8s %-8s %s", cursor, r.Issue.Identifier, status, who)
second := " " + r.Issue.Title
if note := blockerNote(r); note != "" {
second += " " + note
}
if lipgloss.Width(first) > width {
first = truncateVisible(first, width)
}
if lipgloss.Width(second) > width {
second = truncateVisible(second, width)
}
if chosen {
first, second = padTo(first, width), padTo(second, width)
}
return []string{firstStyle.Render(first), secondStyle.Render(second)}
}
// shortStatus is the one word a row has room for.
func shortStatus(status string) (string, string) {
switch status {
case "in_progress":
return "running", active.Info
case "blocked":
return "blocked", active.Error
case "in_review":
return "review", active.Warning
case "done":
return "✓ done", active.Success
case "cancelled":
return "✕ gone", active.Overlay
case "backlog":
return "backlog", active.Muted
default:
return "todo", active.Text
}
}
// isReadyButBlocked is the state that confuses people most: everything it was
// waiting for has finished, and the task still says blocked.
func isReadyButBlocked(r Row) bool {
return r.Blocker != nil &&
len(r.Blocker.BlockingIdentities) > 0 &&
r.Blocker.UnresolvedCount == 0 &&
r.Blocker.AllBlockersDone &&
r.Issue.Status == "blocked"
}
func blockerNote(r Row) string {
if r.Blocker == nil {
return ""
}
if r.Blocker.UnresolvedCount > 0 {
return dimStyle().Render("waits on " + strings.Join(r.Blocker.BlockingIdentities, ", "))
}
if r.Blocker.AllBlockersDone && r.Issue.Status == "blocked" {
return badge("ready", active.Warning)
}
return ""
}
func (m boardModel) summaryLines(width int) []string {
r := m.selectedTask()
if r == nil {
return []string{mutedStyle2().Render(" nothing chosen")}
}
var out []string
out = append(out, panelTitleStyle().Render(truncateVisible(r.Issue.Identifier+" "+r.Issue.Title, width)))
out = append(out, "")
who := r.Who
if who == "" || who == "—" {
who = "nobody yet"
}
out = append(out, " "+statusPill(r.Issue.Status)+" "+
mutedStyle2().Render("assigned to ")+lipgloss.NewStyle().Foreground(lipgloss.Color(active.Text)).Render(who))
if r.Blocker != nil && len(r.Blocker.BlockingIdentities) > 0 {
out = append(out, " "+mutedStyle2().Render("waits on ")+
lipgloss.NewStyle().Foreground(lipgloss.Color(active.Warning)).Render(strings.Join(r.Blocker.BlockingIdentities, ", ")))
} else if isReadyButBlocked(*r) {
out = append(out, " "+badge("ready to start", active.Warning)+" "+
mutedStyle2().Render("its blocker finished, but it is still marked blocked"))
}
out = append(out, "")
// The command that decides the task is finished, from the task itself.
if cmd := verifyFromDescription(r.Issue.Description); cmd != "" {
out = append(out, " "+mutedStyle2().Render("done when"))
for _, ln := range wrap(cmd, width-4) {
out = append(out, " "+lipgloss.NewStyle().Foreground(lipgloss.Color(active.Info)).Render(ln))
}
}
if len(m.runs) > 0 {
run := m.runs[0]
line := " " + mutedStyle2().Render("last run") + " " +
lipgloss.NewStyle().Foreground(lipgloss.Color(active.Text)).Render(run.Status+" · "+run.Source)
if run.StartedAt != "" {
if t, err := time.Parse(time.RFC3339, run.StartedAt); err == nil {
line += mutedStyle2().Render(" · " + t.Local().Format("15:04"))
}
}
out = append(out, line)
} else {
out = append(out, " "+mutedStyle2().Render("never run"))
}
if m.cost.RunCount > 0 {
out = append(out, " "+mutedStyle2().Render("cost")+" "+
lipgloss.NewStyle().Foreground(lipgloss.Color(active.Text)).Render(
fmt.Sprintf("%d cent(s) over %d run(s), %d tokens out", m.cost.CostCents, m.cost.RunCount, m.cost.OutputTokens)))
}
return out
}
func (m boardModel) needsLines(width, height int) []string {
var out []string
n := 0
add := func(r Row, why string) {
n++
if len(out) < height {
out = append(out, " "+badge(r.Issue.Identifier, active.Accent)+" "+lipgloss.NewStyle().
Foreground(lipgloss.Color(active.Text)).Render(truncateVisible(r.Issue.Title, width-14)))
out = append(out, " "+mutedStyle2().Render(why))
}
}
for _, r := range m.rows {
if isReadyButBlocked(r) {
add(r, "its blocker finished; it is still marked blocked")
}
}
for _, r := range m.rows {
if r.Issue.Status == "in_review" {
add(r, "waiting for a review")
}
}
for _, r := range m.rows {
if r.Issue.AssigneeUser != "" && r.Issue.Status != "done" && r.Issue.Status != "cancelled" {
add(r, "assigned to you")
}
}
if n == 0 {
return []string{" " + lipgloss.NewStyle().Foreground(lipgloss.Color(active.Success)).Render("nothing needs you")}
}
if len(out) < height {
out = append(out, "")
out = append(out, dimStyle().Render(fmt.Sprintf(" %s waiting", fmtCount(n, "thing", "things"))))
}
return out
}
func (m boardModel) activityLines(width, height int) []string {
if m.detailID == "" || len(m.activity) == 0 {
return []string{" " + mutedStyle2().Render("nothing recorded for this task yet")}
}
var out []string
for i, a := range m.activity {
if len(out) >= height-1 || i >= 10 {
break
}
when := a.CreatedAt
if t, err := time.Parse(time.RFC3339, a.CreatedAt); err == nil {
when = t.Local().Format("15:04")
}
who := a.ActorType
switch who {
case "user":
who = "you"
case "agent":
who = "agent"
case "system":
who = "system"
}
out = append(out, " "+dimStyle().Render(when)+" "+
mutedStyle2().Render(fmt.Sprintf("%-7s", who))+" "+
lipgloss.NewStyle().Foreground(lipgloss.Color(active.Text)).Render(a.Action))
}
return out
}
var verifyRE = regexp.MustCompile(`(?is)Done when this command succeeds:\s*\n+\s*(.+)`)
func verifyFromDescription(desc string) string {
if m := verifyRE.FindStringSubmatch(desc); m != nil {
return strings.TrimSpace(m[1])
}
return ""
}
// rememberTask writes the chosen task into the project's harness file, so the
// other workspaces follow without being told twice.
func rememberTask(folder, identifier string) error {
path := filepath.Join(folder, ".harness", "harness.toml")
b, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("cannot remember the choice: %w", err)
}
line := fmt.Sprintf("watching = %q", identifier)
re := regexp.MustCompile(`(?m)^watching\s*=\s*".*"`)
if re.Match(b) {
b = re.ReplaceAll(b, []byte(line))
} else {
b = append(b, []byte("\n"+line+"\n")...)
}
return os.WriteFile(path, b, 0o644)
}