Project and Repair workspaces; adoption keeps the person's own pane; truncation fix
This commit is contained in:
315
tui/project.go
Normal file
315
tui/project.go
Normal file
@@ -0,0 +1,315 @@
|
||||
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 {
|
||||
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
|
||||
|
||||
width, height int
|
||||
}
|
||||
|
||||
var projectTabs = []string{"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))
|
||||
}
|
||||
|
||||
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:
|
||||
switch msg.String() {
|
||||
case "q", "ctrl+c":
|
||||
return m, tea.Quit
|
||||
case "r":
|
||||
return m, m.load()
|
||||
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
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
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("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{""}
|
||||
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(" verify command: the first task whose description has one,"))
|
||||
out = append(out, dimStyle().Render(" or .harness/verify.sh when the project keeps one."))
|
||||
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
|
||||
Reference in New Issue
Block a user