Workspaces named for what they are, capitalised; Reply gains a task panel; Project settings and limits; icon test row

This commit is contained in:
Sam
2026-11-10 16:12:54 +11:00
parent 09e32da046
commit 2640049f1f
15 changed files with 575 additions and 31 deletions

View File

@@ -332,9 +332,11 @@ func (c *Client) SetStatus(issueID, status, comment string) (Issue, error) {
}
type Agent struct {
ID string `json:"id"`
Name string `json:"name"`
Status string `json:"status"`
ID string `json:"id"`
Name string `json:"name"`
Status string `json:"status"`
BudgetCents int `json:"budgetMonthlyCents"`
SpentCents int `json:"spentMonthlyCents"`
}
// Agents lists the company's agents, used only to put a name on a task.

View File

@@ -311,12 +311,24 @@ func (m projectModel) costLines() []string {
}
func (m projectModel) settingLines() []string {
out := []string{""}
out := []string{"", groupHeading("this project's own file", 6, m.width-4)}
out = append(out, m.row("session", m.cfg["session"]))
out = append(out, m.row("machine", m.cfg["machine.host"]))
out = append(out, m.row("folder", m.cfg["machine.folder"]))
out = append(out, m.row("paperclip", m.cfg["paperclip.project"]))
out = append(out, m.row("watching", m.cfg["watching"]))
out = append(out, m.row("verify command", m.verifyCommand()))
out = append(out, "")
out = append(out, dimStyle().Render(" verify command: the first task whose description has one,"))
out = append(out, dimStyle().Render(" or .harness/verify.sh when the project keeps one."))
out = append(out, dimStyle().Render(" .harness/harness.toml is the only thing this project keeps for itself."))
out = append(out, dimStyle().Render(" Paperclip owns tasks, blockers, comments, runs, cost and worktrees."))
out = append(out, "")
out = append(out, groupHeading("limits held by Paperclip", len(m.agents), m.width-4))
for _, a := range m.agents {
out = append(out, m.row(a.Name, fmt.Sprintf("%s · monthly budget %d cents · spent %d",
a.Status, a.BudgetCents, a.SpentCents)))
}
out = append(out, "", dimStyle().Render(" change a budget in Paperclip, not here: it is their fact, and a second"))
out = append(out, dimStyle().Render(" copy of it is how two screens start disagreeing."))
return out
}

View File

@@ -339,6 +339,13 @@ func (m repairModel) healthLines() []string {
mutedStyle2().Render(truncateVisible(c.detail, max(10, m.width-30)))))
}
out = append(out, "")
// The one way to tell whether the terminal is drawing the font: show the
// glyphs themselves. Nothing else on this screen answers that question.
out = append(out, " "+mutedStyle2().Render("icons ")+
icons.Done+" "+icons.Running+" "+icons.Blocked+" "+icons.Review+" "+
icons.Branch+" "+icons.Folder+" "+icons.Cost+" "+
dimStyle().Render("if these are boxes, this terminal is not using a Nerd Font"))
out = append(out, "")
if failed == 0 {
out = append(out, " "+lipgloss.NewStyle().Foreground(lipgloss.Color(active.Success)).Render("everything checked passes"))
} else {

View File

@@ -22,6 +22,7 @@ import (
type replyThreadMsg struct {
task paperclip.Issue
comments []paperclip.Comment
names map[string]string
}
type replySentMsg struct{ task paperclip.Issue }
@@ -33,6 +34,7 @@ type replyModel struct {
explicit bool
task paperclip.Issue
names map[string]string
thread []paperclip.Comment
box string
inBox bool
@@ -75,7 +77,13 @@ func (m replyModel) load() tea.Cmd {
if err != nil {
return errMsg{err}
}
return replyThreadMsg{task: task, comments: cs}
names := map[string]string{}
if agents, err := c.Agents(); err == nil {
for _, a := range agents {
names[a.ID] = a.Name
}
}
return replyThreadMsg{task: task, comments: cs, names: names}
}
}
@@ -192,6 +200,7 @@ func (m replyModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case replyThreadMsg:
m.task = msg.task
m.names = msg.names
wasEmpty := len(m.thread)
m.thread = msg.comments
m.readAt = time.Now()
@@ -251,8 +260,10 @@ func (m replyModel) View() string {
}
composerHeight := max(5, min(9, m.height/4))
threadHeight := m.height - composerHeight - 2
headerHeight := 5
threadHeight := m.height - composerHeight - headerHeight - 2
headerPanel := panel{title: "the task", width: m.width, height: headerHeight}
thread := panel{
title: fmt.Sprintf("thread · %s", fmtCount(len(m.thread), "message", "messages")),
width: m.width, height: threadHeight, focused: !m.inBox,
@@ -305,12 +316,35 @@ func (m replyModel) View() string {
gap := max(2, m.width-lipgloss.Width(head)-lipgloss.Width(state)-1)
return strings.Join([]string{
head + strings.Repeat(" ", gap) + mutedStyle2().Render(state),
headerPanel.render(m.headerLines(m.width - 3)),
thread.render(lines[from:to]),
composer.render(boxLines),
footer,
}, "\n")
}
// headerLines is the panel the conversation needed: what task this is, without
// having to go back to the Board to remember.
func (m replyModel) headerLines(width int) []string {
if m.task.Identifier == "" {
return []string{"", mutedStyle2().Render("no task chosen yet")}
}
who := m.names[m.task.AssigneeAgent]
if who == "" {
who = "nobody yet"
}
status, _ := shortStatus(m.task.Status)
line1 := " " + lipgloss.NewStyle().Foreground(lipgloss.Color(active.Text)).Bold(true).
Render(m.task.Identifier) + " " + status + " " + mutedStyle2().Render("assigned to "+who)
out := []string{"", line1}
out = append(out, " "+truncateVisible(m.task.Title, max(8, width-2)))
if cmd := verifyFromDescription(m.task.Description); cmd != "" {
out = append(out, " "+dimStyle().Render("done when ")+
lipgloss.NewStyle().Foreground(lipgloss.Color(active.Info)).Render(cmd))
}
return out
}
func (m replyModel) composerLines(width, height int) []string {
text := m.box
if text == "" {