Add offline-first task-master TUI with tickets, board lists, and undo.

Charm-based CLI with JSON persistence, status/list filters, external editor editing, command-history undo/redo, and glamour markdown without AutoStyle OSC hangs.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Skruecken
2026-10-19 10:21:48 +02:00
co-authored by Cursor
parent 4a3d0d777b
commit 24c1e146ec
37 changed files with 3259 additions and 0 deletions
+66
View File
@@ -0,0 +1,66 @@
package views
import (
"strings"
"task/internal/ticket"
)
// BoardMenu renders a board-list picker overlay.
type BoardMenu struct {
Cursor int
Width int
Styles StatusMenuStyles
}
// NewBoardMenu creates a menu with the given board list pre-selected.
func NewBoardMenu(current ticket.BoardList, width int, styles StatusMenuStyles) BoardMenu {
return BoardMenu{
Cursor: current.Index(),
Width: width,
Styles: styles,
}
}
// MoveDown moves the selection down.
func (m *BoardMenu) MoveDown() {
if m.Cursor < len(ticket.AllBoardLists)-1 {
m.Cursor++
}
}
// MoveUp moves the selection up.
func (m *BoardMenu) MoveUp() {
if m.Cursor > 0 {
m.Cursor--
}
}
// Selected returns the currently highlighted board list.
func (m BoardMenu) Selected() ticket.BoardList {
if m.Cursor < 0 || m.Cursor >= len(ticket.AllBoardLists) {
return ticket.BoardAll
}
return ticket.AllBoardLists[m.Cursor]
}
// View renders the board menu.
func (m BoardMenu) View() string {
var lines []string
lines = append(lines, m.Styles.Title.Render("Switch list"))
for i, bl := range ticket.AllBoardLists {
label := " " + bl.Label()
if i == m.Cursor {
label = m.Styles.Selected.Render("▸ " + bl.Label())
} else {
label = m.Styles.Normal.Render(label)
}
lines = append(lines, label)
}
body := strings.Join(lines, "\n")
w := m.Width
if w < 20 {
w = 20
}
return m.Styles.Box.Width(min(40, w-2)).Render(body)
}
+14
View File
@@ -0,0 +1,14 @@
package views
import (
"github.com/charmbracelet/bubbles/textinput"
)
// NewCreateInput returns a text input configured for creating a ticket.
func NewCreateInput() textinput.Model {
ti := textinput.New()
ti.Placeholder = "Ticket headline…"
ti.CharLimit = 200
ti.Width = 60
return ti
}
+89
View File
@@ -0,0 +1,89 @@
package views
import (
"sync"
"github.com/charmbracelet/glamour"
"github.com/charmbracelet/glamour/styles"
"github.com/muesli/termenv"
)
type mdCacheKey struct {
width int
src string
}
var (
mdMu sync.Mutex
mdRenderer *glamour.TermRenderer
mdWidth int
mdCache = map[mdCacheKey]string{}
)
// RenderMarkdown renders Markdown to ANSI using a cached glamour renderer.
//
// Important: we intentionally use a fixed dark style — NOT WithAutoStyle().
// AutoStyle calls termenv.HasDarkBackground(), which sends an OSC query to the
// terminal and waits up to ~5s for a reply. In Cursor/some PTYs that reply
// never arrives, so the TUI appears to hang on startup (first selected ticket
// with a description triggers the renderer in ensureVisible/View).
func RenderMarkdown(src string, width int) string {
if src == "" {
return ""
}
if width < 20 {
width = 20
}
key := mdCacheKey{width: width, src: src}
mdMu.Lock()
defer mdMu.Unlock()
if out, ok := mdCache[key]; ok {
return out
}
r, err := getRenderer(width)
if err != nil {
return src
}
out, err := r.Render(src)
if err != nil {
return src
}
for len(out) > 0 && (out[len(out)-1] == '\n' || out[len(out)-1] == ' ') {
out = out[:len(out)-1]
}
if len(mdCache) > 128 {
mdCache = map[mdCacheKey]string{}
}
mdCache[key] = out
return out
}
func getRenderer(width int) (*glamour.TermRenderer, error) {
if mdRenderer != nil && mdWidth == width {
return mdRenderer, nil
}
r, err := glamour.NewTermRenderer(
glamour.WithStyles(styles.DarkStyleConfig),
glamour.WithWordWrap(width),
glamour.WithColorProfile(termenv.ANSI256),
glamour.WithEmoji(),
)
if err != nil {
return nil, err
}
mdRenderer = r
mdWidth = width
return r, nil
}
// ClearMarkdownCache drops cached renders (e.g. after theme change).
func ClearMarkdownCache() {
mdMu.Lock()
defer mdMu.Unlock()
mdCache = map[mdCacheKey]string{}
mdRenderer = nil
mdWidth = 0
}
+32
View File
@@ -0,0 +1,32 @@
package views_test
import (
"strings"
"testing"
"time"
"task/internal/ui/views"
)
func TestRenderMarkdownFastWithoutAutoStyle(t *testing.T) {
src := "## Hello\n\nSome **bold** text\n\n- a\n- b\n"
start := time.Now()
out := views.RenderMarkdown(src, 80)
elapsed := time.Since(start)
if elapsed > 2*time.Second {
t.Fatalf("RenderMarkdown took %v — WithAutoStyle/OSC hang likely still present", elapsed)
}
if out == "" || out == src {
// glamour should style bold; at least produce non-empty output
if strings.TrimSpace(out) == "" {
t.Fatal("empty output")
}
}
// cached path should be near-instant
start = time.Now()
_ = views.RenderMarkdown(src, 80)
if time.Since(start) > 500*time.Millisecond {
t.Fatalf("cached render too slow: %v", time.Since(start))
}
t.Logf("first=%v cached=%v", elapsed, time.Since(start))
}
+56
View File
@@ -0,0 +1,56 @@
package views
import (
"github.com/charmbracelet/bubbles/list"
"task/internal/project"
)
// ProjectItem wraps a project for bubbles/list.
type ProjectItem struct {
Project project.Project
Active bool
}
func (i ProjectItem) Title() string {
if i.Active {
return i.Project.Name + " *"
}
return i.Project.Name
}
func (i ProjectItem) Description() string { return i.Project.ID }
func (i ProjectItem) FilterValue() string { return i.Project.Name }
// NewProjectList builds a list of projects.
func NewProjectList(projects []project.Project, activeID string, width, height int) list.Model {
items := make([]list.Item, len(projects))
selected := 0
for i, p := range projects {
items[i] = ProjectItem{Project: p, Active: p.ID == activeID}
if p.ID == activeID {
selected = i
}
}
delegate := list.NewDefaultDelegate()
delegate.ShowDescription = false
l := list.New(items, delegate, width, height)
l.Title = "Projects"
l.SetShowStatusBar(false)
l.SetShowHelp(false)
l.SetFilteringEnabled(false)
l.DisableQuitKeybindings()
if len(items) > 0 {
l.Select(selected)
}
return l
}
// ProjectsToItems converts projects to list items.
func ProjectsToItems(projects []project.Project, activeID string) []list.Item {
items := make([]list.Item, len(projects))
for i, p := range projects {
items[i] = ProjectItem{Project: p, Active: p.ID == activeID}
}
return items
}
+14
View File
@@ -0,0 +1,14 @@
package views
import (
"github.com/charmbracelet/bubbles/textinput"
)
// NewSearchInput returns a text input configured for search/filter.
func NewSearchInput() textinput.Model {
ti := textinput.New()
ti.Placeholder = "Filter tasks…"
ti.CharLimit = 100
ti.Width = 60
return ti
}
+83
View File
@@ -0,0 +1,83 @@
package views
import (
"strings"
"github.com/charmbracelet/lipgloss"
"task/internal/ticket"
)
// StatusMenu renders a status picker overlay.
type StatusMenu struct {
Cursor int
Width int
Styles StatusMenuStyles
}
// StatusMenuStyles holds styles for the status picker.
type StatusMenuStyles struct {
Box lipgloss.Style
Title lipgloss.Style
Selected lipgloss.Style
Normal lipgloss.Style
}
// NewStatusMenu creates a menu with the given status pre-selected.
func NewStatusMenu(current ticket.Status, width int, styles StatusMenuStyles) StatusMenu {
return StatusMenu{
Cursor: current.Index(),
Width: width,
Styles: styles,
}
}
// MoveDown moves the selection down.
func (m *StatusMenu) MoveDown() {
if m.Cursor < len(ticket.AllStatuses)-1 {
m.Cursor++
}
}
// MoveUp moves the selection up.
func (m *StatusMenu) MoveUp() {
if m.Cursor > 0 {
m.Cursor--
}
}
// Selected returns the currently highlighted status.
func (m StatusMenu) Selected() ticket.Status {
if m.Cursor < 0 || m.Cursor >= len(ticket.AllStatuses) {
return ticket.StatusTodo
}
return ticket.AllStatuses[m.Cursor]
}
// View renders the status menu.
func (m StatusMenu) View() string {
var lines []string
lines = append(lines, m.Styles.Title.Render("Set status"))
for i, st := range ticket.AllStatuses {
label := " " + st.Label()
if i == m.Cursor {
label = m.Styles.Selected.Render("▸ " + st.Label())
} else {
label = m.Styles.Normal.Render(label)
}
lines = append(lines, label)
}
body := strings.Join(lines, "\n")
w := m.Width
if w < 20 {
w = 20
}
return m.Styles.Box.Width(min(40, w-2)).Render(body)
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
+243
View File
@@ -0,0 +1,243 @@
package views
import (
"fmt"
"strings"
"github.com/charmbracelet/lipgloss"
"task/internal/ticket"
)
// CollapsedCardHeight is the fixed row height for non-expanded cards
// (single content line; left accent border adds no vertical space).
const CollapsedCardHeight = 1
// TicketListStyles holds styles for card rendering.
type TicketListStyles struct {
Card lipgloss.Style
CardSelected lipgloss.Style
StatusOPEN lipgloss.Style
StatusWIP lipgloss.Style
StatusDONE lipgloss.Style
Headline lipgloss.Style
}
// TicketList is a custom viewport list of ticket cards.
type TicketList struct {
Items []ticket.Ticket
Cursor int
Offset int
Width int
Height int
Styles TicketListStyles
}
// SetSize updates the viewport dimensions.
func (l *TicketList) SetSize(width, height int) {
l.Width = width
l.Height = height
l.ensureVisible()
}
// SetItems replaces the ticket list, clamping the cursor.
func (l *TicketList) SetItems(items []ticket.Ticket) {
l.Items = items
if l.Cursor >= len(l.Items) {
l.Cursor = max(0, len(l.Items)-1)
}
l.Offset = 0
l.ensureVisible()
}
// Selected returns the currently selected ticket and whether one exists.
func (l *TicketList) Selected() (ticket.Ticket, bool) {
if len(l.Items) == 0 || l.Cursor < 0 || l.Cursor >= len(l.Items) {
return ticket.Ticket{}, false
}
return l.Items[l.Cursor], true
}
// CursorIndex returns the current cursor.
func (l *TicketList) CursorIndex() int { return l.Cursor }
// Len returns the number of tickets.
func (l *TicketList) Len() int { return len(l.Items) }
// MoveDown moves the cursor down.
func (l *TicketList) MoveDown() {
if l.Cursor < len(l.Items)-1 {
l.Cursor++
l.ensureVisible()
}
}
// MoveUp moves the cursor up.
func (l *TicketList) MoveUp() {
if l.Cursor > 0 {
l.Cursor--
l.ensureVisible()
}
}
// Select sets the cursor to idx if in range.
func (l *TicketList) Select(idx int) {
if idx < 0 || idx >= len(l.Items) {
return
}
l.Cursor = idx
l.ensureVisible()
}
// View renders only the visible ticket cards.
func (l *TicketList) View() string {
if l.Height < 1 {
return ""
}
if len(l.Items) == 0 {
empty := lipgloss.NewStyle().Faint(true).Render("No tickets — press n to create one")
return lipgloss.NewStyle().Width(l.Width).Height(l.Height).Render(empty)
}
var visible []string
used := 0
for i := l.Offset; i < len(l.Items); i++ {
card := l.renderCard(l.Items[i], i == l.Cursor)
h := lipgloss.Height(card)
if used > 0 && used+h > l.Height {
break
}
visible = append(visible, card)
used += h
}
body := lipgloss.JoinVertical(lipgloss.Left, visible...)
return lipgloss.NewStyle().Width(l.Width).Height(l.Height).MaxHeight(l.Height).Render(body)
}
func (l *TicketList) renderCard(t ticket.Ticket, selected bool) string {
innerWidth := max(10, l.Width-4)
badge := l.statusBadge(t.Status)
headline := truncateOneLine(plainHeadline(t.Title), max(8, innerWidth-lipgloss.Width(badge)-2))
headerLine := badge + " " + l.Styles.Headline.Render(headline)
var content string
if selected {
// Expanded: headline + glamour-rendered description (fixed dark style).
desc := strings.TrimSpace(t.Description)
if desc != "" {
content = headerLine + "\n" + RenderMarkdown(desc, innerWidth)
} else {
content = headerLine
}
} else {
content = headerLine
}
style := l.Styles.Card
if selected {
style = l.Styles.CardSelected
}
return style.Width(max(4, l.Width-2)).Render(content)
}
func (l *TicketList) statusBadge(s ticket.Status) string {
label := fmt.Sprintf(" %s ", s.Label())
switch s {
case ticket.StatusInProgress:
return l.Styles.StatusWIP.Render(label)
case ticket.StatusDone:
return l.Styles.StatusDONE.Render(label)
default:
return l.Styles.StatusOPEN.Render(label)
}
}
func (l *TicketList) ensureVisible() {
if len(l.Items) == 0 {
l.Offset = 0
return
}
if l.Cursor < l.Offset {
l.Offset = l.Cursor
return
}
for {
used := 0
fits := false
for i := l.Offset; i < len(l.Items); i++ {
h := l.estimateHeight(i)
if used > 0 && used+h > l.Height {
break
}
used += h
if i == l.Cursor {
fits = true
break
}
}
if fits || l.Offset >= l.Cursor {
break
}
l.Offset++
}
}
// estimateHeight avoids full glamour for collapsed cards.
func (l *TicketList) estimateHeight(index int) int {
if index < 0 || index >= len(l.Items) {
return CollapsedCardHeight
}
if index == l.Cursor && strings.TrimSpace(l.Items[index].Description) != "" {
return lipgloss.Height(l.renderCard(l.Items[index], true))
}
return CollapsedCardHeight
}
// FilterTickets returns tickets matching query in title or description.
func FilterTickets(tickets []ticket.Ticket, query string) []ticket.Ticket {
q := strings.ToLower(strings.TrimSpace(query))
if q == "" {
return tickets
}
var out []ticket.Ticket
for _, t := range tickets {
if strings.Contains(strings.ToLower(t.Title), q) ||
strings.Contains(strings.ToLower(t.Description), q) {
out = append(out, t)
}
}
return out
}
func plainHeadline(title string) string {
title = strings.TrimSpace(title)
title = strings.TrimLeft(title, "#")
return strings.TrimSpace(title)
}
func truncateOneLine(s string, maxWidth int) string {
if maxWidth <= 0 {
return ""
}
if lipgloss.Width(s) <= maxWidth {
return s
}
runes := []rune(s)
for len(runes) > 0 {
runes = runes[:len(runes)-1]
if lipgloss.Width(string(runes)) <= maxWidth-1 {
return string(runes) + "…"
}
}
return ""
}
func max(a, b int) int {
if a > b {
return a
}
return b
}