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
+694
View File
@@ -0,0 +1,694 @@
package ui
import (
"fmt"
"os"
"strings"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/list"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"task/internal/command"
"task/internal/config"
"task/internal/project"
"task/internal/ticket"
"task/internal/ui/views"
"task/internal/version"
)
// editorFinishedMsg is sent after the external $EDITOR exits.
type editorFinishedMsg struct {
ticketID string
path string
err error
}
// Mode is the active UI mode.
type Mode int
const (
ModeList Mode = iota
ModeCreate
ModeSearch
ModeProjects
ModeStatus
ModeBoard
)
// Model is the root Bubble Tea model.
type Model struct {
cfg *config.Config
store ticket.Store
projects project.Store
exec command.Executor
mode Mode
tickets views.TicketList
projectList list.Model
statusMenu views.StatusMenu
boardMenu views.BoardMenu
boardList ticket.BoardList
input textinput.Model
width int
height int
status string
statusIsError bool
filter string
keys KeyMap
styles Styles
version string
allTickets []ticket.Ticket
creatingProject bool
}
// New creates the root UI model.
func New(cfg *config.Config, store ticket.Store, projects project.Store, exec command.Executor) Model {
styles := DefaultStyles()
if exec == nil {
exec = command.NewHistory()
}
m := Model{
cfg: cfg,
store: store,
projects: projects,
exec: exec,
mode: ModeList,
boardList: ticket.ParseBoardList(cfg.ActiveBoardList(cfg.ActiveProject)),
tickets: views.TicketList{
Styles: views.TicketListStyles{
Card: styles.Card,
CardSelected: styles.CardSelected,
StatusOPEN: styles.StatusTodo,
StatusWIP: styles.StatusWIP,
StatusDONE: styles.StatusDone,
Headline: styles.Headline,
},
},
input: views.NewCreateInput(),
keys: DefaultKeyMap(),
styles: styles,
version: version.BuildVersion,
}
_ = m.reloadTickets()
return m
}
// Init implements tea.Model.
func (m Model) Init() tea.Cmd {
return nil
}
// Update implements tea.Model.
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
m.resizeLists()
return m, nil
case editorFinishedMsg:
return m.handleEditorFinished(msg)
case tea.KeyMsg:
switch m.mode {
case ModeCreate, ModeSearch:
return m.updateInput(msg)
case ModeProjects:
return m.updateProjects(msg)
case ModeStatus:
return m.updateStatus(msg)
case ModeBoard:
return m.updateBoard(msg)
default:
return m.updateList(msg)
}
}
var cmd tea.Cmd
switch m.mode {
case ModeProjects:
m.projectList, cmd = m.projectList.Update(msg)
case ModeCreate, ModeSearch:
m.input, cmd = m.input.Update(msg)
}
return m, cmd
}
// View implements tea.Model.
func (m Model) View() string {
if m.width == 0 {
return "loading…"
}
header := m.renderHeader()
footer := m.renderFooter()
bodyH := m.height - lipgloss.Height(header) - lipgloss.Height(footer)
if bodyH < 1 {
bodyH = 1
}
body := m.renderBody(bodyH)
return lipgloss.JoinVertical(lipgloss.Left, header, body, footer)
}
func (m *Model) resizeLists() {
headerH := 1
footerH := 1
bodyH := m.height - headerH - footerH
if bodyH < 1 {
bodyH = 1
}
m.tickets.SetSize(m.width, bodyH)
if m.mode == ModeProjects {
m.projectList.SetSize(m.width, bodyH)
}
m.input.Width = max(10, m.width-4)
m.statusMenu.Width = m.width
m.boardMenu.Width = m.width
}
func (m Model) updateList(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch {
case key.Matches(msg, m.keys.Quit):
return m, tea.Quit
case key.Matches(msg, m.keys.New):
m.clearStatus()
m.creatingProject = false
m.mode = ModeCreate
m.input = views.NewCreateInput()
m.input.Focus()
return m, textinput.Blink
case key.Matches(msg, m.keys.Search):
m.clearStatus()
m.creatingProject = false
m.mode = ModeSearch
m.input = views.NewSearchInput()
m.input.SetValue(m.filter)
m.input.Focus()
return m, textinput.Blink
case key.Matches(msg, m.keys.Project):
m.clearStatus()
if err := m.openProjects(); err != nil {
m.setError(err)
return m, nil
}
m.mode = ModeProjects
return m, nil
case key.Matches(msg, m.keys.Board):
m.clearStatus()
m.boardMenu = views.NewBoardMenu(m.boardList, m.width, views.StatusMenuStyles{
Box: m.styles.StatusMenuBox,
Title: m.styles.StatusMenuTitle,
Selected: m.styles.StatusMenuSelected,
Normal: m.styles.StatusMenuNormal,
})
m.mode = ModeBoard
return m, nil
case key.Matches(msg, m.keys.Delete):
m.clearStatus()
if err := m.deleteSelected(); err != nil {
m.setError(err)
} else {
m.setStatus("deleted")
}
return m, nil
case key.Matches(msg, m.keys.Undo):
m.clearStatus()
if err := m.undo(); err != nil {
m.setError(err)
}
return m, nil
case key.Matches(msg, m.keys.Redo):
m.clearStatus()
if err := m.redo(); err != nil {
m.setError(err)
}
return m, nil
case key.Matches(msg, m.keys.Edit):
m.clearStatus()
cmd, err := m.openEditorForSelected()
if err != nil {
m.setError(err)
return m, nil
}
return m, cmd
case key.Matches(msg, m.keys.Status):
m.clearStatus()
t, ok := m.tickets.Selected()
if !ok {
m.setError(fmt.Errorf("no ticket selected"))
return m, nil
}
m.statusMenu = views.NewStatusMenu(t.Status, m.width, views.StatusMenuStyles{
Box: m.styles.StatusMenuBox,
Title: m.styles.StatusMenuTitle,
Selected: m.styles.StatusMenuSelected,
Normal: m.styles.StatusMenuNormal,
})
m.mode = ModeStatus
return m, nil
case key.Matches(msg, m.keys.Down):
m.tickets.MoveDown()
return m, nil
case key.Matches(msg, m.keys.Up):
m.tickets.MoveUp()
return m, nil
}
return m, nil
}
func (m Model) updateStatus(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch {
case key.Matches(msg, m.keys.Escape):
m.mode = ModeList
m.clearStatus()
return m, nil
case key.Matches(msg, m.keys.Enter):
if err := m.applyStatus(m.statusMenu.Selected()); err != nil {
m.setError(err)
return m, nil
}
m.mode = ModeList
return m, nil
case key.Matches(msg, m.keys.Down):
m.statusMenu.MoveDown()
return m, nil
case key.Matches(msg, m.keys.Up):
m.statusMenu.MoveUp()
return m, nil
}
return m, nil
}
func (m Model) updateBoard(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch {
case key.Matches(msg, m.keys.Escape):
m.mode = ModeList
m.clearStatus()
return m, nil
case key.Matches(msg, m.keys.Enter):
if err := m.applyBoardList(m.boardMenu.Selected()); err != nil {
m.setError(err)
return m, nil
}
m.mode = ModeList
return m, nil
case key.Matches(msg, m.keys.Down):
m.boardMenu.MoveDown()
return m, nil
case key.Matches(msg, m.keys.Up):
m.boardMenu.MoveUp()
return m, nil
}
return m, nil
}
func (m Model) updateInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch {
case key.Matches(msg, m.keys.Escape):
wasProject := m.creatingProject
m.creatingProject = false
m.input.Blur()
m.clearStatus()
if wasProject {
m.mode = ModeProjects
} else {
m.mode = ModeList
}
return m, nil
case key.Matches(msg, m.keys.Enter):
value := strings.TrimSpace(m.input.Value())
if m.mode == ModeCreate {
if value == "" {
m.setError(fmt.Errorf("name required"))
return m, nil
}
if m.creatingProject {
id := sanitizeProjectID(value)
if err := m.projects.Ensure(id); err != nil {
m.setError(err)
return m, nil
}
if err := m.switchProject(id); err != nil {
m.setError(err)
return m, nil
}
m.creatingProject = false
m.mode = ModeList
m.input.Blur()
m.setStatus(fmt.Sprintf("created project %s", id))
return m, nil
}
if err := m.createTicket(value); err != nil {
m.setError(err)
return m, nil
}
m.mode = ModeList
m.input.Blur()
m.setStatus("created")
return m, nil
}
m.filter = value
m.applyFilter()
m.mode = ModeList
m.input.Blur()
if value == "" {
m.setStatus("filter cleared")
} else {
m.setStatus(fmt.Sprintf("filter: %q", value))
}
return m, nil
}
var cmd tea.Cmd
m.input, cmd = m.input.Update(msg)
return m, cmd
}
func (m Model) updateProjects(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch {
case key.Matches(msg, m.keys.Escape):
m.mode = ModeList
m.clearStatus()
return m, nil
case key.Matches(msg, m.keys.New):
m.clearStatus()
m.creatingProject = true
m.mode = ModeCreate
m.input = views.NewCreateInput()
m.input.Placeholder = "Project name…"
m.input.Focus()
return m, textinput.Blink
case key.Matches(msg, m.keys.Enter):
item, ok := m.projectList.SelectedItem().(views.ProjectItem)
if !ok {
return m, nil
}
if err := m.switchProject(item.Project.ID); err != nil {
m.setError(err)
return m, nil
}
m.mode = ModeList
m.setStatus(fmt.Sprintf("switched to %s", item.Project.ID))
return m, nil
}
var cmd tea.Cmd
m.projectList, cmd = m.projectList.Update(msg)
return m, cmd
}
func (m Model) openEditorForSelected() (tea.Cmd, error) {
t, ok := m.tickets.Selected()
if !ok {
return nil, fmt.Errorf("no ticket selected")
}
path, err := ticket.WriteEditFile(t)
if err != nil {
return nil, err
}
c := ticket.EditorCmd(path)
ticketID := t.ID
return tea.ExecProcess(c, func(err error) tea.Msg {
return editorFinishedMsg{ticketID: ticketID, path: path, err: err}
}), nil
}
func (m Model) handleEditorFinished(msg editorFinishedMsg) (tea.Model, tea.Cmd) {
defer func() { _ = os.Remove(msg.path) }()
if msg.err != nil {
m.setError(fmt.Errorf("editor: %w", msg.err))
return m, nil
}
data, err := os.ReadFile(msg.path)
if err != nil {
m.setError(fmt.Errorf("read edited file: %w", err))
return m, nil
}
title, description, err := ticket.ParseEditDocument(string(data))
if err != nil {
m.setError(err)
return m, nil
}
before, err := m.store.Get(msg.ticketID)
if err != nil {
m.setError(err)
return m, nil
}
after := before
after.Title = title
after.Description = description
if err := m.exec.Do(&command.UpdateTicket{
Store: m.store,
Before: before,
After: after,
}); err != nil {
m.setError(err)
return m, nil
}
idx := m.tickets.CursorIndex()
if err := m.reloadTickets(); err != nil {
m.setError(err)
return m, nil
}
if idx < m.tickets.Len() {
m.tickets.Select(idx)
}
m.setStatus("saved")
return m, nil
}
func (m *Model) createTicket(title string) error {
cmd := &command.CreateTicket{
Store: m.store,
Ticket: ticket.Ticket{
Title: title,
ProjectID: m.cfg.ActiveProject,
Status: m.boardList.DefaultStatus(),
},
}
if err := m.exec.Do(cmd); err != nil {
return err
}
return m.reloadTickets()
}
func (m *Model) deleteSelected() error {
t, ok := m.tickets.Selected()
if !ok {
return fmt.Errorf("no ticket selected")
}
idx := m.tickets.CursorIndex()
if err := m.exec.Do(&command.DeleteTicket{Store: m.store, Snapshot: t}); err != nil {
return err
}
if err := m.reloadTickets(); err != nil {
return err
}
if idx >= m.tickets.Len() && m.tickets.Len() > 0 {
m.tickets.Select(m.tickets.Len() - 1)
} else if idx < m.tickets.Len() {
m.tickets.Select(idx)
}
return nil
}
func (m *Model) applyStatus(st ticket.Status) error {
t, ok := m.tickets.Selected()
if !ok {
return fmt.Errorf("no ticket selected")
}
after := t
after.Status = st
if err := m.exec.Do(&command.UpdateTicket{
Store: m.store,
Before: t,
After: after,
}); err != nil {
return err
}
idx := m.tickets.CursorIndex()
if err := m.reloadTickets(); err != nil {
return err
}
if idx < m.tickets.Len() {
m.tickets.Select(idx)
}
m.setStatus(fmt.Sprintf("status → %s", st.Label()))
return nil
}
func (m *Model) undo() error {
name := m.exec.LastUndoName()
if err := m.exec.Undo(); err != nil {
return err
}
if err := m.reloadTickets(); err != nil {
return err
}
if name != "" {
m.setStatus("undo: " + name)
} else {
m.setStatus("undo")
}
return nil
}
func (m *Model) redo() error {
name := m.exec.LastRedoName()
if err := m.exec.Redo(); err != nil {
return err
}
if err := m.reloadTickets(); err != nil {
return err
}
if name != "" {
m.setStatus("redo: " + name)
} else {
m.setStatus("redo")
}
return nil
}
func (m *Model) applyBoardList(bl ticket.BoardList) error {
m.boardList = bl
m.cfg.SetActiveBoardList(m.cfg.ActiveProject, string(bl))
if err := config.Save(m.cfg); err != nil {
return err
}
m.applyFilter()
m.setStatus(fmt.Sprintf("list → %s", bl.Label()))
return nil
}
func (m *Model) switchProject(id string) error {
if err := m.projects.Ensure(id); err != nil {
return err
}
m.cfg.ActiveProject = id
m.boardList = ticket.ParseBoardList(m.cfg.ActiveBoardList(id))
if err := config.Save(m.cfg); err != nil {
return err
}
m.filter = ""
return m.reloadTickets()
}
func (m *Model) openProjects() error {
projects, err := m.projects.List()
if err != nil {
return err
}
if len(projects) == 0 {
if err := m.projects.Ensure(m.cfg.ActiveProject); err != nil {
return err
}
projects, err = m.projects.List()
if err != nil {
return err
}
}
headerH, footerH := 1, 1
bodyH := m.height - headerH - footerH
if bodyH < 1 {
bodyH = 1
}
m.projectList = views.NewProjectList(projects, m.cfg.ActiveProject, m.width, bodyH)
return nil
}
func (m *Model) reloadTickets() error {
tickets, err := m.store.List(m.cfg.ActiveProject)
if err != nil {
return err
}
m.allTickets = tickets
m.applyFilter()
return nil
}
func (m *Model) applyFilter() {
filtered := make([]ticket.Ticket, 0, len(m.allTickets))
for _, t := range m.allTickets {
if m.boardList.Matches(t.Status) {
filtered = append(filtered, t)
}
}
filtered = views.FilterTickets(filtered, m.filter)
cursor := m.tickets.CursorIndex()
m.tickets.SetItems(filtered)
if cursor < m.tickets.Len() {
m.tickets.Select(cursor)
}
}
func (m Model) ticketCount() int {
return m.tickets.Len()
}
func (m *Model) setStatus(s string) {
m.status = s
m.statusIsError = false
}
func (m *Model) setError(err error) {
m.status = err.Error()
m.statusIsError = true
}
func (m *Model) clearStatus() {
m.status = ""
m.statusIsError = false
}
func sanitizeProjectID(name string) string {
name = strings.TrimSpace(strings.ToLower(name))
var b strings.Builder
for _, r := range name {
switch {
case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '-', r == '_':
b.WriteRune(r)
case r == ' ':
b.WriteRune('-')
}
}
id := b.String()
if id == "" {
return "project"
}
return id
}
func max(a, b int) int {
if a > b {
return a
}
return b
}
+115
View File
@@ -0,0 +1,115 @@
package ui
import (
"strings"
"github.com/charmbracelet/lipgloss"
)
type helpEntry struct {
key string
desc string
}
func (m Model) helpEntries() []helpEntry {
switch m.mode {
case ModeCreate:
return []helpEntry{
{"enter", "save"},
{"esc", "cancel"},
}
case ModeSearch:
return []helpEntry{
{"enter", "apply"},
{"esc", "cancel"},
}
case ModeProjects:
return []helpEntry{
{"j/k", "navigate"},
{"enter", "select"},
{"n", "new project"},
{"esc", "cancel"},
}
case ModeStatus:
return []helpEntry{
{"j/k", "select"},
{"enter", "apply"},
{"esc", "cancel"},
}
case ModeBoard:
return []helpEntry{
{"j/k", "select"},
{"enter", "apply"},
{"esc", "cancel"},
}
default:
return []helpEntry{
{"j/k", "navigate"},
{"n", "new"},
{"e", "edit"},
{"u/U", "undo/redo"},
{"/", "search"},
{"p", "project"},
{"l", "list"},
{"s", "status"},
{"X", "delete"},
{"q", "quit"},
}
}
}
func (m Model) renderHelpbar() string {
var parts []string
for _, e := range m.helpEntries() {
parts = append(parts, m.styles.HelpKey.Render(e.key)+" "+m.styles.HelpDesc.Render(e.desc))
}
sep := m.styles.HelpSep.String()
if sep == "" {
sep = " "
}
return strings.Join(parts, sep)
}
func (m Model) renderFooter() string {
help := m.renderHelpbar()
ver := m.styles.Version.Render(m.version)
width := m.width
if width <= 0 {
width = lipgloss.Width(help) + lipgloss.Width(ver) + 1
}
helpW := lipgloss.Width(help)
verW := lipgloss.Width(ver)
gap := width - helpW - verW
if gap < 1 {
maxHelp := width - verW - 1
if maxHelp < 0 {
maxHelp = 0
}
help = truncateWidth(help, maxHelp)
helpW = lipgloss.Width(help)
gap = width - helpW - verW
if gap < 1 {
gap = 1
}
}
return help + strings.Repeat(" ", gap) + ver
}
func truncateWidth(s string, max int) string {
if max <= 0 {
return ""
}
if lipgloss.Width(s) <= max {
return s
}
runes := []rune(s)
for len(runes) > 0 {
runes = runes[:len(runes)-1]
if lipgloss.Width(string(runes)) <= max-1 {
return string(runes) + "…"
}
}
return ""
}
+93
View File
@@ -0,0 +1,93 @@
package ui
import "github.com/charmbracelet/bubbles/key"
// KeyMap defines vim-style bindings for the TUI.
type KeyMap struct {
Up key.Binding
Down key.Binding
New key.Binding
Search key.Binding
Project key.Binding
Board key.Binding
Status key.Binding
Edit key.Binding
Delete key.Binding
Undo key.Binding
Redo key.Binding
Quit key.Binding
Enter key.Binding
Escape key.Binding
}
// DefaultKeyMap returns the default key bindings.
func DefaultKeyMap() KeyMap {
return KeyMap{
Up: key.NewBinding(
key.WithKeys("k", "up"),
key.WithHelp("j/k", "navigate"),
),
Down: key.NewBinding(
key.WithKeys("j", "down"),
key.WithHelp("j/k", "navigate"),
),
New: key.NewBinding(
key.WithKeys("n"),
key.WithHelp("n", "new"),
),
Search: key.NewBinding(
key.WithKeys("/"),
key.WithHelp("/", "search"),
),
Project: key.NewBinding(
key.WithKeys("p"),
key.WithHelp("p", "project"),
),
Board: key.NewBinding(
key.WithKeys("l", "tab"),
key.WithHelp("l", "list"),
),
Status: key.NewBinding(
key.WithKeys("s", "enter"),
key.WithHelp("s", "status"),
),
Edit: key.NewBinding(
key.WithKeys("e"),
key.WithHelp("e", "edit"),
),
Delete: key.NewBinding(
key.WithKeys("X"),
key.WithHelp("X", "delete"),
),
Undo: key.NewBinding(
key.WithKeys("u", "ctrl+z"),
key.WithHelp("u", "undo"),
),
Redo: key.NewBinding(
key.WithKeys("ctrl+r", "U"),
key.WithHelp("U", "redo"),
),
Quit: key.NewBinding(
key.WithKeys("q", "ctrl+c"),
key.WithHelp("q", "quit"),
),
Enter: key.NewBinding(
key.WithKeys("enter"),
key.WithHelp("enter", "confirm"),
),
Escape: key.NewBinding(
key.WithKeys("esc"),
key.WithHelp("esc", "cancel"),
),
}
}
// ShortHelp implements help.KeyMap.
func (k KeyMap) ShortHelp() []key.Binding {
return []key.Binding{k.Up, k.New, k.Search, k.Project, k.Board, k.Status, k.Edit, k.Delete, k.Undo, k.Redo, k.Quit}
}
// FullHelp implements help.KeyMap.
func (k KeyMap) FullHelp() [][]key.Binding {
return [][]key.Binding{k.ShortHelp()}
}
+70
View File
@@ -0,0 +1,70 @@
package ui
import (
"fmt"
"strings"
"github.com/charmbracelet/lipgloss"
"task/internal/config"
)
func (m Model) renderHeader() string {
brand := m.styles.HeaderBrand.Render(config.AppName)
project := m.styles.HeaderMeta.Render(fmt.Sprintf("project: %s", m.cfg.ActiveProject))
board := m.styles.FilterHint.Render(fmt.Sprintf("list: %s", m.boardList.Label()))
count := m.styles.HeaderMeta.Render(fmt.Sprintf("%d tickets", m.ticketCount()))
parts := []string{brand, project, board, count}
if m.filter != "" {
parts = append(parts, m.styles.FilterHint.Render(fmt.Sprintf("filter: %q", m.filter)))
}
if m.status != "" {
style := m.styles.StatusMsg
if m.statusIsError {
style = m.styles.ErrorMsg
}
parts = append(parts, style.Render(m.status))
}
line := strings.Join(parts, m.styles.HeaderMeta.Render(" │ "))
return m.styles.Header.Width(m.width).Render(line)
}
func (m Model) renderBody(height int) string {
if height < 1 {
height = 1
}
switch m.mode {
case ModeCreate:
label := "New ticket:"
if m.creatingProject {
label = "New project:"
}
prompt := m.styles.InputPrompt.Render(label)
content := prompt + "\n" + m.input.View()
return lipgloss.NewStyle().Height(height).Width(m.width).Render(content)
case ModeSearch:
prompt := m.styles.InputPrompt.Render("Search:")
content := prompt + "\n" + m.input.View()
return lipgloss.NewStyle().Height(height).Width(m.width).Render(content)
case ModeProjects:
m.projectList.SetSize(m.width, height)
return m.projectList.View()
case ModeStatus:
menu := m.statusMenu.View()
return lipgloss.NewStyle().Height(height).Width(m.width).Render(menu)
case ModeBoard:
menu := m.boardMenu.View()
return lipgloss.NewStyle().Height(height).Width(m.width).Render(menu)
default:
m.tickets.SetSize(m.width, height)
return m.tickets.View()
}
}
+120
View File
@@ -0,0 +1,120 @@
package ui
import "github.com/charmbracelet/lipgloss"
// Styles holds Lipgloss styles for the TUI.
type Styles struct {
Header lipgloss.Style
HeaderBrand lipgloss.Style
HeaderMeta lipgloss.Style
HelpKey lipgloss.Style
HelpDesc lipgloss.Style
HelpSep lipgloss.Style
Version lipgloss.Style
StatusTodo lipgloss.Style
StatusWIP lipgloss.Style
StatusDone lipgloss.Style
StatusMsg lipgloss.Style
ErrorMsg lipgloss.Style
Selected lipgloss.Style
Normal lipgloss.Style
InputPrompt lipgloss.Style
FilterHint lipgloss.Style
Card lipgloss.Style
CardSelected lipgloss.Style
Headline lipgloss.Style
StatusMenuBox lipgloss.Style
StatusMenuTitle lipgloss.Style
StatusMenuSelected lipgloss.Style
StatusMenuNormal lipgloss.Style
}
// DefaultStyles returns the neutral terminal theme.
func DefaultStyles() Styles {
accent := lipgloss.Color("39")
faint := lipgloss.Color("245")
green := lipgloss.Color("42")
yellow := lipgloss.Color("214")
muted := lipgloss.Color("240")
border := lipgloss.Color("238")
borderSel := lipgloss.Color("39")
return Styles{
Header: lipgloss.NewStyle().
Bold(true).
Padding(0, 1),
HeaderBrand: lipgloss.NewStyle().
Bold(true).
Foreground(accent),
HeaderMeta: lipgloss.NewStyle().
Foreground(faint),
HelpKey: lipgloss.NewStyle().
Bold(true).
Foreground(accent),
HelpDesc: lipgloss.NewStyle().
Foreground(faint),
HelpSep: lipgloss.NewStyle().
Foreground(muted).
SetString(" "),
Version: lipgloss.NewStyle().
Faint(true).
Foreground(muted),
StatusTodo: lipgloss.NewStyle().
Foreground(faint).
Background(lipgloss.Color("236")),
StatusWIP: lipgloss.NewStyle().
Foreground(lipgloss.Color("0")).
Background(yellow).
Bold(true),
StatusDone: lipgloss.NewStyle().
Foreground(lipgloss.Color("0")).
Background(green),
StatusMsg: lipgloss.NewStyle().
Foreground(faint),
ErrorMsg: lipgloss.NewStyle().
Foreground(lipgloss.Color("196")),
Selected: lipgloss.NewStyle().
Foreground(lipgloss.Color("230")).
Background(accent).
Bold(true),
Normal: lipgloss.NewStyle(),
InputPrompt: lipgloss.NewStyle().
Foreground(accent).
Bold(true),
FilterHint: lipgloss.NewStyle().
Foreground(yellow),
// Left accent only — full box borders stacked a blank line between tickets.
Card: lipgloss.NewStyle().
BorderStyle(lipgloss.NormalBorder()).
BorderLeft(true).
BorderTop(false).
BorderRight(false).
BorderBottom(false).
BorderForeground(border).
PaddingLeft(1),
CardSelected: lipgloss.NewStyle().
BorderStyle(lipgloss.ThickBorder()).
BorderLeft(true).
BorderTop(false).
BorderRight(false).
BorderBottom(false).
BorderForeground(borderSel).
PaddingLeft(1),
Headline: lipgloss.NewStyle().
Bold(true),
StatusMenuBox: lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(accent).
Padding(1, 2),
StatusMenuTitle: lipgloss.NewStyle().
Bold(true).
Foreground(accent).
MarginBottom(1),
StatusMenuSelected: lipgloss.NewStyle().
Foreground(lipgloss.Color("230")).
Background(accent).
Bold(true),
StatusMenuNormal: lipgloss.NewStyle().
Foreground(faint),
}
}
+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
}