Files
paperclip-harness/tui/project.go

394 lines
10 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 {
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 {
panel string
client *paperclip.Client
folder string
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{"facts", "costs", "settings"}
func newProjectModel(c *paperclip.Client, folder, panel string) projectModel {
return projectModel{client: c, folder: folder, panel: panel}
}
func (m projectModel) Init() tea.Cmd {
return tea.Batch(m.load(), tickEvery(10*time.Second))
}
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})
}
return projectMsg{project: proj, cfg: cfg, agents: agents, costs: costs}
}
}
// 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.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
}
// singleView is one panel filling the pane: facts, costs or settings.
func (m projectModel) singleView() string {
titles := map[string]string{"facts": "the project", "costs": "what it has cost", "settings": "settings and limits"}
title := titles[m.panel]
if title == "" {
title = "the project"
}
var body []string
switch m.panel {
case "costs":
body = m.costLines()
case "settings":
body = m.settingLines()
default:
body = m.factLines()
}
if m.err != nil {
body = append([]string{lipgloss.NewStyle().Foreground(lipgloss.Color(active.Error)).Render("error: " + m.err.Error()), ""}, body...)
}
footer := hintLine([]string{
keyHint("↑↓ j k", "scroll"),
keyHint("r", "read again"),
keyHint("R", "rebuild"),
keyHint("?", "keys"),
}, m.width)
if m.showHelp {
return helpOverlay(title, [][2]string{
{"↑ ↓ j k", "scroll"}, {"r / R", "read again / rebuild"}, {"q", "leave"},
}, m.width, m.height)
}
return singlePanel(title, nil, 0, body, m.width, m.height, footer, m.scroll)
}
func (m projectModel) View() string {
if m.panel != "" {
if m.width == 0 {
return "reading…"
}
return m.singleView()
}
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.factLines()
case 1:
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("[ ]", "switch tab"),
keyHint("↑↓ or j k", "scroll"),
keyHint("r", "read again"),
keyHint("R", "rebuild every pane"),
keyHint("q", "leave"),
}, 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)
}
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