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

@@ -414,19 +414,19 @@ func (m boardModel) taskLine(r Row, index, width int) []string {
func shortStatus(status string) (string, string) {
switch status {
case "in_progress":
return "running", active.Info
return icons.Running + " running", active.Info
case "blocked":
return "blocked", active.Error
return icons.Blocked + " blocked", active.Error
case "in_review":
return "review", active.Warning
return icons.Review + " review", active.Warning
case "done":
return "✓ done", active.Success
return icons.Done + " done", active.Success
case "cancelled":
return "✕ gone", active.Overlay
return icons.Cancelled + " gone", active.Overlay
case "backlog":
return "backlog", active.Muted
return icons.Todo + " backlog", active.Muted
default:
return "todo", active.Text
return icons.Todo + " todo", active.Text
}
}

View File

@@ -185,19 +185,19 @@ func statusPill(status string) string {
func statusLabel(status string) (string, string) {
switch status {
case "in_progress":
return "▶ running ", active.Info
return icons.Running + " running ", active.Info
case "blocked":
return "■ blocked ", active.Error
return icons.Blocked + " blocked ", active.Error
case "in_review":
return "◆ review ", active.Warning
return icons.Review + " review ", active.Warning
case "done":
return "✓ done ", active.Success
return icons.Done + " done ", active.Success
case "cancelled":
return "✕ cancel ", active.Overlay
return icons.Cancelled + " cancel ", active.Overlay
case "backlog":
return "· backlog ", active.Muted
return icons.Todo + " backlog ", active.Muted
default:
return "· todo ", active.Text
return icons.Todo + " todo ", active.Text
}
}
@@ -240,3 +240,34 @@ func fmtCount(n int, one, many string) string {
}
return fmt.Sprintf("%d %s", n, many)
}
// wrap breaks one paragraph into lines no wider than w, on word boundaries.
// It is used for anything a person wrote, so it never cuts a word in half.
func wrap(s string, w int) []string {
if w < 4 {
w = 4
}
if s == "" {
return []string{""}
}
var lines []string
cur := ""
for _, word := range strings.Fields(s) {
switch {
case cur == "":
cur = word
case len([]rune(cur))+1+len([]rune(word)) <= w:
cur += " " + word
default:
lines = append(lines, cur)
cur = word
}
}
if cur != "" {
lines = append(lines, cur)
}
if len(lines) == 0 {
return []string{""}
}
return lines
}

View File

@@ -22,12 +22,14 @@ func main() {
screen := flag.String("screen", "board", "which screen to run: board, work, tree, reply, new or log")
folder := flag.String("folder", "", "project folder, for the board screen (defaults to the current directory)")
light := flag.String("theme", "", "colour variant: dark, light, or empty to follow the terminal")
nerd := flag.Bool("nerd", false, "use Nerd Font icons instead of plain characters")
agent := flag.String("agent", defaultAgent, "agent id to assign to, default Builder")
project := flag.String("project", "", "project id or url key, for the tree screen")
issue := flag.String("issue", "", "task id or identifier such as AUD-160, for the reply screen")
dump := flag.Bool("dump", false, "print the tree as plain text and exit (no full-screen interface)")
flag.Parse()
choosePalette(*light)
chooseGlyphs(*nerd)
client, err := paperclip.New()
if err != nil {
@@ -95,12 +97,20 @@ func main() {
m = newLogModel(client, task.ID)
case "reply":
task, err := resolveIssue(client, *issue)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
dir := *folder
if dir == "" {
dir, _ = os.Getwd()
}
m = newReplyModel(client, task.ID)
id := *issue
if id != "" {
task, err := resolveIssue(client, id)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
id = task.ID
}
m = newReplyModel(client, dir, id)
default:
fmt.Fprintf(os.Stderr, "unknown screen %q: use tree, reply, new or log\n", *screen)

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

View File

@@ -1,60 +1,71 @@
package main
// Screen 4: the task's conversation, with a box to reply in.
// The Conversation workspace: what has been said on the chosen task, and a box
// to say something back.
//
// Sending writes a comment as the person and, if a status was chosen, changes
// the task's status in the same call. That pair is what wakes an agent: it sees
// a new comment on a task it owns, and it sees the task waiting on it again.
// Two panels: the thread, and the reply box. Sending writes a comment as the
// person and, when a status has been chosen, changes the task's status in the
// same call — which is what wakes an agent. That pair is the whole point of this
// screen, so both are shown before the key is pressed.
import (
"fmt"
"strings"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
zone "github.com/lrstanley/bubblezone"
"paperclip-harness/tui/paperclip"
)
type threadMsg struct {
issue paperclip.Issue
type replyThreadMsg struct {
task paperclip.Issue
comments []paperclip.Comment
}
type sentMsg struct{ issue paperclip.Issue }
type replySentMsg struct{ task paperclip.Issue }
type replyModel struct {
client *paperclip.Client
issue string
zones *zone.Manager
client *paperclip.Client
folder string
issue string
explicit bool
task paperclip.Issue
thread []paperclip.Comment
box string
focusBox bool
next string // status to send with the reply; "" means leave it alone
sending bool
notice string
err error
task paperclip.Issue
thread []paperclip.Comment
box string
inBox bool
next string
sending bool
notice string
err error
readAt time.Time
width, height int
scroll int
scroll int
width int
height int
}
func newReplyModel(c *paperclip.Client, issueID string) replyModel {
return replyModel{
client: c,
issue: issueID,
zones: zone.New(),
}
func newReplyModel(c *paperclip.Client, folder, issueID string) replyModel {
return replyModel{client: c, folder: folder, issue: issueID, explicit: issueID != ""}
}
func (m replyModel) Init() tea.Cmd { return m.load() }
func (m replyModel) Init() tea.Cmd {
return tea.Batch(m.load(), tickEvery(5*time.Second))
}
func (m replyModel) load() tea.Cmd {
c, id := m.client, m.issue
c, folder := m.client, m.folder
fixed, explicit := m.issue, m.explicit
return func() tea.Msg {
id := fixed
if !explicit {
id = watchedTask(folder)
}
if id == "" {
return errMsg{fmt.Errorf("no task chosen: pick one on the Board, then press Enter")}
}
task, err := c.Issue(id)
if err != nil {
return errMsg{err}
@@ -63,17 +74,15 @@ func (m replyModel) load() tea.Cmd {
if err != nil {
return errMsg{err}
}
return threadMsg{issue: task, comments: cs}
return replyThreadMsg{task: task, comments: cs}
}
}
func (m replyModel) send() tea.Cmd {
c, id, body, status := m.client, m.issue, strings.TrimSpace(m.box), m.next
c, id, body, status := m.client, m.task.ID, strings.TrimSpace(m.box), m.next
return func() tea.Msg {
var (
task paperclip.Issue
err error
)
var task paperclip.Issue
var err error
if status != "" {
task, err = c.SetStatus(id, status, body)
} else {
@@ -88,11 +97,10 @@ func (m replyModel) send() tea.Cmd {
return errMsg{err}
}
}
return sentMsg{issue: task}
return replySentMsg{task: task}
}
}
// statuses the reply box can ask for, in the order they are offered.
var replyStatuses = []string{"", "todo", "in_progress", "in_review", "done", "blocked"}
func (m replyModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
@@ -101,24 +109,28 @@ func (m replyModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.width, m.height = msg.Width, msg.Height
return m, nil
case tickMsg:
if !m.inBox {
return m, tea.Batch(m.load(), tickEvery(5*time.Second))
}
return m, tickEvery(5*time.Second)
case tea.KeyMsg:
// While the box has focus it owns the ordinary keys, so typing a
// message never triggers the screen's own shortcuts.
if m.focusBox {
if m.inBox {
switch msg.Type {
case tea.KeyEsc:
lost := m.box == ""
m.focusBox = false
if lost {
m.notice = ""
}
m.inBox = false
return m, nil
case tea.KeyEnter:
if msg.Alt {
m.box += "\n"
return m, nil
}
if m.sending {
return m, nil
}
if strings.TrimSpace(m.box) == "" {
m.notice = "nothing to send"
m.notice = "nothing to send yet"
return m, nil
}
m.sending = true
@@ -133,7 +145,10 @@ func (m replyModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tea.KeySpace:
m.box += " "
return m, nil
case tea.KeyRunes, tea.KeyTab:
case tea.KeyTab:
m.box += " "
return m, nil
case tea.KeyRunes:
m.box += string(msg.Runes)
return m, nil
}
@@ -143,14 +158,14 @@ func (m replyModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "q", "ctrl+c":
return m, tea.Quit
case "tab", "shift+tab", "enter":
m.focusBox = true
case "tab", "enter":
m.inBox = true
m.notice = ""
return m, nil
case "up":
case "up", "k":
m.scroll--
return m, nil
case "down":
case "down", "j":
m.scroll++
return m, nil
case "s":
@@ -164,21 +179,28 @@ func (m replyModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case "r":
return m, m.load()
case "esc":
m.notice = ""
return m, nil
}
case threadMsg:
m.task = msg.issue
case replyThreadMsg:
m.task = msg.task
wasEmpty := len(m.thread)
m.thread = msg.comments
m.scroll = 1 << 30 // pinned to the bottom until the person scrolls up
m.readAt = time.Now()
m.err = nil
if len(m.thread) != wasEmpty {
m.scroll = 1 << 30
}
return m, nil
case sentMsg:
case replySentMsg:
m.sending = false
m.box = ""
m.next = ""
m.task = msg.issue
m.task = msg.task
m.notice = "sent"
m.inBox = false
return m, m.load()
case errMsg:
@@ -189,33 +211,35 @@ func (m replyModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
}
var (
boxStyle = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).Padding(0, 1)
boxFocusStyle = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("12")).Padding(0, 1)
)
func (m replyModel) View() string {
if m.width == 0 || m.height == 0 {
return "loading…"
return "reading…"
}
if m.width < minWidth || m.height < minHeight {
return m.zones.Scan(lipgloss.NewStyle().Width(m.width).Height(m.height).
Render(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
head := fmt.Sprintf("%s %s · conversation", m.task.Identifier, m.task.Status)
b.WriteString(titleStyle.Render(truncate(head, m.width)))
b.WriteString("\n")
head := titleStyle.Render("Conversation") + mutedStyle2().Render(" · ")
if m.task.Identifier != "" {
head += lipgloss.NewStyle().Foreground(lipgloss.Color(active.Text)).
Render(m.task.Identifier + " " + truncateVisible(m.task.Title, max(10, m.width-60)))
}
state := "read " + m.readAt.Format("15:04:05")
if m.readAt.IsZero() {
state = "reading…"
}
boxHeight := 3
footer := 2
threadRows := max(3, m.height-boxHeight-footer-2)
composerHeight := max(5, min(9, m.height/4))
threadHeight := m.height - composerHeight - 2
lines := m.threadLines(m.width - 2)
// Clamp the scroll to what exists.
maxScroll := max(0, len(lines)-threadRows)
thread := panel{
title: fmt.Sprintf("thread · %s", fmtCount(len(m.thread), "message", "messages")),
width: m.width, height: threadHeight, focused: !m.inBox,
}
lines := m.threadLines(m.width - 4)
visible := thread.bodyHeight()
maxScroll := max(0, len(lines)-visible)
if m.scroll > maxScroll {
m.scroll = maxScroll
}
@@ -223,104 +247,100 @@ func (m replyModel) View() string {
m.scroll = 0
}
from := m.scroll
to := min(len(lines), from+threadRows)
shown := lines[from:to]
for len(shown) < threadRows {
shown = append(shown, "")
}
for _, ln := range shown {
b.WriteString(ln)
b.WriteString("\n")
}
to := min(len(lines), from+visible)
box := m.box
if box == "" {
box = "Tab or Enter to write a reply"
}
style := boxStyle
if m.focusBox {
style = boxFocusStyle
}
b.WriteString(style.Width(m.width - 2).Render(truncate(box, m.width-6)))
b.WriteString("\n")
status := "no status change"
statusNote := "leaves the status alone"
if m.next != "" {
status = "status → " + m.next
statusNote = "also sets the status to " + m.next
}
line := fmt.Sprintf("%s · %s", status, "s changes status · r reload · q quit")
if m.err != nil {
line = "error: " + m.err.Error()
} else if m.notice != "" {
line = m.notice
} else if m.sending {
line = "sending…"
composer := panel{
title: fmt.Sprintf("reply · %s", statusNote),
width: m.width, height: composerHeight, focused: m.inBox,
}
if m.err != nil {
b.WriteString(blockedStyle.Render(truncate(line, m.width)))
} else {
b.WriteString(mutedStyle.Render(truncate(line, m.width)))
}
b.WriteString("\n")
boxLines := m.composerLines(m.width - 4, composer.bodyHeight())
return m.zones.Scan(b.String())
footer := hintLine([]string{
keyHint("enter or tab", "write a reply"),
keyHint("s", "choose a status to send"),
keyHint("↑↓ or j k", "read the thread"),
keyHint("r", "read again"),
keyHint("q", "leave"),
}, m.width)
if m.inBox {
footer = hintLine([]string{
keyHint("enter", "send"),
keyHint("alt+enter", "new line"),
keyHint("esc", "close the box"),
}, m.width)
}
if m.err != nil {
footer = lipgloss.NewStyle().Foreground(lipgloss.Color(active.Error)).Render(truncateVisible("error: "+m.err.Error(), m.width))
} else if m.sending {
footer = mutedStyle2().Render("sending…")
} else if m.notice != "" {
footer = lipgloss.NewStyle().Foreground(lipgloss.Color(active.Success)).Render(m.notice)
}
gap := max(2, m.width-lipgloss.Width(head)-lipgloss.Width(state)-1)
return strings.Join([]string{
head + strings.Repeat(" ", gap) + mutedStyle2().Render(state),
thread.render(lines[from:to]),
composer.render(boxLines),
footer,
}, "\n")
}
func (m replyModel) composerLines(width, height int) []string {
text := m.box
if text == "" {
return []string{mutedStyle2().Render("write a reply, then press enter")}
}
var out []string
for _, para := range strings.Split(text, "\n") {
for _, ln := range wrap(para, width) {
out = append(out, ln)
}
}
// The cursor sits at the end of what has been typed.
if len(out) > 0 {
out[len(out)-1] += lipgloss.NewStyle().Foreground(lipgloss.Color(active.Accent)).Render("▌")
}
if len(out) > height {
out = out[len(out)-height:]
}
return out
}
// threadLines turns the comments into wrapped lines the pane can scroll.
func (m replyModel) threadLines(width int) []string {
if m.err != nil {
return []string{lipgloss.NewStyle().Foreground(lipgloss.Color(active.Error)).Render(m.err.Error())}
}
if len(m.thread) == 0 {
return []string{mutedStyle.Render("no messages yet")}
return []string{"", mutedStyle2().Render("nothing said on this task yet")}
}
var out []string
for _, c := range m.thread {
who := "person"
who, colour := "person", active.Accent
switch c.AuthorType {
case "agent":
who = "agent " + short(c.AuthorID)
who, colour = "agent", active.Mauve
case "user":
who = "you"
who, colour = "you", active.Info
case "system":
who = "system"
who, colour = "system", active.Muted
}
out = append(out, fmt.Sprintf("%s %s", groupStyle.Render(who), mutedStyle.Render(short(c.ID))))
when := ""
if t, err := time.Parse(time.RFC3339, c.CreatedAt); err == nil {
when = t.Local().Format("15:04")
}
out = append(out, lipgloss.NewStyle().Foreground(lipgloss.Color(colour)).Bold(true).Render(who)+
dimStyle().Render(" "+when))
for _, ln := range strings.Split(c.Body, "\n") {
for _, w := range wrap(ln, max(8, width-2)) {
out = append(out, " "+w)
out = append(out, " "+lipgloss.NewStyle().Foreground(lipgloss.Color(active.Text)).Render(w))
}
}
out = append(out, "")
}
return out
}
func short(id string) string {
if len(id) <= 8 {
return id
}
return id[:8]
}
func wrap(s string, w int) []string {
if s == "" {
return []string{""}
}
words := strings.Fields(s)
var lines []string
cur := ""
for _, word := range words {
if cur == "" {
cur = word
continue
}
if len([]rune(cur))+1+len([]rune(word)) <= w {
cur += " " + word
continue
}
lines = append(lines, cur)
cur = word
}
if cur != "" {
lines = append(lines, cur)
}
return lines
}

View File

@@ -80,3 +80,43 @@ func choosePalette(force string) {
}
}
}
// Icons. Nerd Font glyphs, with an ASCII fallback so the interface still reads
// when the font is not installed or the terminal cannot show it: -ascii, or a
// TERM that says it cannot, chooses the fallback.
type glyphSet struct {
Todo, Running, Blocked, Review, Done, Cancelled string
Folder, Branch, Commit, Cost, Person, Agent string
Arrow, Dot, Rule string
}
var nerdGlyphs = glyphSet{
Todo: "\uf10c", Running: "\uf04b", Blocked: "\uf05e", Review: "\uf06e",
Done: "\uf00c", Cancelled: "\uf00d",
Folder: "\uf07b", Branch: "\uf126", Commit: "\uf1d3", Cost: "\uf155",
Person: "\uf007", Agent: "\uf085",
Arrow: "\uf105", Dot: "\uf111", Rule: "─",
}
var plainGlyphs = glyphSet{
Todo: "·", Running: "▶", Blocked: "■", Review: "◆",
Done: "✓", Cancelled: "✕",
Folder: "/", Branch: "branch", Commit: "commit", Cost: "cents",
Person: "you", Agent: "agent",
Arrow: "›", Dot: "·", Rule: "─",
}
// icons starts as plain Unicode on purpose. The Nerd Font is installed, but a
// private-use glyph does not survive TUIOS's own screen capture, so whether it
// draws cannot be checked from here. The words next to every icon carry the
// meaning either way, and -nerd opts into the richer set once it has been seen
// on the terminal that matters.
var icons = plainGlyphs
func chooseGlyphs(useNerd bool) {
if useNerd {
icons = nerdGlyphs
return
}
icons = plainGlyphs
}