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
This commit is contained in:
@@ -0,0 +1,270 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/bubbles/help"
|
||||
"github.com/charmbracelet/bubbles/key"
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
|
||||
"git.unkin.net/unkin/forgebot/pkg/models"
|
||||
)
|
||||
|
||||
type viewMode int
|
||||
|
||||
const (
|
||||
viewBoard viewMode = iota
|
||||
viewDetail
|
||||
viewFilter
|
||||
)
|
||||
|
||||
type tasksLoadedMsg struct {
|
||||
tasks []models.Task
|
||||
err error
|
||||
}
|
||||
|
||||
type taskUpdatedMsg struct {
|
||||
err error
|
||||
}
|
||||
|
||||
type tickMsg time.Time
|
||||
|
||||
type App struct {
|
||||
client *Client
|
||||
board board
|
||||
detail detailView
|
||||
mode viewMode
|
||||
width int
|
||||
height int
|
||||
err error
|
||||
help help.Model
|
||||
showHelp bool
|
||||
filter textinput.Model
|
||||
filterRepo string
|
||||
}
|
||||
|
||||
func NewApp(apiURL string) App {
|
||||
ti := textinput.New()
|
||||
ti.Placeholder = "owner/repo"
|
||||
ti.CharLimit = 100
|
||||
|
||||
return App{
|
||||
client: NewClient(apiURL),
|
||||
board: newBoard(),
|
||||
detail: newDetailView(),
|
||||
help: help.New(),
|
||||
filter: ti,
|
||||
}
|
||||
}
|
||||
|
||||
func (a App) Init() tea.Cmd {
|
||||
return tea.Batch(fetchTasks(a.client, a.filterRepo), tickCmd())
|
||||
}
|
||||
|
||||
func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
a.width = msg.Width
|
||||
a.height = msg.Height
|
||||
a.help.Width = msg.Width
|
||||
return a, nil
|
||||
|
||||
case tasksLoadedMsg:
|
||||
if msg.err != nil {
|
||||
a.err = msg.err
|
||||
} else {
|
||||
a.err = nil
|
||||
a.board.loadTasks(msg.tasks)
|
||||
}
|
||||
return a, nil
|
||||
|
||||
case taskUpdatedMsg:
|
||||
if msg.err != nil {
|
||||
a.err = msg.err
|
||||
}
|
||||
return a, fetchTasks(a.client, a.filterRepo)
|
||||
|
||||
case editorFinishedMsg:
|
||||
if msg.err != nil {
|
||||
a.err = msg.err
|
||||
}
|
||||
return a, fetchTasks(a.client, a.filterRepo)
|
||||
|
||||
case tickMsg:
|
||||
return a, tea.Batch(fetchTasks(a.client, a.filterRepo), tickCmd())
|
||||
|
||||
case tea.KeyMsg:
|
||||
if a.mode == viewFilter {
|
||||
return a.updateFilter(msg)
|
||||
}
|
||||
if a.mode == viewDetail {
|
||||
return a.updateDetail(msg)
|
||||
}
|
||||
return a.updateBoard(msg)
|
||||
}
|
||||
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func (a App) updateBoard(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
switch {
|
||||
case key.Matches(msg, keys.Quit):
|
||||
return a, tea.Quit
|
||||
|
||||
case key.Matches(msg, keys.Left):
|
||||
a.board.moveLeft()
|
||||
|
||||
case key.Matches(msg, keys.Right):
|
||||
a.board.moveRight()
|
||||
|
||||
case key.Matches(msg, keys.Up):
|
||||
a.board.moveUp()
|
||||
|
||||
case key.Matches(msg, keys.Down):
|
||||
a.board.moveDown()
|
||||
|
||||
case key.Matches(msg, keys.Enter):
|
||||
if t := a.board.selectedTask(); t != nil {
|
||||
a.mode = viewDetail
|
||||
a.detail.setTask(t, a.width, a.height)
|
||||
}
|
||||
|
||||
case key.Matches(msg, keys.Edit):
|
||||
if t := a.board.selectedTask(); t != nil {
|
||||
return a, editTaskCmd(t, a.client)
|
||||
}
|
||||
|
||||
case key.Matches(msg, keys.New):
|
||||
return a, newTaskEditorCmd(a.client)
|
||||
|
||||
case key.Matches(msg, keys.Done):
|
||||
if t := a.board.selectedTask(); t != nil && t.Status == models.StatusInReview {
|
||||
return a, updateTaskStatus(a.client, t.ID, models.StatusDone)
|
||||
}
|
||||
|
||||
case key.Matches(msg, keys.Wontdo):
|
||||
if t := a.board.selectedTask(); t != nil && t.Status == models.StatusInReview {
|
||||
return a, updateTaskStatus(a.client, t.ID, models.StatusWontdo)
|
||||
}
|
||||
|
||||
case key.Matches(msg, keys.Refresh):
|
||||
return a, fetchTasks(a.client, a.filterRepo)
|
||||
|
||||
case key.Matches(msg, keys.Filter):
|
||||
a.mode = viewFilter
|
||||
a.filter.SetValue(a.filterRepo)
|
||||
a.filter.Focus()
|
||||
return a, nil
|
||||
|
||||
case key.Matches(msg, keys.Help):
|
||||
a.showHelp = !a.showHelp
|
||||
}
|
||||
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func (a App) updateDetail(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
switch {
|
||||
case key.Matches(msg, keys.Back), key.Matches(msg, keys.Quit):
|
||||
a.mode = viewBoard
|
||||
return a, nil
|
||||
|
||||
case key.Matches(msg, keys.Edit):
|
||||
if a.detail.task != nil {
|
||||
return a, editTaskCmd(a.detail.task, a.client)
|
||||
}
|
||||
}
|
||||
|
||||
var cmd tea.Cmd
|
||||
a.detail.viewport, cmd = a.detail.viewport.Update(msg)
|
||||
return a, cmd
|
||||
}
|
||||
|
||||
func (a App) updateFilter(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
switch msg.String() {
|
||||
case "enter":
|
||||
a.filterRepo = a.filter.Value()
|
||||
a.mode = viewBoard
|
||||
a.filter.Blur()
|
||||
return a, fetchTasks(a.client, a.filterRepo)
|
||||
case "esc":
|
||||
a.mode = viewBoard
|
||||
a.filter.Blur()
|
||||
return a, nil
|
||||
}
|
||||
|
||||
var cmd tea.Cmd
|
||||
a.filter, cmd = a.filter.Update(msg)
|
||||
return a, cmd
|
||||
}
|
||||
|
||||
func (a App) View() string {
|
||||
if a.width == 0 {
|
||||
return "Loading..."
|
||||
}
|
||||
|
||||
var content string
|
||||
switch a.mode {
|
||||
case viewDetail:
|
||||
content = a.detail.view()
|
||||
case viewFilter:
|
||||
content = a.board.view(a.width, a.height-4)
|
||||
content += "\n" + lipgloss.NewStyle().Bold(true).Render("Filter repo: ") + a.filter.View()
|
||||
default:
|
||||
content = a.board.view(a.width, a.height-3)
|
||||
}
|
||||
|
||||
var statusLine string
|
||||
if a.err != nil {
|
||||
errText := a.err.Error()
|
||||
if len(errText) > a.width-2 {
|
||||
errText = errText[:a.width-5] + "..."
|
||||
}
|
||||
statusLine = errStyle.Render(errText)
|
||||
} else if a.filterRepo != "" {
|
||||
statusLine = dimStyle.Render(fmt.Sprintf("filter: %s", a.filterRepo))
|
||||
}
|
||||
|
||||
var helpView string
|
||||
if a.showHelp && a.mode == viewBoard {
|
||||
helpView = a.help.View(keys)
|
||||
} else if a.mode == viewBoard {
|
||||
helpView = helpStyle.Render("? help q quit")
|
||||
}
|
||||
|
||||
parts := []string{content}
|
||||
if statusLine != "" {
|
||||
parts = append(parts, statusLine)
|
||||
}
|
||||
if helpView != "" {
|
||||
parts = append(parts, helpView)
|
||||
}
|
||||
|
||||
return strings.Join(parts, "\n")
|
||||
}
|
||||
|
||||
func fetchTasks(client *Client, repo string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
tasks, err := client.ListTasks(context.Background(), "", repo)
|
||||
return tasksLoadedMsg{tasks: tasks, err: err}
|
||||
}
|
||||
}
|
||||
|
||||
func updateTaskStatus(client *Client, id string, status models.TaskStatus) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
err := client.UpdateTask(context.Background(), id, models.UpdateTaskRequest{Status: status})
|
||||
return taskUpdatedMsg{err: err}
|
||||
}
|
||||
}
|
||||
|
||||
func tickCmd() tea.Cmd {
|
||||
return tea.Tick(5*time.Second, func(t time.Time) tea.Msg {
|
||||
return tickMsg(t)
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user