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()) }