Files
paperclip-harness/tui/newtask.go

319 lines
9.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}
func (m newTaskModel) guidanceLines(width int) []string {
out := []string{"", groupHeading("what a task needs", 4, width-2), ""}
add := func(k, v string) {
out = append(out, " "+lipgloss.NewStyle().Foreground(lipgloss.Color(active.Accent)).Render(k))
for _, ln := range wrap(v, max(8, width-4)) {
out = append(out, " "+mutedStyle2().Render(ln))
}
out = append(out, "")
}
add("one thing", "A task that asks for two things cannot be judged, only argued about.")
add("a command", "The verify command decides when the work is finished. The agent does not.")
add("a boundary", "Say which files may change. An unbounded task is how a review becomes an autopsy.")
add("a blocker", "If it must wait, name the task it waits for. A sentence in the description is not a blocker.")
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
}