Files
paperclip-harness/tui/project.go

577 lines
17 KiB
Go
Raw Permalink 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 (
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"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
regAt int
regs []registryRow
confirmSwitch bool
showHelp bool
width, height int
}
var projectTabs = []string{"overview", "facts", "costs", "settings", "projects"}
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.confirmSwitch {
switch msg.String() {
case "y":
m.confirmSwitch = false
if m.regAt < len(m.regs) {
r := m.regs[m.regAt]
params := map[string]any{"name": r.session}
me, _ := os.Hostname()
if r.host != "" && r.host != me && r.host != strings.Split(me, ".")[0] {
params["host"] = r.host
}
body, _ := json.Marshal(params)
out, err := exec.Command("tuios", "call", "switch-session", string(body)).CombinedOutput()
if err != nil {
m.err = fmt.Errorf("could not switch to %q: %v %s", r.session, err, strings.TrimSpace(string(out)))
}
}
return m, nil
case "esc", "n", "q":
m.confirmSwitch = false
}
return m, nil
}
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 "enter":
if m.tab == 4 {
m.regs = readRegistry()
if len(m.regs) > 0 && m.regAt < len(m.regs) {
m.confirmSwitch = true
}
}
return m, nil
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":
if m.tab == 4 && m.regAt > 0 {
m.regAt--
}
m.scroll--
return m, nil
case "down", "j":
if m.tab == 4 && m.regAt < len(m.regs)-1 {
m.regAt++
}
m.scroll++
return m, nil
case "esc":
return m, nil
}
case projectMsg:
m.regs = readRegistry()
m.regAt = min(m.regAt, max(0, len(m.regs)-1))
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.confirmSwitch && m.regAt < len(m.regs) {
r := m.regs[m.regAt]
return helpOverlay("switch to "+r.session+"?", [][2]string{
{"what happens", "your TUIOS client moves to that session"},
{"session", r.session},
{"machine", r.host},
{"folder", r.folder},
{"y", "switch"},
{"esc or n", "stay here"},
}, 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()
case 4:
body = m.projectListLines(m.width - 4)
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
peak := 1
for _, c := range m.costs {
if c.Cents > peak {
peak = c.Cents
}
}
out := []string{"", dimStyle().Render(fmt.Sprintf("%-10s %-10s %6s %5s %s", "task", "state", "cents", "runs", "share of the largest"))}
for _, c := range m.costs {
total += c.Cents
runs += c.Runs
bars := int(float64(c.Cents) / float64(peak) * 24)
bar := strings.Repeat("█", bars)
if c.Cents > 0 && bars == 0 {
bar = "▏"
}
out = append(out, fmt.Sprintf("%-10s %-10s %6d %5d %s", c.Identifier, c.Status, c.Cents, c.Runs,
lipgloss.NewStyle().Foreground(lipgloss.Color(active.Accent)).Render(bar)))
}
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
// registryRow is one adopted project, from the file bin/office writes.
type registryRow struct {
name, folder, host, session, id string
}
// readRegistry lists adopted projects. It is the only thing this program keeps
// besides the project file: where a folder is, and on which machine.
func readRegistry() []registryRow {
home, _ := os.UserHomeDir()
path := filepath.Join(home, ".config", "paperclip-harness", "projects.toml")
b, err := os.ReadFile(path)
if err != nil {
return nil
}
var rows []registryRow
cur := map[string]string{}
id := ""
flush := func() {
if id != "" && cur["folder"] != "" {
rows = append(rows, registryRow{
name: cur["name"], folder: cur["folder"], host: cur["host"],
session: cur["session"], id: id,
})
}
cur = map[string]string{}
}
for _, line := range strings.Split(string(b), "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "[projects.") {
flush()
id = strings.Trim(strings.TrimPrefix(line, "[projects."), "]'\"")
continue
}
parts := strings.SplitN(line, "=", 2)
if len(parts) != 2 {
continue
}
cur[strings.TrimSpace(parts[0])] = strings.Trim(strings.TrimSpace(parts[1]), "\"'")
}
flush()
return rows
}
// projectListLines shows every adopted project and where it lives.
func (m projectModel) projectListLines(width int) []string {
rows := readRegistry()
out := []string{"", dimStyle().Render(fmt.Sprintf(" %-26s %-12s %-14s %s", "project", "machine", "session", "folder"))}
me, _ := os.Hostname()
for i, r := range rows {
here := ""
if r.host == me || r.host == strings.Split(me, ".")[0] {
here = " (this machine)"
}
line := fmt.Sprintf(" %-26s %-12s %-14s %s%s",
truncateVisible(r.name, 26), r.host, truncateVisible(r.session, 14),
truncateVisible(r.folder, max(10, width-60)), here)
if i == m.regAt {
out = append(out, selectedStyle2().Render(padTo("▸"+line[1:], width)))
continue
}
out = append(out, line)
}
if len(rows) == 0 {
out = append(out, " "+mutedStyle2().Render("none adopted yet: bin/office <folder> adds one"))
}
out = append(out, "")
out = append(out, dimStyle().Render(" enter switches your TUIOS client to that project's session."))
out = append(out, dimStyle().Render(" a session on another machine switches only when that machine is in your"))
out = append(out, dimStyle().Render(" ~/.config/tuios hosts table; otherwise attach to it by hand."))
return out
}