357 lines
12 KiB
Go
357 lines
12 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", "A command that proves it (optional)", "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",
|
||
"leave empty and the agent decides how to check its own work",
|
||
"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 }
|
||
|
||
// fieldTitle is index 0; kept as a method so the intent is readable above.
|
||
func (m newTaskModel) fieldTitle() int { return fieldTitle }
|
||
|
||
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
|
||
}
|
||
// Stay open with a receipt and empty fields. A pane that quits on
|
||
// success leaves nothing behind, which reads like a crash.
|
||
m.result = "created " + msg.issue.Identifier
|
||
if msg.blocked != "" {
|
||
m.result += ", waiting on " + msg.blocked
|
||
}
|
||
if msg.assigned {
|
||
m.result += ", handed to an agent which will start on its own"
|
||
}
|
||
for i := range m.inputs {
|
||
m.inputs[i].SetValue("")
|
||
}
|
||
m.focus = fieldTitle
|
||
m.inputs[m.fieldTitle()].Focus()
|
||
m.tab = 0
|
||
return m, nil
|
||
}
|
||
|
||
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("A command that proves it", "Optional. Most tasks need nothing here: the agent does the work, runs the project's own tests, and says what it did. Fill this in only when you want a hard gate that no agent can talk its way past.",
|
||
"make test or go 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, "")
|
||
if m.result != "" {
|
||
body = append(body, " "+lipgloss.NewStyle().Foreground(lipgloss.Color(active.Success)).Bold(true).Render(m.result))
|
||
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
|
||
}
|