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
+79
View File
@@ -0,0 +1,79 @@
package ticket
// BoardList is a project-scoped view/filter over tickets by status.
type BoardList string
const (
BoardAll BoardList = "all"
BoardBacklog BoardList = "backlog"
BoardInProgress BoardList = "in_progress"
BoardDone BoardList = "done"
)
// AllBoardLists is the ordered picker list.
var AllBoardLists = []BoardList{BoardAll, BoardBacklog, BoardInProgress, BoardDone}
// Label returns a display name for the board list.
func (b BoardList) Label() string {
switch b {
case BoardAll:
return "All"
case BoardBacklog:
return "Backlog"
case BoardInProgress:
return "In Progress"
case BoardDone:
return "Done"
default:
return string(b)
}
}
// Index returns the position in AllBoardLists.
func (b BoardList) Index() int {
for i, bl := range AllBoardLists {
if bl == b {
return i
}
}
return 0
}
// ParseBoardList validates a board list id; invalid values become BoardAll.
func ParseBoardList(s string) BoardList {
b := BoardList(s)
switch b {
case BoardAll, BoardBacklog, BoardInProgress, BoardDone:
return b
default:
return BoardAll
}
}
// Matches reports whether a ticket belongs on this board list.
func (b BoardList) Matches(status Status) bool {
switch b {
case BoardAll:
return true
case BoardBacklog:
return status == StatusTodo
case BoardInProgress:
return status == StatusInProgress
case BoardDone:
return status == StatusDone
default:
return true
}
}
// DefaultStatus is the status used when creating a ticket on this list.
func (b BoardList) DefaultStatus() Status {
switch b {
case BoardInProgress:
return StatusInProgress
case BoardDone:
return StatusDone
default:
return StatusTodo
}
}
+43
View File
@@ -0,0 +1,43 @@
package ticket_test
import (
"testing"
"task/internal/ticket"
)
func TestBoardListMatches(t *testing.T) {
cases := []struct {
list ticket.BoardList
status ticket.Status
want bool
}{
{ticket.BoardAll, ticket.StatusTodo, true},
{ticket.BoardAll, ticket.StatusDone, true},
{ticket.BoardBacklog, ticket.StatusTodo, true},
{ticket.BoardBacklog, ticket.StatusInProgress, false},
{ticket.BoardInProgress, ticket.StatusInProgress, true},
{ticket.BoardDone, ticket.StatusDone, true},
{ticket.BoardDone, ticket.StatusTodo, false},
}
for _, tc := range cases {
if got := tc.list.Matches(tc.status); got != tc.want {
t.Errorf("%s.Matches(%s) = %v, want %v", tc.list, tc.status, got, tc.want)
}
}
}
func TestBoardListDefaultStatus(t *testing.T) {
if ticket.BoardBacklog.DefaultStatus() != ticket.StatusTodo {
t.Fatal("backlog default")
}
if ticket.BoardInProgress.DefaultStatus() != ticket.StatusInProgress {
t.Fatal("in progress default")
}
if ticket.BoardDone.DefaultStatus() != ticket.StatusDone {
t.Fatal("done default")
}
if ticket.BoardAll.DefaultStatus() != ticket.StatusTodo {
t.Fatal("all default")
}
}
+105
View File
@@ -0,0 +1,105 @@
package ticket
import (
"fmt"
"os"
"os/exec"
"strings"
)
// FormatEditDocument is the markdown representation used for external editing.
// Format:
//
// # Title
//
// optional description body…
func FormatEditDocument(t Ticket) string {
title := strings.TrimSpace(t.Title)
title = strings.TrimLeft(title, "#")
title = strings.TrimSpace(title)
var b strings.Builder
b.WriteString("# ")
b.WriteString(title)
b.WriteString("\n")
if desc := strings.TrimSpace(t.Description); desc != "" {
b.WriteString("\n")
b.WriteString(desc)
if !strings.HasSuffix(desc, "\n") {
b.WriteString("\n")
}
}
return b.String()
}
// ParseEditDocument extracts title and description from an edited markdown file.
func ParseEditDocument(content string) (title, description string, err error) {
content = strings.ReplaceAll(content, "\r\n", "\n")
content = strings.TrimSpace(content)
if content == "" {
return "", "", fmt.Errorf("ticket content is empty")
}
lines := strings.Split(content, "\n")
first := strings.TrimSpace(lines[0])
if strings.HasPrefix(first, "#") {
title = strings.TrimSpace(strings.TrimLeft(first, "#"))
rest := strings.Join(lines[1:], "\n")
description = strings.TrimSpace(rest)
} else {
title = first
rest := strings.Join(lines[1:], "\n")
description = strings.TrimSpace(rest)
}
if title == "" {
return "", "", fmt.Errorf("ticket title is required")
}
return title, description, nil
}
// WriteEditFile writes the ticket to a temp .md file and returns its path.
func WriteEditFile(t Ticket) (string, error) {
f, err := os.CreateTemp("", "task-master-ticket-*.md")
if err != nil {
return "", fmt.Errorf("create temp file: %w", err)
}
path := f.Name()
if _, err := f.WriteString(FormatEditDocument(t)); err != nil {
_ = f.Close()
_ = os.Remove(path)
return "", fmt.Errorf("write temp file: %w", err)
}
if err := f.Close(); err != nil {
_ = os.Remove(path)
return "", fmt.Errorf("close temp file: %w", err)
}
return path, nil
}
// ResolveEditor returns the user's preferred editor command string.
// Order: $EDITOR → $VISUAL → nvim → vim → vi.
func ResolveEditor() string {
if ed := strings.TrimSpace(os.Getenv("EDITOR")); ed != "" {
return ed
}
if ed := strings.TrimSpace(os.Getenv("VISUAL")); ed != "" {
return ed
}
for _, cand := range []string{"nvim", "vim", "vi"} {
if _, err := exec.LookPath(cand); err == nil {
return cand
}
}
return "vi"
}
// EditorCmd builds a command that opens path in the default terminal editor.
// Supports multi-word EDITOR values like "code --wait".
func EditorCmd(path string) *exec.Cmd {
ed := ResolveEditor()
cmd := exec.Command("sh", "-c", ed+` "$1"`, "--", path)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd
}
+34
View File
@@ -0,0 +1,34 @@
package ticket_test
import (
"os"
"os/exec"
"testing"
"task/internal/ticket"
)
func TestResolveEditorPrefersEnv(t *testing.T) {
t.Setenv("EDITOR", "custom-ed")
t.Setenv("VISUAL", "visual-ed")
if got := ticket.ResolveEditor(); got != "custom-ed" {
t.Fatalf("got %q, want custom-ed", got)
}
}
func TestResolveEditorFallsBackToNvim(t *testing.T) {
t.Setenv("EDITOR", "")
t.Setenv("VISUAL", "")
got := ticket.ResolveEditor()
if _, err := exec.LookPath("nvim"); err == nil {
if got != "nvim" {
t.Fatalf("got %q, want nvim when available", got)
}
return
}
// Without nvim, accept vim/vi.
if got != "vim" && got != "vi" {
t.Fatalf("got unexpected fallback %q", got)
}
_ = os.Environ()
}
+45
View File
@@ -0,0 +1,45 @@
package ticket_test
import (
"strings"
"testing"
"task/internal/ticket"
)
func TestFormatAndParseEditDocument(t *testing.T) {
doc := ticket.FormatEditDocument(ticket.Ticket{
Title: "Fix login",
Description: "Steps:\n\n1. repro\n2. patch",
})
if !strings.HasPrefix(doc, "# Fix login\n") {
t.Fatalf("format = %q", doc)
}
title, desc, err := ticket.ParseEditDocument(doc)
if err != nil {
t.Fatal(err)
}
if title != "Fix login" {
t.Fatalf("title = %q", title)
}
if desc != "Steps:\n\n1. repro\n2. patch" {
t.Fatalf("desc = %q", desc)
}
}
func TestParseEditDocumentPlainTitle(t *testing.T) {
title, desc, err := ticket.ParseEditDocument("Plain title\n\nBody text")
if err != nil {
t.Fatal(err)
}
if title != "Plain title" || desc != "Body text" {
t.Fatalf("got %q / %q", title, desc)
}
}
func TestParseEditDocumentEmpty(t *testing.T) {
if _, _, err := ticket.ParseEditDocument(" "); err == nil {
t.Fatal("expected error")
}
}
+245
View File
@@ -0,0 +1,245 @@
package ticket
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sync"
"time"
"github.com/google/uuid"
)
const (
ticketsFileName = "tickets.json"
legacyFileName = "tasks.json"
)
// JSONStore persists tickets as JSON arrays under DataDir/projects/<id>/tickets.json.
type JSONStore struct {
dataDir string
mu sync.Mutex
}
// NewJSONStore creates a store rooted at dataDir.
func NewJSONStore(dataDir string) *JSONStore {
return &JSONStore{dataDir: dataDir}
}
func (s *JSONStore) projectPath(projectID string) string {
return filepath.Join(s.dataDir, "projects", projectID)
}
func (s *JSONStore) ticketsPath(projectID string) string {
return filepath.Join(s.projectPath(projectID), ticketsFileName)
}
func (s *JSONStore) legacyPath(projectID string) string {
return filepath.Join(s.projectPath(projectID), legacyFileName)
}
// List returns all tickets for a project.
func (s *JSONStore) List(projectID string) ([]Ticket, error) {
s.mu.Lock()
defer s.mu.Unlock()
return s.readUnlocked(projectID)
}
// Get finds a ticket by ID across all project directories.
func (s *JSONStore) Get(id string) (Ticket, error) {
s.mu.Lock()
defer s.mu.Unlock()
projects, err := os.ReadDir(filepath.Join(s.dataDir, "projects"))
if err != nil {
if os.IsNotExist(err) {
return Ticket{}, fmt.Errorf("ticket %s: not found", id)
}
return Ticket{}, fmt.Errorf("list projects: %w", err)
}
for _, p := range projects {
if !p.IsDir() {
continue
}
tickets, err := s.readUnlocked(p.Name())
if err != nil {
return Ticket{}, err
}
for _, t := range tickets {
if t.ID == id {
return t, nil
}
}
}
return Ticket{}, fmt.Errorf("ticket %s: not found", id)
}
// Create adds a new ticket, assigning ID and timestamps if missing.
func (s *JSONStore) Create(t Ticket) (Ticket, error) {
s.mu.Lock()
defer s.mu.Unlock()
if t.ProjectID == "" {
return Ticket{}, fmt.Errorf("projectId is required")
}
if t.Title == "" {
return Ticket{}, fmt.Errorf("title is required")
}
if t.ID == "" {
t.ID = uuid.NewString()
}
if t.Status == "" {
t.Status = StatusTodo
}
now := time.Now().UTC()
if t.CreatedAt.IsZero() {
t.CreatedAt = now
}
if t.UpdatedAt.IsZero() {
t.UpdatedAt = now
}
if err := os.MkdirAll(s.projectPath(t.ProjectID), 0o755); err != nil {
return Ticket{}, fmt.Errorf("create project dir: %w", err)
}
tickets, err := s.readUnlocked(t.ProjectID)
if err != nil {
return Ticket{}, err
}
tickets = append(tickets, t)
if err := s.writeUnlocked(t.ProjectID, tickets); err != nil {
return Ticket{}, err
}
return t, nil
}
// Update replaces an existing ticket (matched by ID within its project).
func (s *JSONStore) Update(t Ticket) error {
s.mu.Lock()
defer s.mu.Unlock()
if t.ID == "" || t.ProjectID == "" {
return fmt.Errorf("id and projectId are required")
}
tickets, err := s.readUnlocked(t.ProjectID)
if err != nil {
return err
}
found := false
for i := range tickets {
if tickets[i].ID == t.ID {
t.CreatedAt = tickets[i].CreatedAt
t.UpdatedAt = time.Now().UTC()
tickets[i] = t
found = true
break
}
}
if !found {
return fmt.Errorf("ticket %s: not found", t.ID)
}
return s.writeUnlocked(t.ProjectID, tickets)
}
// Delete removes a ticket by ID from its project's file.
func (s *JSONStore) Delete(id string) error {
s.mu.Lock()
defer s.mu.Unlock()
projects, err := os.ReadDir(filepath.Join(s.dataDir, "projects"))
if err != nil {
if os.IsNotExist(err) {
return fmt.Errorf("ticket %s: not found", id)
}
return fmt.Errorf("list projects: %w", err)
}
for _, p := range projects {
if !p.IsDir() {
continue
}
tickets, err := s.readUnlocked(p.Name())
if err != nil {
return err
}
for i, t := range tickets {
if t.ID == id {
tickets = append(tickets[:i], tickets[i+1:]...)
return s.writeUnlocked(p.Name(), tickets)
}
}
}
return fmt.Errorf("ticket %s: not found", id)
}
func (s *JSONStore) readUnlocked(projectID string) ([]Ticket, error) {
path := s.ticketsPath(projectID)
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
// Migrate from legacy tasks.json if present.
legacy := s.legacyPath(projectID)
data, err = os.ReadFile(legacy)
if err != nil {
if os.IsNotExist(err) {
return []Ticket{}, nil
}
return nil, fmt.Errorf("read %s: %w", legacy, err)
}
path = legacy
} else {
return nil, fmt.Errorf("read %s: %w", path, err)
}
}
if len(data) == 0 {
return []Ticket{}, nil
}
var tickets []Ticket
if err := json.Unmarshal(data, &tickets); err != nil {
return nil, fmt.Errorf("parse %s: %w", path, err)
}
if tickets == nil {
tickets = []Ticket{}
}
return tickets, nil
}
func (s *JSONStore) writeUnlocked(projectID string, tickets []Ticket) error {
path := s.ticketsPath(projectID)
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return fmt.Errorf("create project dir: %w", err)
}
data, err := json.MarshalIndent(tickets, "", " ")
if err != nil {
return fmt.Errorf("marshal tickets: %w", err)
}
data = append(data, '\n')
tmp, err := os.CreateTemp(filepath.Dir(path), "tickets-*.tmp")
if err != nil {
return fmt.Errorf("create temp file: %w", err)
}
tmpName := tmp.Name()
defer func() { _ = os.Remove(tmpName) }()
if _, err := tmp.Write(data); err != nil {
_ = tmp.Close()
return fmt.Errorf("write temp file: %w", err)
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("close temp file: %w", err)
}
if err := os.Rename(tmpName, path); err != nil {
return fmt.Errorf("rename temp to %s: %w", path, err)
}
// Clean up legacy file after successful write to tickets.json.
_ = os.Remove(s.legacyPath(projectID))
return nil
}
+99
View File
@@ -0,0 +1,99 @@
package ticket_test
import (
"os"
"path/filepath"
"testing"
"task/internal/ticket"
)
func TestJSONStoreCreateListUpdate(t *testing.T) {
dir := t.TempDir()
store := ticket.NewJSONStore(dir)
created, err := store.Create(ticket.Ticket{
Title: "Write tests",
ProjectID: "inbox",
})
if err != nil {
t.Fatalf("Create: %v", err)
}
if created.ID == "" {
t.Fatal("expected ID to be set")
}
if created.Status != ticket.StatusTodo {
t.Fatalf("status = %s, want todo", created.Status)
}
list, err := store.List("inbox")
if err != nil {
t.Fatalf("List: %v", err)
}
if len(list) != 1 {
t.Fatalf("len(list) = %d, want 1", len(list))
}
path := filepath.Join(dir, "projects", "inbox", "tickets.json")
if _, err := os.Stat(path); err != nil {
t.Fatalf("tickets.json missing: %v", err)
}
created.Status = ticket.StatusDone
created.Title = "Write better tests"
if err := store.Update(created); err != nil {
t.Fatalf("Update: %v", err)
}
got, err := store.Get(created.ID)
if err != nil {
t.Fatalf("Get: %v", err)
}
if got.Status != ticket.StatusDone || got.Title != "Write better tests" {
t.Fatalf("got %+v", got)
}
if err := store.Delete(created.ID); err != nil {
t.Fatalf("Delete: %v", err)
}
list, err = store.List("inbox")
if err != nil {
t.Fatalf("List after delete: %v", err)
}
if len(list) != 0 {
t.Fatalf("len(list) = %d, want 0", len(list))
}
}
func TestJSONStoreMigratesLegacyTasksJSON(t *testing.T) {
dir := t.TempDir()
projectDir := filepath.Join(dir, "projects", "inbox")
if err := os.MkdirAll(projectDir, 0o755); err != nil {
t.Fatal(err)
}
legacy := `[{"id":"abc","title":"Legacy","status":"todo","projectId":"inbox","createdAt":"2024-01-01T00:00:00Z","updatedAt":"2024-01-01T00:00:00Z"}]`
if err := os.WriteFile(filepath.Join(projectDir, "tasks.json"), []byte(legacy), 0o644); err != nil {
t.Fatal(err)
}
store := ticket.NewJSONStore(dir)
list, err := store.List("inbox")
if err != nil {
t.Fatalf("List: %v", err)
}
if len(list) != 1 || list[0].Title != "Legacy" {
t.Fatalf("got %+v", list)
}
// Write should create tickets.json and remove legacy.
list[0].Title = "Migrated"
if err := store.Update(list[0]); err != nil {
t.Fatalf("Update: %v", err)
}
if _, err := os.Stat(filepath.Join(projectDir, "tickets.json")); err != nil {
t.Fatalf("tickets.json missing after write: %v", err)
}
if _, err := os.Stat(filepath.Join(projectDir, "tasks.json")); !os.IsNotExist(err) {
t.Fatalf("legacy tasks.json should be removed, err=%v", err)
}
}
+11
View File
@@ -0,0 +1,11 @@
package ticket
// Store is the persistence interface for tickets.
// Implementations must be safe for concurrent use.
type Store interface {
List(projectID string) ([]Ticket, error)
Get(id string) (Ticket, error)
Create(t Ticket) (Ticket, error)
Update(t Ticket) error
Delete(id string) error
}
+51
View File
@@ -0,0 +1,51 @@
package ticket
import "time"
// Status represents the workflow state of a ticket.
type Status string
const (
StatusTodo Status = "todo"
StatusInProgress Status = "in_progress"
StatusDone Status = "done"
)
// AllStatuses is the ordered list shown in the status picker.
var AllStatuses = []Status{StatusTodo, StatusInProgress, StatusDone}
// Label returns a short display label for the status.
func (s Status) Label() string {
switch s {
case StatusTodo:
return "OPEN"
case StatusInProgress:
return "WIP"
case StatusDone:
return "DONE"
default:
return string(s)
}
}
// Index returns the position in AllStatuses, or 0 if unknown.
func (s Status) Index() int {
for i, st := range AllStatuses {
if st == s {
return i
}
}
return 0
}
// Ticket is the domain model for a work item.
// Title (headline) and Description are Markdown.
type Ticket struct {
ID string `json:"id"`
Title string `json:"title"`
Description string `json:"description,omitempty"`
Status Status `json:"status"`
ProjectID string `json:"projectId"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}