598 lines
17 KiB
Go
598 lines
17 KiB
Go
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", "k":
|
||
if m.cursor > 0 {
|
||
m.cursor--
|
||
return m, m.loadDetail(m.selectedID())
|
||
}
|
||
return m, nil
|
||
case "down", "j":
|
||
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 "[", "h":
|
||
if m.focus == focusNeeds && m.needsTab > 0 {
|
||
m.needsTab--
|
||
}
|
||
return m, nil
|
||
case "]", "l":
|
||
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
|
||
|
||
// Three ways to use the same width, chosen by what the terminal actually
|
||
// has. Wide screens get the three panels side by side; narrow ones stack.
|
||
if m.width >= 140 {
|
||
treeW := max(34, m.width*30/100)
|
||
sumW := max(38, m.width*38/100)
|
||
needsW := max(30, m.width-treeW-sumW-2)
|
||
|
||
left := panel{title: "tasks", width: treeW, height: bodyHeight, focused: m.focus == focusTree}
|
||
mid := panel{title: "the chosen task", width: sumW, height: bodyHeight, focused: m.focus == focusSummary}
|
||
rightP := panel{
|
||
title: "needs you", width: needsW, height: bodyHeight,
|
||
tabs: []string{"decisions", "activity"}, active: m.needsTab, focused: m.focus == focusNeeds,
|
||
}
|
||
body := m.needsLines(needsW-3, bodyHeight-3)
|
||
if m.needsTab == 1 {
|
||
body = m.activityLines(needsW-3, bodyHeight-3)
|
||
}
|
||
page := lipgloss.JoinHorizontal(lipgloss.Top,
|
||
left.render(m.treeLines(treeW-3)), " ",
|
||
mid.render(m.summaryLines(sumW-3)), " ",
|
||
rightP.render(body))
|
||
return strings.Join([]string{header, page, footer}, "\n")
|
||
}
|
||
|
||
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 - 3)
|
||
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-3, needsHeight-3)
|
||
if m.needsTab == 1 {
|
||
needsBody = m.activityLines(rightWidth-3, needsHeight-3)
|
||
}
|
||
|
||
right := lipgloss.JoinVertical(lipgloss.Left,
|
||
sum.render(m.summaryLines(rightWidth-3)),
|
||
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("↑↓ or j k", "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("↑↓ or j k", "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(g, len(group), width))
|
||
for _, r := range group {
|
||
out = append(out, m.taskLine(r, index, width)...)
|
||
index++
|
||
}
|
||
out = append(out, "")
|
||
}
|
||
return out
|
||
}
|
||
|
||
// groupHeading is a label, a rule, and how many tasks are under it. The rule
|
||
// makes the groups visible at a glance without a box around every one.
|
||
func groupHeading(name string, count, width int) string {
|
||
label := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(active.Mauve)).Render(strings.ToUpper(name))
|
||
n := lipgloss.NewStyle().Foreground(lipgloss.Color(active.Muted)).Render(fmt.Sprintf(" %d ", count))
|
||
rule := strings.Repeat("─", max(0, width-lipgloss.Width(label)-lipgloss.Width(n)))
|
||
return label + dimStyle().Render(rule) + n
|
||
}
|
||
|
||
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 icons.Running + " running", active.Info
|
||
case "blocked":
|
||
return icons.Blocked + " blocked", active.Error
|
||
case "in_review":
|
||
return icons.Review + " review", active.Warning
|
||
case "done":
|
||
return icons.Done + " done", active.Success
|
||
case "cancelled":
|
||
return icons.Cancelled + " gone", active.Overlay
|
||
case "backlog":
|
||
return icons.Todo + " backlog", active.Muted
|
||
default:
|
||
return icons.Todo + " 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)
|
||
}
|