Screens 2 and 3: new-task form, live log and diff; measured the merge gap they expose
This commit is contained in:
226
tui/newtask.go
Normal file
226
tui/newtask.go
Normal file
@@ -0,0 +1,226 @@
|
||||
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())
|
||||
}
|
||||
Reference in New Issue
Block a user