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 }