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 } }