package main // The Board workspace: the state of the work, and what needs a person. // // Three panels on one page: the task tree, the summary of the chosen task, and // the list of things waiting for a decision. Choosing a task changes what the // other panels show, here and in every other workspace, because the choice is // written to the project's own .harness file. import ( "fmt" "os" "path/filepath" "regexp" "strings" "time" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" zone "github.com/lrstanley/bubblezone" "paperclip-harness/tui/paperclip" ) type boardDetailMsg struct { issueID string runs []paperclip.Run cost paperclip.Cost activity []paperclip.Activity err error } type boardModel struct { client *paperclip.Client project paperclip.Project folder string rows []Row cursor int focus int // 0 tree, 1 summary, 2 needs-you loading bool readAt time.Time notice string err error detailID string runs []paperclip.Run cost paperclip.Cost activity []paperclip.Activity needsTab int tools bool toolAt int zones *zone.Manager filter string typing bool showHelp bool width, height int } const ( focusTree = iota focusSummary focusNeeds focusCount ) func newBoardModel(c *paperclip.Client, proj paperclip.Project, folder string) boardModel { // One click-zone manager for the whole program, created once. Wrapping each // panel separately drifts as soon as a pane is resized. return boardModel{client: c, project: proj, folder: folder, loading: true, zones: zone.New()} } func (m boardModel) Init() tea.Cmd { return tea.Batch(m.reload(), tickEvery(5*time.Second), tea.WindowSize()) } func (m boardModel) reload() tea.Cmd { c, proj, want := m.client, m.project, m.selectedID() return func() tea.Msg { rows, err := loadRows(c, proj) if err != nil { return errMsg{err} } _ = want return rowsMsg(rows) } } func (m boardModel) loadDetail(id string) tea.Cmd { c := m.client return func() tea.Msg { if id == "" { return boardDetailMsg{} } runs, err := c.Runs(id) if err != nil { return boardDetailMsg{issueID: id, err: err} } cost, _ := c.CostSummary(id) act, _ := c.Activity(id) return boardDetailMsg{issueID: id, runs: runs, cost: cost, activity: act} } } // visible is the list after the filter. The cursor moves over this, so what is // highlighted is always what would be acted on. func (m boardModel) visible() []Row { if m.filter == "" { return m.rows } needle := strings.ToLower(m.filter) var out []Row for _, r := range m.rows { if strings.Contains(strings.ToLower(r.Issue.Identifier), needle) || strings.Contains(strings.ToLower(r.Issue.Title), needle) || strings.Contains(strings.ToLower(r.Who), needle) { out = append(out, r) } } return out } func (m boardModel) selectedID() string { vis := m.visible() if m.cursor >= 0 && m.cursor < len(vis) { return vis[m.cursor].Issue.ID } return "" } func (m boardModel) selectedTask() *Row { vis := m.visible() if m.cursor >= 0 && m.cursor < len(vis) { return &vis[m.cursor] } return nil } func (m boardModel) 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.reload(), tickEvery(5*time.Second)) case tea.MouseMsg: if msg.Action == tea.MouseActionPress && msg.Button == tea.MouseButtonLeft { for i, r := range m.visible() { if z := m.zones.Get("row-" + r.Issue.ID); z != nil && z.InBounds(msg) { m.cursor = i return m, m.loadDetail(r.Issue.ID) } } } return m, nil case tea.KeyMsg: 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 } if m.typing { switch msg.Type { case tea.KeyEsc: m.typing = false m.filter = "" return m, nil case tea.KeyEnter, tea.KeyTab: m.typing = false return m, nil case tea.KeyBackspace: r := []rune(m.filter) if len(r) > 0 { m.filter = string(r[:len(r)-1]) } return m, nil case tea.KeySpace: m.filter += " " return m, nil case tea.KeyRunes: m.filter += string(msg.Runes) return m, nil } return m, nil } switch msg.String() { case "q", "ctrl+c": return m, tea.Quit case "?": m.showHelp = true return m, nil case "t": m.tools = true return m, nil case "/": m.typing = true return m, nil case "u": // A task whose blocker finished but which still says blocked is the // one thing a person unblocks without reading anything else. if r := m.selectedTask(); r != nil && isReadyButBlocked(*r) { c, id := m.client, r.Issue.ID return m, func() tea.Msg { if _, err := c.SetStatus(id, "todo", "Unblocked: its blocker finished."); err != nil { return errMsg{err} } rows, err := loadRows(c, m.project) if err != nil { return errMsg{err} } return rowsMsg(rows) } } return m, nil case "tab": m.focus = (m.focus + 1) % focusCount return m, nil case "shift+tab": m.focus = (m.focus + focusCount - 1) % focusCount return m, nil case "r": m.loading = true return m, m.reload() case "R": if err := startRebuild(m.folder); err != nil { m.err = err return m, nil } return m, tea.Quit case "up", "k": if m.cursor > 0 { m.cursor-- return m, m.loadDetail(m.selectedID()) } return m, nil case "down", "j": if m.cursor < len(m.visible())-1 { m.cursor++ return m, m.loadDetail(m.selectedID()) } return m, nil case "a": if r := m.selectedTask(); r != nil && r.Issue.AssigneeAgent == "" { c, id, agent := m.client, r.Issue.ID, defaultAgent return m, func() tea.Msg { if _, err := c.Assign(id, agent); err != nil { return errMsg{err} } rows, err := loadRows(c, m.project) if err != nil { return errMsg{err} } return rowsMsg(rows) } } return m, nil case "enter": if r := m.selectedTask(); r != nil { if err := rememberTask(m.folder, r.Issue.Identifier); err != nil { m.err = err return m, nil } m.notice = "showing " + r.Issue.Identifier + " in every workspace" // On the needs panel, enter also takes you where the answer is. if m.focus == focusNeeds { if err := switchWorkspace("Reply"); err != nil { m.err = err } } } return m, nil case "(", "[", "h": if m.focus == focusNeeds && m.needsTab > 0 { m.needsTab-- } return m, nil case "]", "l": if m.focus == focusNeeds && m.needsTab < 1 { m.needsTab++ } return m, nil case "esc": m.notice = "" return m, nil } case rowsMsg: m.rows = msg m.loading = false m.readAt = time.Now() m.err = nil if m.cursor >= len(m.visible()) { m.cursor = max(0, len(m.visible())-1) } if id := m.selectedID(); id != "" && id != m.detailID { return m, m.loadDetail(id) } return m, nil case boardDetailMsg: m.detailID = msg.issueID m.runs = msg.runs m.cost = msg.cost m.activity = msg.activity if msg.err != nil { m.err = msg.err } return m, nil case errMsg: m.err = msg.err m.loading = false return m, nil } return m, nil } func tickEvery(d time.Duration) tea.Cmd { return tea.Tick(d, func(t time.Time) tea.Msg { return tickMsg(t) }) } func (m boardModel) 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.\n\nMake it larger, or use Tab and zoom.", 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) } if m.showHelp { return helpOverlay("keys on this panel", [][2]string{ {"↑ ↓ j k", "choose a task"}, {"enter", "show that task in every workspace"}, {"a", "assign it to the configured agent"}, {"u", "unblock it, when its blocker has finished"}, {"/", "filter the list by identifier, title or name"}, {"tab", "move to the next panel"}, {"r / R", "read again / rebuild every pane"}, {"q", "leave this panel"}, }, m.width, m.height) } header := m.headerLine() 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 m.zones.Scan(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 - 3) left := tree.render(treeLines) // Right, upper: the summary of the chosen task. sumHeight := max(10, bodyHeight*58/100) sum := panel{title: "the chosen task", width: rightWidth, height: sumHeight, focused: m.focus == focusSummary} // Right, lower: what needs a person. needsHeight := bodyHeight - sumHeight needs := panel{ title: "needs you", width: rightWidth, height: needsHeight, tabs: []string{"decisions", "activity"}, active: m.needsTab, focused: m.focus == focusNeeds, } needsBody := m.needsLines(rightWidth-3, needsHeight-3) if m.needsTab == 1 { needsBody = m.activityLines(rightWidth-3, needsHeight-3) } right := lipgloss.JoinVertical(lipgloss.Left, sum.render(m.summaryLines(rightWidth-3)), needs.render(needsBody), ) page := lipgloss.JoinHorizontal(lipgloss.Top, left, " ", right) return m.zones.Scan(strings.Join([]string{header, page, footer}, "\n")) } func (m boardModel) headerLine() string { left := panelTitleStyle().Render("Board") + mutedStyle2().Render(" · "+m.project.Name) state := "reading…" if !m.readAt.IsZero() { state = "read " + m.readAt.Format("15:04:05") } if m.loading { state = "reading…" } if m.err != nil { state = "last read failed" } right := mutedStyle2().Render(state) gap := max(2, m.width-lipgloss.Width(left)-lipgloss.Width(right)-2) if m.notice != "" { right = lipgloss.NewStyle().Foreground(lipgloss.Color(active.Success)).Render(m.notice) gap = max(2, m.width-lipgloss.Width(left)-lipgloss.Width(right)-2) } return left + strings.Repeat(" ", gap) + right } func (m boardModel) footerLine() string { switch m.focus { case focusTree: return hintLine([]string{ keyHint("↑↓", "move"), keyHint("enter", "open"), keyHint("a", "assign"), keyHint("u", "unblock"), keyHint("t", "tools"), keyHint("/", "filter"), keyHint("?", "keys"), keyHint("tab", "panel"), keyHint("r", "refresh"), keyHint("R", "rebuild"), keyHint("q", "quit"), }, m.width) case focusSummary: return hintLine([]string{ keyHint("↑↓", "move"), keyHint("tab", "panel"), keyHint("r", "refresh"), keyHint("q", "quit"), }, m.width) default: return hintLine([]string{ keyHint("tab", "panel"), keyHint("( )", "tab"), keyHint("enter", "show this task everywhere"), keyHint("r", "refresh"), keyHint("q", "quit"), }, m.width) } } // treeLines draws the grouped task list. func (m boardModel) treeLines(width int) []string { var out []string vis := m.visible() if len(vis) == 0 && !m.loading { if m.filter != "" { return []string{mutedStyle2().Render(" nothing matches " + m.filter)} } return []string{mutedStyle2().Render(" no tasks in this project")} } index := 0 for _, g := range groupOrder { var group []Row for _, r := range vis { if r.Group == g { group = append(group, r) } } if len(group) == 0 { continue } out = append(out, groupHeading(g, len(group), width)) for _, r := range group { out = append(out, m.taskLine(r, index, width)...) index++ } out = append(out, "") } return out } // 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 := "" if count >= 0 { 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 { chosen := index == m.cursor cursor := " " firstStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(active.Text)) secondStyle := mutedStyle2() if chosen { cursor = "▸ " if m.focus == focusTree { firstStyle = selectedStyle2() secondStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(active.Base)) } else { firstStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(active.Accent)).Bold(true) secondStyle = mutedStyle2() } } status, _ := shortStatus(r.Issue.Status) who := r.Who if who == "" || who == "—" { who = "unassigned" } first := fmt.Sprintf("%s%-8s %-8s %s", cursor, r.Issue.Identifier, status, who) second := " " + r.Issue.Title if note := blockerNote(r); note != "" { second += " " + note } if lipgloss.Width(first) > width { first = truncateVisible(first, width) } if lipgloss.Width(second) > width { second = truncateVisible(second, width) } if chosen { first, second = padTo(first, width), padTo(second, width) } return []string{ m.zones.Mark("row-"+r.Issue.ID, firstStyle.Render(first)), secondStyle.Render(second), } } // shortStatus is the one word a row has room for. func shortStatus(status string) (string, string) { switch status { case "in_progress": return icons.Running + " running", active.Info case "blocked": return icons.Blocked + " blocked", active.Error case "in_review": return icons.Review + " review", active.Warning case "done": return icons.Done + " done", active.Success case "cancelled": return icons.Cancelled + " gone", active.Overlay case "backlog": return icons.Todo + " backlog", active.Muted default: return icons.Todo + " todo", active.Text } } // isReadyButBlocked is the state that confuses people most: everything it was // waiting for has finished, and the task still says blocked. func isReadyButBlocked(r Row) bool { return r.Blocker != nil && len(r.Blocker.BlockingIdentities) > 0 && r.Blocker.UnresolvedCount == 0 && r.Blocker.AllBlockersDone && r.Issue.Status == "blocked" } func blockerNote(r Row) string { if r.Blocker == nil { return "" } if r.Blocker.UnresolvedCount > 0 { return dimStyle().Render("waits on " + strings.Join(r.Blocker.BlockingIdentities, ", ")) } if r.Blocker.AllBlockersDone && r.Issue.Status == "blocked" { return badge("ready", active.Warning) } return "" } func (m boardModel) summaryLines(width int) []string { r := m.selectedTask() if r == nil { return []string{mutedStyle2().Render(" nothing chosen")} } var out []string out = append(out, panelTitleStyle().Render(truncateVisible(r.Issue.Identifier+" "+r.Issue.Title, width))) out = append(out, "") who := r.Who if who == "" || who == "—" { who = "nobody yet" } out = append(out, " "+statusPill(r.Issue.Status)+" "+ mutedStyle2().Render("assigned to ")+lipgloss.NewStyle().Foreground(lipgloss.Color(active.Text)).Render(who)) if r.Blocker != nil && len(r.Blocker.BlockingIdentities) > 0 { out = append(out, " "+mutedStyle2().Render("waits on ")+ lipgloss.NewStyle().Foreground(lipgloss.Color(active.Warning)).Render(strings.Join(r.Blocker.BlockingIdentities, ", "))) } else if isReadyButBlocked(*r) { out = append(out, " "+badge("ready to start", active.Warning)+" "+ mutedStyle2().Render("its blocker finished, but it is still marked blocked")) } out = append(out, "") // The command that decides the task is finished, from the task itself. if cmd := verifyFromDescription(r.Issue.Description); cmd != "" { out = append(out, " "+mutedStyle2().Render("done when")) for _, ln := range wrap(cmd, width-4) { out = append(out, " "+lipgloss.NewStyle().Foreground(lipgloss.Color(active.Info)).Render(ln)) } } if len(m.runs) > 0 { run := m.runs[0] line := " " + mutedStyle2().Render("last run") + " " + lipgloss.NewStyle().Foreground(lipgloss.Color(active.Text)).Render(run.Status+" · "+run.Source) if run.StartedAt != "" { if t, err := time.Parse(time.RFC3339, run.StartedAt); err == nil { line += mutedStyle2().Render(" · " + t.Local().Format("15:04")) } } out = append(out, line) } else { out = append(out, " "+mutedStyle2().Render("never run")) } if m.cost.RunCount > 0 { out = append(out, " "+mutedStyle2().Render("cost")+" "+ lipgloss.NewStyle().Foreground(lipgloss.Color(active.Text)).Render( fmt.Sprintf("%d cent(s) over %d run(s), %d tokens out", m.cost.CostCents, m.cost.RunCount, m.cost.OutputTokens))) } return out } func (m boardModel) needsLines(width, height int) []string { var out []string n := 0 add := func(r Row, why string) { n++ if len(out) < height { out = append(out, " "+badge(r.Issue.Identifier, active.Accent)+" "+lipgloss.NewStyle(). Foreground(lipgloss.Color(active.Text)).Render(truncateVisible(r.Issue.Title, width-14))) out = append(out, " "+mutedStyle2().Render(why)) } } for _, r := range m.rows { if isReadyButBlocked(r) { add(r, "its blocker finished; it is still marked blocked") } } for _, r := range m.rows { if r.Issue.Status == "in_review" { add(r, "waiting for a review") } } for _, r := range m.rows { if r.Issue.AssigneeUser != "" && r.Issue.Status != "done" && r.Issue.Status != "cancelled" { add(r, "assigned to you") } } if n == 0 { return []string{" " + lipgloss.NewStyle().Foreground(lipgloss.Color(active.Success)).Render("nothing needs you")} } if len(out) < height { out = append(out, "") out = append(out, dimStyle().Render(fmt.Sprintf(" %s waiting", fmtCount(n, "thing", "things")))) } return out } func (m boardModel) activityLines(width, height int) []string { if m.detailID == "" || len(m.activity) == 0 { return []string{" " + mutedStyle2().Render("nothing recorded for this task yet")} } var out []string for i, a := range m.activity { if len(out) >= height-1 || i >= 10 { break } when := a.CreatedAt if t, err := time.Parse(time.RFC3339, a.CreatedAt); err == nil { when = t.Local().Format("15:04") } who := a.ActorType switch who { case "user": who = "you" case "agent": who = "agent" case "system": who = "system" } out = append(out, " "+dimStyle().Render(when)+" "+ mutedStyle2().Render(fmt.Sprintf("%-7s", who))+" "+ lipgloss.NewStyle().Foreground(lipgloss.Color(active.Text)).Render(a.Action)) } return out } var verifyRE = regexp.MustCompile(`(?is)Done when this command succeeds:\s*\n+\s*(.+)`) func verifyFromDescription(desc string) string { if m := verifyRE.FindStringSubmatch(desc); m != nil { return strings.TrimSpace(m[1]) } return "" } // rememberTask writes the chosen task into the project's harness file, so the // other workspaces follow without being told twice. func rememberTask(folder, identifier string) error { path := filepath.Join(folder, ".harness", "harness.toml") b, err := os.ReadFile(path) if err != nil { return fmt.Errorf("cannot remember the choice: %w", err) } line := fmt.Sprintf("watching = %q", identifier) re := regexp.MustCompile(`(?m)^watching\s*=\s*".*"`) if re.Match(b) { b = re.ReplaceAll(b, []byte(line)) } else { b = append(b, []byte("\n"+line+"\n")...) } return os.WriteFile(path, b, 0o644) }