6f2ee1c654
Each task row gets a 2-col glyph — ● ready (workable now) / ○ waiting (blocked) / blank — and the detail pane shows "ready to work" or "blocked — waiting on <ids>", fed by the new GET /tasks ready/blockedBy fields. Makes a decomposed graph legible at a glance; a dependency-blocked task is status TODO (not the red BLOCKED lifecycle state), so the glyph is what distinguishes them. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
494 lines
14 KiB
Go
494 lines
14 KiB
Go
package app
|
|
|
|
import (
|
|
"encoding/json"
|
|
"image/color"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
|
|
tea "charm.land/bubbletea/v2"
|
|
"charm.land/lipgloss/v2"
|
|
)
|
|
|
|
// TaskSummary is one task from GET /tasks. Mirrors the server's TaskResponse
|
|
// (apps/server routes/TaskRoutes.kt) — the wire shape. Nullable server fields
|
|
// decode to the zero value ("" / nil) here.
|
|
type TaskSummary struct {
|
|
ID string `json:"id"`
|
|
Key string `json:"key"`
|
|
Status string `json:"status"`
|
|
Title string `json:"title"`
|
|
Goal string `json:"goal"`
|
|
AcceptanceCriteria []string `json:"acceptanceCriteria"`
|
|
AffectedPaths []string `json:"affectedPaths"`
|
|
Claimant string `json:"claimant"`
|
|
Links []TaskLinkWire `json:"links"`
|
|
Notes []TaskNoteWire `json:"notes"`
|
|
CreatedAt string `json:"createdAt"`
|
|
UpdatedAt string `json:"updatedAt"`
|
|
// Dependency-graph status from the server: Ready = workable now (TODO, no unmet deps);
|
|
// BlockedBy = ids of unfinished tasks this one waits on. Absent on older servers → zero values.
|
|
Ready bool `json:"ready"`
|
|
BlockedBy []string `json:"blockedBy"`
|
|
}
|
|
|
|
type TaskLinkWire struct {
|
|
TargetID string `json:"targetId"`
|
|
Type string `json:"type"`
|
|
TargetKind string `json:"targetKind"`
|
|
}
|
|
|
|
type TaskNoteWire struct {
|
|
Author string `json:"author"`
|
|
Body string `json:"body"`
|
|
At string `json:"at"`
|
|
}
|
|
|
|
// tasksLoadedMsg carries the result of a GET /tasks fetch. Exactly one of tasks /
|
|
// err is meaningful; a non-empty err means the fetch failed and the board shows it.
|
|
type tasksLoadedMsg struct {
|
|
tasks []TaskSummary
|
|
err string
|
|
}
|
|
|
|
// fetchTasks is a tea.Cmd that GETs the cross-project task board over HTTP and
|
|
// reports it as a tasksLoadedMsg. base is the http://host:port origin (from the ws
|
|
// client); an empty base yields an error message rather than a panic.
|
|
func fetchTasks(base string) tea.Cmd {
|
|
return func() tea.Msg {
|
|
if base == "" {
|
|
return tasksLoadedMsg{err: "no server address"}
|
|
}
|
|
client := &http.Client{Timeout: sessionsHTTPTimeout}
|
|
resp, err := client.Get(base + "/tasks")
|
|
if err != nil {
|
|
return tasksLoadedMsg{err: "fetch failed: " + err.Error()}
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
return tasksLoadedMsg{err: "server returned " + resp.Status}
|
|
}
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return tasksLoadedMsg{err: "read failed: " + err.Error()}
|
|
}
|
|
var out []TaskSummary
|
|
if err := json.Unmarshal(body, &out); err != nil {
|
|
return tasksLoadedMsg{err: "decode failed: " + err.Error()}
|
|
}
|
|
return tasksLoadedMsg{tasks: out}
|
|
}
|
|
}
|
|
|
|
// openTasks opens the cross-project task board and kicks off the HTTP fetch. It is
|
|
// global (not bound to a session), so it opens from anywhere.
|
|
func (m *Model) openTasks() tea.Cmd {
|
|
m.overlay = OverlayTasks
|
|
m.taskListIndex = 0
|
|
m.taskListErr = ""
|
|
m.taskDetail = false
|
|
m.taskDetailScroll = 0
|
|
m.taskFilter = ""
|
|
m.taskFilterTyping = false
|
|
m.taskListLoading = true
|
|
return fetchTasks(m.client.HTTPBase())
|
|
}
|
|
|
|
// filteredTasks narrows the board by the `/` query — a case-insensitive substring over
|
|
// id/title/goal. An empty query returns the whole list.
|
|
func (m Model) filteredTasks() []TaskSummary {
|
|
if strings.TrimSpace(m.taskFilter) == "" {
|
|
return m.taskList
|
|
}
|
|
q := strings.ToLower(m.taskFilter)
|
|
var out []TaskSummary
|
|
for _, tk := range m.taskList {
|
|
if strings.Contains(strings.ToLower(tk.ID+" "+tk.Title+" "+tk.Goal), q) {
|
|
out = append(out, tk)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// applyTasksLoaded folds a GET /tasks result into the board, clamping the cursor and
|
|
// surfacing any fetch error in place of a crash.
|
|
func (m *Model) applyTasksLoaded(msg tasksLoadedMsg) {
|
|
m.taskListLoading = false
|
|
if msg.err != "" {
|
|
m.taskListErr = msg.err
|
|
return
|
|
}
|
|
m.taskListErr = ""
|
|
m.taskList = msg.tasks
|
|
if m.taskListIndex >= len(m.taskList) {
|
|
m.taskListIndex = 0
|
|
}
|
|
}
|
|
|
|
// selectedTask returns the highlighted task, or false when the list is empty / the
|
|
// cursor is out of range.
|
|
func (m Model) selectedTask() (TaskSummary, bool) {
|
|
f := m.filteredTasks()
|
|
if m.taskListIndex < 0 || m.taskListIndex >= len(f) {
|
|
return TaskSummary{}, false
|
|
}
|
|
return f[m.taskListIndex], true
|
|
}
|
|
|
|
// handleTasksKey owns every key while the board is open. In list mode: navigate, r
|
|
// refreshes, enter opens the detail pane, T/esc closes. In detail mode: scroll, and
|
|
// enter/esc returns to the list.
|
|
func (m Model) handleTasksKey(k keyMsg) (tea.Model, tea.Cmd) {
|
|
if m.taskDetail {
|
|
switch {
|
|
case k.Type == keyEsc || k.Type == keyEnter:
|
|
m.taskDetail = false
|
|
case k.Type == keyUp || runeIs(k, "k"):
|
|
m.scrollTaskDetail(-1)
|
|
case k.Type == keyDown || runeIs(k, "j"):
|
|
m.scrollTaskDetail(1)
|
|
case k.Type == keyPgUp:
|
|
m.scrollTaskDetail(-m.taskDetailBodyH())
|
|
case k.Type == keyPgDown:
|
|
m.scrollTaskDetail(m.taskDetailBodyH())
|
|
case runeIs(k, "g"):
|
|
m.taskDetailScroll = 0
|
|
case runeIs(k, "G"):
|
|
m.taskDetailScroll = m.taskDetailMaxScroll()
|
|
}
|
|
return m, nil
|
|
}
|
|
// While editing the `/` filter, keys feed the query (esc/enter stop editing, keeping it).
|
|
if m.taskFilterTyping {
|
|
switch {
|
|
case k.Type == keyEnter || k.Type == keyEsc:
|
|
m.taskFilterTyping = false
|
|
case k.Type == keyBackspace:
|
|
if n := len(m.taskFilter); n > 0 {
|
|
m.taskFilter = m.taskFilter[:n-1]
|
|
m.taskListIndex = 0
|
|
}
|
|
case k.Type == keyRunes || k.Type == keySpace:
|
|
m.taskFilter += string(k.Runes)
|
|
m.taskListIndex = 0
|
|
}
|
|
return m, nil
|
|
}
|
|
switch {
|
|
case k.Type == keyEsc || runeIs(k, "T"):
|
|
m.overlay = OverlayNone
|
|
case runeIs(k, "/"):
|
|
m.taskFilterTyping = true
|
|
case runeIs(k, "c"):
|
|
m.taskFilter = ""
|
|
m.taskListIndex = 0
|
|
case k.Type == keyUp || runeIs(k, "k"):
|
|
if m.taskListIndex > 0 {
|
|
m.taskListIndex--
|
|
}
|
|
case k.Type == keyDown || runeIs(k, "j"):
|
|
if m.taskListIndex < len(m.filteredTasks())-1 {
|
|
m.taskListIndex++
|
|
}
|
|
case runeIs(k, "r"):
|
|
return m, m.openTasks()
|
|
case k.Type == keyEnter:
|
|
if _, ok := m.selectedTask(); ok {
|
|
m.taskDetail = true
|
|
m.taskDetailScroll = 0
|
|
}
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
// taskDetailBodyH is the number of detail lines visible in the detail pane.
|
|
func (m Model) taskDetailBodyH() int {
|
|
h := m.height*70/100 - 6
|
|
if h < 4 {
|
|
h = 4
|
|
}
|
|
return h
|
|
}
|
|
|
|
// taskDetailMaxScroll keeps the last page of detail anchored to the body bottom.
|
|
func (m Model) taskDetailMaxScroll() int {
|
|
max := len(m.taskDetailBody(m.modalWidth()-4)) - m.taskDetailBodyH()
|
|
if max < 0 {
|
|
max = 0
|
|
}
|
|
return max
|
|
}
|
|
|
|
// scrollTaskDetail moves the detail offset by delta lines, clamped to [0, max].
|
|
func (m *Model) scrollTaskDetail(delta int) {
|
|
max := m.taskDetailMaxScroll()
|
|
off := m.taskDetailScroll + delta
|
|
if off > max {
|
|
off = max
|
|
}
|
|
if off < 0 {
|
|
off = 0
|
|
}
|
|
m.taskDetailScroll = off
|
|
}
|
|
|
|
func (m Model) tasksModal() string {
|
|
if m.taskDetail {
|
|
return m.taskDetailModal()
|
|
}
|
|
t := m.theme
|
|
w := m.modalWidth()
|
|
tasks := m.filteredTasks()
|
|
|
|
var b strings.Builder
|
|
b.WriteString(m.titleLine("tasks"))
|
|
if len(tasks) > 0 {
|
|
b.WriteString(mbg(t, " ("+itoa(m.taskListIndex+1)+"/"+itoa(len(tasks))+")", t.P.Faint))
|
|
}
|
|
b.WriteString("\n\n")
|
|
|
|
if m.taskListErr != "" {
|
|
b.WriteString(lipgloss.NewStyle().Foreground(t.P.Bad).Background(t.P.BgPanel).Render(" ▲ "+truncate(m.taskListErr, w-8)) + "\n")
|
|
b.WriteString("\n" + modalHints(t, [][2]string{{"T/esc", "close"}}))
|
|
return t.Overlay.Width(w).Render(b.String())
|
|
}
|
|
if m.taskListLoading && len(m.taskList) == 0 {
|
|
b.WriteString(mbg(t, " loading…", t.P.Faint) + "\n")
|
|
b.WriteString("\n" + modalHints(t, [][2]string{{"T/esc", "close"}}))
|
|
return t.Overlay.Width(w).Render(b.String())
|
|
}
|
|
|
|
// Filter prompt: shown while editing the `/` query or whenever one is set.
|
|
if m.taskFilterTyping || m.taskFilter != "" {
|
|
b.WriteString(m.taskFilterLine() + "\n\n")
|
|
}
|
|
|
|
if len(tasks) == 0 {
|
|
msg := " no tasks"
|
|
if m.taskFilter != "" {
|
|
msg = " no tasks match \"" + m.taskFilter + "\""
|
|
}
|
|
b.WriteString(mbg(t, msg, t.P.Faint) + "\n")
|
|
b.WriteString("\n" + modalHints(t, m.taskListHints()))
|
|
return t.Overlay.Width(w).Render(b.String())
|
|
}
|
|
|
|
// Windowed roster, keeping the selected row in view.
|
|
bodyH := m.height*70/100 - 6
|
|
if bodyH < 4 {
|
|
bodyH = 4
|
|
}
|
|
off := 0
|
|
if len(tasks) > bodyH {
|
|
off = m.taskListIndex - bodyH/2
|
|
if off < 0 {
|
|
off = 0
|
|
}
|
|
if off > len(tasks)-bodyH {
|
|
off = len(tasks) - bodyH
|
|
}
|
|
}
|
|
end := off + bodyH
|
|
if end > len(tasks) {
|
|
end = len(tasks)
|
|
}
|
|
for i := off; i < end; i++ {
|
|
b.WriteString(m.taskListRow(tasks, i) + "\n")
|
|
}
|
|
|
|
b.WriteString("\n" + modalHints(t, m.taskListHints()))
|
|
return t.Overlay.Width(w).Render(b.String())
|
|
}
|
|
|
|
// taskListHints is the list-mode footer (shared by the populated and empty-filter views).
|
|
func (m Model) taskListHints() [][2]string {
|
|
return [][2]string{{"↑↓", "select"}, {"enter", "detail"}, {"/", "filter"}, {"r", "refresh"}, {"T/esc", "close"}}
|
|
}
|
|
|
|
// taskFilterLine renders the `/` filter prompt over the current query, with a caret while editing.
|
|
func (m Model) taskFilterLine() string {
|
|
t := m.theme
|
|
prompt := lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("/ ")
|
|
caret := mbg(t, "", t.P.BgPanel)
|
|
if m.taskFilterTyping && m.caretVisible() {
|
|
caret = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("▏")
|
|
}
|
|
if m.taskFilter == "" {
|
|
return prompt + caret + mbg(t, "type to filter tasks…", t.P.Faint)
|
|
}
|
|
return prompt + mbg(t, m.taskFilter, t.P.FgStrong) + caret
|
|
}
|
|
|
|
// taskListRow renders one board line: marker, id, status, title, and claimant.
|
|
func (m Model) taskListRow(tasks []TaskSummary, i int) string {
|
|
t := m.theme
|
|
tk := tasks[i]
|
|
|
|
marker := mbg(t, " ", t.P.BgPanel)
|
|
idFg := t.P.Fg
|
|
if i == m.taskListIndex {
|
|
marker = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("▸ ")
|
|
idFg = t.P.FgStrong
|
|
}
|
|
|
|
idCell := lipgloss.NewStyle().Foreground(idFg).Background(t.P.BgPanel).Render(padRaw(tk.ID, 12))
|
|
statusCell := lipgloss.NewStyle().Foreground(taskStatusColor(t, tk.Status)).Background(t.P.BgPanel).Bold(true).
|
|
Render(padRaw(tk.Status, 12))
|
|
|
|
claim := ""
|
|
if tk.Claimant != "" {
|
|
claim = " @" + shortSessionID(tk.Claimant)
|
|
}
|
|
readyCell := taskReadyCell(t, tk) // 2-col dependency-readiness glyph
|
|
titleW := m.modalWidth() - 6 - 12 - 1 - 12 - 1 - 2 - 1 - len([]rune(claim))
|
|
if titleW < 8 {
|
|
titleW = 8
|
|
}
|
|
titleCell := mbg(t, padRaw(truncate(tk.Title, titleW), titleW), t.P.Fg)
|
|
claimCell := mbg(t, claim, t.P.Faint)
|
|
|
|
return marker + idCell + " " + statusCell + " " + readyCell + " " + titleCell + claimCell
|
|
}
|
|
|
|
// taskReadyCell is a 2-column glyph showing dependency-graph readiness, so a decomposed graph reads
|
|
// at a glance: a filled dot when the task is workable now (TODO, no unmet deps), a hollow dot when it
|
|
// is waiting on unfinished blockers, blank for anything already in flight or finished.
|
|
func taskReadyCell(t Theme, tk TaskSummary) string {
|
|
switch {
|
|
case tk.Ready:
|
|
return lipgloss.NewStyle().Foreground(t.P.OK).Background(t.P.BgPanel).Render(padRaw("●", 2))
|
|
case len(tk.BlockedBy) > 0:
|
|
return lipgloss.NewStyle().Foreground(t.P.Dim).Background(t.P.BgPanel).Render(padRaw("○", 2))
|
|
default:
|
|
return mbg(t, " ", t.P.BgPanel)
|
|
}
|
|
}
|
|
|
|
func (m Model) taskDetailModal() string {
|
|
t := m.theme
|
|
w := m.modalWidth()
|
|
tk, _ := m.selectedTask()
|
|
|
|
body := m.taskDetailBody(w - 4)
|
|
bodyH := m.taskDetailBodyH()
|
|
off := m.taskDetailScroll
|
|
if mx := m.taskDetailMaxScroll(); off > mx {
|
|
off = mx
|
|
}
|
|
if off < 0 {
|
|
off = 0
|
|
}
|
|
|
|
var b strings.Builder
|
|
b.WriteString(m.titleLine("task " + tk.ID))
|
|
b.WriteString(lipgloss.NewStyle().Foreground(taskStatusColor(t, tk.Status)).Background(t.P.BgPanel).Bold(true).
|
|
Render(" " + tk.Status))
|
|
if len(body) > bodyH {
|
|
b.WriteString(mbg(t, " ("+itoa(off+1)+"-"+itoa(min(off+bodyH, len(body)))+"/"+itoa(len(body))+")", t.P.Faint))
|
|
}
|
|
b.WriteString("\n\n")
|
|
|
|
end := off + bodyH
|
|
if end > len(body) {
|
|
end = len(body)
|
|
}
|
|
for i := off; i < end; i++ {
|
|
b.WriteString(body[i] + "\n")
|
|
}
|
|
b.WriteString("\n" + modalHints(t, [][2]string{{"↑↓", "scroll"}, {"g/G", "ends"}, {"enter/esc", "back"}}))
|
|
return t.Overlay.Width(w).Render(b.String())
|
|
}
|
|
|
|
// taskDetailBody renders the selected task as styled, wrapped lines: goal, acceptance
|
|
// criteria, affected paths, links, and notes. Built entirely from the list payload, so
|
|
// it needs no extra fetch.
|
|
func (m Model) taskDetailBody(width int) []string {
|
|
t := m.theme
|
|
tk, ok := m.selectedTask()
|
|
if !ok {
|
|
return []string{mbg(t, " (no task selected)", t.P.Faint)}
|
|
}
|
|
if width < 8 {
|
|
width = 8
|
|
}
|
|
var out []string
|
|
section := func(label string) {
|
|
out = append(out, lipgloss.NewStyle().Foreground(t.P.Accent2).Background(t.P.BgPanel).Bold(true).Render(label))
|
|
}
|
|
put := func(s string, fg color.Color) {
|
|
for _, ln := range wrapLines([]string{s}, width) {
|
|
out = append(out, mbg(t, ln, fg))
|
|
}
|
|
}
|
|
|
|
put(tk.Title, t.P.FgStrong)
|
|
if tk.Claimant != "" {
|
|
out = append(out, mbg(t, "claimant: "+tk.Claimant, t.P.Faint))
|
|
}
|
|
if tk.Ready {
|
|
out = append(out, mbg(t, "● ready to work (no unmet dependencies)", t.P.OK))
|
|
} else if len(tk.BlockedBy) > 0 {
|
|
out = append(out, mbg(t, "○ blocked — waiting on "+strings.Join(tk.BlockedBy, ", "), t.P.Dim))
|
|
}
|
|
out = append(out, "")
|
|
|
|
if tk.Goal != "" {
|
|
section("goal")
|
|
put(tk.Goal, t.P.Fg)
|
|
out = append(out, "")
|
|
}
|
|
if len(tk.AcceptanceCriteria) > 0 {
|
|
section("acceptance criteria")
|
|
for _, c := range tk.AcceptanceCriteria {
|
|
put("- "+c, t.P.Fg)
|
|
}
|
|
out = append(out, "")
|
|
}
|
|
if len(tk.AffectedPaths) > 0 {
|
|
section("affected paths")
|
|
for _, p := range tk.AffectedPaths {
|
|
out = append(out, mbg(t, clip(" "+p, width), t.P.Dim))
|
|
}
|
|
out = append(out, "")
|
|
}
|
|
if len(tk.Links) > 0 {
|
|
section("links")
|
|
for _, l := range tk.Links {
|
|
out = append(out, mbg(t, clip(" "+l.TargetID+" ("+l.Type+" → "+l.TargetKind+")", width), t.P.Fg))
|
|
}
|
|
out = append(out, "")
|
|
}
|
|
if len(tk.Notes) > 0 {
|
|
section("notes")
|
|
for _, n := range tk.Notes {
|
|
put("["+n.Author+"] "+n.Body, t.P.Fg)
|
|
}
|
|
out = append(out, "")
|
|
}
|
|
|
|
// Drop the trailing blank so the body never has a dangling empty line.
|
|
for len(out) > 0 && out[len(out)-1] == "" {
|
|
out = out[:len(out)-1]
|
|
}
|
|
return out
|
|
}
|
|
|
|
// taskStatusColor maps a task status to a palette color for the board / detail header.
|
|
func taskStatusColor(t Theme, status string) color.Color {
|
|
switch status {
|
|
case "DONE":
|
|
return t.P.OK
|
|
case "IN_PROGRESS":
|
|
return t.P.Accent
|
|
case "IN_REVIEW":
|
|
return t.P.Accent2
|
|
case "BLOCKED":
|
|
return t.P.Bad
|
|
case "CANCELLED":
|
|
return t.P.Dim
|
|
default: // TODO
|
|
return t.P.Faint
|
|
}
|
|
}
|