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 runLogMsg2 struct { runID string text string err error } type pickTasksMsg struct { issues []paperclip.Issue err error } type workMsg struct { task paperclip.Issue tasks []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 logRunID string logText string tab int scroll int runAt int confirm bool tools bool toolAt int // choosing a task here, so the screen does not depend on the Board taskList []paperclip.Issue picking bool pickList []paperclip.Issue pickNames map[string]string pickAt int readAt time.Time showHelp bool width, height int } var workTabs = []string{"runs", "change", "facts", "review", "log", "merge"} 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), tea.WindowSize()) } // loadRunLog reads one run's output for the log tab. func (m workModel) loadRunLog(runID string) tea.Cmd { c := m.client return func() tea.Msg { text, err := c.RunLog(runID, 8<<20) return runLogMsg2{runID: runID, text: text, err: err} } } 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) var list []paperclip.Issue if all, err := c.Issues(); err == nil { cfg, _ := readHarnessFile(folder) for _, i := range all { if i.ProjectID == cfg["paperclip.project"] { list = append(list, i) } } } act, _ := c.Activity(id) cost, _ := c.CostSummary(id) msg := workMsg{task: task, runs: runs, activity: act, cost: cost, tasks: list} 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), tea.WindowSize()) case tea.KeyMsg: if m.confirm { switch msg.String() { case "y": m.confirm = false if err := startMerge(m.folder, m.task.Identifier); err != nil { m.err = err return m, nil } m.tab = 5 m.scroll = 1 << 30 return m, m.load() case "esc", "n", "q": m.confirm = false } return m, nil } if m.picking { switch msg.String() { case "esc", "q", "o": m.picking = false return m, nil case "up", "k": m.pickAt = max(0, m.pickAt-1) return m, nil case "down", "j": m.pickAt = min(len(m.pickList)-1, m.pickAt+1) return m, nil case "enter": if m.pickAt < len(m.pickList) { chosen := m.pickList[m.pickAt] m.picking = false if err := rememberTask(m.folder, chosen.Identifier); err != nil { m.err = err return m, nil } m.explicit = true m.issue = chosen.ID m.task = chosen return m, m.load() } return m, nil } return m, nil } if m.tools { switch msg.String() { case "esc", "q", "t": m.tools = false return m, nil case "up", "k": m.toolAt = max(0, m.toolAt-1) return m, nil case "down", "j": m.toolAt = min(len(tools)-1, m.toolAt+1) return m, nil case "g": m.toolAt = 0 return m, nil case "G": m.toolAt = len(tools) - 1 return m, nil case "enter": t := tools[m.toolAt] m.tools = false if err := openTool(m.folder, t.name); err != nil { m.err = err } return m, nil } return m, nil } if m.showHelp { m.showHelp = false return m, nil } switch msg.String() { case "t": m.tools = true return m, nil case "n", "p": if len(m.taskList) == 0 { return m, nil } at := -1 for i, t := range m.taskList { if t.ID == m.task.ID { at = i } } if at < 0 { at = 0 } if msg.String() == "n" { at = (at + 1) % len(m.taskList) } else { at = (at + len(m.taskList) - 1) % len(m.taskList) } chosen := m.taskList[at] if err := rememberTask(m.folder, chosen.Identifier); err != nil { m.err = err return m, nil } m.explicit = true m.issue = chosen.ID m.task = chosen m.tab = 0 m.runAt = 0 return m, m.load() case "o": c := m.client folder := m.folder return m, func() tea.Msg { cfg, err := readHarnessFile(folder) if err != nil { return errMsg{err} } issues, err := c.Issues() if err != nil { return errMsg{err} } var mine []paperclip.Issue for _, i := range issues { if i.ProjectID == cfg["paperclip.project"] { mine = append(mine, i) } } return pickTasksMsg{issues: mine} } case "v": // A review is cheap to start and posts its verdict on the task, so // the record lives with the work. if m.task.Identifier == "" { return m, nil } if err := startReview(m.folder, m.task.Identifier); err != nil { m.err = err return m, nil } m.tab = 3 return m, m.load() case "m": // Merging is a decision, so it asks first and shows what it will do. if m.task.Identifier == "" { return m, nil } m.confirm = true return m, nil case "?": m.showHelp = true return m, nil case "q", "ctrl+c": return m, tea.Quit case "r": return m, m.load() case "R": if err := startRebuild(m.folder); err != nil { m.err = err return m, nil } return m, tea.Quit 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": if m.tab == 0 && m.runAt > 0 { m.runAt-- } m.scroll-- return m, nil case "down", "j": if m.tab == 0 && m.runAt < len(m.runs)-1 { m.runAt++ } m.scroll++ return m, nil case "g": m.scroll = 0 return m, nil case "G": m.scroll = 1 << 30 return m, nil case "enter": if m.tab == 0 && m.runAt < len(m.runs) { m.tab = 4 m.scroll = 1 << 30 return m, m.loadRunLog(m.runs[m.runAt].ID) } return m, nil case "esc": return m, nil } case runLogMsg2: m.logRunID = msg.runID m.logText = msg.text if msg.err != nil { m.err = msg.err } return m, nil case pickTasksMsg: m.pickList = msg.issues m.pickAt = 0 m.picking = true if msg.err != nil { m.err = msg.err } return m, nil case workMsg: if msg.task.ID != "" && msg.task.ID != m.task.ID { m.runAt = 0 m.logRunID, m.logText = "", "" if len(msg.runs) > 0 { m.logRunID = msg.runs[0].ID } } m.task = msg.task if msg.tasks != nil { m.taskList = msg.tasks } 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)) } if m.showHelp { return helpOverlay("the work", [][2]string{ {"[ ] h l", "switch between runs, change and facts"}, {"↑ ↓ j k", "scroll"}, {"g / G", "top / bottom"}, {"r", "read again"}, {"R", "rebuild every pane"}, {"q", "leave"}, {"?", "this list"}, }, 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-60))) for i, t := range m.taskList { if t.ID == m.task.ID { head += mutedStyle2().Render(fmt.Sprintf(" task %d of %d", i+1, len(m.taskList))) break } } } 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) case 3: body = m.reviewLines(m.width - 4) case 4: body = m.logLinesWork(m.width - 4) case 5: body = m.mergeLines(m.width - 4) default: body = m.factsLines(m.width - 4) } if m.confirm { check := "no project check: the review decides" if cmd := verifyFromDescription(m.task.Description); cmd != "" { check = "project check: " + cmd } branch := "(no branch yet)" if m.task.Workspace != nil && m.task.Workspace.Branch != "" { branch = m.task.Workspace.Branch } return helpOverlay("merge "+m.task.Identifier+" into main?", [][2]string{ {"what is merged", branch + " → " + baseRefOf(m.task)}, {"what happens", "rebase the branch onto the base, then merge it"}, {"", check}, {"", "ocr reviews the change; a critical finding refuses the merge"}, {"", "merge, then re-run the checks on the base branch"}, {"y", "do it"}, {"esc or n", "do nothing"}, }, m.width, m.height) } if m.picking { entries := make([][2]string, 0, len(m.pickList)) for i, is := range m.pickList { mark := " " if i == m.pickAt { mark = "▸ " } entries = append(entries, [2]string{mark + is.Identifier, is.Status + " " + is.Title}) } return helpOverlay("choose a task to look at", entries, m.width, m.height) } if m.tools { entries := make([][2]string, 0, len(tools)) for i, t := range tools { mark := " " if i == m.toolAt { mark = "▸ " } entries = append(entries, [2]string{mark + t.name, t.about}) } return helpOverlay("open a tool pane here", entries, m.width, m.height) } title := "the work" if m.tab == 0 { title = fmt.Sprintf("the work · %s", fmtCount(len(m.runs), "run", "runs")) } p := panel{ title: title, 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("n p", "task"), keyHint("( )", "tab"), keyHint("↑↓", "scroll"), keyHint("enter", "log"), keyHint("o", "pick"), keyHint("m", "merge"), keyHint("v", "review"), keyHint("t", "tools"), keyHint("r", "refresh"), keyHint("R", "rebuild"), }, 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 i, 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. row := fmt.Sprintf("%-18s %-12s %-10s %-10s %s", r.Status, r.Source, started, ended, took) if i == m.runAt { out = append(out, selectedStyle2().Render(padTo("▸ "+row, width))) continue } 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 } // reviewLines shows the verdicts already recorded on the task. A review is // written as a comment, so the record lives with the work and survives us. func (m workModel) reviewLines(width int) []string { out := []string{"", dimStyle().Render(" verdicts recorded on this task")} if rl := reviewLogTailFor(m.task.Identifier); len(rl) > 0 { out = append(out, "") out = append(out, dimStyle().Render(" the last review started from here:")) for _, ln := range rl { out = append(out, " "+mutedStyle2().Render(truncateVisible(ln, width-2))) } } if m.task.Identifier == "" { return append(out, mutedStyle2().Render(" no task chosen")) } cs, err := m.client.Comments(m.task.ID) if err != nil { return append(out, lipgloss.NewStyle().Foreground(lipgloss.Color(active.Error)).Render(" "+err.Error())) } found := 0 for i := len(cs) - 1; i >= 0 && found < 3; i-- { body := cs[i].Body if !strings.HasPrefix(body, "## Review") && !strings.HasPrefix(body, "## Merge refused") && !strings.HasPrefix(body, "## Merged") && !strings.HasPrefix(body, "## Merge undone") { continue } found++ blocking := strings.Contains(body, "critical") || strings.Contains(body, "**high/") colour := active.Success if strings.HasPrefix(body, "## Merge refused") || blocking { colour = active.Error } for _, ln := range wrap(strings.ReplaceAll(body, "\n", " · "), width-2) { out = append(out, " "+lipgloss.NewStyle().Foreground(lipgloss.Color(colour)).Render(ln)) } out = append(out, "") } if found == 0 { out = append(out, mutedStyle2().Render(" no review recorded for this task yet")) out = append(out, "") out = append(out, dimStyle().Render(" run one with: bin/review "+m.task.Identifier+" --post")) } return out } // mergeLines shows the last merge this screen started, read from the log the // merge writes. Pressing m again is safe: the merge takes a lock. // logLinesWork shows the output of the run chosen on the runs tab. func (m workModel) logLinesWork(width int) []string { if m.logRunID == "" { return []string{"", mutedStyle2().Render(" choose a run on the runs tab, then press enter")} } if m.logText == "" { return []string{"", mutedStyle2().Render(" no output recorded for this run")} } out := []string{"", dimStyle().Render(" run " + short(m.logRunID)), ""} entries := decodeRunLog(m.logText) if len(entries) > 2000 { entries = entries[len(entries)-2000:] } for _, e := range entries { colour := active.Text switch e.stream { case "thinking": colour = active.Muted case "prompt": colour = active.Info case "tool": colour = active.Warning case "event": colour = active.Overlay } out = append(out, " "+dimStyle().Render(e.when)+" "+ lipgloss.NewStyle().Foreground(lipgloss.Color(colour)).Render(truncateVisible(e.line, width-10))) } return out } func (m workModel) mergeLines(width int) []string { tail := mergeLogTail(max(6, m.height-6)) joined := strings.Join(tail, "\n") if m.task.Identifier != "" && !strings.Contains(joined, m.task.Identifier) { // The log on disk belongs to another task. Say so instead of showing // someone else's merge as if it were this one. return []string{"", " " + mutedStyle2().Render("no merge has been run from this screen for "+m.task.Identifier), "", " " + dimStyle().Render("the last merge recorded was for another task:"), " " + dimStyle().Render(firstLineWith(tail, "merge of ")), "", " " + dimStyle().Render("press m to merge this task"), } } out := []string{"", dimStyle().Render(" " + mergeLogPath()), ""} for _, ln := range tail { colour := active.Text switch { case strings.Contains(ln, "refused"), strings.Contains(ln, "stopped"): colour = active.Error case strings.Contains(ln, "merged into"), strings.Contains(ln, "finished"): colour = active.Success } out = append(out, " "+lipgloss.NewStyle().Foreground(lipgloss.Color(colour)).Render(truncateVisible(ln, width-2))) } out = append(out, "", dimStyle().Render(" m runs the checks and merges this task; the log above is what it did")) 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 } // baseRefOf is the branch a task's work will be merged into. func baseRefOf(t paperclip.Issue) string { if t.Workspace != nil && t.Workspace.BaseRef != "" { return t.Workspace.BaseRef } return "main" } // firstLineWith finds the first line mentioning a marker, for a one-line hint. func firstLineWith(lines []string, marker string) string { for _, l := range lines { if strings.Contains(l, marker) { return strings.TrimSpace(l) } } return "none" } // reviewLogTailFor returns the last review log, but only when it was for this // task: another task's review is not evidence about this one. func reviewLogTailFor(identifier string) []string { tail := reviewLogTail(8) joined := strings.Join(tail, "\n") if !strings.Contains(joined, identifier) { return nil } return tail }