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 width, height int } 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, 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") 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 } 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) 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()) hint := fieldHints[i] if i == m.focus { body = append(body, " "+dimStyle().Render(hint)) } 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…")) } p := panel{title: "new task", width: m.width, height: m.height - 2, focused: true} footer := hintLine([]string{ keyHint("tab", "next field"), keyHint("enter", "next, and create on the last"), keyHint("esc", "cancel"), }, m.width) return strings.Join([]string{head, p.render(body), footer}, "\n") }