feat(tui): add tag autocomplete and query composition
CI / test (pull_request) Successful in 2m31s

Tag autocomplete shows suggestions when typing #partial in capture bar.
Tab/enter accepts, up/down navigates, esc dismisses.

Query composition extends ? search with date filters (@today, @week,
@month, <7d, >30d), card type filters (^snippet), all composable
with existing text and tag filters.
This commit is contained in:
2026-05-21 12:12:07 -04:00
parent 29bd7d3dc6
commit e22e040688
8 changed files with 485 additions and 38 deletions
+53 -12
View File
@@ -4,19 +4,23 @@ import (
"fmt" "fmt"
"strconv" "strconv"
"strings" "strings"
"time"
) )
type Result struct { type Result struct {
Body string Body string
Glyph string Glyph string
Title *string Title *string
Description *string Description *string
TimeAnchor *string TimeAnchor *string
Tags []string Tags []string
FilterTags []string FilterTags []string
CardSuffix *string CardSuffix *string
Pin bool Pin bool
Query bool Query bool
QueryDateFrom *string
QueryDateTo *string
QueryCardType *string
} }
var validCardTypes = map[string]string{ var validCardTypes = map[string]string{
@@ -66,11 +70,48 @@ func Parse(input string) (*Result, error) {
r.Glyph = "" r.Glyph = ""
tokens := strings.Fields(remaining) tokens := strings.Fields(remaining)
var bodyParts []string var bodyParts []string
now := time.Now()
for _, tok := range tokens { for _, tok := range tokens {
if strings.HasPrefix(tok, "#") && len(tok) > 1 && !strings.HasPrefix(tok, "##") { switch {
case strings.HasPrefix(tok, "#") && len(tok) > 1 && !strings.HasPrefix(tok, "##"):
tag := strings.ToLower(tok[1:]) tag := strings.ToLower(tok[1:])
r.FilterTags = append(r.FilterTags, tag) r.FilterTags = append(r.FilterTags, tag)
} else { case tok == "@today":
d := now.Format("2006-01-02")
r.QueryDateFrom = &d
r.QueryDateTo = &d
case tok == "@yesterday":
d := now.AddDate(0, 0, -1).Format("2006-01-02")
r.QueryDateFrom = &d
r.QueryDateTo = &d
case tok == "@week":
d := now.AddDate(0, 0, -7).Format("2006-01-02")
r.QueryDateFrom = &d
case tok == "@month":
d := now.AddDate(0, -1, 0).Format("2006-01-02")
r.QueryDateFrom = &d
case strings.HasPrefix(tok, ">") && strings.HasSuffix(tok, "d"):
if n, err := strconv.Atoi(tok[1 : len(tok)-1]); err == nil && n > 0 {
d := now.AddDate(0, 0, -n).Format("2006-01-02")
r.QueryDateTo = &d
} else {
bodyParts = append(bodyParts, tok)
}
case strings.HasPrefix(tok, "<") && strings.HasSuffix(tok, "d"):
if n, err := strconv.Atoi(tok[1 : len(tok)-1]); err == nil && n > 0 {
d := now.AddDate(0, 0, -n).Format("2006-01-02")
r.QueryDateFrom = &d
} else {
bodyParts = append(bodyParts, tok)
}
case strings.HasPrefix(tok, "^") && len(tok) > 1:
suffix := tok[1:]
if ct, ok := validCardTypes[suffix]; ok {
r.QueryCardType = &ct
} else {
bodyParts = append(bodyParts, tok)
}
default:
bodyParts = append(bodyParts, tok) bodyParts = append(bodyParts, tok)
} }
} }
+61
View File
@@ -158,6 +158,67 @@ func TestParse(t *testing.T) {
} }
} }
func TestParseQueryComposition(t *testing.T) {
tests := []struct {
name string
input string
wantBody string
wantTags []string
wantDateFrom bool
wantDateTo bool
wantCardType *string
}{
{"today", "?@today", "", nil, true, true, nil},
{"yesterday", "?@yesterday", "", nil, true, true, nil},
{"week", "?@week", "", nil, true, false, nil},
{"month", "?@month", "", nil, true, false, nil},
{"newer than", "?<7d", "", nil, true, false, nil},
{"older than", "?>30d", "", nil, false, true, nil},
{"card type snippet", "?^snippet", "", nil, false, false, sp("snippet")},
{"card type shorthand", "?^c", "", nil, false, false, sp("snippet")},
{"card type checklist", "?^checklist", "", nil, false, false, sp("checklist")},
{"invalid card type stays as body", "?^bogus", "^bogus", nil, false, false, nil},
{"combined text and date", "?deploy @today", "deploy", nil, true, true, nil},
{"combined tags and date", "?#ops @week", "", []string{"ops"}, true, false, nil},
{"combined all", "?deploy #ops @week ^snippet", "deploy", []string{"ops"}, true, false, sp("snippet")},
{"invalid age stays as body", "?>abcd", ">abcd", nil, false, false, nil},
{"zero days stays as body", "?>0d", ">0d", nil, false, false, nil},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := Parse(tt.input)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !got.Query {
t.Fatal("expected Query=true")
}
if got.Body != tt.wantBody {
t.Errorf("body: got %q, want %q", got.Body, tt.wantBody)
}
if !tagsEq(got.FilterTags, tt.wantTags) {
t.Errorf("tags: got %v, want %v", got.FilterTags, tt.wantTags)
}
if tt.wantDateFrom && got.QueryDateFrom == nil {
t.Error("expected QueryDateFrom to be set")
}
if !tt.wantDateFrom && got.QueryDateFrom != nil {
t.Errorf("expected QueryDateFrom nil, got %v", *got.QueryDateFrom)
}
if tt.wantDateTo && got.QueryDateTo == nil {
t.Error("expected QueryDateTo to be set")
}
if !tt.wantDateTo && got.QueryDateTo != nil {
t.Errorf("expected QueryDateTo nil, got %v", *got.QueryDateTo)
}
if !ptrEq(got.QueryCardType, tt.wantCardType) {
t.Errorf("card type: got %v, want %v", strPtr(got.QueryCardType), strPtr(tt.wantCardType))
}
})
}
}
func ptrEq(a, b *string) bool { func ptrEq(a, b *string) bool {
if a == nil && b == nil { if a == nil && b == nil {
return true return true
+112
View File
@@ -0,0 +1,112 @@
package tui
import (
"strings"
"github.com/charmbracelet/lipgloss"
"github.com/lerko/nib/internal/db"
)
const maxSuggestions = 5
type autocompleteModel struct {
suggestions []string
cursor int
active bool
prefix string
tokenStart int
tokenEnd int
}
func (a *autocompleteModel) moveUp() {
if a.cursor > 0 {
a.cursor--
}
}
func (a *autocompleteModel) moveDown() {
if a.cursor < len(a.suggestions)-1 {
a.cursor++
}
}
func (a autocompleteModel) selected() string {
if len(a.suggestions) == 0 || a.cursor >= len(a.suggestions) {
return ""
}
return a.suggestions[a.cursor]
}
func (a autocompleteModel) visibleCount() int {
if len(a.suggestions) > maxSuggestions {
return maxSuggestions
}
return len(a.suggestions)
}
func (a autocompleteModel) view(width int) string {
if !a.active || len(a.suggestions) == 0 {
return ""
}
var b strings.Builder
n := a.visibleCount()
for i := 0; i < n; i++ {
tag := "#" + a.suggestions[i]
if i == a.cursor {
b.WriteString(acSelectedStyle.Render(" " + tag))
} else {
b.WriteString(acItemStyle.Render(" " + tag))
}
if i < n-1 {
b.WriteString("\n")
}
}
if len(a.suggestions) > maxSuggestions {
b.WriteString("\n")
b.WriteString(acItemStyle.Render(" …"))
}
box := lipgloss.NewStyle().
Width(min(30, width)).
Render(b.String())
return box
}
func tagTokenAtCursor(val string, cursorPos int) (tokenStart, tokenEnd int, prefix string, ok bool) {
if cursorPos > len(val) {
cursorPos = len(val)
}
start := cursorPos
for start > 0 && val[start-1] != ' ' {
start--
}
if start >= len(val) || val[start] != '#' {
return 0, 0, "", false
}
end := cursorPos
for end < len(val) && val[end] != ' ' {
end++
}
prefix = strings.ToLower(val[start+1 : cursorPos])
return start, end, prefix, true
}
func filterTagSuggestions(tags []db.TagCount, prefix string) []string {
if prefix == "" {
return nil
}
prefix = strings.ToLower(prefix)
var result []string
for _, tc := range tags {
lower := strings.ToLower(tc.Tag)
if strings.HasPrefix(lower, prefix) && lower != prefix {
result = append(result, tc.Tag)
}
}
return result
}
+85
View File
@@ -0,0 +1,85 @@
package tui
import (
"testing"
"github.com/lerko/nib/internal/db"
)
func TestTagTokenAtCursor(t *testing.T) {
tests := []struct {
name string
val string
cursor int
wantStart int
wantEnd int
wantPfx string
wantOk bool
}{
{"mid tag cursor after a", "hello #par world", 9, 6, 10, "pa", true},
{"end of tag", "hello #par world", 10, 6, 10, "par", true},
{"end of input", "hello #parenting", 16, 6, 16, "parenting", true},
{"start of tag just hash", "hello # world", 7, 6, 7, "", true},
{"not in tag", "hello world", 5, 0, 0, "", false},
{"tag at start", "#ops stuff", 4, 0, 4, "ops", true},
{"cursor at hash", "#ops", 1, 0, 4, "", true},
{"multiple tags second", "hello #ops #inf", 15, 11, 15, "inf", true},
{"empty string", "", 0, 0, 0, "", false},
{"cursor past end", "#ops", 10, 0, 4, "ops", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
start, end, pfx, ok := tagTokenAtCursor(tt.val, tt.cursor)
if ok != tt.wantOk {
t.Fatalf("ok = %v, want %v", ok, tt.wantOk)
}
if !ok {
return
}
if start != tt.wantStart || end != tt.wantEnd {
t.Fatalf("range = [%d,%d), want [%d,%d)", start, end, tt.wantStart, tt.wantEnd)
}
if pfx != tt.wantPfx {
t.Fatalf("prefix = %q, want %q", pfx, tt.wantPfx)
}
})
}
}
func TestFilterTagSuggestions(t *testing.T) {
tags := []db.TagCount{
{Tag: "ops", Count: 5},
{Tag: "ops-deploy", Count: 3},
{Tag: "infra", Count: 2},
{Tag: "ops-team", Count: 1},
}
tests := []struct {
name string
prefix string
want []string
}{
{"empty prefix", "", nil},
{"exact match excluded", "ops", []string{"ops-deploy", "ops-team"}},
{"partial match", "op", []string{"ops", "ops-deploy", "ops-team"}},
{"no match", "zzz", nil},
{"case insensitive", "OP", []string{"ops", "ops-deploy", "ops-team"}},
{"single match", "inf", []string{"infra"}},
{"full match excluded", "infra", nil},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := filterTagSuggestions(tags, tt.prefix)
if len(got) != len(tt.want) {
t.Fatalf("got %v, want %v", got, tt.want)
}
for i := range got {
if got[i] != tt.want[i] {
t.Fatalf("got %v, want %v", got, tt.want)
}
}
})
}
}
+8
View File
@@ -18,10 +18,18 @@ func renderHelp(width, height int) string {
{"Capture Bar", [][2]string{ {"Capture Bar", [][2]string{
{"enter", "submit (or browse if empty)"}, {"enter", "submit (or browse if empty)"},
{"?…", "search (type ?query)"}, {"?…", "search (type ?query)"},
{"#…", "tag (autocomplete with tab)"},
{"-", "todo prefix"}, {"-", "todo prefix"},
{"@", "event prefix"}, {"@", "event prefix"},
{"!", "reminder prefix"}, {"!", "reminder prefix"},
}}, }},
{"Query Operators", [][2]string{
{"?text", "substring search"},
{"?#tag1 #tag2", "filter by tags (AND)"},
{"?@today @week", "date filter (@yesterday @month)"},
{"?<7d >30d", "newer/older than N days"},
{"?^snippet", "card type filter"},
}},
{"Navigation", [][2]string{ {"Navigation", [][2]string{
{"j/k ↑/↓", "move cursor"}, {"j/k ↑/↓", "move cursor"},
{"g/G home/end", "top / bottom"}, {"g/G home/end", "top / bottom"},
+27 -8
View File
@@ -11,10 +11,13 @@ import (
) )
type inputResult struct { type inputResult struct {
entity *db.Entity entity *db.Entity
query bool query bool
body string body string
tags []string tags []string
dateFrom *string
dateTo *string
cardType *db.CardType
} }
type inputModel struct { type inputModel struct {
@@ -47,11 +50,18 @@ func (i inputModel) submit() *inputResult {
} }
if parsed.Query { if parsed.Query {
return &inputResult{ r := &inputResult{
query: true, query: true,
body: parsed.Body, body: parsed.Body,
tags: parsed.FilterTags, tags: parsed.FilterTags,
dateFrom: parsed.QueryDateFrom,
dateTo: parsed.QueryDateTo,
} }
if parsed.QueryCardType != nil {
ct := db.CardType(*parsed.QueryCardType)
r.cardType = &ct
}
return r
} }
e := &db.Entity{ e := &db.Entity{
@@ -120,6 +130,15 @@ func (i inputModel) previewText() string {
for _, t := range p.FilterTags { for _, t := range p.FilterTags {
q += " #" + t q += " #" + t
} }
if p.QueryDateFrom != nil {
q += " from:" + *p.QueryDateFrom
}
if p.QueryDateTo != nil {
q += " to:" + *p.QueryDateTo
}
if p.QueryCardType != nil {
q += " ^" + *p.QueryCardType
}
return "search: " + q return "search: " + q
} }
+135 -18
View File
@@ -79,26 +79,30 @@ type model struct {
width int width int
height int height int
list listModel list listModel
cards cardsModel cards cardsModel
detail detailModel detail detailModel
input inputModel input inputModel
filter filterModel filter filterModel
promote promoteModel promote promoteModel
absorb absorbModel absorb absorbModel
tagRail tagRailModel tagRail tagRailModel
stumble stumbleModel stumble stumbleModel
showHelp bool showHelp bool
autocomplete autocompleteModel
focus focusPane focus focusPane
splitDetail bool splitDetail bool
showTagRail bool showTagRail bool
filterTag string filterTag string
confirmID string confirmID string
cardsSort cardsSort cardsSort cardsSort
searchQuery string searchQuery string
searchTags []string searchTags []string
queryDateFrom *string
queryDateTo *string
queryCardType *db.CardType
status string status string
statusSeq int statusSeq int
@@ -149,6 +153,15 @@ func (m model) listParams() db.ListParams {
if m.filterTag != "" { if m.filterTag != "" {
p.Tag = &m.filterTag p.Tag = &m.filterTag
} }
if m.queryDateFrom != nil {
p.From = m.queryDateFrom
}
if m.queryDateTo != nil {
p.To = m.queryDateTo
}
if m.queryCardType != nil {
p.CardTypeFilter = m.queryCardType
}
if m.mode == modeCards { if m.mode == modeCards {
p.CardsOnly = true p.CardsOnly = true
switch m.cardsSort { switch m.cardsSort {
@@ -167,7 +180,7 @@ func (m model) listParams() db.ListParams {
} }
func (m model) hasSearch() bool { func (m model) hasSearch() bool {
return m.searchQuery != "" || len(m.searchTags) > 0 return m.searchQuery != "" || len(m.searchTags) > 0 || m.queryDateFrom != nil || m.queryDateTo != nil || m.queryCardType != nil
} }
func (m *model) applySearch() { func (m *model) applySearch() {
@@ -363,6 +376,10 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
func (m model) updateCapture(msg tea.KeyMsg) (tea.Model, tea.Cmd) { func (m model) updateCapture(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() { switch msg.String() {
case "enter": case "enter":
if m.autocomplete.active {
m.acceptAutocomplete()
return m, nil
}
val := m.input.ti.Value() val := m.input.ti.Value()
if val == "" { if val == "" {
cmd := m.setFocus(focusList) cmd := m.setFocus(focusList)
@@ -375,23 +392,92 @@ func (m model) updateCapture(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
if result.query { if result.query {
m.searchQuery = result.body m.searchQuery = result.body
m.searchTags = result.tags m.searchTags = result.tags
m.queryDateFrom = result.dateFrom
m.queryDateTo = result.dateTo
m.queryCardType = result.cardType
m.input.clearText() m.input.clearText()
m.autocomplete.active = false
if result.dateFrom != nil || result.dateTo != nil || result.cardType != nil {
cmd := m.setFocus(focusList)
return m, tea.Batch(cmd, loadEntities(m.store, m.listParams()))
}
m.applySearch() m.applySearch()
cmd := m.setFocus(focusList) cmd := m.setFocus(focusList)
return m, cmd return m, cmd
} }
if result.entity != nil { if result.entity != nil {
m.autocomplete.active = false
return m, createEntity(m.store, result.entity) return m, createEntity(m.store, result.entity)
} }
return m, nil return m, nil
case "esc", "tab": case "tab":
if m.autocomplete.active {
m.acceptAutocomplete()
return m, nil
}
cmd := m.setFocus(focusList) cmd := m.setFocus(focusList)
return m, cmd return m, cmd
case "esc":
if m.autocomplete.active {
m.autocomplete.active = false
return m, nil
}
cmd := m.setFocus(focusList)
return m, cmd
case "up":
if m.autocomplete.active {
m.autocomplete.moveUp()
return m, nil
}
case "down":
if m.autocomplete.active {
m.autocomplete.moveDown()
return m, nil
}
} }
m.input = m.input.updateKey(msg) m.input = m.input.updateKey(msg)
m.updateAutocompleteSuggestions()
return m, nil return m, nil
} }
func (m *model) updateAutocompleteSuggestions() {
val := m.input.ti.Value()
pos := m.input.ti.Position()
start, end, prefix, ok := tagTokenAtCursor(val, pos)
if !ok || prefix == "" {
m.autocomplete.active = false
return
}
suggestions := filterTagSuggestions(m.tagRail.tags, prefix)
if len(suggestions) == 0 {
m.autocomplete.active = false
return
}
m.autocomplete.suggestions = suggestions
m.autocomplete.prefix = prefix
m.autocomplete.tokenStart = start
m.autocomplete.tokenEnd = end
m.autocomplete.active = true
if m.autocomplete.cursor >= len(suggestions) {
m.autocomplete.cursor = 0
}
}
func (m *model) acceptAutocomplete() {
if !m.autocomplete.active || len(m.autocomplete.suggestions) == 0 {
return
}
selected := m.autocomplete.selected()
if selected == "" {
return
}
val := m.input.ti.Value()
newVal := val[:m.autocomplete.tokenStart] + "#" + selected + " " + val[m.autocomplete.tokenEnd:]
m.input.ti.SetValue(newVal)
m.input.ti.SetCursor(m.autocomplete.tokenStart + 1 + len(selected) + 1)
m.autocomplete.active = false
}
func (m model) updateBrowse(msg tea.KeyMsg) (tea.Model, tea.Cmd) { func (m model) updateBrowse(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
// Tag rail focus handling // Tag rail focus handling
if m.focus == focusTagRail && m.state == stateList { if m.focus == focusTagRail && m.state == stateList {
@@ -636,9 +722,16 @@ func (m model) updateBrowse(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, nil return m, nil
} }
if m.state == stateList && m.hasSearch() { if m.state == stateList && m.hasSearch() {
hadDBFilters := m.queryDateFrom != nil || m.queryDateTo != nil || m.queryCardType != nil
m.searchQuery = "" m.searchQuery = ""
m.searchTags = nil m.searchTags = nil
m.queryDateFrom = nil
m.queryDateTo = nil
m.queryCardType = nil
m.status = "" m.status = ""
if hadDBFilters {
return m, loadEntities(m.store, m.listParams())
}
if m.mode == modeCards { if m.mode == modeCards {
m.cards.applyFilter() m.cards.applyFilter()
} else { } else {
@@ -953,6 +1046,10 @@ func (m model) View() string {
content = lipgloss.NewStyle().Width(m.width).Height(m.contentHeight()).Render(content) content = lipgloss.NewStyle().Width(m.width).Height(m.contentHeight()).Render(content)
acView := m.autocomplete.view(m.width)
if acView != "" {
return header + "\n" + content + "\n" + acView + "\n" + captureBar + "\n" + statusLine
}
return header + "\n" + content + "\n" + captureBar + "\n" + statusLine return header + "\n" + content + "\n" + captureBar + "\n" + statusLine
} }
@@ -994,6 +1091,15 @@ func (m model) headerView() string {
for _, t := range m.searchTags { for _, t := range m.searchTags {
pill += " #" + t pill += " #" + t
} }
if m.queryDateFrom != nil {
pill += " from:" + *m.queryDateFrom
}
if m.queryDateTo != nil {
pill += " to:" + *m.queryDateTo
}
if m.queryCardType != nil {
pill += " ^" + string(*m.queryCardType)
}
header += " " + searchPillStyle.Render(pill) header += " " + searchPillStyle.Render(pill)
} }
@@ -1021,7 +1127,18 @@ func (m model) statusLine() string {
} }
func (m model) contentHeight() int { func (m model) contentHeight() int {
return m.height - 4 h := m.height - 4
if m.autocomplete.active && len(m.autocomplete.suggestions) > 0 {
n := m.autocomplete.visibleCount()
if len(m.autocomplete.suggestions) > maxSuggestions {
n++
}
h -= n + 1
}
if h < 1 {
h = 1
}
return h
} }
func (m *model) recalcSizes() { func (m *model) recalcSizes() {
+4
View File
@@ -41,6 +41,8 @@ var (
railActiveTagStyle lipgloss.Style railActiveTagStyle lipgloss.Style
railCountStyle lipgloss.Style railCountStyle lipgloss.Style
stumbleAgeStyle lipgloss.Style stumbleAgeStyle lipgloss.Style
acSelectedStyle lipgloss.Style
acItemStyle lipgloss.Style
) )
func init() { func init() {
@@ -96,4 +98,6 @@ func applyTheme() {
railActiveTagStyle = lipgloss.NewStyle().Foreground(ok).Bold(true) railActiveTagStyle = lipgloss.NewStyle().Foreground(ok).Bold(true)
railCountStyle = lipgloss.NewStyle().Foreground(dim) railCountStyle = lipgloss.NewStyle().Foreground(dim)
stumbleAgeStyle = lipgloss.NewStyle().Foreground(remind) stumbleAgeStyle = lipgloss.NewStyle().Foreground(remind)
acSelectedStyle = lipgloss.NewStyle().Foreground(accent).Bold(true)
acItemStyle = lipgloss.NewStyle().Foreground(muted)
} }