Files
correx/apps/tui-go/internal/app/tasks_overlay.go
T
kami 99f781687f feat(tasks): task search across REST, tool, and TUI
Ranked exact/substring search: TaskSearch (all terms AND, ranked title >
key > goal > criteria > notes) over one project or the whole board via
TaskService.search. Surfaced as GET /tasks?q=, a read-only task_search tool
for agents, and a `/` filter in the TUI board.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 22:08:11 +00:00

470 lines
13 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"`
}
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)
}
titleW := m.modalWidth() - 6 - 12 - 1 - 12 - 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 + " " + titleCell + claimCell
}
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))
}
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
}
}