Conversation and Author screens styled to match; glyph policy chosen from measurement

This commit is contained in:
Sam
2026-11-10 14:18:33 +11:00
parent 4e3c955350
commit 031073fc46
13 changed files with 755 additions and 240 deletions

View File

@@ -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")
}