Go module with the task tree screen; worktrees on and measured; Go installed

This commit is contained in:
Sam
2026-11-10 11:20:21 +11:00
parent a60a4a3067
commit 713774485a
10 changed files with 868 additions and 23 deletions

View File

@@ -5,7 +5,8 @@ Tick each line. Nothing after the first unchecked line should be started early.
## Before you start ## Before you start
- [x] Paperclip answers: `curl -s -H "Authorization: Bearer $PAPERCLIP_BOARD_KEY" http://127.0.0.1:3100/api/health` → 200. - [x] Paperclip answers: `curl -s -H "Authorization: Bearer $PAPERCLIP_BOARD_KEY" http://127.0.0.1:3100/api/health` → 200.
- [ ] Go is installed: `go version` → **still not installed** (checked 2026-11-10). Needed before Milestone 2. - [x] Go is installed: `go version` → `go1.26.5` from `~/.nix-profile` (2026-11-10). The
NixOS config change is staged but not rebuilt; see `docs/ENVIRONMENT-FACTS.md`.
- [x] You are using the **user** key (`PAPERCLIP_BOARD_KEY`), not the agent key. - [x] You are using the **user** key (`PAPERCLIP_BOARD_KEY`), not the agent key.
## Milestone 1 — one task, really run (no interface) — PASSED 2026-11-10 ## Milestone 1 — one task, really run (no interface) — PASSED 2026-11-10
@@ -30,9 +31,14 @@ been measured.
## Milestone 2 — task tree and reply box ## Milestone 2 — task tree and reply box
- [ ] Create the Go module in `tui/`. - [x] Create the Go module in `tui/`. → `tui/`, module `paperclip-harness/tui`, Bubble Tea +
- [ ] Screen 1 reads tasks for one project; blockers read **per task** (`/api/issues/{id}/diagnostics/blockers`), never from the list. Lipgloss + BubbleZone. `go build ./...` and `go vet ./...` are clean.
- [ ] Screen 4 shows the thread and posts a reply with the user key. - [x] Screen 1 reads tasks for one project; blockers read **per task** (`/api/issues/{id}/diagnostics/blockers`), never from the list.
→ seen in a real terminal: `TODO (1) / BLOCKED (1) / DONE (2)`, with
`[AUD-161 todo]` shown against the blocked task. Resize shows "too small" below
56×12 instead of a broken tree.
- [ ] Screen 4 shows the thread and posts a reply with the user key. → client call `PostComment`
exists; the screen does not.
- [ ] Pass: move a blocked task forward from the terminal, no browser, no shell. - [ ] Pass: move a blocked task forward from the terminal, no browser, no shell.
## Milestone 3 — add a task, watch it work ## Milestone 3 — add a task, watch it work

View File

@@ -26,10 +26,23 @@ Nothing else.
- Paperclip server running locally on `http://127.0.0.1:3100` (it is; 685 endpoints). - Paperclip server running locally on `http://127.0.0.1:3100` (it is; 685 endpoints).
- `PAPERCLIP_BOARD_KEY` in the environment — the **user** key. The terminal program uses - `PAPERCLIP_BOARD_KEY` in the environment — the **user** key. The terminal program uses
this one. An agent key cannot write outside a heartbeat run. this one. An agent key cannot write outside a heartbeat run.
- Go toolchain — **not installed yet**; add `go` to `/etc/nixos/configuration.nix` before - Go toolchain — **now installed** (`go1.26.5`, from `nix profile`). The NixOS config change
Milestone 2. See `docs/ENVIRONMENT-FACTS.md`. that makes it permanent is staged in `/etc/nixos/configuration.nix` and needs one
`sudo nixos-rebuild switch`. See `docs/ENVIRONMENT-FACTS.md`.
- Pi, which is already the worker adapter on your Paperclip agent. - Pi, which is already the worker adapter on your Paperclip agent.
## Running the terminal program
```bash
cd tui
go run . -project spike-2026-11-10 # full-screen task tree
go run . -project spike-2026-11-10 -dump # same data as plain text, no interface
```
The project can be an id or its url key. Keys inside the program: arrows or Tab and
Shift-Tab move, `r` re-reads from Paperclip, `q` quits. A bare Esc is left free, because in
TUIOS it has to close a box.
## Rules for this project ## Rules for this project
- No interface work before a real task has run end to end. - No interface work before a real task has run end to end.

View File

@@ -59,32 +59,49 @@ A wake can still be refused without any error reaching us. Read
and a plain-English diagnosis. Refusal reasons seen in the documentation: the agent is and a plain-English diagnosis. Refusal reasons seen in the documentation: the agent is
paused, its budget is exhausted, its cooldown has not elapsed, or its run slot is full. paused, its budget is exhausted, its cooldown has not elapsed, or its run slot is full.
## Isolated workspaces are switched off on this instance ## Isolated workspaces: switched on 2026-11-10, and measured working
The project was created asking for a git worktree per task (`executionWorkspacePolicy` The instance first had them off. With the flags off, the project's request for a git
with `workspaceStrategy.type: "git_worktree"`). **No worktree was made.** The run worked worktree per task was silently ignored: `AUD-159` worked in `/tmp/pc-sandbox-2026-11-10`
directly in `/tmp/pc-sandbox-2026-11-10` and committed onto `main`. The task's on `main`, and the task's `executionWorkspaceId` read `null`.
`executionWorkspaceId` and `currentExecutionWorkspace` both read `null`.
`GET /api/instance/settings/experimental` returns: Then, with the person's approval, two experimental settings were switched on:
``` ```
"enableIsolatedWorkspaces": false, PATCH /api/instance/settings/experimental
"enableIsolatedWorkspacesByDefault": false, {"enableIsolatedWorkspaces": true, "enableWorktreeRunExecution": true}
"enableWorktreeRunExecution": false, → 200
``` ```
With `enableIsolatedWorkspaces` false, Paperclip discards a task's workspace settings `enableIsolatedWorkspacesByDefault` was deliberately **left off**. That flag would give
before the run is dispatched, so the mode falls back to the shared project folder. The every project without a policy of its own a worktree, which would change the other nine
worktree code is present in the server (`git worktree add` in projects on this instance. Only projects that ask for one now get one.
dist/services/workspace-runtime.js). Switching it on is an instance-wide experimental
setting and a decision for the person, not a setting for one project. The next task (`AUD-160`) made a real worktree, and Paperclip posted a "Workspace Ready"
comment on the task naming it:
```
strategy git_worktree
branch AUD-160-milestone-1-t2-read-hello-txt
cwd /tmp/pc-sandbox-2026-11-10/.paperclip/worktrees/AUD-160-milestone-1-t2-read-hello-txt
baseRef main
deliveryState merged_by_ancestry (no commit was needed; the branch equals main)
```
`git worktree list` in the sandbox repository shows that path on that branch. So the
worktree is Paperclip's job after all, once the instance allows it, and the terminal
program never needs to run `git worktree` itself.
## Toolchain ## Toolchain
- **Go is not installed on this machine.** `go version` → not found. The terminal program - **Go is installed** (2026-11-10), by `nix profile install nixpkgs#go` → `go1.26.5` at
needs it. Under Nix, add `go` to `/etc/nixos/configuration.nix` and rebuild; do not `/home/sam/.nix-profile/bin/go`. That is the quick path: it works now, and it is not part
install it by hand. of the system closure.
- The durable path is also prepared: `go` and `gopls` were added to
`environment.systemPackages` in `/etc/nixos/configuration.nix`. `nix flake check` passes
and the dry-run build evaluates. **The rebuild has not been run**, because `sudo` needs a
password here. One command finishes it: `sudo nixos-rebuild switch --flake /etc/nixos#nixos-desktop`.
Until then the profile copy is what runs.
## The new repository ## The new repository

View File

@@ -220,3 +220,57 @@ step. That is a decision to put to the person, not a bug to work around.
`AUD-160` stayed `todo`, unassigned, blocked by a task that is now `done`. Nothing woke, `AUD-160` stayed `todo`, unassigned, blocked by a task that is now `done`. Nothing woke,
because no agent is assigned to it. Whether the `issue_blockers_resolved` wake fires for an because no agent is assigned to it. Whether the `issue_blockers_resolved` wake fires for an
assigned dependent task is still unmeasured. assigned dependent task is still unmeasured.
---
# Worktrees switched on, measured, 2026-11-10
The first worktree attempt proved the flags, not the strategy: with
`enableIsolatedWorkspaces: false` the project's `git_worktree` policy was discarded before
dispatch. That is recorded above and is worth keeping, because it is what an operator will
see on any fresh instance.
After switching on `enableIsolatedWorkspaces` and `enableWorktreeRunExecution` (and leaving
`enableIsolatedWorkspacesByDefault` off, so the other projects are untouched), the same
project produced a real worktree on the next task:
| Fact | Value |
|---|---|
| Task | `AUD-160`, run `11a94551-dd12-4b13-92fe-835ea63d7753` |
| Workspace id | `7900dbf1-692c-4217-aebb-5d539c30aa98` |
| Mode / strategy | `isolated_workspace` / `git_worktree` |
| Branch | `AUD-160-milestone-1-t2-read-hello-txt` |
| Path | `/tmp/pc-sandbox-2026-11-10/.paperclip/worktrees/AUD-160-milestone-1-t2-read-hello-txt` |
| Base | `main` at `b093301` |
| Delivery state | `merged_by_ancestry` (the task made no commit) |
Paperclip wrote its own comment on the task, titled **Workspace Ready**, naming the
strategy, the branch and the working directory. `git worktree list` shows the new path, and
`git branch` shows the new branch. The file `hello.txt` from the first task was present in
the worktree, so the branch really was cut from the finished work.
Two consequences worth remembering:
- **Name the branch with a template.** The default is `{{issue.identifier}}-{{slug}}`;
usable placeholders include `{{issue.identifier}}`, `{{slug}}`, and the project and agent.
Without a template every worktree of the same task would land on the same name.
- **`merged_by_ancestry` is not a merge.** A task that changes nothing leaves its branch at
the base commit, and Paperclip marks it delivered because the branch adds nothing. A task
that does commit will stay `unmerged` until something merges it. That is the state the
review gate will read.
## Budgets as they stand
Read from `GET /api/companies/{companyId}/agents`:
| Agent | Monthly budget (cents) | Spent (cents) |
|---|---|---|
| Builder | 5000 | 2 |
| Chief of Staff | 5000 | 0 |
| System | 3000 | 0 |
| researcher | 2000 | 0 |
| Documenter | 2000 | 0 |
| Hub | 0 | 1 |
The company's `budgetMonthlyCents` is 0. There is no currency field anywhere in the payload:
Paperclip counts cents and does not name a currency, so "5000" is a number, not "$50 AUD".

3
tui/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
# built binaries and local caches
/harness
/tui

29
tui/go.mod Normal file
View File

@@ -0,0 +1,29 @@
module paperclip-harness/tui
go 1.26.5
require (
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0
github.com/lrstanley/bubblezone v1.0.0
)
require (
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/colorprofile v0.3.1 // indirect
github.com/charmbracelet/x/ansi v0.10.1 // indirect
github.com/charmbracelet/x/cellbuf v0.0.13 // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sys v0.36.0 // indirect
golang.org/x/text v0.24.0 // indirect
)

45
tui/go.sum Normal file
View File

@@ -0,0 +1,45 @@
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/charmbracelet/colorprofile v0.3.1 h1:k8dTHMd7fgw4bnFd7jXTLZrSU/CQrKnL3m+AxCzDz40=
github.com/charmbracelet/colorprofile v0.3.1/go.mod h1:/GkGusxNs8VB/RSOh3fu0TJmQ4ICMMPApIIVn0KszZ0=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ=
github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k=
github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/lrstanley/bubblezone v1.0.0 h1:bIpUaBilD42rAQwlg/4u5aTqVAt6DSRKYZuSdmkr8UA=
github.com/lrstanley/bubblezone v1.0.0/go.mod h1:kcTekA8HE/0Ll2bWzqHlhA2c513KDNLW7uDfDP4Mly8=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=

86
tui/main.go Normal file
View File

@@ -0,0 +1,86 @@
// Command harness is the terminal front end for the Paperclip coding factory.
//
// This file only wires flags and starts the program. The screens live in
// model.go. Every fact the program shows comes from the Paperclip API; nothing
// is stored locally.
package main
import (
"flag"
"fmt"
"os"
tea "github.com/charmbracelet/bubbletea"
"paperclip-harness/tui/paperclip"
)
func main() {
project := flag.String("project", "", "project id or url key (for example spike-2026-11-10)")
dump := flag.Bool("dump", false, "print the task tree as plain text and exit (no full-screen interface)")
flag.Parse()
client, err := paperclip.New()
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
if !client.UsingUserKey() {
fmt.Fprintln(os.Stderr, "warning: PAPERCLIP_BOARD_KEY is not set, so replies and status changes will be refused")
}
proj, err := client.ResolveProject(*project)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
if *dump {
if err := dumpTree(client, proj); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
return
}
// Alternate screen: TUIOS keeps its own scrollback, and a full-screen
// program that redraws in place must not fight it.
p := tea.NewProgram(newModel(client, proj),
tea.WithAltScreen(),
tea.WithMouseCellMotion(),
)
if _, err := p.Run(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
// dumpTree prints the tree without starting the interface. It is how the data
// path is checked from a script or over ssh.
func dumpTree(c *paperclip.Client, proj paperclip.Project) error {
rows, err := loadRows(c, proj)
if err != nil {
return err
}
fmt.Printf("%s (%s)\n\n", proj.Name, proj.Status)
for _, g := range groupOrder {
any := false
for _, r := range rows {
if r.Group != g {
continue
}
if !any {
fmt.Printf("%s\n", g)
any = true
}
fmt.Printf(" %-8s %-12s %-14s %s\n", r.Issue.Identifier, r.Issue.Status, r.Who, r.Issue.Title)
if r.Blocker != nil && r.Blocker.UnresolvedCount > 0 {
fmt.Printf(" blocked by %v\n", r.Blocker.BlockingIdentities)
}
}
if any {
fmt.Println()
}
}
return nil
}

334
tui/model.go Normal file
View File

@@ -0,0 +1,334 @@
package main
import (
"fmt"
"strings"
"sync"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
zone "github.com/lrstanley/bubblezone"
"paperclip-harness/tui/paperclip"
)
// groupOrder is the order of the four status groups on screen 1.
var groupOrder = []string{"Todo", "In progress", "Blocked", "In review", "Done"}
// Row is one task as the screen needs it: the task itself, who has it, and
// what Paperclip says blocks it.
type Row struct {
Issue paperclip.Issue
Group string
Who string
Blocker *paperclip.BlockerInfo
}
type rowsMsg []Row
type errMsg struct{ err error }
func groupOf(status string) string {
switch status {
case "todo", "backlog":
return "Todo"
case "in_progress":
return "In progress"
case "blocked":
return "Blocked"
case "in_review":
return "In review"
case "done", "cancelled":
return "Done"
}
return "Todo"
}
// loadRows reads the project's tasks from Paperclip, then asks about each
// task's blockers, because the list endpoint never carries them.
func loadRows(c *paperclip.Client, proj paperclip.Project) ([]Row, error) {
issues, err := c.Issues()
if err != nil {
return nil, err
}
mine := make([]paperclip.Issue, 0, len(issues))
for _, is := range issues {
if is.ProjectID == proj.ID {
mine = append(mine, is)
}
}
names := map[string]string{}
if agents, err := c.Agents(); err == nil {
for _, a := range agents {
names[a.ID] = a.Name
}
}
rows := make([]Row, len(mine))
for i, is := range mine {
who := "—"
switch {
case names[is.AssigneeAgent] != "":
who = names[is.AssigneeAgent]
case is.AssigneeAgent != "":
who = "agent"
case is.AssigneeUser != "":
who = "person"
}
rows[i] = Row{Issue: is, Group: groupOf(is.Status), Who: who}
}
// One request per task. Bounded so a large project does not open a
// hundred sockets at once.
var wg sync.WaitGroup
sem := make(chan struct{}, 6)
for i := range rows {
wg.Add(1)
go func(i int) {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
info, err := c.Blockers(rows[i].Issue.ID)
if err == nil {
rows[i].Blocker = &info
}
}(i)
}
wg.Wait()
order := map[string]int{}
for i, g := range groupOrder {
order[g] = i
}
// Stable sort by group, then by identifier so the tree does not jump
// around between refreshes.
for i := 1; i < len(rows); i++ {
for j := i; j > 0; j-- {
a, b := rows[j-1], rows[j]
if order[a.Group] > order[b.Group] ||
(order[a.Group] == order[b.Group] && a.Issue.Identifier > b.Issue.Identifier) {
rows[j-1], rows[j] = rows[j], rows[j-1]
continue
}
break
}
}
return rows, nil
}
type model struct {
client *paperclip.Client
project paperclip.Project
zones *zone.Manager
rows []Row
cursor int
err error
loading bool
width int
height int
}
func newModel(c *paperclip.Client, proj paperclip.Project) model {
return model{
client: c,
project: proj,
// The click-zone manager is created once, here, and the whole view is
// wrapped and scanned through it. Wrapping parts separately drifts
// when a pane is resized.
zones: zone.New(),
loading: true,
}
}
func (m model) Init() tea.Cmd {
return m.reload()
}
func (m model) reload() tea.Cmd {
c, proj := m.client, m.project
return func() tea.Msg {
rows, err := loadRows(c, proj)
if err != nil {
return errMsg{err}
}
return rowsMsg(rows)
}
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
// Sizes are always taken from the message. Nothing is hardcoded.
m.width, m.height = msg.Width, msg.Height
return m, nil
case tea.KeyMsg:
switch msg.String() {
case "q", "ctrl+c":
return m, tea.Quit
case "r":
m.loading = true
return m, m.reload()
case "up", "shift+tab":
if m.cursor > 0 {
m.cursor--
}
return m, nil
case "down", "tab":
if m.cursor < len(m.rows)-1 {
m.cursor++
}
return m, nil
case "home":
m.cursor = 0
return m, nil
case "end":
if len(m.rows) > 0 {
m.cursor = len(m.rows) - 1
}
return m, nil
case "esc":
// A bare Esc stays free for closing a box. Nothing to close yet.
return m, nil
}
case rowsMsg:
m.rows = msg
m.loading = false
m.err = nil
if m.cursor >= len(m.rows) {
m.cursor = max(0, len(m.rows)-1)
}
return m, nil
case errMsg:
m.err = msg.err
m.loading = false
return m, nil
}
return m, nil
}
var (
titleStyle = lipgloss.NewStyle().Bold(true)
groupStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12"))
selectedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("0")).Background(lipgloss.Color("12"))
mutedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
blockedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("9"))
)
// minWidth and minHeight are the smallest box that still shows something
// honest. Below this the program says so instead of drawing a broken tree.
const (
minWidth = 56
minHeight = 12
)
func (m model) View() string {
if m.width == 0 || m.height == 0 {
return "loading…"
}
if m.width < minWidth || m.height < minHeight {
return m.zones.Scan(lipgloss.NewStyle().
Width(m.width).Height(m.height).
Render(fmt.Sprintf("Window is too small.\nNeed %d×%d, have %d×%d.", minWidth, minHeight, m.width, m.height)))
}
var b strings.Builder
b.WriteString(titleStyle.Render(truncate(m.project.Name+" · task tree", m.width)))
b.WriteString("\n")
if m.err != nil {
b.WriteString(blockedStyle.Render(truncate("error: "+m.err.Error(), m.width)))
b.WriteString("\n")
}
if m.loading && len(m.rows) == 0 {
b.WriteString(mutedStyle.Render("reading tasks from Paperclip…"))
b.WriteString("\n")
}
lines := 0
budget := m.height - 3
for _, g := range groupOrder {
var group []Row
for _, r := range m.rows {
if r.Group == g {
group = append(group, r)
}
}
if len(group) == 0 {
continue
}
if lines >= budget {
break
}
b.WriteString(groupStyle.Render(fmt.Sprintf("%s (%d)", strings.ToUpper(g), len(group))))
b.WriteString("\n")
lines++
for _, r := range group {
if lines >= budget {
break
}
b.WriteString(m.renderRow(r))
b.WriteString("\n")
lines++
}
}
if len(m.rows) == 0 && !m.loading && m.err == nil {
b.WriteString(mutedStyle.Render("no tasks in this project"))
b.WriteString("\n")
}
b.WriteString(mutedStyle.Render(truncate(
"↑/↓ or Tab/Shift-Tab move · r refresh · q quit", m.width)))
// One scan for the whole view.
return m.zones.Scan(b.String())
}
func (m model) renderRow(r Row) string {
i := m.indexOf(r.Issue.ID)
marker := " "
if i == m.cursor {
marker = "▸ "
}
who := r.Who
if who == "" {
who = "—"
}
line := fmt.Sprintf("%s%-8s %-11s %-10s %s", marker, r.Issue.Identifier, r.Issue.Status, who, r.Issue.Title)
if r.Blocker != nil && r.Blocker.UnresolvedCount > 0 {
line += " [" + strings.Join(r.Blocker.BlockingIdentities, ", ") + "]"
}
line = truncate(line, m.width)
if i == m.cursor {
return m.zones.Mark("row-"+r.Issue.ID, selectedStyle.Render(line))
}
return m.zones.Mark("row-"+r.Issue.ID, line)
}
func (m model) indexOf(id string) int {
for i, r := range m.rows {
if r.Issue.ID == id {
return i
}
}
return -1
}
func truncate(s string, w int) string {
if w <= 0 {
return ""
}
r := []rune(s)
if len(r) <= w {
return s
}
if w == 1 {
return "…"
}
return string(r[:w-1]) + "…"
}

258
tui/paperclip/client.go Normal file
View File

@@ -0,0 +1,258 @@
// Package paperclip is a thin read/write client for the Paperclip control plane.
//
// It holds no state. Every value it returns came from Paperclip on this call.
// Nothing here caches, and nothing here writes to disk.
package paperclip
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"
"time"
)
// Client talks to one Paperclip instance.
type Client struct {
BaseURL string
Company string
token string
http *http.Client
}
// New builds a client from the environment.
//
// The token is read from PAPERCLIP_BOARD_KEY (the person's key) and only falls
// back to PAPERCLIP_API_KEY for read-only use. The token is never logged.
func New() (*Client, error) {
base := os.Getenv("PAPERCLIP_API_URL")
if base == "" {
base = "http://127.0.0.1:3100"
}
company := os.Getenv("PAPERCLIP_COMPANY_ID")
if company == "" {
company = "d4921013-e5da-4d88-8016-57d5746e2d03"
}
token := os.Getenv("PAPERCLIP_BOARD_KEY")
if token == "" {
token = os.Getenv("PAPERCLIP_API_KEY")
}
if token == "" {
return nil, errors.New("no key in the environment: set PAPERCLIP_BOARD_KEY")
}
return &Client{
BaseURL: strings.TrimRight(base, "/"),
Company: company,
token: token,
http: &http.Client{Timeout: 20 * time.Second},
}, nil
}
// UsingUserKey reports whether the person's key is in use. Writes need it.
func (c *Client) UsingUserKey() bool { return os.Getenv("PAPERCLIP_BOARD_KEY") != "" }
type Issue struct {
ID string `json:"id"`
Identifier string `json:"identifier"`
Title string `json:"title"`
Status string `json:"status"`
Priority string `json:"priority"`
ProjectID string `json:"projectId"`
ParentID string `json:"parentId"`
AssigneeAgent string `json:"assigneeAgentId"`
AssigneeUser string `json:"assigneeUserId"`
ExecutionState string `json:"-"`
UpdatedAt string `json:"updatedAt"`
}
type Project struct {
ID string `json:"id"`
Name string `json:"name"`
URLKey string `json:"urlKey"`
Status string `json:"status"`
}
// Comment is one entry on a task's thread.
type Comment struct {
ID string `json:"id"`
Body string `json:"body"`
AuthorType string `json:"authorType"`
AuthorID string `json:"authorAgentId"`
AuthorUser string `json:"authorUserId"`
CreatedAt string `json:"createdAt"`
}
// BlockerInfo is what Paperclip says about one task's blockers.
//
// The list endpoint does not carry blockers, so the tree asks per task.
type BlockerInfo struct {
IssueID string
UnresolvedCount int
AllBlockersDone bool
DependencyReady bool
Diagnosis string
BlockingIdentities []string
}
type blockerResponse struct {
Diagnosis string `json:"diagnosis"`
Readiness struct {
AllBlockersDone bool `json:"allBlockersDone"`
DependencyReady bool `json:"isDependencyReady"`
UnresolvedBlockerCount int `json:"unresolvedBlockerCount"`
} `json:"readiness"`
Blockers []struct {
Identifier string `json:"identifier"`
Title string `json:"title"`
Status string `json:"status"`
} `json:"blockers"`
}
func (c *Client) do(method, path string, body any, out any) error {
var rd io.Reader
if body != nil {
b, err := json.Marshal(body)
if err != nil {
return err
}
rd = bytes.NewReader(b)
}
req, err := http.NewRequest(method, c.BaseURL+path, rd)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+c.token)
req.Header.Set("Accept", "application/json")
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := c.http.Do(req)
if err != nil {
var ue *url.Error
if errors.As(err, &ue) {
return fmt.Errorf("cannot reach Paperclip at %s: %w", c.BaseURL, ue.Err)
}
return err
}
defer resp.Body.Close()
raw, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
if err != nil {
return err
}
if resp.StatusCode < 200 || resp.StatusCode > 299 {
return fmt.Errorf("%s %s returned %d: %s", method, path, resp.StatusCode, snippet(raw))
}
if out == nil || len(raw) == 0 {
return nil
}
if err := json.Unmarshal(raw, out); err != nil {
return fmt.Errorf("%s %s returned unreadable JSON: %w", method, path, err)
}
return nil
}
func snippet(b []byte) string {
s := strings.TrimSpace(string(b))
if len(s) > 300 {
s = s[:300] + "…"
}
return s
}
// Projects lists the company's projects.
func (c *Client) Projects() ([]Project, error) {
var out []Project
err := c.do("GET", "/api/companies/"+c.Company+"/projects", nil, &out)
return out, err
}
// ResolveProject accepts a project id or url key and returns the project.
func (c *Client) ResolveProject(ref string) (Project, error) {
if ref == "" {
return Project{}, errors.New("no project given: use -project <id or urlKey>")
}
ps, err := c.Projects()
if err != nil {
return Project{}, err
}
for _, p := range ps {
if p.ID == ref || p.URLKey == ref {
return p, nil
}
}
return Project{}, fmt.Errorf("no project matches %q", ref)
}
// Issues returns every task in the company. The caller filters by project,
// because the list endpoint takes no project filter.
func (c *Client) Issues() ([]Issue, error) {
var out []Issue
err := c.do("GET", "/api/companies/"+c.Company+"/issues", nil, &out)
return out, err
}
// Blockers asks Paperclip what blocks one task.
func (c *Client) Blockers(issueID string) (BlockerInfo, error) {
var raw blockerResponse
err := c.do("GET", "/api/issues/"+issueID+"/diagnostics/blockers", nil, &raw)
info := BlockerInfo{
IssueID: issueID,
UnresolvedCount: raw.Readiness.UnresolvedBlockerCount,
AllBlockersDone: raw.Readiness.AllBlockersDone,
DependencyReady: raw.Readiness.DependencyReady,
Diagnosis: raw.Diagnosis,
}
for _, b := range raw.Blockers {
info.BlockingIdentities = append(info.BlockingIdentities, b.Identifier+" "+b.Status)
}
return info, err
}
// Comments returns a task's thread, oldest first.
func (c *Client) Comments(issueID string) ([]Comment, error) {
var out []Comment
err := c.do("GET", "/api/issues/"+issueID+"/comments?order=asc", nil, &out)
return out, err
}
// PostComment writes a comment as the person, which requires the user key.
func (c *Client) PostComment(issueID, body string) (Comment, error) {
if !c.UsingUserKey() {
return Comment{}, errors.New("refusing to write with the agent key: set PAPERCLIP_BOARD_KEY")
}
var out Comment
err := c.do("POST", "/api/issues/"+issueID+"/comments", map[string]string{"body": body}, &out)
return out, err
}
// SetStatus changes one task's status as the person.
func (c *Client) SetStatus(issueID, status, comment string) (Issue, error) {
if !c.UsingUserKey() {
return Issue{}, errors.New("refusing to write with the agent key: set PAPERCLIP_BOARD_KEY")
}
payload := map[string]any{"status": status}
if comment != "" {
payload["comment"] = comment
}
var out Issue
err := c.do("PATCH", "/api/issues/"+issueID, payload, &out)
return out, err
}
type Agent struct {
ID string `json:"id"`
Name string `json:"name"`
Status string `json:"status"`
}
// Agents lists the company's agents, used only to put a name on a task.
func (c *Client) Agents() ([]Agent, error) {
var out []Agent
err := c.do("GET", "/api/companies/"+c.Company+"/agents", nil, &out)
return out, err
}