Work workspace with runs, change and facts tabs; vim keys; responsive Board; theme file
This commit is contained in:
@@ -35,16 +35,16 @@ def parse(argv):
|
|||||||
return opts
|
return opts
|
||||||
|
|
||||||
|
|
||||||
def pages(cfg):
|
def pages(cfg, folder):
|
||||||
"""The pages, in the order they must appear on the workspace rail."""
|
"""The pages, in the order they must appear on the workspace rail."""
|
||||||
bin_path = BIN
|
bin_path = BIN
|
||||||
proj = cfg["paperclip"]["project"]
|
proj = cfg["paperclip"]["project"]
|
||||||
task = pj.watch_task(cfg)
|
task = pj.watch_task(cfg)
|
||||||
return task, [
|
return task, [
|
||||||
("shell", "shell", "exec zsh -i"),
|
("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)),
|
("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)),
|
("new", "new", "%s -screen new -project %s" % (shlex.quote(bin_path), proj)),
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -61,7 +61,7 @@ def main():
|
|||||||
if not os.path.isfile(BIN):
|
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)
|
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("board: %s (session %s, folder %s)" % (cfg["paperclip"]["project"], session, folder))
|
||||||
h.say("watching: %s" % task)
|
h.say("watching: %s" % task)
|
||||||
|
|
||||||
|
|||||||
BIN
bin/harness
BIN
bin/harness
Binary file not shown.
55
tui/board.go
55
tui/board.go
@@ -130,13 +130,13 @@ func (m boardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
case "r":
|
case "r":
|
||||||
m.loading = true
|
m.loading = true
|
||||||
return m, m.reload()
|
return m, m.reload()
|
||||||
case "up":
|
case "up", "k":
|
||||||
if m.cursor > 0 {
|
if m.cursor > 0 {
|
||||||
m.cursor--
|
m.cursor--
|
||||||
return m, m.loadDetail(m.selectedID())
|
return m, m.loadDetail(m.selectedID())
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
case "down":
|
case "down", "j":
|
||||||
if m.cursor < len(m.rows)-1 {
|
if m.cursor < len(m.rows)-1 {
|
||||||
m.cursor++
|
m.cursor++
|
||||||
return m, m.loadDetail(m.selectedID())
|
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"
|
m.notice = "showing " + r.Issue.Identifier + " in every workspace"
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
case "[":
|
case "[", "h":
|
||||||
if m.focus == focusNeeds && m.needsTab > 0 {
|
if m.focus == focusNeeds && m.needsTab > 0 {
|
||||||
m.needsTab--
|
m.needsTab--
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
case "]":
|
case "]", "l":
|
||||||
if m.focus == focusNeeds && m.needsTab < 1 {
|
if m.focus == focusNeeds && m.needsTab < 1 {
|
||||||
m.needsTab++
|
m.needsTab++
|
||||||
}
|
}
|
||||||
@@ -230,12 +230,36 @@ func (m boardModel) View() string {
|
|||||||
footer := m.footerLine()
|
footer := m.footerLine()
|
||||||
bodyHeight := m.height - 2
|
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)
|
leftWidth := max(34, m.width*40/100)
|
||||||
rightWidth := max(30, m.width-leftWidth-1)
|
rightWidth := max(30, m.width-leftWidth-1)
|
||||||
|
|
||||||
// Left: the task tree.
|
// Left: the task tree.
|
||||||
tree := panel{title: "tasks", width: leftWidth, height: bodyHeight, focused: m.focus == focusTree}
|
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)
|
left := tree.render(treeLines)
|
||||||
|
|
||||||
// Right, upper: the summary of the chosen task.
|
// Right, upper: the summary of the chosen task.
|
||||||
@@ -247,13 +271,13 @@ func (m boardModel) View() string {
|
|||||||
title: "needs you", width: rightWidth, height: needsHeight,
|
title: "needs you", width: rightWidth, height: needsHeight,
|
||||||
tabs: []string{"decisions", "activity"}, active: m.needsTab, focused: m.focus == focusNeeds,
|
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 {
|
if m.needsTab == 1 {
|
||||||
needsBody = m.activityLines(rightWidth-2, needsHeight-2)
|
needsBody = m.activityLines(rightWidth-3, needsHeight-3)
|
||||||
}
|
}
|
||||||
|
|
||||||
right := lipgloss.JoinVertical(lipgloss.Left,
|
right := lipgloss.JoinVertical(lipgloss.Left,
|
||||||
sum.render(m.summaryLines(rightWidth-2)),
|
sum.render(m.summaryLines(rightWidth-3)),
|
||||||
needs.render(needsBody),
|
needs.render(needsBody),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -286,7 +310,7 @@ func (m boardModel) footerLine() string {
|
|||||||
switch m.focus {
|
switch m.focus {
|
||||||
case focusTree:
|
case focusTree:
|
||||||
return hintLine([]string{
|
return hintLine([]string{
|
||||||
keyHint("↑↓", "choose a task"),
|
keyHint("↑↓ or j k", "choose a task"),
|
||||||
keyHint("enter", "show it everywhere"),
|
keyHint("enter", "show it everywhere"),
|
||||||
keyHint("a", "assign"),
|
keyHint("a", "assign"),
|
||||||
keyHint("tab", "next panel"),
|
keyHint("tab", "next panel"),
|
||||||
@@ -295,7 +319,7 @@ func (m boardModel) footerLine() string {
|
|||||||
}, m.width)
|
}, m.width)
|
||||||
case focusSummary:
|
case focusSummary:
|
||||||
return hintLine([]string{
|
return hintLine([]string{
|
||||||
keyHint("↑↓", "choose a task"),
|
keyHint("↑↓ or j k", "choose a task"),
|
||||||
keyHint("tab", "next panel"),
|
keyHint("tab", "next panel"),
|
||||||
keyHint("r", "read again"),
|
keyHint("r", "read again"),
|
||||||
keyHint("q", "leave"),
|
keyHint("q", "leave"),
|
||||||
@@ -328,7 +352,7 @@ func (m boardModel) treeLines(width int) []string {
|
|||||||
if len(group) == 0 {
|
if len(group) == 0 {
|
||||||
continue
|
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 {
|
for _, r := range group {
|
||||||
out = append(out, m.taskLine(r, index, width)...)
|
out = append(out, m.taskLine(r, index, width)...)
|
||||||
index++
|
index++
|
||||||
@@ -338,8 +362,13 @@ func (m boardModel) treeLines(width int) []string {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
func groupHeading(s string) string {
|
// groupHeading is a label, a rule, and how many tasks are under it. The rule
|
||||||
return lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(active.Mauve)).Render(s)
|
// 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 {
|
func (m boardModel) taskLine(r Row, index, width int) []string {
|
||||||
|
|||||||
@@ -79,7 +79,8 @@ func (p panel) render(lines []string) string {
|
|||||||
top := border.Render("╭─") + panelTitleStyle().Render(title) + tabs +
|
top := border.Render("╭─") + panelTitleStyle().Render(title) + tabs +
|
||||||
border.Render(strings.Repeat("─", fill)) + right + border.Render("╮")
|
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 := make([]string, 0, p.height)
|
||||||
out = append(out, top)
|
out = append(out, top)
|
||||||
side := border.Render("│")
|
side := border.Render("│")
|
||||||
@@ -88,7 +89,7 @@ func (p panel) render(lines []string) string {
|
|||||||
if i < len(lines) {
|
if i < len(lines) {
|
||||||
line = lines[i]
|
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)+"╯"))
|
out = append(out, border.Render("╰"+strings.Repeat("─", p.width-2)+"╯"))
|
||||||
return strings.Join(out, "\n")
|
return strings.Join(out, "\n")
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -77,6 +78,9 @@ func worktreeDiff(dir, base string) (string, error) {
|
|||||||
if base == "" {
|
if base == "" {
|
||||||
base = "main"
|
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)
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
|
|||||||
13
tui/main.go
13
tui/main.go
@@ -19,7 +19,7 @@ import (
|
|||||||
const defaultAgent = "403de1f7-645c-4d35-bc7e-19b01d77bacc"
|
const defaultAgent = "403de1f7-645c-4d35-bc7e-19b01d77bacc"
|
||||||
|
|
||||||
func main() {
|
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)")
|
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")
|
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")
|
agent := flag.String("agent", defaultAgent, "agent id to assign to, default Builder")
|
||||||
@@ -52,6 +52,17 @@ func main() {
|
|||||||
}
|
}
|
||||||
m = newBoardModel(client, proj, dir)
|
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":
|
case "tree":
|
||||||
proj, err := client.ResolveProject(*project)
|
proj, err := client.ResolveProject(*project)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
351
tui/work.go
Normal file
351
tui/work.go
Normal 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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user