Project and Repair workspaces; adoption keeps the person's own pane; truncation fix

This commit is contained in:
Sam
2026-11-10 14:28:18 +11:00
parent 031073fc46
commit c9a1a61cba
15 changed files with 910 additions and 23 deletions

View File

@@ -137,9 +137,10 @@ func truncateVisible(s string, w int) string {
i += len(string(r[0]))
visible++
}
out.WriteString("…")
// close any styling that is still open
out.WriteString("\x1b[0m")
if i < len(s) {
out.WriteString("…")
out.WriteString("\x1b[0m")
}
return out.String()
}

View File

@@ -19,17 +19,17 @@ import (
const defaultAgent = "403de1f7-645c-4d35-bc7e-19b01d77bacc"
func main() {
screen := flag.String("screen", "board", "which screen to run: board, work, tree, reply, new or log")
screen := flag.String("screen", "board", "which screen to run: board, work, reply, new, project, repair")
folder := flag.String("folder", "", "project folder, for the board screen (defaults to the current directory)")
light := flag.String("theme", "", "colour variant: dark, light, or empty to follow the terminal")
nerd := flag.Bool("nerd", false, "use Nerd Font icons instead of plain characters")
ascii := flag.Bool("ascii", false, "plain characters instead of Nerd Font icons")
agent := flag.String("agent", defaultAgent, "agent id to assign to, default Builder")
project := flag.String("project", "", "project id or url key, for the tree screen")
issue := flag.String("issue", "", "task id or identifier such as AUD-160, for the reply screen")
dump := flag.Bool("dump", false, "print the tree as plain text and exit (no full-screen interface)")
flag.Parse()
choosePalette(*light)
chooseGlyphs(*nerd)
chooseGlyphs(*ascii)
client, err := paperclip.New()
if err != nil {
@@ -54,6 +54,20 @@ func main() {
}
m = newBoardModel(client, proj, dir)
case "project":
dir := *folder
if dir == "" {
dir, _ = os.Getwd()
}
m = newProjectModel(client, dir)
case "repair":
dir := *folder
if dir == "" {
dir, _ = os.Getwd()
}
m = newRepairModel(client, dir)
case "work":
dir := *folder
if dir == "" {
@@ -113,7 +127,7 @@ func main() {
m = newReplyModel(client, dir, id)
default:
fmt.Fprintf(os.Stderr, "unknown screen %q: use tree, reply, new or log\n", *screen)
fmt.Fprintf(os.Stderr, "unknown screen %q: use board, work, reply, new, project or repair\n", *screen)
os.Exit(1)
}

View File

@@ -366,3 +366,8 @@ func (c *Client) CostSummary(issueID string) (Cost, error) {
err := c.do("GET", "/api/issues/"+issueID+"/cost-summary", nil, &out)
return out, err
}
// Health asks the control plane whether it is answering at all.
func (c *Client) Health() error {
return c.do("GET", "/api/health", nil, nil)
}

315
tui/project.go Normal file
View 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

323
tui/repair.go Normal file
View File

@@ -0,0 +1,323 @@
package main
// The Repair workspace: is the layout sound, and what is broken.
//
// Every line is a check that was run, with three outcomes and never two: it
// passed, it failed with a reason, or it has not been run. "Not run" is not a
// pass, and a screen that says otherwise is how an afternoon disappears.
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"paperclip-harness/tui/paperclip"
)
type checkResult struct {
name string
state string // pass, fail, not run
detail string
}
type repairMsg struct {
checks []checkResult
workspaces []layoutRow
runs int
err error
}
type layoutRow struct {
number int
name string
panes int
}
type repairModel struct {
client *paperclip.Client
folder string
checks []checkResult
workspaces []layoutRow
runs int
readAt time.Time
err error
tab int
scroll int
width, height int
}
var repairTabs = []string{"health", "layout"}
func newRepairModel(c *paperclip.Client, folder string) repairModel {
return repairModel{client: c, folder: folder}
}
func (m repairModel) Init() tea.Cmd {
return tea.Batch(m.load(), tickEvery(8*time.Second))
}
func (m repairModel) load() tea.Cmd {
c, folder := m.client, m.folder
return func() tea.Msg {
var checks []checkResult
add := func(name, state, detail string) {
checks = append(checks, checkResult{name, state, detail})
}
if err := c.Health(); err != nil {
add("Paperclip answers", "fail", err.Error())
} else {
add("Paperclip answers", "pass", "the control plane replied")
}
session := "paperclip-harness"
cfgPath := filepath.Join(folder, ".harness", "harness.toml")
if _, err := os.Stat(cfgPath); err != nil {
add("project file", "fail", "no .harness/harness.toml: run bin/office")
} else if cfg, err := readHarnessFile(folder); err != nil {
add("project file", "fail", err.Error())
} else if cfg["paperclip.project"] == "" {
add("project file", "fail", "no paperclip project recorded")
} else {
add("project file", "pass", cfg["paperclip.project"])
if cfg["session"] != "" {
session = cfg["session"]
}
}
bin, _ := os.Executable()
if _, err := os.Stat(bin); err != nil {
add("harness binary", "fail", "not found")
} else {
add("harness binary", "pass", bin)
}
for _, tool := range []string{"git", "go", "ocr", "tuios", "lazygit", "hunk"} {
if path, ok := findTool(tool); ok {
add(tool, "pass", path)
} else {
add(tool, "fail", "not found on the path or in the usual places")
}
}
// Fonts: the icons are drawn from a Nerd Font, so say whether it is here.
out, _ := exec.Command("fc-list").Output()
nerd := strings.Count(strings.ToLower(string(out)), "nerd")
if nerd == 0 {
add("Nerd Font", "fail", "no Nerd Font installed; icons will be boxes")
} else {
add("Nerd Font", "pass", fmt.Sprintf("%d font files", nerd))
}
ws, layoutErr := layoutRows(session)
res := repairMsg{checks: checks, workspaces: ws}
if layoutErr != nil {
res.err = layoutErr
}
return res
}
}
// findTool looks where a person's tools actually live. A pane started by TUIOS
// gets a smaller PATH than an interactive shell, so a tool that works in the
// shell can look missing from in here.
func findTool(name string) (string, bool) {
if p, err := exec.LookPath(name); err == nil {
return p, true
}
home, _ := os.UserHomeDir()
for _, dir := range []string{
filepath.Join(home, ".local", "bin"),
filepath.Join(home, ".nix-profile", "bin"),
"/run/current-system/sw/bin",
"/etc/profiles/per-user/" + os.Getenv("USER") + "/bin",
} {
p := filepath.Join(dir, name)
if st, err := os.Stat(p); err == nil && !st.IsDir() {
return p, true
}
}
return "", false
}
// layoutRows reads what TUIOS actually has, not what we think it has.
func layoutRows(session string) ([]layoutRow, error) {
out, err := exec.Command("tuios", "list-workspaces", "-s", session, "--json").Output()
if err != nil {
return nil, err
}
return parseWorkspaces(string(out)), nil
}
func parseWorkspaces(raw string) []layoutRow {
var rows []layoutRow
for _, chunk := range strings.Split(raw, "{") {
if !strings.Contains(chunk, "\"workspace\"") {
continue
}
var number, panes int
var name string
fmt.Sscanf(strings.TrimSpace(chunk), "%d", &number)
for _, field := range strings.Split(chunk, ",") {
field = strings.TrimSpace(field)
if strings.HasPrefix(field, "\"workspace\":") {
fmt.Sscanf(strings.TrimPrefix(field, "\"workspace\":"), "%d", &number)
}
if strings.HasPrefix(field, "\"window_count\":") {
fmt.Sscanf(strings.TrimPrefix(field, "\"window_count\":"), "%d", &panes)
}
if strings.HasPrefix(field, "\"name\":") {
name = strings.Trim(strings.TrimPrefix(field, "\"name\":"), `" `)
}
}
if number > 0 {
rows = append(rows, layoutRow{number, name, panes})
}
}
return rows
}
func (m repairModel) 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(8*time.Second))
case tea.KeyMsg:
switch msg.String() {
case "q", "ctrl+c":
return m, tea.Quit
case "r":
return m, m.load()
case "[", "h", "]", "l", "tab":
m.tab = (m.tab + 1) % len(repairTabs)
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 repairMsg:
m.checks = msg.checks
m.workspaces = msg.workspaces
m.err = msg.err
m.readAt = time.Now()
return m, nil
}
return m, nil
}
func (m repairModel) 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("Repair") + mutedStyle2().Render(" · layout and health")
state := "read " + m.readAt.Format("15:04:05")
if m.readAt.IsZero() {
state = "checking…"
}
body := m.healthLines()
if m.tab == 1 {
body = m.layoutLines()
}
p := panel{title: "checks", width: m.width, height: m.height - 2, tabs: repairTabs, 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", "check 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 repairModel) healthLines() []string {
out := []string{""}
if m.err != nil {
out = append(out, lipgloss.NewStyle().Foreground(lipgloss.Color(active.Error)).Render("layout: "+m.err.Error()))
out = append(out, "")
}
failed := 0
for _, c := range m.checks {
mark, colour := icons.Done, active.Success
switch c.state {
case "fail":
mark, colour = icons.Cancelled, active.Error
failed++
case "not run":
mark, colour = icons.Todo, active.Warning
}
out = append(out, fmt.Sprintf(" %s %-20s %s",
lipgloss.NewStyle().Foreground(lipgloss.Color(colour)).Render(mark),
lipgloss.NewStyle().Foreground(lipgloss.Color(active.Text)).Render(c.name),
mutedStyle2().Render(truncateVisible(c.detail, max(10, m.width-30)))))
}
out = append(out, "")
if failed == 0 {
out = append(out, " "+lipgloss.NewStyle().Foreground(lipgloss.Color(active.Success)).Render("everything checked passes"))
} else {
out = append(out, " "+lipgloss.NewStyle().Foreground(lipgloss.Color(active.Error)).Render(
fmt.Sprintf("%d check(s) failing", failed)))
}
return out
}
func (m repairModel) layoutLines() []string {
out := []string{""}
out = append(out, dimStyle().Render(fmt.Sprintf(" %-9s %-14s %s", "workspace", "name", "panes")))
for _, w := range m.workspaces {
name := w.name
if name == "" {
name = "(unnamed)"
}
if w.panes == 0 {
continue
}
out = append(out, fmt.Sprintf(" %-9d %-14s %d", w.number, name, w.panes))
}
out = append(out, "")
out = append(out, dimStyle().Render(" a workspace with no panes is removed by TUIOS and the rest renumber,"))
out = append(out, dimStyle().Render(" so every panel is found by name, never by number."))
return out
}

View File

@@ -111,12 +111,15 @@ var plainGlyphs = glyphSet{
// draws cannot be checked from here. The words next to every icon carry the
// meaning either way, and -nerd opts into the richer set once it has been seen
// on the terminal that matters.
var icons = plainGlyphs
// The Nerd Font is installed, so the icons are on by default; -ascii switches
// to plain characters for a terminal that cannot draw them. Either way the word
// next to the icon carries the meaning.
var icons = nerdGlyphs
func chooseGlyphs(useNerd bool) {
if useNerd {
icons = nerdGlyphs
func chooseGlyphs(useASCII bool) {
if useASCII {
icons = plainGlyphs
return
}
icons = plainGlyphs
icons = nerdGlyphs
}