feat(tui): add status bar, help overlay, tag filter, and entity actions

Status bar with entity count and context-sensitive key hints. Help
overlay via ? key. Tag filter via # with cursor-navigable tag list.
Todo toggle (x), pin (!), promote (p), demote (D), copy (c), edit (e)
via $EDITOR. Delete confirmation with 3s timeout. Date-grouped list
with completed todo and pinned indicators. Esc clears active tag filter.

Adds CompletedAt/ClearCompleted to EntityUpdate for todo toggling.
This commit is contained in:
2026-05-17 20:33:34 -04:00
parent 36999cd825
commit c2ea63dd16
10 changed files with 804 additions and 77 deletions
+105 -13
View File
@@ -3,6 +3,7 @@ package tui
import (
"fmt"
"strings"
"time"
tea "github.com/charmbracelet/bubbletea"
@@ -87,18 +88,49 @@ func (l listModel) view(width int) string {
return statusStyle.Render("no entities")
}
var b strings.Builder
groups := groupByDate(l.entities)
type displayLine struct {
text string
entityIdx int
isHeader bool
}
var lines []displayLine
entityIdx := 0
for _, g := range groups {
lines = append(lines, displayLine{
text: dateHeaderStyle.Render("── " + g.label + " ──"),
isHeader: true,
})
for _, e := range g.entities {
line := renderEntity(e, width-4)
lines = append(lines, displayLine{
text: line,
entityIdx: entityIdx,
})
entityIdx++
}
}
cursorLine := l.cursorDisplayLine(groups)
visible := l.visibleCount()
end := min(l.offset+visible, len(l.entities))
for i := l.offset; i < end; i++ {
e := l.entities[i]
line := renderEntity(e, width-4)
offset := 0
if cursorLine >= visible {
offset = cursorLine - visible + 1
}
if i == l.cursor {
b.WriteString(selectedItemStyle.Render(" " + line))
var b strings.Builder
end := min(offset+visible, len(lines))
for i := offset; i < end; i++ {
dl := lines[i]
if dl.isHeader {
b.WriteString(dl.text)
} else if dl.entityIdx == l.cursor {
b.WriteString(selectedItemStyle.Render(" " + dl.text))
} else {
b.WriteString(listItemStyle.Render(line))
b.WriteString(listItemStyle.Render(dl.text))
}
if i < end-1 {
b.WriteString("\n")
@@ -108,6 +140,22 @@ func (l listModel) view(width int) string {
return b.String()
}
func (l listModel) cursorDisplayLine(groups []dateGroup) int {
line := 0
entityIdx := 0
for _, g := range groups {
line++
for range g.entities {
if entityIdx == l.cursor {
return line
}
line++
entityIdx++
}
}
return 0
}
func (l listModel) visibleCount() int {
if l.height <= 0 {
return 20
@@ -115,8 +163,44 @@ func (l listModel) visibleCount() int {
return l.height
}
type dateGroup struct {
label string
entities []*db.Entity
}
func groupByDate(entities []*db.Entity) []dateGroup {
var groups []dateGroup
var current *dateGroup
for _, e := range entities {
label := formatDateLabel(e.CreatedAt)
if current == nil || current.label != label {
if current != nil {
groups = append(groups, *current)
}
current = &dateGroup{label: label}
}
current.entities = append(current.entities, e)
}
if current != nil {
groups = append(groups, *current)
}
return groups
}
func formatDateLabel(t time.Time) string {
return strings.ToLower(t.Format("Jan 2"))
}
func renderEntity(e *db.Entity, maxWidth int) string {
glyph := glyphStyle.Render(display.DisplayGlyph(e.Glyph, e.CardType))
glyphStr := display.DisplayGlyph(e.Glyph, e.CardType)
style := glyphStyle
if e.Glyph == db.GlyphTodo && e.CompletedAt != nil {
glyphStr = "●"
style = completedGlyphStyle
}
glyph := style.Render(glyphStr)
id := idStyle.Render("[" + display.FormatID(e.ID) + "]")
body := e.Body
@@ -124,20 +208,28 @@ func renderEntity(e *db.Entity, maxWidth int) string {
body = *e.Title
}
var tags string
var extras []string
if e.Pinned {
extras = append(extras, pinnedStyle.Render("•"))
}
if len(e.Tags) > 0 {
tagParts := make([]string, len(e.Tags))
for i, t := range e.Tags {
tagParts[i] = tagStyle.Render("#" + t)
}
tags = " " + strings.Join(tagParts, " ")
extras = append(extras, strings.Join(tagParts, " "))
}
line := fmt.Sprintf("%s %s%s %s", glyph, body, tags, id)
extraStr := ""
if len(extras) > 0 {
extraStr = " " + strings.Join(extras, " ")
}
line := fmt.Sprintf("%s %s%s %s", glyph, body, extraStr, id)
if maxWidth > 0 && len(stripAnsi(line)) > maxWidth {
body = truncate(body, maxWidth-20)
line = fmt.Sprintf("%s %s%s %s", glyph, body, tags, id)
line = fmt.Sprintf("%s %s%s %s", glyph, body, extraStr, id)
}
return line