Files
forgebot/internal/tui/board.go
T
unkinben 8f48dd838b Add TUI kanban board, review workflow, and new task statuses
Replace task statuses (pending/running/succeeded/failed/cancelled) with
a kanban workflow: todo → in_progress → in_review → done/wontdo.

When a non-review agent task completes, the API auto-creates a child
review task and moves the parent to in_review. Only humans can move
tasks from in_review to done/wontdo via the TUI.

New components:
- cmd/tui: bubbletea kanban board with $EDITOR integration
- POST /api/v1/tasks/{id}/complete: agent completion callback
- Operator --api-url flag for completion callbacks
- ProviderQueue sets tasks to in_progress on pickup
- AgentTask reconciler calls /complete on job finish
2026-06-12 22:47:40 +10:00

181 lines
3.7 KiB
Go

package tui
import (
"fmt"
"strings"
"github.com/charmbracelet/lipgloss"
"git.unkin.net/unkin/forgebot/pkg/models"
)
var columnOrder = []columnDef{
{status: models.StatusTodo, title: "Todo", extra: ""},
{status: models.StatusInProgress, title: "In Progress", extra: ""},
{status: models.StatusInReview, title: "In Review", extra: ""},
{status: models.StatusDone, title: "Done", extra: string(models.StatusWontdo)},
}
type columnDef struct {
status models.TaskStatus
title string
extra string
}
type column struct {
def columnDef
tasks []models.Task
cursor int
offset int
}
type board struct {
columns [4]column
activeCol int
}
func newBoard() board {
var b board
for i, def := range columnOrder {
b.columns[i] = column{def: def}
}
return b
}
func (b *board) loadTasks(tasks []models.Task) {
for i := range b.columns {
b.columns[i].tasks = nil
}
for _, t := range tasks {
for i := range b.columns {
col := &b.columns[i]
if t.Status == col.def.status || string(t.Status) == col.def.extra {
col.tasks = append(col.tasks, t)
break
}
}
}
for i := range b.columns {
col := &b.columns[i]
if col.cursor >= len(col.tasks) {
col.cursor = max(0, len(col.tasks)-1)
}
}
}
func (b *board) selectedTask() *models.Task {
col := &b.columns[b.activeCol]
if len(col.tasks) == 0 {
return nil
}
return &col.tasks[col.cursor]
}
func (b *board) moveLeft() {
if b.activeCol > 0 {
b.activeCol--
}
}
func (b *board) moveRight() {
if b.activeCol < len(b.columns)-1 {
b.activeCol++
}
}
func (b *board) moveUp() {
col := &b.columns[b.activeCol]
if col.cursor > 0 {
col.cursor--
}
}
func (b *board) moveDown() {
col := &b.columns[b.activeCol]
if col.cursor < len(col.tasks)-1 {
col.cursor++
}
}
func (b *board) view(width, height int) string {
colWidth := width / 4
if colWidth < 20 {
colWidth = 20
}
cardHeight := height - 4
if cardHeight < 1 {
cardHeight = 1
}
var cols []string
for i := range b.columns {
cols = append(cols, b.renderColumn(i, colWidth, cardHeight))
}
return lipgloss.JoinHorizontal(lipgloss.Top, cols...)
}
func (b *board) renderColumn(idx, width, maxHeight int) string {
col := &b.columns[idx]
color := statusColors[col.def.status]
active := idx == b.activeCol
titleStyle := columnTitleStyle.
Width(width).
Align(lipgloss.Center).
Foreground(color)
if active {
titleStyle = titleStyle.Underline(true)
}
title := titleStyle.Render(fmt.Sprintf("%s (%d)", col.def.title, len(col.tasks)))
if col.offset > col.cursor {
col.offset = col.cursor
}
var cards []string
usedHeight := 0
visibleStart := col.offset
for j := visibleStart; j < len(col.tasks); j++ {
selected := active && j == col.cursor
card := renderCard(col.tasks[j], width, selected)
cardLines := strings.Count(card, "\n") + 1
if usedHeight+cardLines > maxHeight && len(cards) > 0 {
break
}
cards = append(cards, card)
usedHeight += cardLines
}
if col.cursor >= visibleStart+len(cards) && len(col.tasks) > 0 {
col.offset = col.cursor
return b.renderColumn(idx, width, maxHeight)
}
content := strings.Join(cards, "\n")
if len(col.tasks) == 0 {
content = dimStyle.Width(width).Align(lipgloss.Center).Render("empty")
}
scrollInfo := ""
if col.offset > 0 {
scrollInfo = dimStyle.Render(fmt.Sprintf("↑ %d more", col.offset))
}
remaining := len(col.tasks) - visibleStart - len(cards)
if remaining > 0 {
if scrollInfo != "" {
scrollInfo += " "
}
scrollInfo += dimStyle.Render(fmt.Sprintf("↓ %d more", remaining))
}
parts := []string{title}
if scrollInfo != "" {
parts = append(parts, scrollInfo)
}
parts = append(parts, content)
return lipgloss.JoinVertical(lipgloss.Left, parts...)
}