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
+135 -18
View File
@@ -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() {