diff --git a/bin/board b/bin/board index 68d4b68..d02b200 100755 --- a/bin/board +++ b/bin/board @@ -35,16 +35,16 @@ def parse(argv): return opts -def pages(cfg): +def pages(cfg, folder): """The pages, in the order they must appear on the workspace rail.""" bin_path = BIN proj = cfg["paperclip"]["project"] task = pj.watch_task(cfg) return task, [ ("shell", "shell", "exec zsh -i"), - ("tree", "tree", "%s -screen tree -project %s" % (shlex.quote(bin_path), proj)), + ("board", "board", "%s -screen board -project %s -folder %s" % (shlex.quote(bin_path), proj, shlex.quote(folder))), + ("work", "work", "%s -screen work -folder %s" % (shlex.quote(bin_path), shlex.quote(folder))), ("reply", "reply", "%s -screen reply -issue %s" % (shlex.quote(bin_path), task)), - ("log", "log", "%s -screen log -issue %s" % (shlex.quote(bin_path), task)), ("new", "new", "%s -screen new -project %s" % (shlex.quote(bin_path), proj)), ] @@ -61,7 +61,7 @@ def main(): if not os.path.isfile(BIN): raise h.Refused(2, "no harness binary at %s; build it with 'cd tui && go build -o ../bin/harness .'" % BIN) - task, page_list = pages(cfg) + task, page_list = pages(cfg, folder) h.say("board: %s (session %s, folder %s)" % (cfg["paperclip"]["project"], session, folder)) h.say("watching: %s" % task) diff --git a/bin/harness b/bin/harness index 02985cd..17f0f29 100755 Binary files a/bin/harness and b/bin/harness differ diff --git a/tui/board.go b/tui/board.go index cf71b5c..05cd9c7 100644 --- a/tui/board.go +++ b/tui/board.go @@ -130,13 +130,13 @@ func (m boardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case "r": m.loading = true return m, m.reload() - case "up": + case "up", "k": if m.cursor > 0 { m.cursor-- return m, m.loadDetail(m.selectedID()) } return m, nil - case "down": + case "down", "j": if m.cursor < len(m.rows)-1 { m.cursor++ return m, m.loadDetail(m.selectedID()) @@ -166,12 +166,12 @@ func (m boardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.notice = "showing " + r.Issue.Identifier + " in every workspace" } return m, nil - case "[": + case "[", "h": if m.focus == focusNeeds && m.needsTab > 0 { m.needsTab-- } return m, nil - case "]": + case "]", "l": if m.focus == focusNeeds && m.needsTab < 1 { m.needsTab++ } @@ -230,12 +230,36 @@ func (m boardModel) View() string { footer := m.footerLine() bodyHeight := m.height - 2 + // Three ways to use the same width, chosen by what the terminal actually + // has. Wide screens get the three panels side by side; narrow ones stack. + if m.width >= 140 { + treeW := max(34, m.width*30/100) + sumW := max(38, m.width*38/100) + needsW := max(30, m.width-treeW-sumW-2) + + left := panel{title: "tasks", width: treeW, height: bodyHeight, focused: m.focus == focusTree} + mid := panel{title: "the chosen task", width: sumW, height: bodyHeight, focused: m.focus == focusSummary} + rightP := panel{ + title: "needs you", width: needsW, height: bodyHeight, + tabs: []string{"decisions", "activity"}, active: m.needsTab, focused: m.focus == focusNeeds, + } + body := m.needsLines(needsW-3, bodyHeight-3) + if m.needsTab == 1 { + body = m.activityLines(needsW-3, bodyHeight-3) + } + page := lipgloss.JoinHorizontal(lipgloss.Top, + left.render(m.treeLines(treeW-3)), " ", + mid.render(m.summaryLines(sumW-3)), " ", + rightP.render(body)) + return strings.Join([]string{header, page, footer}, "\n") + } + 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) + treeLines := m.treeLines(leftWidth - 3) left := tree.render(treeLines) // Right, upper: the summary of the chosen task. @@ -247,13 +271,13 @@ func (m boardModel) View() string { 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) + needsBody := m.needsLines(rightWidth-3, needsHeight-3) if m.needsTab == 1 { - needsBody = m.activityLines(rightWidth-2, needsHeight-2) + needsBody = m.activityLines(rightWidth-3, needsHeight-3) } right := lipgloss.JoinVertical(lipgloss.Left, - sum.render(m.summaryLines(rightWidth-2)), + sum.render(m.summaryLines(rightWidth-3)), needs.render(needsBody), ) @@ -286,7 +310,7 @@ func (m boardModel) footerLine() string { switch m.focus { case focusTree: return hintLine([]string{ - keyHint("↑↓", "choose a task"), + keyHint("↑↓ or j k", "choose a task"), keyHint("enter", "show it everywhere"), keyHint("a", "assign"), keyHint("tab", "next panel"), @@ -295,7 +319,7 @@ func (m boardModel) footerLine() string { }, m.width) case focusSummary: return hintLine([]string{ - keyHint("↑↓", "choose a task"), + keyHint("↑↓ or j k", "choose a task"), keyHint("tab", "next panel"), keyHint("r", "read again"), keyHint("q", "leave"), @@ -328,7 +352,7 @@ func (m boardModel) treeLines(width int) []string { if len(group) == 0 { continue } - out = append(out, groupHeading(fmt.Sprintf("%s %d", strings.ToUpper(g), len(group)))) + out = append(out, groupHeading(g, len(group), width)) for _, r := range group { out = append(out, m.taskLine(r, index, width)...) index++ @@ -338,8 +362,13 @@ func (m boardModel) treeLines(width int) []string { return out } -func groupHeading(s string) string { - return lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(active.Mauve)).Render(s) +// groupHeading is a label, a rule, and how many tasks are under it. The rule +// makes the groups visible at a glance without a box around every one. +func groupHeading(name string, count, width int) string { + label := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(active.Mauve)).Render(strings.ToUpper(name)) + n := lipgloss.NewStyle().Foreground(lipgloss.Color(active.Muted)).Render(fmt.Sprintf(" %d ", count)) + rule := strings.Repeat("─", max(0, width-lipgloss.Width(label)-lipgloss.Width(n))) + return label + dimStyle().Render(rule) + n } func (m boardModel) taskLine(r Row, index, width int) []string { diff --git a/tui/frame.go b/tui/frame.go index 3178519..45eb12a 100644 --- a/tui/frame.go +++ b/tui/frame.go @@ -79,7 +79,8 @@ func (p panel) render(lines []string) string { top := border.Render("╭─") + panelTitleStyle().Render(title) + tabs + border.Render(strings.Repeat("─", fill)) + right + border.Render("╮") - // Body: every line exactly the inner width, no exceptions. + // Body: every line exactly the inner width, no exceptions. Content is + // inset by one column so text never touches the border. out := make([]string, 0, p.height) out = append(out, top) side := border.Render("│") @@ -88,7 +89,7 @@ func (p panel) render(lines []string) string { if i < len(lines) { line = lines[i] } - out = append(out, side+padTo(line, bw)+side) + out = append(out, side+" "+padTo(line, bw-1)+side) } out = append(out, border.Render("╰"+strings.Repeat("─", p.width-2)+"╯")) return strings.Join(out, "\n") diff --git a/tui/logdiff.go b/tui/logdiff.go index 70684d8..8e5bdd4 100644 --- a/tui/logdiff.go +++ b/tui/logdiff.go @@ -10,6 +10,7 @@ package main import ( "context" "fmt" + "os" "os/exec" "strings" "time" @@ -77,6 +78,9 @@ func worktreeDiff(dir, base string) (string, error) { if base == "" { base = "main" } + if st, err := os.Stat(dir); err != nil || !st.IsDir() { + return "", fmt.Errorf("the task's folder %s no longer exists, so there is nothing to compare", dir) + } ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() diff --git a/tui/main.go b/tui/main.go index 452f391..67bffe9 100644 --- a/tui/main.go +++ b/tui/main.go @@ -19,7 +19,7 @@ import ( const defaultAgent = "403de1f7-645c-4d35-bc7e-19b01d77bacc" func main() { - screen := flag.String("screen", "board", "which screen to run: board, tree, reply, new or log") + 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") agent := flag.String("agent", defaultAgent, "agent id to assign to, default Builder") @@ -52,6 +52,17 @@ func main() { } m = newBoardModel(client, proj, dir) + case "work": + dir := *folder + if dir == "" { + dir, _ = os.Getwd() + } + issue := *issue + if issue == "" { + issue = watchedTask(dir) + } + m = newWorkModel(client, dir, issue) + case "tree": proj, err := client.ResolveProject(*project) if err != nil { diff --git a/tui/work.go b/tui/work.go new file mode 100644 index 0000000..3105347 --- /dev/null +++ b/tui/work.go @@ -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 +}