227 lines
6.2 KiB
Go
227 lines
6.2 KiB
Go
package main
|
||
|
||
// Screen 2: make a task.
|
||
//
|
||
// The order the fields ask for is the order Paperclip needs them in: a title,
|
||
// what it must do, what must finish first, and the command that proves it is
|
||
// finished. 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 — the blocker write would be
|
||
// refused.
|
||
|
||
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
|
||
err error
|
||
}
|
||
|
||
type newTaskModel struct {
|
||
client *paperclip.Client
|
||
proj paperclip.Project
|
||
agent string
|
||
|
||
inputs []textinput.Model
|
||
focus int
|
||
err error
|
||
busy bool
|
||
|
||
created *paperclip.Issue
|
||
notice string
|
||
|
||
width, height int
|
||
}
|
||
|
||
const (
|
||
fieldTitle = iota
|
||
fieldBody
|
||
fieldBlocker
|
||
fieldVerify
|
||
fieldAssign
|
||
fieldCount
|
||
)
|
||
|
||
func newTaskForm(c *paperclip.Client, proj paperclip.Project, agentID string) newTaskModel {
|
||
placeholders := []string{
|
||
"what needs doing, in one line",
|
||
"details the agent needs: files, behaviour, limits",
|
||
"identifier of the task that must finish first, or empty",
|
||
"the command that proves it is done, or empty",
|
||
"assign after the blocker is set: yes, or empty",
|
||
}
|
||
m := newTaskModel{client: c, proj: proj, agent: agentID}
|
||
for _, l := range placeholders {
|
||
ti := textinput.New()
|
||
ti.Placeholder = l
|
||
ti.CharLimit = 1000
|
||
ti.Width = 60
|
||
ti.Prompt = ""
|
||
m.inputs = append(m.inputs, ti)
|
||
}
|
||
m.inputs[fieldTitle].Focus()
|
||
return m
|
||
}
|
||
|
||
var fieldNames = []string{"Title", "What it must do", "Must finish first", "Proves it is done", "Assign to"}
|
||
|
||
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 != "" {
|
||
// The command lives with the task, in the task's own words, because
|
||
// Paperclip owns the task. The project's .harness/verify.sh is the
|
||
// convention it follows.
|
||
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 the blocker %q was not found: %w", is.Identifier, blocker, err)}
|
||
}
|
||
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)}
|
||
}
|
||
}
|
||
if assign != "" {
|
||
if agent == "" {
|
||
return createdMsg{issue: is, err: fmt.Errorf("created %s, but no agent is configured to assign to", is.Identifier)}
|
||
}
|
||
// Assigned last, on purpose: this is the call that wakes the agent.
|
||
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)}
|
||
}
|
||
}
|
||
return createdMsg{issue: is, blocked: blocker}
|
||
}
|
||
}
|
||
|
||
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(72, m.width-8))
|
||
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.KeyEsc:
|
||
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")
|
||
return m, nil
|
||
}
|
||
if m.busy {
|
||
return m, nil
|
||
}
|
||
m.busy = true
|
||
m.err = nil
|
||
return m, m.submit()
|
||
}
|
||
|
||
case createdMsg:
|
||
m.busy = false
|
||
m.err = msg.err
|
||
if msg.err == nil {
|
||
m.created = &msg.issue
|
||
m.notice = "created " + msg.issue.Identifier
|
||
if msg.blocked != "" {
|
||
m.notice += " after " + msg.blocked
|
||
}
|
||
// Leave the form filled so a failed run can be corrected, but say
|
||
// plainly what happened in the pane.
|
||
return m, tea.Quit
|
||
}
|
||
return m, nil
|
||
}
|
||
|
||
var cmd tea.Cmd
|
||
m.inputs[m.focus], cmd = m.inputs[m.focus].Update(msg)
|
||
return m, cmd
|
||
}
|
||
|
||
func (m newTaskModel) View() string {
|
||
if m.width == 0 || m.height == 0 {
|
||
return "loading…"
|
||
}
|
||
if m.width < minWidth || m.height < minHeight {
|
||
return fmt.Sprintf("Window is too small.\nNeed %d×%d, have %d×%d.", minWidth, minHeight, m.width, m.height)
|
||
}
|
||
|
||
var b strings.Builder
|
||
b.WriteString(titleStyle.Render(truncate("new task · "+m.proj.Name, m.width)))
|
||
b.WriteString("\n\n")
|
||
|
||
for i, in := range m.inputs {
|
||
label := fieldNames[i]
|
||
if i == fieldTitle {
|
||
label += " (required)"
|
||
}
|
||
muted := mutedStyle
|
||
if i == m.focus {
|
||
muted = groupStyle
|
||
}
|
||
b.WriteString(muted.Render(label))
|
||
b.WriteString("\n")
|
||
b.WriteString(" " + in.View())
|
||
b.WriteString("\n")
|
||
}
|
||
|
||
b.WriteString("\n")
|
||
if m.err != nil {
|
||
b.WriteString(blockedStyle.Render(truncate("error: "+m.err.Error(), m.width)))
|
||
} else if m.busy {
|
||
b.WriteString(mutedStyle.Render("writing to Paperclip…"))
|
||
} else {
|
||
b.WriteString(mutedStyle.Render(truncate("Enter next field, Enter on the last line creates · Esc cancels", m.width)))
|
||
}
|
||
b.WriteString("\n")
|
||
return lipgloss.NewStyle().MaxHeight(m.height).Render(b.String())
|
||
}
|