From e22e04068894148441a216727ac9ad99b9062dbf Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Thu, 21 May 2026 12:12:07 -0400 Subject: [PATCH] feat(tui): add tag autocomplete and query composition 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. --- internal/parse/grammar.go | 65 ++++++++++--- internal/parse/grammar_test.go | 61 ++++++++++++ internal/tui/autocomplete.go | 112 ++++++++++++++++++++++ internal/tui/autocomplete_test.go | 85 +++++++++++++++++ internal/tui/help.go | 8 ++ internal/tui/input.go | 35 +++++-- internal/tui/model.go | 153 ++++++++++++++++++++++++++---- internal/tui/styles.go | 4 + 8 files changed, 485 insertions(+), 38 deletions(-) create mode 100644 internal/tui/autocomplete.go create mode 100644 internal/tui/autocomplete_test.go diff --git a/internal/parse/grammar.go b/internal/parse/grammar.go index 0556320..cada015 100644 --- a/internal/parse/grammar.go +++ b/internal/parse/grammar.go @@ -4,19 +4,23 @@ import ( "fmt" "strconv" "strings" + "time" ) type Result struct { - Body string - Glyph string - Title *string - Description *string - TimeAnchor *string - Tags []string - FilterTags []string - CardSuffix *string - Pin bool - Query bool + Body string + Glyph string + Title *string + Description *string + TimeAnchor *string + Tags []string + FilterTags []string + CardSuffix *string + Pin bool + Query bool + QueryDateFrom *string + QueryDateTo *string + QueryCardType *string } var validCardTypes = map[string]string{ @@ -66,11 +70,48 @@ func Parse(input string) (*Result, error) { r.Glyph = "" tokens := strings.Fields(remaining) var bodyParts []string + now := time.Now() 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:]) 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) } } diff --git a/internal/parse/grammar_test.go b/internal/parse/grammar_test.go index 0734dc5..a99a581 100644 --- a/internal/parse/grammar_test.go +++ b/internal/parse/grammar_test.go @@ -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 { if a == nil && b == nil { return true diff --git a/internal/tui/autocomplete.go b/internal/tui/autocomplete.go new file mode 100644 index 0000000..299d000 --- /dev/null +++ b/internal/tui/autocomplete.go @@ -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 +} diff --git a/internal/tui/autocomplete_test.go b/internal/tui/autocomplete_test.go new file mode 100644 index 0000000..fee8f87 --- /dev/null +++ b/internal/tui/autocomplete_test.go @@ -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) + } + } + }) + } +} diff --git a/internal/tui/help.go b/internal/tui/help.go index a5e3958..20fae7f 100644 --- a/internal/tui/help.go +++ b/internal/tui/help.go @@ -18,10 +18,18 @@ func renderHelp(width, height int) string { {"Capture Bar", [][2]string{ {"enter", "submit (or browse if empty)"}, {"?…", "search (type ?query)"}, + {"#…", "tag (autocomplete with tab)"}, {"-", "todo prefix"}, {"@", "event 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{ {"j/k ↑/↓", "move cursor"}, {"g/G home/end", "top / bottom"}, diff --git a/internal/tui/input.go b/internal/tui/input.go index c8e6abd..9e2a155 100644 --- a/internal/tui/input.go +++ b/internal/tui/input.go @@ -11,10 +11,13 @@ import ( ) type inputResult struct { - entity *db.Entity - query bool - body string - tags []string + entity *db.Entity + query bool + body string + tags []string + dateFrom *string + dateTo *string + cardType *db.CardType } type inputModel struct { @@ -47,11 +50,18 @@ func (i inputModel) submit() *inputResult { } if parsed.Query { - return &inputResult{ - query: true, - body: parsed.Body, - tags: parsed.FilterTags, + r := &inputResult{ + query: true, + body: parsed.Body, + 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{ @@ -120,6 +130,15 @@ func (i inputModel) previewText() string { for _, t := range p.FilterTags { 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 } diff --git a/internal/tui/model.go b/internal/tui/model.go index 3430af3..c128980 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -79,26 +79,30 @@ type model struct { width int height int - list listModel - cards cardsModel - detail detailModel - input inputModel - filter filterModel - promote promoteModel - absorb absorbModel - tagRail tagRailModel - stumble stumbleModel - showHelp bool + list listModel + cards cardsModel + detail detailModel + input inputModel + filter filterModel + promote promoteModel + absorb absorbModel + tagRail tagRailModel + stumble stumbleModel + showHelp bool + autocomplete autocompleteModel focus focusPane splitDetail bool showTagRail bool - filterTag string - confirmID string - cardsSort cardsSort - searchQuery string - searchTags []string + filterTag string + confirmID string + cardsSort cardsSort + searchQuery string + searchTags []string + queryDateFrom *string + queryDateTo *string + queryCardType *db.CardType status string statusSeq int @@ -149,6 +153,15 @@ func (m model) listParams() db.ListParams { if 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 { p.CardsOnly = true switch m.cardsSort { @@ -167,7 +180,7 @@ func (m model) listParams() db.ListParams { } 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() { @@ -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) { switch msg.String() { case "enter": + if m.autocomplete.active { + m.acceptAutocomplete() + return m, nil + } val := m.input.ti.Value() if val == "" { cmd := m.setFocus(focusList) @@ -375,23 +392,92 @@ func (m model) updateCapture(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if result.query { m.searchQuery = result.body m.searchTags = result.tags + m.queryDateFrom = result.dateFrom + m.queryDateTo = result.dateTo + m.queryCardType = result.cardType 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() cmd := m.setFocus(focusList) return m, cmd } if result.entity != nil { + m.autocomplete.active = false return m, createEntity(m.store, result.entity) } return m, nil - case "esc", "tab": + case "tab": + if m.autocomplete.active { + m.acceptAutocomplete() + return m, nil + } cmd := m.setFocus(focusList) 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.updateAutocompleteSuggestions() 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) { // Tag rail focus handling 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 } if m.state == stateList && m.hasSearch() { + hadDBFilters := m.queryDateFrom != nil || m.queryDateTo != nil || m.queryCardType != nil m.searchQuery = "" m.searchTags = nil + m.queryDateFrom = nil + m.queryDateTo = nil + m.queryCardType = nil m.status = "" + if hadDBFilters { + return m, loadEntities(m.store, m.listParams()) + } if m.mode == modeCards { m.cards.applyFilter() } else { @@ -953,6 +1046,10 @@ func (m model) View() string { 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 } @@ -994,6 +1091,15 @@ func (m model) headerView() string { for _, t := range m.searchTags { 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) } @@ -1021,7 +1127,18 @@ func (m model) statusLine() string { } 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() { diff --git a/internal/tui/styles.go b/internal/tui/styles.go index f744ca2..41d594a 100644 --- a/internal/tui/styles.go +++ b/internal/tui/styles.go @@ -41,6 +41,8 @@ var ( railActiveTagStyle lipgloss.Style railCountStyle lipgloss.Style stumbleAgeStyle lipgloss.Style + acSelectedStyle lipgloss.Style + acItemStyle lipgloss.Style ) func init() { @@ -96,4 +98,6 @@ func applyTheme() { railActiveTagStyle = lipgloss.NewStyle().Foreground(ok).Bold(true) railCountStyle = lipgloss.NewStyle().Foreground(dim) stumbleAgeStyle = lipgloss.NewStyle().Foreground(remind) + acSelectedStyle = lipgloss.NewStyle().Foreground(accent).Bold(true) + acItemStyle = lipgloss.NewStyle().Foreground(muted) }