8f48dd838b
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
181 lines
3.7 KiB
Go
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...)
|
|
}
|