Screens 1 and 4 running as TUIOS panes: reply screen, adopt-or-create launcher, layout script

This commit is contained in:
Sam
2026-11-10 11:48:07 +11:00
parent 8cfe38ee84
commit 34f60872f6
10 changed files with 564 additions and 23 deletions

View File

@@ -37,9 +37,12 @@ been measured.
→ seen in a real terminal: `TODO (1) / BLOCKED (1) / DONE (2)`, with → 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 `[AUD-161 todo]` shown against the blocked task. Resize shows "too small" below
56×12 instead of a broken tree. 56×12 instead of a broken tree.
- [ ] Screen 4 shows the thread and posts a reply with the user key. → client call `PostComment` - [x] Screen 4 shows the thread and posts a reply with the user key. → `bin/screen reply AUD-162`
exists; the screen does not. shows the conversation; typing and pressing Enter posts, and `s` picks a status to send
- [ ] Pass: move a blocked task forward from the terminal, no browser, no shell. 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 ## Milestone 3 — add a task, watch it work

View File

@@ -36,11 +36,17 @@ Nothing else.
## Running the terminal program ## Running the terminal program
```bash ```bash
bin/layout # open every screen as a TUIOS pane
bin/screen reply AUD-162 # or one screen
cd tui cd tui
go run . -project spike-2026-11-10 # full-screen task tree go run . -screen tree -project spike-2026-11-10 # one screen in this terminal
go run . -project spike-2026-11-10 -dump # same data as plain text, no interface 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 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 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. TUIOS it has to close a box.

BIN
bin/harness Executable file

Binary file not shown.

33
bin/layout Executable file
View File

@@ -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"]))
'

83
bin/screen Executable file
View File

@@ -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 <task>" >&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"

View File

@@ -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: 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". 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.

View File

@@ -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. 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.

View File

@@ -1,8 +1,8 @@
// Command harness is the terminal front end for the Paperclip coding factory. // 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 // One screen per run. Each screen is meant to run in its own TUIOS pane, on a
// model.go. Every fact the program shows comes from the Paperclip API; nothing // workspace named after it; the layout script in bin/ opens and adopts them.
// is stored locally. // This file only wires flags. The screens live in model.go and reply.go.
package main package main
import ( import (
@@ -16,8 +16,10 @@ import (
) )
func main() { func main() {
project := flag.String("project", "", "project id or url key (for example spike-2026-11-10)") screen := flag.String("screen", "tree", "which screen to run: tree or reply")
dump := flag.Bool("dump", false, "print the task tree as plain text and exit (no full-screen interface)") 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() flag.Parse()
client, err := paperclip.New() client, err := paperclip.New()
@@ -29,12 +31,14 @@ func main() {
fmt.Fprintln(os.Stderr, "warning: PAPERCLIP_BOARD_KEY is not set, so replies and status changes will be refused") fmt.Fprintln(os.Stderr, "warning: PAPERCLIP_BOARD_KEY is not set, so replies and status changes will be refused")
} }
var m tea.Model
switch *screen {
case "tree":
proj, err := client.ResolveProject(*project) proj, err := client.ResolveProject(*project)
if err != nil { if err != nil {
fmt.Fprintln(os.Stderr, err) fmt.Fprintln(os.Stderr, err)
os.Exit(1) os.Exit(1)
} }
if *dump { if *dump {
if err := dumpTree(client, proj); err != nil { if err := dumpTree(client, proj); err != nil {
fmt.Fprintln(os.Stderr, err) fmt.Fprintln(os.Stderr, err)
@@ -42,19 +46,52 @@ func main() {
} }
return 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 // Alternate screen: TUIOS keeps its own scrollback, and a full-screen
// program that redraws in place must not fight it. // program that redraws in place must not fight it.
p := tea.NewProgram(newModel(client, proj), p := tea.NewProgram(m, tea.WithAltScreen(), tea.WithMouseCellMotion())
tea.WithAltScreen(),
tea.WithMouseCellMotion(),
)
if _, err := p.Run(); err != nil { if _, err := p.Run(); err != nil {
fmt.Fprintln(os.Stderr, err) fmt.Fprintln(os.Stderr, err)
os.Exit(1) 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 // dumpTree prints the tree without starting the interface. It is how the data
// path is checked from a script or over ssh. // path is checked from a script or over ssh.
func dumpTree(c *paperclip.Client, proj paperclip.Project) error { func dumpTree(c *paperclip.Client, proj paperclip.Project) error {

View File

@@ -256,3 +256,10 @@ func (c *Client) Agents() ([]Agent, error) {
err := c.do("GET", "/api/companies/"+c.Company+"/agents", nil, &out) err := c.do("GET", "/api/companies/"+c.Company+"/agents", nil, &out)
return out, err 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
}

326
tui/reply.go Normal file
View File

@@ -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
}