Files
paperclip-harness/tui/project.go

436 lines
13 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 Project workspace: which folder, which machine, which session, what it
// has cost. Read-only on purpose. A screen that can change a project's settings
// is a screen that can break it while nobody is looking; changing them belongs
// to a command with a name, not a keystroke.
import (
"fmt"
"os"
"os/exec"
"strings"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"paperclip-harness/tui/paperclip"
)
type projectMsg struct {
dash paperclip.Dashboard
project paperclip.Project
cfg map[string]string
agents []paperclip.Agent
costs []taskCost
verified string
err error
}
type taskCost struct {
Identifier string
Status string
Cents int
Runs int
}
type projectModel struct {
client *paperclip.Client
folder string
dash paperclip.Dashboard
project paperclip.Project
cfg map[string]string
agents []paperclip.Agent
costs []taskCost
verified string
err error
readAt time.Time
tab int
scroll int
showHelp bool
width, height int
}
var projectTabs = []string{"overview", "facts", "costs", "settings"}
func newProjectModel(c *paperclip.Client, folder string) projectModel {
return projectModel{client: c, folder: folder}
}
func (m projectModel) Init() tea.Cmd {
return tea.Batch(m.load(), tickEvery(10*time.Second), tea.WindowSize())
}
func (m projectModel) load() tea.Cmd {
c, folder := m.client, m.folder
return func() tea.Msg {
cfg, err := readHarnessFile(folder)
if err != nil {
return projectMsg{err: err}
}
proj, err := c.ResolveProject(cfg["paperclip.project"])
if err != nil {
return projectMsg{cfg: cfg, err: err}
}
agents, _ := c.Agents()
issues, err := c.Issues()
if err != nil {
return projectMsg{cfg: cfg, project: proj, err: err}
}
var costs []taskCost
// The most recent tasks only: a cost per task is one request each, and
// a whole project's history would be hundreds.
var mine []paperclip.Issue
for _, i := range issues {
if i.ProjectID == proj.ID {
mine = append(mine, i)
}
}
for i := len(mine) - 1; i >= 0 && len(costs) < 12; i-- {
t := mine[i]
cost, err := c.CostSummary(t.ID)
if err != nil {
continue
}
costs = append(costs, taskCost{t.Identifier, t.Status, cost.CostCents, cost.RunCount})
}
dash, _ := c.DashboardSummary()
return projectMsg{project: proj, cfg: cfg, agents: agents, costs: costs, dash: dash}
}
}
// readHarnessFile reads the one file this project keeps for itself.
func readHarnessFile(folder string) (map[string]string, error) {
b, err := os.ReadFile(projectFilePath(folder))
if err != nil {
return nil, fmt.Errorf("no .harness/harness.toml in %s: run bin/office first", folder)
}
out := map[string]string{}
section := ""
for _, line := range strings.Split(string(b), "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
if strings.HasPrefix(line, "[") {
section = strings.Trim(line, "[]")
continue
}
parts := strings.SplitN(line, "=", 2)
if len(parts) != 2 {
continue
}
key := strings.TrimSpace(parts[0])
val := strings.Trim(strings.TrimSpace(parts[1]), `"'`)
if section != "" {
key = section + "." + key
}
out[key] = val
}
return out, nil
}
func (m projectModel) 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(10*time.Second))
case tea.KeyMsg:
if m.showHelp {
m.showHelp = false
return m, nil
}
switch msg.String() {
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(projectTabs)-1 {
m.tab++
m.scroll = 0
}
return m, nil
case "up", "k":
m.scroll--
return m, nil
case "down", "j":
m.scroll++
return m, nil
case "esc":
return m, nil
}
case projectMsg:
m.dash = msg.dash
m.project = msg.project
m.cfg = msg.cfg
m.agents = msg.agents
m.costs = msg.costs
m.err = msg.err
m.readAt = time.Now()
return m, nil
}
return m, nil
}
func (m projectModel) 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 project", [][2]string{
{"[ ] h l", "facts, costs, settings"},
{"↑ ↓ j k", "scroll"},
{"r", "read again"},
{"R", "rebuild every pane"},
{"q", "leave"},
{"?", "this list"},
}, m.width, m.height)
}
head := titleStyle.Render("Project") + mutedStyle2().Render(" · ")
if m.project.Name != "" {
head += lipgloss.NewStyle().Foreground(lipgloss.Color(active.Text)).Render(m.project.Name)
}
state := "read " + m.readAt.Format("15:04:05")
if m.readAt.IsZero() {
state = "reading…"
}
var body []string
switch m.tab {
case 0:
body = m.overviewLines(m.width - 4)
case 1:
body = m.factLines()
case 2:
body = m.costLines()
default:
body = m.settingLines()
}
if m.err != nil {
body = append([]string{lipgloss.NewStyle().Foreground(lipgloss.Color(active.Error)).Render("error: " + m.err.Error()), ""}, body...)
}
p := panel{title: "the project", width: m.width, height: m.height - 2, tabs: projectTabs, active: m.tab, focused: true}
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("[ ]", "tab"),
keyHint("↑↓", "scroll"),
keyHint("r", "refresh"),
keyHint("R", "rebuild"),
keyHint("q", "quit"),
}, m.width)
gap := max(2, m.width-lipgloss.Width(head)-lipgloss.Width(state)-1)
return strings.Join([]string{
head + strings.Repeat(" ", gap) + mutedStyle2().Render(state),
p.render(body[from:to]),
footer,
}, "\n")
}
func (m projectModel) row(label, value string) string {
return mutedStyle2().Render(fmt.Sprintf("%-16s", label)) +
lipgloss.NewStyle().Foreground(lipgloss.Color(active.Text)).Render(value)
}
// overviewLines is the company dashboard, drawn as numbers a person can act on
// and a sparkline of the last two weeks of runs. Everything here comes from
// GET /api/companies/{id}/dashboard in one call.
func (m projectModel) overviewLines(width int) []string {
d := m.dash
out := []string{"", groupHeading("agents", -1, width), ""}
out = append(out, " "+stat("active", d.Agents.Active, active.Text)+
" "+stat("running", d.Agents.Running, active.Info)+
" "+stat("paused", d.Agents.Paused, active.Warning)+
" "+stat("error", d.Agents.Error, active.Error))
out = append(out, "", groupHeading("tasks", -1, width), "")
out = append(out, " "+stat("open", d.Tasks.Open, active.Text)+
" "+stat("in progress", d.Tasks.InProgress, active.Info)+
" "+stat("blocked", d.Tasks.Blocked, active.Error)+
" "+stat("done", d.Tasks.Done, active.Success))
out = append(out, "", groupHeading("money, this month", -1, width), "")
budget := "no company budget set"
if d.Costs.MonthBudgetCents > 0 {
budget = fmt.Sprintf("%.0f%% of %d cents", d.Costs.MonthUtilizationPercent, d.Costs.MonthBudgetCents)
}
out = append(out, " "+stat("cents spent", d.Costs.MonthSpendCents, active.Text)+" "+
mutedStyle2().Render(budget))
out = append(out, " "+stat("pending approvals", d.PendingApprovals, active.Warning))
if d.Budgets.ActiveIncidents > 0 || d.Budgets.PausedAgents > 0 || d.Budgets.PausedProjects > 0 {
out = append(out, " "+stat("budget incidents", d.Budgets.ActiveIncidents, active.Error)+
mutedStyle2().Render(fmt.Sprintf(" paused: %d agent(s), %d project(s)",
d.Budgets.PausedAgents, d.Budgets.PausedProjects)))
}
out = append(out, "", groupHeading("runs, last two weeks", -1, width), "")
out = append(out, " "+sparkline(d.RunActivity))
if n := len(d.RunActivity); n > 0 {
first, last := d.RunActivity[0].Date, d.RunActivity[n-1].Date
out = append(out, dimStyle().Render(" "+first+" → "+last+" one bar per day, taller is more runs"))
}
out = append(out, "")
out = append(out, dimStyle().Render(" read from Paperclip's own dashboard, not counted here."))
return out
}
// stat is a label and a number, the number first because it is what you look at.
func stat(label string, value int, colour string) string {
return lipgloss.NewStyle().Foreground(lipgloss.Color(colour)).Bold(true).Render(fmt.Sprintf("%d", value)) +
" " + mutedStyle2().Render(label)
}
// sparkline draws one bar per day using block characters, scaled so the busiest
// day fills the height. No chart library, no second language: the data is in
// memory and the terminal already knows how to draw blocks.
func sparkline(points []struct {
Date string `json:"date"`
Total int `json:"total"`
Succeeded int `json:"succeeded"`
Failed int `json:"failed"`
}) string {
bars := []rune("▁▂▃▄▅▆▇█")
peak := 0
for _, p := range points {
if p.Total > peak {
peak = p.Total
}
}
if peak == 0 {
return mutedStyle2().Render("no runs in the last two weeks")
}
var b strings.Builder
for _, p := range points {
idx := int(float64(p.Total) / float64(peak) * float64(len(bars)-1))
colour := active.Success
if p.Failed > 0 {
colour = active.Warning
}
b.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color(colour)).Render(string(bars[idx])))
}
return b.String()
}
func (m projectModel) factLines() []string {
out := []string{""}
if m.project.Name != "" {
out = append(out, m.row("project", m.project.Name))
out = append(out, m.row("project id", m.project.ID))
out = append(out, m.row("status", m.project.Status))
}
out = append(out, m.row("folder", m.folder))
out = append(out, m.row("machine", m.cfg["machine.host"]))
out = append(out, m.row("session", m.cfg["session"]))
out = append(out, "")
out = append(out, groupHeading("agents", len(m.agents), m.width-4))
for _, a := range m.agents {
mark := icons.Todo
if a.Status == "idle" {
mark = icons.Done
}
out = append(out, fmt.Sprintf(" %s %-16s %s", mark,
lipgloss.NewStyle().Foreground(lipgloss.Color(active.Text)).Render(a.Name),
mutedStyle2().Render(a.Status)))
}
return out
}
func (m projectModel) costLines() []string {
if len(m.costs) == 0 {
return []string{"", mutedStyle2().Render("no recorded spend for recent tasks")}
}
total := 0
runs := 0
out := []string{"", dimStyle().Render(fmt.Sprintf("%-10s %-10s %8s %6s", "task", "state", "cents", "runs"))}
for _, c := range m.costs {
total += c.Cents
runs += c.Runs
out = append(out, fmt.Sprintf("%-10s %-10s %8d %6d", c.Identifier, c.Status, c.Cents, c.Runs))
}
out = append(out, "")
out = append(out, m.row("total, recent", fmt.Sprintf("%d cent(s) over %d run(s)", total, runs)))
out = append(out, dimStyle().Render(" costs come from Paperclip; nothing is added up twice"))
return out
}
func (m projectModel) settingLines() []string {
out := []string{"", groupHeading("this project's own file", 6, m.width-4)}
out = append(out, m.row("session", m.cfg["session"]))
out = append(out, m.row("machine", m.cfg["machine.host"]))
out = append(out, m.row("folder", m.cfg["machine.folder"]))
out = append(out, m.row("paperclip", m.cfg["paperclip.project"]))
out = append(out, m.row("watching", m.cfg["watching"]))
out = append(out, m.row("verify command", m.verifyCommand()))
out = append(out, "")
out = append(out, dimStyle().Render(" .harness/harness.toml is the only thing this project keeps for itself."))
out = append(out, dimStyle().Render(" Paperclip owns tasks, blockers, comments, runs, cost and worktrees."))
out = append(out, "")
out = append(out, groupHeading("limits held by Paperclip", len(m.agents), m.width-4))
for _, a := range m.agents {
out = append(out, m.row(a.Name, fmt.Sprintf("%s · monthly budget %d cents · spent %d",
a.Status, a.BudgetCents, a.SpentCents)))
}
out = append(out, "", dimStyle().Render(" change a budget in Paperclip, not here: it is their fact, and a second"))
out = append(out, dimStyle().Render(" copy of it is how two screens start disagreeing."))
return out
}
func (m projectModel) verifyCommand() string {
issues, err := m.client.Issues()
if err == nil {
for _, i := range issues {
if i.ProjectID == m.project.ID {
if cmd := verifyFromDescription(i.Description); cmd != "" {
return cmd + " (from " + i.Identifier + ")"
}
}
}
}
path := m.folder + "/.harness/verify.sh"
if _, err := os.Stat(path); err == nil {
return ".harness/verify.sh"
}
return "none agreed: this would pass on an agent's word"
}
var _ = exec.Command