Files
paperclip-harness/tui/board.go

772 lines
21 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"
zone "github.com/lrstanley/bubblezone"
"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
tools bool
toolAt int
zones *zone.Manager
filter string
typing bool
showHelp bool
width, height int
}
const (
focusTree = iota
focusSummary
focusNeeds
focusCount
)
func newBoardModel(c *paperclip.Client, proj paperclip.Project, folder string) boardModel {
// One click-zone manager for the whole program, created once. Wrapping each
// panel separately drifts as soon as a pane is resized.
return boardModel{client: c, project: proj, folder: folder, loading: true, zones: zone.New()}
}
func (m boardModel) Init() tea.Cmd {
return tea.Batch(m.reload(), tickEvery(5*time.Second), tea.WindowSize())
}
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}
}
}
// visible is the list after the filter. The cursor moves over this, so what is
// highlighted is always what would be acted on.
func (m boardModel) visible() []Row {
if m.filter == "" {
return m.rows
}
needle := strings.ToLower(m.filter)
var out []Row
for _, r := range m.rows {
if strings.Contains(strings.ToLower(r.Issue.Identifier), needle) ||
strings.Contains(strings.ToLower(r.Issue.Title), needle) ||
strings.Contains(strings.ToLower(r.Who), needle) {
out = append(out, r)
}
}
return out
}
func (m boardModel) selectedID() string {
vis := m.visible()
if m.cursor >= 0 && m.cursor < len(vis) {
return vis[m.cursor].Issue.ID
}
return ""
}
func (m boardModel) selectedTask() *Row {
vis := m.visible()
if m.cursor >= 0 && m.cursor < len(vis) {
return &vis[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.MouseMsg:
if msg.Action == tea.MouseActionPress && msg.Button == tea.MouseButtonLeft {
for i, r := range m.visible() {
if z := m.zones.Get("row-" + r.Issue.ID); z != nil && z.InBounds(msg) {
m.cursor = i
return m, m.loadDetail(r.Issue.ID)
}
}
}
return m, nil
case tea.KeyMsg:
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
}
if m.typing {
switch msg.Type {
case tea.KeyEsc:
m.typing = false
m.filter = ""
return m, nil
case tea.KeyEnter, tea.KeyTab:
m.typing = false
return m, nil
case tea.KeyBackspace:
r := []rune(m.filter)
if len(r) > 0 {
m.filter = string(r[:len(r)-1])
}
return m, nil
case tea.KeySpace:
m.filter += " "
return m, nil
case tea.KeyRunes:
m.filter += string(msg.Runes)
return m, nil
}
return m, nil
}
switch msg.String() {
case "q", "ctrl+c":
return m, tea.Quit
case "?":
m.showHelp = true
return m, nil
case "t":
m.tools = true
return m, nil
case "/":
m.typing = true
return m, nil
case "u":
// A task whose blocker finished but which still says blocked is the
// one thing a person unblocks without reading anything else.
if r := m.selectedTask(); r != nil && isReadyButBlocked(*r) {
c, id := m.client, r.Issue.ID
return m, func() tea.Msg {
if _, err := c.SetStatus(id, "todo", "Unblocked: its blocker finished."); err != nil {
return errMsg{err}
}
rows, err := loadRows(c, m.project)
if err != nil {
return errMsg{err}
}
return rowsMsg(rows)
}
}
return m, nil
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 "R":
if err := startRebuild(m.folder); err != nil {
m.err = err
return m, nil
}
return m, tea.Quit
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.visible())-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"
// On the needs panel, enter also takes you where the answer is.
if m.focus == focusNeeds {
if err := switchWorkspace("Reply"); err != nil {
m.err = err
}
}
}
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.visible()) {
m.cursor = max(0, len(m.visible())-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))
}
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)
}
if m.showHelp {
return helpOverlay("keys on this panel", [][2]string{
{"↑ ↓ j k", "choose a task"},
{"enter", "show that task in every workspace"},
{"a", "assign it to the configured agent"},
{"u", "unblock it, when its blocker has finished"},
{"/", "filter the list by identifier, title or name"},
{"tab", "move to the next panel"},
{"r / R", "read again / rebuild every pane"},
{"q", "leave this panel"},
}, 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 m.zones.Scan(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 m.zones.Scan(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("↑↓", "move"),
keyHint("enter", "open"),
keyHint("a", "assign"),
keyHint("u", "unblock"),
keyHint("t", "tools"),
keyHint("/", "filter"),
keyHint("?", "keys"),
keyHint("tab", "panel"),
keyHint("r", "refresh"),
keyHint("R", "rebuild"),
keyHint("q", "quit"),
}, m.width)
case focusSummary:
return hintLine([]string{
keyHint("↑↓", "move"),
keyHint("tab", "panel"),
keyHint("r", "refresh"),
keyHint("q", "quit"),
}, m.width)
default:
return hintLine([]string{
keyHint("tab", "panel"),
keyHint("( )", "tab"),
keyHint("enter", "show this task everywhere"),
keyHint("r", "refresh"),
keyHint("q", "quit"),
}, m.width)
}
}
// treeLines draws the grouped task list.
func (m boardModel) treeLines(width int) []string {
var out []string
vis := m.visible()
if len(vis) == 0 && !m.loading {
if m.filter != "" {
return []string{mutedStyle2().Render(" nothing matches " + m.filter)}
}
return []string{mutedStyle2().Render(" no tasks in this project")}
}
index := 0
for _, g := range groupOrder {
var group []Row
for _, r := range vis {
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 := ""
if count >= 0 {
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{
m.zones.Mark("row-"+r.Issue.ID, 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)
}