342 lines
11 KiB
Go
342 lines
11 KiB
Go
package main
|
||
|
||
// The Author workspace: write a task.
|
||
//
|
||
// One panel with five fields, in the order Paperclip needs them: a title, what
|
||
// it must do, what must finish first, the command that proves it is done, and
|
||
// whether to hand it to an agent. The task is created unassigned and given its
|
||
// blocker in a separate call, because a task assigned at creation is claimed by
|
||
// an agent's run within seconds and that run then owns the task's fields.
|
||
|
||
import (
|
||
"fmt"
|
||
"strings"
|
||
|
||
"github.com/charmbracelet/bubbles/textinput"
|
||
tea "github.com/charmbracelet/bubbletea"
|
||
"github.com/charmbracelet/lipgloss"
|
||
|
||
"paperclip-harness/tui/paperclip"
|
||
)
|
||
|
||
type createdMsg struct {
|
||
issue paperclip.Issue
|
||
blocked string
|
||
assigned bool
|
||
err error
|
||
}
|
||
|
||
type newTaskModel struct {
|
||
client *paperclip.Client
|
||
proj paperclip.Project
|
||
agent string
|
||
|
||
inputs []textinput.Model
|
||
focus int
|
||
err error
|
||
busy bool
|
||
result string
|
||
|
||
tab int
|
||
width, height int
|
||
}
|
||
|
||
var authorTabs = []string{"form", "guidance", "preview"}
|
||
|
||
const (
|
||
fieldTitle = iota
|
||
fieldBody
|
||
fieldBlocker
|
||
fieldVerify
|
||
fieldAssign
|
||
fieldCount
|
||
)
|
||
|
||
var fieldNames = []string{"Title", "What it must do", "Must finish first", "Proves it is done", "Assign to an agent"}
|
||
var fieldHints = []string{
|
||
"one line, the thing to be done",
|
||
"details the agent needs: files, behaviour, limits",
|
||
"identifier of a task that must finish first, or empty",
|
||
"a command that succeeds only when the work is right, or empty",
|
||
"yes, to hand it to the configured agent after the blocker is set",
|
||
}
|
||
|
||
func newTaskForm(c *paperclip.Client, proj paperclip.Project, agentID string) newTaskModel {
|
||
m := newTaskModel{client: c, proj: proj, agent: agentID}
|
||
for range fieldNames {
|
||
ti := textinput.New()
|
||
ti.Placeholder = ""
|
||
ti.CharLimit = 1000
|
||
ti.Prompt = ""
|
||
m.inputs = append(m.inputs, ti)
|
||
}
|
||
m.inputs[fieldTitle].Focus()
|
||
return m
|
||
}
|
||
|
||
func (m newTaskModel) Init() tea.Cmd { return textinput.Blink }
|
||
|
||
func (m newTaskModel) submit() tea.Cmd {
|
||
title := strings.TrimSpace(m.inputs[fieldTitle].Value())
|
||
body := strings.TrimSpace(m.inputs[fieldBody].Value())
|
||
blocker := strings.TrimSpace(m.inputs[fieldBlocker].Value())
|
||
verify := strings.TrimSpace(m.inputs[fieldVerify].Value())
|
||
assign := strings.TrimSpace(m.inputs[fieldAssign].Value())
|
||
|
||
c, proj, agent := m.client, m.proj, m.agent
|
||
|
||
desc := body
|
||
if verify != "" {
|
||
desc += "\n\nDone when this command succeeds:\n\n " + verify
|
||
}
|
||
if blocker != "" {
|
||
desc += "\n\nMust finish first: " + blocker
|
||
}
|
||
|
||
return func() tea.Msg {
|
||
is, err := c.CreateTask(paperclip.NewTask{Title: title, Description: desc, ProjectID: proj.ID})
|
||
if err != nil {
|
||
return createdMsg{err: err}
|
||
}
|
||
if blocker != "" {
|
||
b, err := resolveIssue(c, blocker)
|
||
if err != nil {
|
||
return createdMsg{issue: is, err: fmt.Errorf("created %s, but no task matches %q", is.Identifier, blocker)}
|
||
}
|
||
if _, err := c.SetBlockers(is.ID, []string{b.ID}); err != nil {
|
||
return createdMsg{issue: is, err: fmt.Errorf("created %s, but the blocker was refused: %w", is.Identifier, err)}
|
||
}
|
||
}
|
||
assigned := false
|
||
if strings.EqualFold(assign, "yes") || strings.EqualFold(assign, "y") {
|
||
if _, err := c.Assign(is.ID, agent); err != nil {
|
||
return createdMsg{issue: is, err: fmt.Errorf("created %s, but the assignment was refused: %w", is.Identifier, err)}
|
||
}
|
||
assigned = true
|
||
}
|
||
return createdMsg{issue: is, blocked: blocker, assigned: assigned}
|
||
}
|
||
}
|
||
|
||
func (m newTaskModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||
switch msg := msg.(type) {
|
||
case tea.WindowSizeMsg:
|
||
m.width, m.height = msg.Width, msg.Height
|
||
w := max(20, min(70, m.width-12))
|
||
for i := range m.inputs {
|
||
m.inputs[i].Width = w
|
||
}
|
||
return m, nil
|
||
|
||
case tea.KeyMsg:
|
||
switch msg.Type {
|
||
case tea.KeyCtrlC:
|
||
return m, tea.Quit
|
||
case tea.KeyCtrlN:
|
||
m.tab = (m.tab + 1) % len(authorTabs)
|
||
return m, nil
|
||
case tea.KeyCtrlP:
|
||
m.tab = (m.tab + len(authorTabs) - 1) % len(authorTabs)
|
||
return m, nil
|
||
case tea.KeyEsc:
|
||
if m.tab != 0 {
|
||
m.tab = 0
|
||
return m, m.inputs[m.focus].Focus()
|
||
}
|
||
return m, tea.Quit
|
||
case tea.KeyTab, tea.KeyDown:
|
||
m.inputs[m.focus].Blur()
|
||
m.focus = (m.focus + 1) % fieldCount
|
||
return m, m.inputs[m.focus].Focus()
|
||
case tea.KeyShiftTab, tea.KeyUp:
|
||
m.inputs[m.focus].Blur()
|
||
m.focus = (m.focus + fieldCount - 1) % fieldCount
|
||
return m, m.inputs[m.focus].Focus()
|
||
case tea.KeyEnter:
|
||
if m.focus < fieldCount-1 {
|
||
m.inputs[m.focus].Blur()
|
||
m.focus++
|
||
return m, m.inputs[m.focus].Focus()
|
||
}
|
||
if strings.TrimSpace(m.inputs[fieldTitle].Value()) == "" {
|
||
m.err = fmt.Errorf("a task needs a title")
|
||
m.focus = fieldTitle
|
||
return m, m.inputs[m.focus].Focus()
|
||
}
|
||
if m.busy {
|
||
return m, nil
|
||
}
|
||
m.busy = true
|
||
m.err = nil
|
||
return m, m.submit()
|
||
}
|
||
|
||
case createdMsg:
|
||
m.busy = false
|
||
if msg.err != nil {
|
||
m.err = msg.err
|
||
return m, nil
|
||
}
|
||
m.result = "created " + msg.issue.Identifier
|
||
if msg.blocked != "" {
|
||
m.result += ", waiting on " + msg.blocked
|
||
}
|
||
if msg.assigned {
|
||
m.result += ", handed to an agent"
|
||
}
|
||
return m, tea.Quit
|
||
}
|
||
|
||
var cmd tea.Cmd
|
||
m.inputs[m.focus], cmd = m.inputs[m.focus].Update(msg)
|
||
return m, cmd
|
||
}
|
||
|
||
// descriptionPreview shows exactly what the agent will be given: the same
|
||
// text that submit() puts in the task's description, built the same way.
|
||
func (m newTaskModel) descriptionPreview() string {
|
||
body := strings.TrimSpace(m.inputs[fieldBody].Value())
|
||
verify := strings.TrimSpace(m.inputs[fieldVerify].Value())
|
||
blocker := strings.TrimSpace(m.inputs[fieldBlocker].Value())
|
||
out := body
|
||
if verify != "" {
|
||
out += "\n\nDone when this command succeeds:\n\n " + verify
|
||
}
|
||
if blocker != "" {
|
||
out += "\n\nMust finish first: " + blocker
|
||
}
|
||
if strings.TrimSpace(out) == "" {
|
||
return "nothing written yet"
|
||
}
|
||
return out
|
||
}
|
||
|
||
func (m newTaskModel) previewLines(width int) []string {
|
||
out := []string{"", dimStyle().Render(" the task's description, exactly as the agent receives it"), ""}
|
||
for _, para := range strings.Split(m.descriptionPreview(), "\n") {
|
||
for _, ln := range wrap(para, max(8, width-2)) {
|
||
out = append(out, " "+lipgloss.NewStyle().Foreground(lipgloss.Color(active.Text)).Render(ln))
|
||
}
|
||
if para == "" {
|
||
out = append(out, "")
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// guidanceLines answers the four questions the form asks, with examples rather
|
||
// than metaphors. If a line here needs a second reading, it is the wrong line.
|
||
func (m newTaskModel) guidanceLines(width int) []string {
|
||
out := []string{"", groupHeading("the five fields", -1, width-2), ""}
|
||
add := func(field, what, example string) {
|
||
out = append(out, " "+lipgloss.NewStyle().Foreground(lipgloss.Color(active.Accent)).Bold(true).Render(field))
|
||
for _, ln := range wrap(what, max(8, width-4)) {
|
||
out = append(out, " "+mutedStyle2().Render(ln))
|
||
}
|
||
out = append(out, " "+dimStyle().Render("example: "+example))
|
||
out = append(out, "")
|
||
}
|
||
add("Title", "One sentence naming the change. It becomes the task title in Paperclip.",
|
||
"Add a CHANGELOG.md with an Unreleased section")
|
||
add("What it must do", "The files to create or change, by path, and the exact behaviour. Mention what must NOT be touched.",
|
||
"Create CHANGELOG.md at the repository root. One section titled Unreleased with three bullets. Change no other file. Commit it.")
|
||
add("Must finish first", "The identifier of another task, for example AUD-171. Paperclip then records a real blocker edge and will not let this task run before that one is done. Leave it empty when nothing must come first.",
|
||
"AUD-171")
|
||
add("Proves it is done", "A shell command that exits 0 only when the work is right. It runs in the task's own copy of the code. Leave it empty and the task will be accepted on the agent's word, which is a weaker test.",
|
||
"test -s CHANGELOG.md or make test")
|
||
add("Assign to", "yes, or empty. yes hands the task to the configured agent as soon as it is created; empty leaves it for a person to assign on the board.",
|
||
"yes")
|
||
|
||
out = append(out, groupHeading("what these words mean", -1, width-2), "")
|
||
for _, pair := range [][2]string{
|
||
{"blocker", "Another task that must reach done before this one may start. It is a link between two tasks in Paperclip, not a sentence."},
|
||
{"verify command", "The command that decides the task is finished. Run in the task's worktree; exit 0 means finished."},
|
||
{"assign", "Giving the task to one agent. That is what wakes the agent and makes it start work."},
|
||
{"worktree", "The task's own copy of the repository, made by Paperclip. Two tasks cannot overwrite each other because of it."},
|
||
} {
|
||
out = append(out, " "+lipgloss.NewStyle().Foreground(lipgloss.Color(active.Text)).Render(pair[0]))
|
||
for _, ln := range wrap(pair[1], max(8, width-4)) {
|
||
out = append(out, " "+mutedStyle2().Render(ln))
|
||
}
|
||
out = append(out, "")
|
||
}
|
||
return out
|
||
}
|
||
|
||
func (m newTaskModel) View() string {
|
||
if m.width == 0 || m.height == 0 {
|
||
return "reading…"
|
||
}
|
||
if m.width < 80 || m.height < 24 {
|
||
return lipgloss.NewStyle().Width(m.width).Height(m.height).Render(
|
||
fmt.Sprintf("This workspace needs 80 × 24.\nThis panel is %d × %d.", m.width, m.height))
|
||
}
|
||
|
||
head := titleStyle.Render("Author") + mutedStyle2().Render(" · new task in "+m.proj.Name)
|
||
bodyHeight := m.height - 2
|
||
|
||
formLines := m.formLines(m.width - 4)
|
||
footer := hintLine([]string{
|
||
keyHint("tab", "next field"),
|
||
keyHint("enter", "next, and create on the last"),
|
||
keyHint("ctrl+n", "form, guidance, preview"),
|
||
keyHint("esc", "cancel"),
|
||
}, m.width)
|
||
|
||
// Wide screens: the form on the left, what to write and what will be sent on
|
||
// the right. Narrow ones: one panel, switched with the tabs.
|
||
if m.width >= 120 {
|
||
formW := max(50, m.width*58/100)
|
||
sideW := m.width - formW - 1
|
||
topH := bodyHeight / 2
|
||
bottomH := bodyHeight - topH
|
||
|
||
form := panel{title: "new task", width: formW, height: bodyHeight, focused: true}
|
||
guide := panel{title: "what a task needs", width: sideW, height: topH}
|
||
prev := panel{title: "what will be sent", width: sideW, height: bottomH}
|
||
page := lipgloss.JoinHorizontal(lipgloss.Top,
|
||
form.render(formLines), " ",
|
||
lipgloss.JoinVertical(lipgloss.Left,
|
||
guide.render(m.guidanceLines(sideW-3)),
|
||
prev.render(m.previewLines(sideW-3))))
|
||
return strings.Join([]string{head, page, footer}, "\n")
|
||
}
|
||
|
||
p := panel{title: "new task", width: m.width, height: bodyHeight, tabs: authorTabs, active: m.tab, focused: true}
|
||
lines := formLines
|
||
switch m.tab {
|
||
case 1:
|
||
lines = m.guidanceLines(m.width - 3)
|
||
case 2:
|
||
lines = m.previewLines(m.width - 3)
|
||
}
|
||
return strings.Join([]string{head, p.render(lines), footer}, "\n")
|
||
}
|
||
|
||
func (m newTaskModel) formLines(width int) []string {
|
||
var body []string
|
||
body = append(body, "")
|
||
for i, in := range m.inputs {
|
||
label := fieldNames[i]
|
||
if i == fieldTitle {
|
||
label += " (required)"
|
||
}
|
||
if i == m.focus {
|
||
body = append(body, " ▸ "+lipgloss.NewStyle().Foreground(lipgloss.Color(active.Accent)).Bold(true).Render(label))
|
||
} else {
|
||
body = append(body, " "+mutedStyle2().Render(label))
|
||
}
|
||
body = append(body, " "+in.View())
|
||
if i == m.focus {
|
||
body = append(body, " "+dimStyle().Render(fieldHints[i]))
|
||
}
|
||
body = append(body, "")
|
||
}
|
||
if m.err != nil {
|
||
body = append(body, " "+lipgloss.NewStyle().Foreground(lipgloss.Color(active.Error)).Render("error: "+m.err.Error()))
|
||
} else if m.busy {
|
||
body = append(body, " "+mutedStyle2().Render("writing to Paperclip…"))
|
||
}
|
||
return body
|
||
}
|