diff --git a/CHECKLIST.md b/CHECKLIST.md index b6020b5..e8ac879 100644 --- a/CHECKLIST.md +++ b/CHECKLIST.md @@ -37,9 +37,12 @@ been measured. → 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. +- [x] Screen 4 shows the thread and posts a reply with the user key. → `bin/screen reply AUD-162` + shows the conversation; typing and pressing Enter posts, and `s` picks a status to send + with it. Posted as `authorType: user`. +- [x] Pass: move a blocked task forward from the terminal, no browser, no shell. + → `AUD-162` went `blocked` → `todo` from the reply screen, with a comment, and the + activity record names the person. Verified against the API afterwards. ## Milestone 3 — add a task, watch it work diff --git a/README.md b/README.md index 7fead69..f1e7e01 100644 --- a/README.md +++ b/README.md @@ -36,11 +36,17 @@ Nothing else. ## Running the terminal program ```bash +bin/layout # open every screen as a TUIOS pane +bin/screen reply AUD-162 # or one screen + 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 +go run . -screen tree -project spike-2026-11-10 # one screen in this terminal +go run . -screen tree -project spike-2026-11-10 -dump # the same data as plain text ``` +The screens live in the session named `paperclip-harness`, one per workspace. How that is +built and repaired is in `docs/TUIOS-RUNBOOK.md`. + 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. diff --git a/bin/harness b/bin/harness new file mode 100755 index 0000000..06f9d5f Binary files /dev/null and b/bin/harness differ diff --git a/bin/layout b/bin/layout new file mode 100755 index 0000000..9ba76b6 --- /dev/null +++ b/bin/layout @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# Build the project's pages in TUIOS, in a fixed order, without closing anything. +# +# Workspace 1 keeps a shell: it is the pane that never exits, so the session +# cannot lose its first workspace. Each screen then gets a workspace named after +# it, in this order. Running this again adopts what is already there. +# +# bin/layout # the default project +# bin/layout spike-2026-11-10 AUD-162 +set -euo pipefail + +SESSION="${PAPERCLIP_HARNESS_SESSION:-paperclip-harness}" +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +project="${1:-spike-2026-11-10}" +task="${2:-AUD-162}" + +# Resolve workspace 1 by name, never by number. +first_name="$(tuios list-workspaces -s "$SESSION" --json | + python3 -c 'import json,sys; w=json.load(sys.stdin)["workspaces"]; print(next((x["name"] for x in w if x["workspace"]==1), ""))')" +if [ -z "$first_name" ]; then + tuios set-workspace-name 1 shell -s "$SESSION" >/dev/null + echo "named workspace 1 shell" +fi + +"$ROOT/bin/screen" tree "$project" +"$ROOT/bin/screen" reply "$task" + +tuios list-workspaces -s "$SESSION" --json | python3 -c ' +import json,sys +for w in json.load(sys.stdin)["workspaces"]: + if w["window_count"]: + print(" workspace %d %-8s %d pane(s)" % (w["workspace"], w["name"] or "(unnamed)", w["window_count"])) +' diff --git a/bin/screen b/bin/screen new file mode 100755 index 0000000..7bbe5e2 --- /dev/null +++ b/bin/screen @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +# Open one screen as a TUIOS pane, or adopt the pane that already runs it. +# +# One screen, one workspace, named after the screen. The pane runs a shell that +# starts the program and returns to the shell when the program exits, so the +# pane never closes by itself; a workspace that loses its last pane is deleted +# by TUIOS and the rest renumber, which is the failure this avoids. +# +# bin/screen tree +# bin/screen reply AUD-162 +# +# Run it twice and the second run focuses the existing pane. Print "created" or +# "adopted" so the caller can tell which happened. +set -euo pipefail + +# The session is named, never taken from where the caller happens to be. +# TUIOS_SESSION is the session of the pane you are typing in, which is not +# necessarily this project's session; using it once put a screen in the wrong +# place. +SESSION="${PAPERCLIP_HARNESS_SESSION:-paperclip-harness}" +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +BIN="$ROOT/bin/harness" + +screen="${1:-}" +case "$screen" in + tree) args=(-screen tree -project "${2:-spike-2026-11-10}") ;; + reply) args=(-screen reply -issue "${2:?reply needs a task, for example AUD-162}") ;; + *) echo "usage: $(basename "$0") tree [project] | reply " >&2; exit 2 ;; +esac + +if [ ! -x "$BIN" ] || [ -n "$(find "$ROOT/tui" -name '*.go' -newer "$BIN" -print -quit 2>/dev/null)" ]; then + if ! command -v go >/dev/null 2>&1; then + echo "no harness binary and no Go to build one" >&2 + exit 1 + fi + ( cd "$ROOT/tui" && go build -o "$BIN" . ) +fi + +# Workspaces and windows are resolved by name every time. A number is never +# remembered, because closing a pane renumbers the rest. +state="$(python3 - "$SESSION" "$screen" <<'PY' +import json, subprocess, sys +session, want = sys.argv[1], sys.argv[2] + +def call(*args): + out = subprocess.run(["tuios", *args, "--json"], capture_output=True, text=True) + if out.returncode != 0: + sys.exit("tuios " + " ".join(args) + " failed: " + out.stderr.strip()) + return json.loads(out.stdout) + +ws = call("list-workspaces", "-s", session)["workspaces"] +win = call("list-windows", "-s", session)["windows"] + +for w in win: + # custom_name is the name we gave the pane. It survives the program + # exiting and a shell taking over the title, so it is what adoption reads. + if w.get("custom_name") == want: + print("adopt", w["window_id"]); sys.exit() + +named = next((w["workspace"] for w in ws if w.get("name") == want), None) +if named is not None: + print("create", named); sys.exit() + +free = next((w["workspace"] for w in ws if w.get("window_count") == 0), None) +if free is None: + sys.exit("no empty workspace in session %r; close a pane or add a workspace" % session) +print("create", free) +PY +)" + +action="$(echo "$state" | awk '{print $1}')" +where="$(echo "$state" | awk '{print $2}')" + +if [ "$action" = "adopt" ]; then + tuios focus-window "$where" -s "$SESSION" >/dev/null + echo "adopted $screen ($where) in session $SESSION" + exit 0 +fi + +tuios new-window "$screen" -s "$SESSION" --workspace "$where" --cwd "$ROOT" \ + --no-focus --keep-open -- /bin/sh -c "$BIN ${args[*]}; exec zsh -i" >/dev/null +tuios set-workspace-name "$where" "$screen" -s "$SESSION" >/dev/null +echo "created $screen on workspace $where of session $SESSION" diff --git a/docs/PAPERCLIP-API-NOTES.md b/docs/PAPERCLIP-API-NOTES.md index 40f23a7..02f3177 100644 --- a/docs/PAPERCLIP-API-NOTES.md +++ b/docs/PAPERCLIP-API-NOTES.md @@ -274,3 +274,28 @@ Read from `GET /api/companies/{companyId}/agents`: 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". + +--- + +# Milestone 2 passed from the terminal, 2026-11-10 + +The reply screen sent one call that both comments and changes status: + +``` +PATCH /api/issues/AUD-162 {"status":"todo","comment":"Unblocked: AUD-161 is done, …"} +→ 200, status todo +``` + +Read back afterwards: + +``` +GET /api/issues/AUD-162/comments → 1 comment, authorType: user, authorUserId: 2204TMD0… +GET /api/issues/AUD-162/activity → actorType: user, issue.comment_added, then + issue.updated status {from blocked, to todo} +``` + +Before the move, `AUD-161` was set to `done` and `diagnostics/blockers` for `AUD-162` +returned `allBlockersDone: true, unresolvedBlockerCount: 0` while the task itself stayed +`blocked`. So Paperclip does not walk a task forward on its own when its blocker clears; it +makes the task ready and leaves the decision to a person or a program. That is the job the +reply screen does, and the reason this milestone exists. diff --git a/docs/TUIOS-RUNBOOK.md b/docs/TUIOS-RUNBOOK.md index 8313ee3..001df19 100644 --- a/docs/TUIOS-RUNBOOK.md +++ b/docs/TUIOS-RUNBOOK.md @@ -98,3 +98,24 @@ tuios send-keys -s "$TUIOS_SESSION" -w harness-check Down --repeat 2 ``` If the pane shows what you expect after the keys, it works where it will be used. + +## Opening the screens + +The session for this project is named **`paperclip-harness`**. The scripts take that name +themselves; they never read `TUIOS_SESSION`, because that is the session of the pane you +happen to be typing in, and using it once put a screen in the wrong session. + +``` +bin/layout # every screen, in order, adopting what is already there +bin/screen tree # one screen +bin/screen reply AUD-162 +``` + +Both are idempotent: a second run focuses the pane that is already running that screen. The +pane is a shell that starts the program and returns to the shell when it exits, so a pane +never closes itself and the workspace keeps the pane that stops it being deleted. + +Measured: closing the last pane on a workspace leaves the number but **erases the +workspace's name**. Reopening therefore falls back to the first empty workspace and renames +it, which is why `bin/screen` prints either `created` or `adopted` and the workspace it +used. Nothing stores a workspace number. diff --git a/tui/main.go b/tui/main.go index 90f7e9f..588fd62 100644 --- a/tui/main.go +++ b/tui/main.go @@ -1,8 +1,8 @@ // 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. +// One screen per run. Each screen is meant to run in its own TUIOS pane, on a +// workspace named after it; the layout script in bin/ opens and adopts them. +// This file only wires flags. The screens live in model.go and reply.go. package main import ( @@ -16,8 +16,10 @@ import ( ) 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)") + screen := flag.String("screen", "tree", "which screen to run: tree or reply") + 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() client, err := paperclip.New() @@ -29,32 +31,67 @@ func main() { 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 { + var m tea.Model + switch *screen { + case "tree": + proj, err := client.ResolveProject(*project) + if err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) } - return + if *dump { + if err := dumpTree(client, proj); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + return + } + m = newModel(client, proj) + + case "reply": + task, err := resolveIssue(client, *issue) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + m = newReplyModel(client, task.ID) + + default: + fmt.Fprintf(os.Stderr, "unknown screen %q: use tree or reply\n", *screen) + os.Exit(1) } // 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(), - ) + p := tea.NewProgram(m, tea.WithAltScreen(), tea.WithMouseCellMotion()) if _, err := p.Run(); err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) } } +// resolveIssue accepts a task id or an identifier such as AUD-160. +func resolveIssue(c *paperclip.Client, ref string) (paperclip.Issue, error) { + if ref == "" { + return paperclip.Issue{}, fmt.Errorf("no task given: use -issue AUD-160 or a task id") + } + if len(ref) == 36 { // looks like a uuid; ask directly + if is, err := c.Issue(ref); err == nil && is.ID != "" { + return is, nil + } + } + issues, err := c.Issues() + if err != nil { + return paperclip.Issue{}, err + } + for _, is := range issues { + if is.Identifier == ref || is.ID == ref { + return is, nil + } + } + return paperclip.Issue{}, fmt.Errorf("no task matches %q", ref) +} + // 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 { diff --git a/tui/paperclip/client.go b/tui/paperclip/client.go index 3780943..83cb88c 100644 --- a/tui/paperclip/client.go +++ b/tui/paperclip/client.go @@ -256,3 +256,10 @@ func (c *Client) Agents() ([]Agent, error) { err := c.do("GET", "/api/companies/"+c.Company+"/agents", nil, &out) return out, err } + +// Issue returns one task by id. +func (c *Client) Issue(issueID string) (Issue, error) { + var out Issue + err := c.do("GET", "/api/issues/"+issueID, nil, &out) + return out, err +} diff --git a/tui/reply.go b/tui/reply.go new file mode 100644 index 0000000..989a9b0 --- /dev/null +++ b/tui/reply.go @@ -0,0 +1,326 @@ +package main + +// Screen 4: the task's conversation, with a box to reply in. +// +// Sending writes a comment as the person and, if a status was chosen, changes +// the task's status in the same call. That pair is what wakes an agent: it sees +// a new comment on a task it owns, and it sees the task waiting on it again. + +import ( + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + zone "github.com/lrstanley/bubblezone" + + "paperclip-harness/tui/paperclip" +) + +type threadMsg struct { + issue paperclip.Issue + comments []paperclip.Comment +} + +type sentMsg struct{ issue paperclip.Issue } + +type replyModel struct { + client *paperclip.Client + issue string + zones *zone.Manager + + task paperclip.Issue + thread []paperclip.Comment + box string + focusBox bool + next string // status to send with the reply; "" means leave it alone + sending bool + notice string + err error + + width, height int + scroll int +} + +func newReplyModel(c *paperclip.Client, issueID string) replyModel { + return replyModel{ + client: c, + issue: issueID, + zones: zone.New(), + } +} + +func (m replyModel) Init() tea.Cmd { return m.load() } + +func (m replyModel) load() tea.Cmd { + c, id := m.client, m.issue + return func() tea.Msg { + task, err := c.Issue(id) + if err != nil { + return errMsg{err} + } + cs, err := c.Comments(id) + if err != nil { + return errMsg{err} + } + return threadMsg{issue: task, comments: cs} + } +} + +func (m replyModel) send() tea.Cmd { + c, id, body, status := m.client, m.issue, strings.TrimSpace(m.box), m.next + return func() tea.Msg { + var ( + task paperclip.Issue + err error + ) + if status != "" { + task, err = c.SetStatus(id, status, body) + } else { + _, err = c.PostComment(id, body) + } + if err != nil { + return errMsg{err} + } + if task.ID == "" { + task, err = c.Issue(id) + if err != nil { + return errMsg{err} + } + } + return sentMsg{issue: task} + } +} + +// statuses the reply box can ask for, in the order they are offered. +var replyStatuses = []string{"", "todo", "in_progress", "in_review", "done", "blocked"} + +func (m replyModel) 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 tea.KeyMsg: + // While the box has focus it owns the ordinary keys, so typing a + // message never triggers the screen's own shortcuts. + if m.focusBox { + switch msg.Type { + case tea.KeyEsc: + lost := m.box == "" + m.focusBox = false + if lost { + m.notice = "" + } + return m, nil + case tea.KeyEnter: + if m.sending { + return m, nil + } + if strings.TrimSpace(m.box) == "" { + m.notice = "nothing to send" + return m, nil + } + m.sending = true + m.notice = "" + return m, m.send() + case tea.KeyBackspace: + r := []rune(m.box) + if len(r) > 0 { + m.box = string(r[:len(r)-1]) + } + return m, nil + case tea.KeySpace: + m.box += " " + return m, nil + case tea.KeyRunes, tea.KeyTab: + m.box += string(msg.Runes) + return m, nil + } + return m, nil + } + + switch msg.String() { + case "q", "ctrl+c": + return m, tea.Quit + case "tab", "shift+tab", "enter": + m.focusBox = true + m.notice = "" + return m, nil + case "up": + m.scroll-- + return m, nil + case "down": + m.scroll++ + return m, nil + case "s": + for i, st := range replyStatuses { + if st == m.next { + m.next = replyStatuses[(i+1)%len(replyStatuses)] + break + } + } + return m, nil + case "r": + return m, m.load() + case "esc": + return m, nil + } + + case threadMsg: + m.task = msg.issue + m.thread = msg.comments + m.scroll = 1 << 30 // pinned to the bottom until the person scrolls up + return m, nil + + case sentMsg: + m.sending = false + m.box = "" + m.next = "" + m.task = msg.issue + m.notice = "sent" + return m, m.load() + + case errMsg: + m.sending = false + m.err = msg.err + return m, nil + } + return m, nil +} + +var ( + boxStyle = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).Padding(0, 1) + boxFocusStyle = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("12")).Padding(0, 1) +) + +func (m replyModel) 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 + head := fmt.Sprintf("%s %s · conversation", m.task.Identifier, m.task.Status) + b.WriteString(titleStyle.Render(truncate(head, m.width))) + b.WriteString("\n") + + boxHeight := 3 + footer := 2 + threadRows := max(3, m.height-boxHeight-footer-2) + + lines := m.threadLines(m.width - 2) + // Clamp the scroll to what exists. + maxScroll := max(0, len(lines)-threadRows) + if m.scroll > maxScroll { + m.scroll = maxScroll + } + if m.scroll < 0 { + m.scroll = 0 + } + from := m.scroll + to := min(len(lines), from+threadRows) + shown := lines[from:to] + for len(shown) < threadRows { + shown = append(shown, "") + } + for _, ln := range shown { + b.WriteString(ln) + b.WriteString("\n") + } + + box := m.box + if box == "" { + box = "Tab or Enter to write a reply" + } + style := boxStyle + if m.focusBox { + style = boxFocusStyle + } + b.WriteString(style.Width(m.width - 2).Render(truncate(box, m.width-6))) + b.WriteString("\n") + + status := "no status change" + if m.next != "" { + status = "status → " + m.next + } + line := fmt.Sprintf("%s · %s", status, "s changes status · r reload · q quit") + if m.err != nil { + line = "error: " + m.err.Error() + } else if m.notice != "" { + line = m.notice + } else if m.sending { + line = "sending…" + } + if m.err != nil { + b.WriteString(blockedStyle.Render(truncate(line, m.width))) + } else { + b.WriteString(mutedStyle.Render(truncate(line, m.width))) + } + b.WriteString("\n") + + return m.zones.Scan(b.String()) +} + +// threadLines turns the comments into wrapped lines the pane can scroll. +func (m replyModel) threadLines(width int) []string { + if len(m.thread) == 0 { + return []string{mutedStyle.Render("no messages yet")} + } + var out []string + for _, c := range m.thread { + who := "person" + switch c.AuthorType { + case "agent": + who = "agent " + short(c.AuthorID) + case "user": + who = "you" + case "system": + who = "system" + } + out = append(out, fmt.Sprintf("%s %s", groupStyle.Render(who), mutedStyle.Render(short(c.ID)))) + for _, ln := range strings.Split(c.Body, "\n") { + for _, w := range wrap(ln, max(8, width-2)) { + out = append(out, " "+w) + } + } + out = append(out, "") + } + return out +} + +func short(id string) string { + if len(id) <= 8 { + return id + } + return id[:8] +} + +func wrap(s string, w int) []string { + if s == "" { + return []string{""} + } + words := strings.Fields(s) + var lines []string + cur := "" + for _, word := range words { + if cur == "" { + cur = word + continue + } + if len([]rune(cur))+1+len([]rune(word)) <= w { + cur += " " + word + continue + } + lines = append(lines, cur) + cur = word + } + if cur != "" { + lines = append(lines, cur) + } + return lines +}