Conversation and Author screens styled to match; glyph policy chosen from measurement
This commit is contained in:
133
tui/newtask.go
133
tui/newtask.go
@@ -1,13 +1,12 @@
|
||||
package main
|
||||
|
||||
// Screen 2: make a task.
|
||||
// The Author workspace: write 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.
|
||||
// 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"
|
||||
@@ -23,6 +22,7 @@ import (
|
||||
type createdMsg struct {
|
||||
issue paperclip.Issue
|
||||
blocked string
|
||||
assigned bool
|
||||
err error
|
||||
}
|
||||
|
||||
@@ -35,9 +35,7 @@ type newTaskModel struct {
|
||||
focus int
|
||||
err error
|
||||
busy bool
|
||||
|
||||
created *paperclip.Issue
|
||||
notice string
|
||||
result string
|
||||
|
||||
width, height int
|
||||
}
|
||||
@@ -51,20 +49,21 @@ const (
|
||||
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 {
|
||||
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 {
|
||||
for range fieldNames {
|
||||
ti := textinput.New()
|
||||
ti.Placeholder = l
|
||||
ti.Placeholder = ""
|
||||
ti.CharLimit = 1000
|
||||
ti.Width = 60
|
||||
ti.Prompt = ""
|
||||
m.inputs = append(m.inputs, ti)
|
||||
}
|
||||
@@ -72,8 +71,6 @@ func newTaskForm(c *paperclip.Client, proj paperclip.Project, agentID string) ne
|
||||
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 {
|
||||
@@ -87,9 +84,6 @@ func (m newTaskModel) submit() tea.Cmd {
|
||||
|
||||
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 != "" {
|
||||
@@ -104,22 +98,20 @@ func (m newTaskModel) submit() tea.Cmd {
|
||||
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)}
|
||||
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)}
|
||||
}
|
||||
}
|
||||
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.
|
||||
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}
|
||||
return createdMsg{issue: is, blocked: blocker, assigned: assigned}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,7 +119,7 @@ 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))
|
||||
w := max(20, min(70, m.width-12))
|
||||
for i := range m.inputs {
|
||||
m.inputs[i].Width = w
|
||||
}
|
||||
@@ -135,9 +127,7 @@ func (m newTaskModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
|
||||
case tea.KeyMsg:
|
||||
switch msg.Type {
|
||||
case tea.KeyCtrlC:
|
||||
return m, tea.Quit
|
||||
case tea.KeyEsc:
|
||||
case tea.KeyCtrlC, tea.KeyEsc:
|
||||
return m, tea.Quit
|
||||
case tea.KeyTab, tea.KeyDown:
|
||||
m.inputs[m.focus].Blur()
|
||||
@@ -155,7 +145,8 @@ func (m newTaskModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
if strings.TrimSpace(m.inputs[fieldTitle].Value()) == "" {
|
||||
m.err = fmt.Errorf("a task needs a title")
|
||||
return m, nil
|
||||
m.focus = fieldTitle
|
||||
return m, m.inputs[m.focus].Focus()
|
||||
}
|
||||
if m.busy {
|
||||
return m, nil
|
||||
@@ -167,18 +158,18 @@ func (m newTaskModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
|
||||
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
|
||||
if msg.err != nil {
|
||||
m.err = msg.err
|
||||
return m, nil
|
||||
}
|
||||
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
|
||||
@@ -188,39 +179,47 @@ func (m newTaskModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
|
||||
func (m newTaskModel) View() string {
|
||||
if m.width == 0 || m.height == 0 {
|
||||
return "loading…"
|
||||
return "reading…"
|
||||
}
|
||||
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)
|
||||
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))
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString(titleStyle.Render(truncate("new task · "+m.proj.Name, m.width)))
|
||||
b.WriteString("\n\n")
|
||||
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)"
|
||||
}
|
||||
muted := mutedStyle
|
||||
if i == m.focus {
|
||||
muted = groupStyle
|
||||
body = append(body, " ▸ "+lipgloss.NewStyle().Foreground(lipgloss.Color(active.Accent)).Bold(true).Render(label))
|
||||
} else {
|
||||
body = append(body, " "+mutedStyle2().Render(label))
|
||||
}
|
||||
b.WriteString(muted.Render(label))
|
||||
b.WriteString("\n")
|
||||
b.WriteString(" " + in.View())
|
||||
b.WriteString("\n")
|
||||
body = append(body, " "+in.View())
|
||||
hint := fieldHints[i]
|
||||
if i == m.focus {
|
||||
body = append(body, " "+dimStyle().Render(hint))
|
||||
}
|
||||
body = append(body, "")
|
||||
}
|
||||
|
||||
b.WriteString("\n")
|
||||
if m.err != nil {
|
||||
b.WriteString(blockedStyle.Render(truncate("error: "+m.err.Error(), m.width)))
|
||||
body = append(body, " "+lipgloss.NewStyle().Foreground(lipgloss.Color(active.Error)).Render("error: "+m.err.Error()))
|
||||
} 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)))
|
||||
body = append(body, " "+mutedStyle2().Render("writing to Paperclip…"))
|
||||
}
|
||||
b.WriteString("\n")
|
||||
return lipgloss.NewStyle().MaxHeight(m.height).Render(b.String())
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user