Board workspace with real panels, tabs and theme; Nerd Font declared for all three machines

This commit is contained in:
Sam
2026-11-10 13:45:33 +11:00
parent 5b829000ae
commit 1bfb9b05ed
17 changed files with 1424 additions and 15 deletions

568
tui/board.go Normal file
View File

@@ -0,0 +1,568 @@
package main
// The Board workspace: the state of the work, and what needs a person.
//
// Three panels on one page: the task tree, the summary of the chosen task, and
// the list of things waiting for a decision. Choosing a task changes what the
// other panels show, here and in every other workspace, because the choice is
// written to the project's own .harness file.
import (
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"paperclip-harness/tui/paperclip"
)
type boardDetailMsg struct {
issueID string
runs []paperclip.Run
cost paperclip.Cost
activity []paperclip.Activity
err error
}
type boardModel struct {
client *paperclip.Client
project paperclip.Project
folder string
rows []Row
cursor int
focus int // 0 tree, 1 summary, 2 needs-you
loading bool
readAt time.Time
notice string
err error
detailID string
runs []paperclip.Run
cost paperclip.Cost
activity []paperclip.Activity
needsTab int
width, height int
}
const (
focusTree = iota
focusSummary
focusNeeds
focusCount
)
func newBoardModel(c *paperclip.Client, proj paperclip.Project, folder string) boardModel {
return boardModel{client: c, project: proj, folder: folder, loading: true}
}
func (m boardModel) Init() tea.Cmd {
return tea.Batch(m.reload(), tickEvery(5*time.Second))
}
func (m boardModel) reload() tea.Cmd {
c, proj, want := m.client, m.project, m.selectedID()
return func() tea.Msg {
rows, err := loadRows(c, proj)
if err != nil {
return errMsg{err}
}
_ = want
return rowsMsg(rows)
}
}
func (m boardModel) loadDetail(id string) tea.Cmd {
c := m.client
return func() tea.Msg {
if id == "" {
return boardDetailMsg{}
}
runs, err := c.Runs(id)
if err != nil {
return boardDetailMsg{issueID: id, err: err}
}
cost, _ := c.CostSummary(id)
act, _ := c.Activity(id)
return boardDetailMsg{issueID: id, runs: runs, cost: cost, activity: act}
}
}
func (m boardModel) selectedID() string {
if m.cursor >= 0 && m.cursor < len(m.rows) {
return m.rows[m.cursor].Issue.ID
}
return ""
}
func (m boardModel) selectedTask() *Row {
if m.cursor >= 0 && m.cursor < len(m.rows) {
return &m.rows[m.cursor]
}
return nil
}
func (m boardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width, m.height = msg.Width, msg.Height
return m, nil
case tickMsg:
return m, tea.Batch(m.reload(), tickEvery(5*time.Second))
case tea.KeyMsg:
switch msg.String() {
case "q", "ctrl+c":
return m, tea.Quit
case "tab":
m.focus = (m.focus + 1) % focusCount
return m, nil
case "shift+tab":
m.focus = (m.focus + focusCount - 1) % focusCount
return m, nil
case "r":
m.loading = true
return m, m.reload()
case "up":
if m.cursor > 0 {
m.cursor--
return m, m.loadDetail(m.selectedID())
}
return m, nil
case "down":
if m.cursor < len(m.rows)-1 {
m.cursor++
return m, m.loadDetail(m.selectedID())
}
return m, nil
case "a":
if r := m.selectedTask(); r != nil && r.Issue.AssigneeAgent == "" {
c, id, agent := m.client, r.Issue.ID, defaultAgent
return m, func() tea.Msg {
if _, err := c.Assign(id, agent); err != nil {
return errMsg{err}
}
rows, err := loadRows(c, m.project)
if err != nil {
return errMsg{err}
}
return rowsMsg(rows)
}
}
return m, nil
case "enter":
if r := m.selectedTask(); r != nil {
if err := rememberTask(m.folder, r.Issue.Identifier); err != nil {
m.err = err
return m, nil
}
m.notice = "showing " + r.Issue.Identifier + " in every workspace"
}
return m, nil
case "[":
if m.focus == focusNeeds && m.needsTab > 0 {
m.needsTab--
}
return m, nil
case "]":
if m.focus == focusNeeds && m.needsTab < 1 {
m.needsTab++
}
return m, nil
case "esc":
m.notice = ""
return m, nil
}
case rowsMsg:
m.rows = msg
m.loading = false
m.readAt = time.Now()
m.err = nil
if m.cursor >= len(m.rows) {
m.cursor = max(0, len(m.rows)-1)
}
if id := m.selectedID(); id != "" && id != m.detailID {
return m, m.loadDetail(id)
}
return m, nil
case boardDetailMsg:
m.detailID = msg.issueID
m.runs = msg.runs
m.cost = msg.cost
m.activity = msg.activity
if msg.err != nil {
m.err = msg.err
}
return m, nil
case errMsg:
m.err = msg.err
m.loading = false
return m, nil
}
return m, nil
}
func tickEvery(d time.Duration) tea.Cmd {
return tea.Tick(d, func(t time.Time) tea.Msg { return tickMsg(t) })
}
func (m boardModel) 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.\n\nMake it larger, or use Tab and zoom.",
m.width, m.height))
}
header := m.headerLine()
footer := m.footerLine()
bodyHeight := m.height - 2
leftWidth := max(34, m.width*40/100)
rightWidth := max(30, m.width-leftWidth-1)
// Left: the task tree.
tree := panel{title: "tasks", width: leftWidth, height: bodyHeight, focused: m.focus == focusTree}
treeLines := m.treeLines(leftWidth - 2)
left := tree.render(treeLines)
// Right, upper: the summary of the chosen task.
sumHeight := max(10, bodyHeight*58/100)
sum := panel{title: "the chosen task", width: rightWidth, height: sumHeight, focused: m.focus == focusSummary}
// Right, lower: what needs a person.
needsHeight := bodyHeight - sumHeight
needs := panel{
title: "needs you", width: rightWidth, height: needsHeight,
tabs: []string{"decisions", "activity"}, active: m.needsTab, focused: m.focus == focusNeeds,
}
needsBody := m.needsLines(rightWidth-2, needsHeight-2)
if m.needsTab == 1 {
needsBody = m.activityLines(rightWidth-2, needsHeight-2)
}
right := lipgloss.JoinVertical(lipgloss.Left,
sum.render(m.summaryLines(rightWidth-2)),
needs.render(needsBody),
)
page := lipgloss.JoinHorizontal(lipgloss.Top, left, " ", right)
return strings.Join([]string{header, page, footer}, "\n")
}
func (m boardModel) headerLine() string {
left := panelTitleStyle().Render("Board") + mutedStyle2().Render(" · "+m.project.Name)
state := "reading…"
if !m.readAt.IsZero() {
state = "read " + m.readAt.Format("15:04:05")
}
if m.loading {
state = "reading…"
}
if m.err != nil {
state = "last read failed"
}
right := mutedStyle2().Render(state)
gap := max(2, m.width-lipgloss.Width(left)-lipgloss.Width(right)-2)
if m.notice != "" {
right = lipgloss.NewStyle().Foreground(lipgloss.Color(active.Success)).Render(m.notice)
gap = max(2, m.width-lipgloss.Width(left)-lipgloss.Width(right)-2)
}
return left + strings.Repeat(" ", gap) + right
}
func (m boardModel) footerLine() string {
switch m.focus {
case focusTree:
return hintLine([]string{
keyHint("↑↓", "choose a task"),
keyHint("enter", "show it everywhere"),
keyHint("a", "assign"),
keyHint("tab", "next panel"),
keyHint("r", "read again"),
keyHint("q", "leave"),
}, m.width)
case focusSummary:
return hintLine([]string{
keyHint("↑↓", "choose a task"),
keyHint("tab", "next panel"),
keyHint("r", "read again"),
keyHint("q", "leave"),
}, m.width)
default:
return hintLine([]string{
keyHint("tab", "next panel"),
keyHint("[ ]", "switch tab"),
keyHint("enter", "show this task everywhere"),
keyHint("r", "read again"),
keyHint("q", "leave"),
}, m.width)
}
}
// treeLines draws the grouped task list.
func (m boardModel) treeLines(width int) []string {
var out []string
if len(m.rows) == 0 && !m.loading {
return []string{mutedStyle2().Render(" no tasks in this project")}
}
index := 0
for _, g := range groupOrder {
var group []Row
for _, r := range m.rows {
if r.Group == g {
group = append(group, r)
}
}
if len(group) == 0 {
continue
}
out = append(out, groupHeading(fmt.Sprintf("%s %d", strings.ToUpper(g), len(group))))
for _, r := range group {
out = append(out, m.taskLine(r, index, width)...)
index++
}
out = append(out, "")
}
return out
}
func groupHeading(s string) string {
return lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(active.Mauve)).Render(s)
}
func (m boardModel) taskLine(r Row, index, width int) []string {
chosen := index == m.cursor
cursor := " "
firstStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(active.Text))
secondStyle := mutedStyle2()
if chosen {
cursor = "▸ "
if m.focus == focusTree {
firstStyle = selectedStyle2()
secondStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(active.Base))
} else {
firstStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(active.Accent)).Bold(true)
secondStyle = mutedStyle2()
}
}
status, _ := shortStatus(r.Issue.Status)
who := r.Who
if who == "" || who == "—" {
who = "unassigned"
}
first := fmt.Sprintf("%s%-8s %-8s %s", cursor, r.Issue.Identifier, status, who)
second := " " + r.Issue.Title
if note := blockerNote(r); note != "" {
second += " " + note
}
if lipgloss.Width(first) > width {
first = truncateVisible(first, width)
}
if lipgloss.Width(second) > width {
second = truncateVisible(second, width)
}
if chosen {
first, second = padTo(first, width), padTo(second, width)
}
return []string{firstStyle.Render(first), secondStyle.Render(second)}
}
// shortStatus is the one word a row has room for.
func shortStatus(status string) (string, string) {
switch status {
case "in_progress":
return "running", active.Info
case "blocked":
return "blocked", active.Error
case "in_review":
return "review", active.Warning
case "done":
return "✓ done", active.Success
case "cancelled":
return "✕ gone", active.Overlay
case "backlog":
return "backlog", active.Muted
default:
return "todo", active.Text
}
}
// isReadyButBlocked is the state that confuses people most: everything it was
// waiting for has finished, and the task still says blocked.
func isReadyButBlocked(r Row) bool {
return r.Blocker != nil &&
len(r.Blocker.BlockingIdentities) > 0 &&
r.Blocker.UnresolvedCount == 0 &&
r.Blocker.AllBlockersDone &&
r.Issue.Status == "blocked"
}
func blockerNote(r Row) string {
if r.Blocker == nil {
return ""
}
if r.Blocker.UnresolvedCount > 0 {
return dimStyle().Render("waits on " + strings.Join(r.Blocker.BlockingIdentities, ", "))
}
if r.Blocker.AllBlockersDone && r.Issue.Status == "blocked" {
return badge("ready", active.Warning)
}
return ""
}
func (m boardModel) summaryLines(width int) []string {
r := m.selectedTask()
if r == nil {
return []string{mutedStyle2().Render(" nothing chosen")}
}
var out []string
out = append(out, panelTitleStyle().Render(truncateVisible(r.Issue.Identifier+" "+r.Issue.Title, width)))
out = append(out, "")
who := r.Who
if who == "" || who == "—" {
who = "nobody yet"
}
out = append(out, " "+statusPill(r.Issue.Status)+" "+
mutedStyle2().Render("assigned to ")+lipgloss.NewStyle().Foreground(lipgloss.Color(active.Text)).Render(who))
if r.Blocker != nil && len(r.Blocker.BlockingIdentities) > 0 {
out = append(out, " "+mutedStyle2().Render("waits on ")+
lipgloss.NewStyle().Foreground(lipgloss.Color(active.Warning)).Render(strings.Join(r.Blocker.BlockingIdentities, ", ")))
} else if isReadyButBlocked(*r) {
out = append(out, " "+badge("ready to start", active.Warning)+" "+
mutedStyle2().Render("its blocker finished, but it is still marked blocked"))
}
out = append(out, "")
// The command that decides the task is finished, from the task itself.
if cmd := verifyFromDescription(r.Issue.Description); cmd != "" {
out = append(out, " "+mutedStyle2().Render("done when"))
for _, ln := range wrap(cmd, width-4) {
out = append(out, " "+lipgloss.NewStyle().Foreground(lipgloss.Color(active.Info)).Render(ln))
}
}
if len(m.runs) > 0 {
run := m.runs[0]
line := " " + mutedStyle2().Render("last run") + " " +
lipgloss.NewStyle().Foreground(lipgloss.Color(active.Text)).Render(run.Status+" · "+run.Source)
if run.StartedAt != "" {
if t, err := time.Parse(time.RFC3339, run.StartedAt); err == nil {
line += mutedStyle2().Render(" · " + t.Local().Format("15:04"))
}
}
out = append(out, line)
} else {
out = append(out, " "+mutedStyle2().Render("never run"))
}
if m.cost.RunCount > 0 {
out = append(out, " "+mutedStyle2().Render("cost")+" "+
lipgloss.NewStyle().Foreground(lipgloss.Color(active.Text)).Render(
fmt.Sprintf("%d cent(s) over %d run(s), %d tokens out", m.cost.CostCents, m.cost.RunCount, m.cost.OutputTokens)))
}
return out
}
func (m boardModel) needsLines(width, height int) []string {
var out []string
n := 0
add := func(r Row, why string) {
n++
if len(out) < height {
out = append(out, " "+badge(r.Issue.Identifier, active.Accent)+" "+lipgloss.NewStyle().
Foreground(lipgloss.Color(active.Text)).Render(truncateVisible(r.Issue.Title, width-14)))
out = append(out, " "+mutedStyle2().Render(why))
}
}
for _, r := range m.rows {
if isReadyButBlocked(r) {
add(r, "its blocker finished; it is still marked blocked")
}
}
for _, r := range m.rows {
if r.Issue.Status == "in_review" {
add(r, "waiting for a review")
}
}
for _, r := range m.rows {
if r.Issue.AssigneeUser != "" && r.Issue.Status != "done" && r.Issue.Status != "cancelled" {
add(r, "assigned to you")
}
}
if n == 0 {
return []string{" " + lipgloss.NewStyle().Foreground(lipgloss.Color(active.Success)).Render("nothing needs you")}
}
if len(out) < height {
out = append(out, "")
out = append(out, dimStyle().Render(fmt.Sprintf(" %s waiting", fmtCount(n, "thing", "things"))))
}
return out
}
func (m boardModel) activityLines(width, height int) []string {
if m.detailID == "" || len(m.activity) == 0 {
return []string{" " + mutedStyle2().Render("nothing recorded for this task yet")}
}
var out []string
for i, a := range m.activity {
if len(out) >= height-1 || i >= 10 {
break
}
when := a.CreatedAt
if t, err := time.Parse(time.RFC3339, a.CreatedAt); err == nil {
when = t.Local().Format("15:04")
}
who := a.ActorType
switch who {
case "user":
who = "you"
case "agent":
who = "agent"
case "system":
who = "system"
}
out = append(out, " "+dimStyle().Render(when)+" "+
mutedStyle2().Render(fmt.Sprintf("%-7s", who))+" "+
lipgloss.NewStyle().Foreground(lipgloss.Color(active.Text)).Render(a.Action))
}
return out
}
var verifyRE = regexp.MustCompile(`(?is)Done when this command succeeds:\s*\n+\s*(.+)`)
func verifyFromDescription(desc string) string {
if m := verifyRE.FindStringSubmatch(desc); m != nil {
return strings.TrimSpace(m[1])
}
return ""
}
// rememberTask writes the chosen task into the project's harness file, so the
// other workspaces follow without being told twice.
func rememberTask(folder, identifier string) error {
path := filepath.Join(folder, ".harness", "harness.toml")
b, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("cannot remember the choice: %w", err)
}
line := fmt.Sprintf("watching = %q", identifier)
re := regexp.MustCompile(`(?m)^watching\s*=\s*".*"`)
if re.Match(b) {
b = re.ReplaceAll(b, []byte(line))
} else {
b = append(b, []byte("\n"+line+"\n")...)
}
return os.WriteFile(path, b, 0o644)
}

241
tui/frame.go Normal file
View File

@@ -0,0 +1,241 @@
package main
// The frame every panel is drawn in, and the small pieces that sit inside one.
//
// A frame is drawn by hand rather than with lipgloss borders, because the title
// and the tab names live inside the top border and must line up with the body
// exactly, whatever the width.
import (
"fmt"
"strings"
"github.com/charmbracelet/lipgloss"
)
type panel struct {
title string
tabs []string // empty when the panel has one view
active int
focused bool
width int
height int
}
func (p panel) bodyHeight() int { return max(1, p.height-2) }
func (p panel) bodyWidth() int { return max(1, p.width-2) }
// render wraps already-styled lines in the frame.
func (p panel) render(lines []string) string {
bw, bh := p.bodyWidth(), p.bodyHeight()
border := lipgloss.NewStyle().Foreground(lipgloss.Color(active.Surface))
mark := lipgloss.NewStyle().Foreground(lipgloss.Color(active.Muted))
if p.focused {
border = lipgloss.NewStyle().Foreground(lipgloss.Color(active.Accent))
mark = lipgloss.NewStyle().Foreground(lipgloss.Color(active.Accent)).Bold(true)
}
// Top edge: the title, then the tabs, then the focus mark on the right.
// When there is not enough room, the focus mark goes first and the title is
// cut second, because a missing word reads better than a broken frame.
title := " " + p.title + " "
tabs := ""
if len(p.tabs) > 1 {
var b strings.Builder
for i, t := range p.tabs {
if i > 0 {
b.WriteString(" ")
}
if i == p.active {
b.WriteString(tabActiveStyle().Render(" " + t + " "))
} else {
b.WriteString(tabStyle().Render(" " + t + " "))
}
}
tabs = " " + b.String()
}
right := ""
if p.focused {
right = mark.Render("·focus· ")
}
// Two corners and a lead-in take three columns.
avail := p.width - 3
if lipgloss.Width(title)+lipgloss.Width(tabs)+lipgloss.Width(right) > avail {
right = ""
}
if w := lipgloss.Width(title) + lipgloss.Width(tabs); w > avail {
if aw := lipgloss.Width(tabs); aw < avail {
title = truncateVisible(title, avail-aw)
} else {
title = ""
tabs = truncateVisible(tabs, avail)
}
}
used := lipgloss.Width(title) + lipgloss.Width(tabs) + lipgloss.Width(right)
fill := max(0, avail-used)
top := border.Render("╭─") + panelTitleStyle().Render(title) + tabs +
border.Render(strings.Repeat("─", fill)) + right + border.Render("╮")
// Body: every line exactly the inner width, no exceptions.
out := make([]string, 0, p.height)
out = append(out, top)
side := border.Render("│")
for i := 0; i < bh; i++ {
line := ""
if i < len(lines) {
line = lines[i]
}
out = append(out, side+padTo(line, bw)+side)
}
out = append(out, border.Render("╰"+strings.Repeat("─", p.width-2)+"╯"))
return strings.Join(out, "\n")
}
// padTo makes a line exactly w columns wide, cutting it if it is too long.
// Widths are measured on the visible text, so styled strings work.
func padTo(s string, w int) string {
visible := lipgloss.Width(s)
if visible > w {
s = truncateVisible(s, w)
visible = lipgloss.Width(s)
}
if visible < w {
return s + strings.Repeat(" ", w-visible)
}
return s
}
// truncateVisible cuts a styled string to w columns, keeping the styling of the
// runes it keeps. It works rune by rune because a byte slice would split an
// escape sequence and break the terminal.
func truncateVisible(s string, w int) string {
var out strings.Builder
visible, i := 0, 0
for i < len(s) && visible < w-1 {
if s[i] == 0x1b {
// copy the whole escape sequence
j := i + 1
if j < len(s) && s[j] == '[' {
j++
for j < len(s) && (s[j] < '@' || s[j] > '~') {
j++
}
if j < len(s) {
j++
}
}
out.WriteString(s[i:j])
i = j
continue
}
r := []rune(s[i:])
out.WriteRune(r[0])
i += len(string(r[0]))
visible++
}
out.WriteString("…")
// close any styling that is still open
out.WriteString("\x1b[0m")
return out.String()
}
func panelTitleStyle() lipgloss.Style {
return lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(active.Text))
}
func tabStyle() lipgloss.Style {
return lipgloss.NewStyle().Foreground(lipgloss.Color(active.Muted))
}
func tabActiveStyle() lipgloss.Style {
return lipgloss.NewStyle().
Foreground(lipgloss.Color(active.Base)).
Background(lipgloss.Color(active.Accent)).
Bold(true)
}
func mutedStyle2() lipgloss.Style {
return lipgloss.NewStyle().Foreground(lipgloss.Color(active.Muted))
}
func dimStyle() lipgloss.Style {
return lipgloss.NewStyle().Foreground(lipgloss.Color(active.Overlay))
}
func selectedStyle2() lipgloss.Style {
return lipgloss.NewStyle().
Foreground(lipgloss.Color(active.Base)).
Background(lipgloss.Color(active.Accent)).
Bold(true)
}
// statusPill is a state in two letters of shape and one of colour, so it still
// reads in a monochrome terminal.
func statusPill(status string) string {
label, colour := statusLabel(status)
return lipgloss.NewStyle().
Foreground(lipgloss.Color(colour)).
Render(label)
}
func statusLabel(status string) (string, string) {
switch status {
case "in_progress":
return "▶ running ", active.Info
case "blocked":
return "■ blocked ", active.Error
case "in_review":
return "◆ review ", active.Warning
case "done":
return "✓ done ", active.Success
case "cancelled":
return "✕ cancel ", active.Overlay
case "backlog":
return "· backlog ", active.Muted
default:
return "· todo ", active.Text
}
}
func badge(text, colour string) string {
return lipgloss.NewStyle().
Foreground(lipgloss.Color(colour)).
Render("[" + text + "]")
}
func keyHint(keys, what string) string {
k := lipgloss.NewStyle().Foreground(lipgloss.Color(active.Accent)).Bold(true).Render(keys)
w := lipgloss.NewStyle().Foreground(lipgloss.Color(active.Muted)).Render(what)
return k + " " + w
}
// hintLine joins key hints with a dot, dropping the ones that do not apply.
func hintLine(hints []string, width int) string {
joined := strings.Join(hints, dimStyle().Render(" · "))
if lipgloss.Width(joined) > width {
return truncateVisible(joined, width)
}
return joined
}
// stack joins rendered blocks vertically, padding each to the same width so a
// short block does not leave the frame ragged.
func stack(width int, blocks ...string) []string {
var out []string
for _, b := range blocks {
for _, ln := range strings.Split(b, "\n") {
out = append(out, padTo(ln, width))
}
}
return out
}
func fmtCount(n int, one, many string) string {
if n == 1 {
return fmt.Sprintf("%d %s", n, one)
}
return fmt.Sprintf("%d %s", n, many)
}

58
tui/frame_test.go Normal file
View File

@@ -0,0 +1,58 @@
package main
// The frame is the one place where an off-by-one becomes a broken screen, so it
// is checked rather than eyeballed: every rendered line must be exactly the
// width asked for, at every size, with and without tabs.
import (
"strings"
"testing"
"github.com/charmbracelet/lipgloss"
)
func lines(s string) []string { return strings.Split(s, "\n") }
func TestFrameEveryLineIsExactWidth(t *testing.T) {
for w := 10; w <= 90; w++ {
for h := 3; h <= 20; h++ {
for _, tabs := range [][]string{nil, {"one", "two"}, {"a"}} {
for _, focused := range []bool{false, true} {
p := panel{title: "tasks", tabs: tabs, width: w, height: h, focused: focused}
out := p.render([]string{"first", "second"})
got := lines(out)
if len(got) != h {
t.Fatalf("width %d height %d: got %d lines, want %d", w, h, len(got), h)
}
for i, ln := range got {
if lipgloss.Width(ln) != w {
t.Fatalf("width %d height %d tab=%v focus=%v line %d: width %d (%q)",
w, h, tabs, focused, i, lipgloss.Width(ln), ln)
}
}
}
}
}
}
}
func TestFrameBodyIsClippedNotWrapped(t *testing.T) {
p := panel{title: "t", width: 20, height: 4}
out := p.render([]string{"a line that is much too long for the frame"})
for i, ln := range lines(out) {
if lipgloss.Width(ln) != 20 {
t.Fatalf("line %d is %d wide, want 20: %q", i, lipgloss.Width(ln), ln)
}
}
}
func TestTruncateVisibleKeepsStylingAndWidth(t *testing.T) {
styled := lipgloss.NewStyle().Bold(true).Render("abcdefghij")
got := truncateVisible(styled, 5)
if lipgloss.Width(got) > 5 {
t.Fatalf("truncated width %d, want at most 5: %q", lipgloss.Width(got), got)
}
if !strings.Contains(got, "…") {
t.Fatalf("truncation should say it truncated: %q", got)
}
}

View File

@@ -15,13 +15,19 @@ import (
"paperclip-harness/tui/paperclip"
)
// defaultAgent is Builder, the agent this project assigns work to.
const defaultAgent = "403de1f7-645c-4d35-bc7e-19b01d77bacc"
func main() {
screen := flag.String("screen", "tree", "which screen to run: tree, reply, new or log")
agent := flag.String("agent", "403de1f7-645c-4d35-bc7e-19b01d77bacc", "agent id to assign to, default Builder")
screen := flag.String("screen", "board", "which screen to run: board, 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")
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)
client, err := paperclip.New()
if err != nil {
@@ -34,6 +40,18 @@ func main() {
var m tea.Model
switch *screen {
case "board":
proj, err := client.ResolveProject(*project)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
dir := *folder
if dir == "" {
dir, _ = os.Getwd()
}
m = newBoardModel(client, proj, dir)
case "tree":
proj, err := client.ResolveProject(*project)
if err != nil {

View File

@@ -232,11 +232,11 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
var (
titleStyle = lipgloss.NewStyle().Bold(true)
groupStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12"))
titleStyle = lipgloss.NewStyle().Bold(true)
groupStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12"))
selectedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("0")).Background(lipgloss.Color("12"))
mutedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
blockedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("9"))
mutedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
blockedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("9"))
)
// minWidth and minHeight are the smallest box that still shows something

View File

@@ -189,9 +189,9 @@ type BlockerInfo struct {
type blockerResponse struct {
Diagnosis string `json:"diagnosis"`
Readiness struct {
AllBlockersDone bool `json:"allBlockersDone"`
DependencyReady bool `json:"isDependencyReady"`
UnresolvedBlockerCount int `json:"unresolvedBlockerCount"`
AllBlockersDone bool `json:"allBlockersDone"`
DependencyReady bool `json:"isDependencyReady"`
UnresolvedBlockerCount int `json:"unresolvedBlockerCount"`
} `json:"readiness"`
Blockers []struct {
Identifier string `json:"identifier"`
@@ -350,3 +350,19 @@ func (c *Client) Issue(issueID string) (Issue, error) {
err := c.do("GET", "/api/issues/"+issueID, nil, &out)
return out, err
}
// Cost is what one task has cost so far.
type Cost struct {
CostCents int `json:"costCents"`
InputTokens int `json:"inputTokens"`
OutputTokens int `json:"outputTokens"`
RunCount int `json:"runCount"`
RuntimeMs int `json:"runtimeMs"`
}
// CostSummary returns the task's recorded spend.
func (c *Client) CostSummary(issueID string) (Cost, error) {
var out Cost
err := c.do("GET", "/api/issues/"+issueID+"/cost-summary", nil, &out)
return out, err
}

82
tui/theme.go Normal file
View File

@@ -0,0 +1,82 @@
package main
// One palette, two variants, named by role.
//
// Catppuccin, because it is the scheme people already run in this terminal and
// because its two variants are designed together: the same roles keep the same
// meaning in light and dark. Panels ask for a role and never for a colour, so a
// light terminal needs no second design.
import (
"os"
"strings"
)
type palette struct {
Base string
Surface string
Overlay string
Text string
Muted string
Accent string
Info string
Success string
Warning string
Error string
Mauve string
Selection string
}
var mocha = palette{
Base: "#1e1e2e",
Surface: "#313244",
Overlay: "#6c7086",
Text: "#cdd6f4",
Muted: "#a6adc8",
Accent: "#89b4fa",
Info: "#94e2d5",
Success: "#a6e3a1",
Warning: "#f9e2af",
Error: "#f38ba8",
Mauve: "#cba6f7",
Selection: "#45475a",
}
var latte = palette{
Base: "#eff1f5",
Surface: "#ccd0da",
Overlay: "#8c8fa1",
Text: "#4c4f69",
Muted: "#6c6f85",
Accent: "#1e66f5",
Info: "#179299",
Success: "#40a02b",
Warning: "#df8e1d",
Error: "#d20f39",
Mauve: "#8839ef",
Selection: "#bcc0cc",
}
// active is the palette this run draws with. It is chosen once, at startup, so
// nothing downstream has to think about it.
var active = mocha
func choosePalette(force string) {
switch strings.ToLower(force) {
case "light":
active = latte
case "dark":
active = mocha
default:
// COLORFGBG is what many terminals export: "foreground;background".
if v := os.Getenv("COLORFGBG"); v != "" {
parts := strings.Split(v, ";")
bg := parts[len(parts)-1]
// A light background is one of the high numbers in the 16-colour
// range; anything above 6 is light in practice.
if bg == "15" || bg == "7" {
active = latte
}
}
}
}