Work workspace with runs, change and facts tabs; vim keys; responsive Board; theme file

This commit is contained in:
Sam
2026-11-10 14:00:58 +11:00
parent 1bfb9b05ed
commit 4e3c955350
7 changed files with 416 additions and 20 deletions

351
tui/work.go Normal file
View File

@@ -0,0 +1,351 @@
package main
// The Work workspace: what the chosen task is doing, and what it changed.
//
// One panel with three tabs, because the three things are read one at a time:
// the runs, the change, and the facts about the copy of the code the task is
// working in. The change is read with git in the task's own worktree, in a
// command, never while drawing.
import (
"fmt"
"os"
"strings"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"paperclip-harness/tui/paperclip"
)
type workMsg struct {
task paperclip.Issue
runs []paperclip.Run
activity []paperclip.Activity
cost paperclip.Cost
diff string
diffErr error
}
type workModel struct {
client *paperclip.Client
folder string
issue string
explicit bool // the task was named on the command line, so do not follow the file
task paperclip.Issue
runs []paperclip.Run
activity []paperclip.Activity
cost paperclip.Cost
diff string
diffErr error
err error
tab int
scroll int
readAt time.Time
width, height int
}
var workTabs = []string{"runs", "change", "facts"}
func newWorkModel(c *paperclip.Client, folder, issueID string) workModel {
return workModel{client: c, folder: folder, issue: issueID, explicit: issueID != ""}
}
func (m workModel) Init() tea.Cmd {
return tea.Batch(m.load(), tickEvery(5*time.Second))
}
func (m workModel) load() tea.Cmd {
c, folder := m.client, m.folder
fixed, explicit := m.issue, m.explicit
return func() tea.Msg {
// Unless a task was named on the command line, follow the choice made
// on the Board: it is written in the project's own file, so this panel
// needs no message from any other program.
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}
}
runs, _ := c.Runs(id)
act, _ := c.Activity(id)
cost, _ := c.CostSummary(id)
msg := workMsg{task: task, runs: runs, activity: act, cost: cost}
if ws := task.Workspace; ws != nil && ws.CWD != "" {
msg.diff, msg.diffErr = worktreeDiff(ws.CWD, ws.BaseRef)
} else {
msg.diffErr = fmt.Errorf("this task has no worktree on this machine, so there is nothing to compare")
}
return msg
}
}
// watchedTask reads the chosen task from the project's own harness file.
func watchedTask(folder string) string {
b, err := os.ReadFile(projectFilePath(folder))
if err != nil {
return ""
}
for _, line := range strings.Split(string(b), "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "watching") {
parts := strings.SplitN(line, "=", 2)
if len(parts) == 2 {
return strings.Trim(strings.TrimSpace(parts[1]), `"`)
}
}
}
return ""
}
func projectFilePath(folder string) string {
return folder + "/.harness/harness.toml"
}
func (m workModel) 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.load(), tickEvery(5*time.Second))
case tea.KeyMsg:
switch msg.String() {
case "q", "ctrl+c":
return m, tea.Quit
case "r":
return m, m.load()
case "[", "h":
if m.tab > 0 {
m.tab--
m.scroll = 0
}
return m, nil
case "]", "l", "tab":
if m.tab < len(workTabs)-1 {
m.tab++
m.scroll = 0
}
return m, nil
case "up", "k":
m.scroll--
return m, nil
case "down", "j":
m.scroll++
return m, nil
case "g":
m.scroll = 0
return m, nil
case "G":
m.scroll = 1 << 30
return m, nil
case "esc":
return m, nil
}
case workMsg:
m.task = msg.task
m.runs = msg.runs
m.activity = msg.activity
m.cost = msg.cost
m.diff = msg.diff
m.diffErr = msg.diffErr
m.err = nil
m.readAt = time.Now()
return m, nil
case errMsg:
m.err = msg.err
return m, nil
}
return m, nil
}
func (m workModel) 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.", m.width, m.height))
}
head := titleStyle.Render("Work") + mutedStyle2().Render(" · ")
if m.task.Identifier != "" {
head += lipgloss.NewStyle().Foreground(lipgloss.Color(active.Text)).Render(m.task.Identifier)
head += mutedStyle2().Render(" " + truncateVisible(m.task.Title, max(10, m.width-50)))
}
state := "read " + m.readAt.Format("15:04:05")
if m.readAt.IsZero() {
state = "reading…"
}
gap := max(2, m.width-lipgloss.Width(head)-lipgloss.Width(state)-1)
var body []string
switch m.tab {
case 0:
body = m.runsLines(m.width - 4)
case 1:
body = m.diffLines(m.width - 4)
default:
body = m.factsLines(m.width - 4)
}
p := panel{
title: "the work", width: m.width, height: m.height - 2,
tabs: workTabs, active: m.tab, focused: true,
}
// Scroll inside the panel.
visible := p.bodyHeight()
maxScroll := max(0, len(body)-visible)
if m.scroll > maxScroll {
m.scroll = maxScroll
}
if m.scroll < 0 {
m.scroll = 0
}
from := m.scroll
to := min(len(body), from+visible)
footer := hintLine([]string{
keyHint("[ ]", "switch tab"),
keyHint("↑↓ or j k", "scroll"),
keyHint("r", "read again"),
keyHint("q", "leave"),
}, m.width)
return strings.Join([]string{
head + strings.Repeat(" ", gap) + mutedStyle2().Render(state),
p.render(body[from:to]),
footer,
}, "\n")
}
func (m workModel) runsLines(width int) []string {
if m.err != nil {
return []string{lipgloss.NewStyle().Foreground(lipgloss.Color(active.Error)).Render("error: " + m.err.Error())}
}
var out []string
out = append(out, "")
if len(m.runs) == 0 {
out = append(out, mutedStyle2().Render("this task has never run"))
} else {
out = append(out, dimStyle().Render(fmt.Sprintf("%-18s %-12s %-10s %-10s %s",
"state", "woken by", "started", "ended", "took")))
for _, r := range m.runs {
started, took := "-", "-"
if t, err := time.Parse(time.RFC3339, r.StartedAt); err == nil {
started = t.Local().Format("15:04:05")
}
if r.FinishedAt != "" {
if s, err := time.Parse(time.RFC3339, r.StartedAt); err == nil {
if f, err := time.Parse(time.RFC3339, r.FinishedAt); err == nil {
took = f.Sub(s).Round(time.Second).String()
}
}
}
ended := "-"
if t, err := time.Parse(time.RFC3339, r.FinishedAt); err == nil {
ended = t.Local().Format("15:04:05")
}
colour := active.Text
switch r.Status {
case "running":
colour = active.Info
case "succeeded":
colour = active.Success
case "failed":
colour = active.Error
}
// Pad the plain text first, then colour it: styling first would
// make the escape codes count as characters and break the columns.
out = append(out, fmt.Sprintf("%-18s %-12s %-10s %-10s %s",
lipgloss.NewStyle().Foreground(lipgloss.Color(colour)).Render(fmt.Sprintf("%-18s", r.Status)),
fmt.Sprintf("%-12s", r.Source), started, ended, took))
}
}
out = append(out, "")
out = append(out, groupHeading("activity", len(m.activity), width))
for i, a := range m.activity {
if i >= 20 {
out = append(out, dimStyle().Render(fmt.Sprintf("… %d more", len(m.activity)-20)))
break
}
when := a.CreatedAt
if t, err := time.Parse(time.RFC3339, a.CreatedAt); err == nil {
when = t.Local().Format("15:04")
}
who := a.ActorType
if who == "user" {
who = "you"
}
out = append(out, fmt.Sprintf("%s %-7s %s", dimStyle().Render(when), mutedStyle2().Render(who),
lipgloss.NewStyle().Foreground(lipgloss.Color(active.Text)).Render(a.Action)))
}
return out
}
func (m workModel) diffLines(width int) []string {
if m.diffErr != nil {
return []string{"", mutedStyle2().Render(m.diffErr.Error())}
}
if m.diff == "" {
return []string{"", mutedStyle2().Render("nothing to compare yet")}
}
var out []string
for _, ln := range strings.Split(strings.TrimRight(m.diff, "\n"), "\n") {
style := lipgloss.NewStyle().Foreground(lipgloss.Color(active.Text))
switch {
case strings.HasPrefix(ln, "+++"), strings.HasPrefix(ln, "---"):
style = lipgloss.NewStyle().Foreground(lipgloss.Color(active.Muted))
case strings.HasPrefix(ln, "+"):
style = lipgloss.NewStyle().Foreground(lipgloss.Color(active.Success))
case strings.HasPrefix(ln, "-"):
style = lipgloss.NewStyle().Foreground(lipgloss.Color(active.Error))
case strings.HasPrefix(ln, "@@"):
style = lipgloss.NewStyle().Foreground(lipgloss.Color(active.Info))
case strings.HasPrefix(ln, "diff "), strings.HasPrefix(ln, "commit "), strings.HasPrefix(ln, "+"):
style = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(active.Text))
}
if lipgloss.Width(ln) > width {
ln = truncateVisible(ln, width)
}
out = append(out, style.Render(ln))
}
return out
}
func (m workModel) factsLines(width int) []string {
var out []string
out = append(out, "")
row := func(label, value string) {
out = append(out, mutedStyle2().Render(fmt.Sprintf("%-14s", label))+
lipgloss.NewStyle().Foreground(lipgloss.Color(active.Text)).Render(value))
}
if m.task.Identifier != "" {
row("task", m.task.Identifier+" "+m.task.Title)
row("status", m.task.Status)
}
if m.task.Workspace != nil {
row("worktree", m.task.Workspace.CWD)
row("branch", m.task.Workspace.Branch)
row("cut from", m.task.Workspace.BaseRef)
} else {
row("worktree", "none: this task has no copy of the code yet")
}
row("cost", fmt.Sprintf("%d cent(s), %d run(s), %d tokens out", m.cost.CostCents, m.cost.RunCount, m.cost.OutputTokens))
return out
}