feat(tui): add tag autocomplete and query composition #43

Merged
lerko merged 1 commits from feat/tag-autocomplete-query-compose into main 2026-05-21 16:22:01 +00:00
8 changed files with 485 additions and 38 deletions
Showing only changes of commit e22e040688 - Show all commits
+42 -1
View File
@@ -4,6 +4,7 @@ import (
"fmt" "fmt"
"strconv" "strconv"
"strings" "strings"
"time"
) )
type Result struct { type Result struct {
@@ -17,6 +18,9 @@ type Result struct {
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,13 +70,50 @@ 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)
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 { } else {
bodyParts = append(bodyParts, tok) 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)
}
} }
r.Body = strings.Join(bodyParts, " ") r.Body = strings.Join(bodyParts, " ")
return r, nil return r, nil
+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"},
+20 -1
View File
@@ -15,6 +15,9 @@ type inputResult struct {
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
} }
+120 -3
View File
@@ -89,6 +89,7 @@ type model struct {
tagRail tagRailModel tagRail tagRailModel
stumble stumbleModel stumble stumbleModel
showHelp bool showHelp bool
autocomplete autocompleteModel
focus focusPane focus focusPane
splitDetail bool splitDetail bool
@@ -99,6 +100,9 @@ type model struct {
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)
} }