Screens 2 and 3: new-task form, live log and diff; measured the merge gap they expose

This commit is contained in:
Sam
2026-11-10 12:15:45 +11:00
parent 34f60872f6
commit 50fde87450
12 changed files with 801 additions and 44 deletions

226
tui/newtask.go Normal file
View 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())
}