Screens 1 and 4 running as TUIOS panes: reply screen, adopt-or-create launcher, layout script
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
|||||||
10
README.md
10
README.md
@@ -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
BIN
bin/harness
Executable file
Binary file not shown.
33
bin/layout
Executable file
33
bin/layout
Executable 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
83
bin/screen
Executable 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"
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
57
tui/main.go
57
tui/main.go
@@ -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 {
|
||||||
|
|||||||
@@ -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
326
tui/reply.go
Normal 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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user