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
271 lines
5.5 KiB
Go
271 lines
5.5 KiB
Go
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)
|
|
})
|
|
}
|
|
|