feat(tui): add search via capture bar and absorb flow

Search uses existing parse grammar ?prefix — type `?query #tag` in
capture bar to filter entities client-side. Substring match on
body+title+description with AND tag filtering. Esc clears search.

Absorb via m key on fluid entities — opens source picker showing all
other entities, enter merges source into target. Uses existing
store.Absorb() backend.
This commit is contained in:
2026-05-17 21:35:44 -04:00
parent ce335cabd6
commit 1066c0bc7d
10 changed files with 387 additions and 17 deletions
+136
View File
@@ -0,0 +1,136 @@
package tui
import (
"fmt"
"strings"
tea "github.com/charmbracelet/bubbletea"
"github.com/lerko/nib/internal/db"
"github.com/lerko/nib/internal/display"
)
type absorbModel struct {
targetID string
sources []*db.Entity
cursor int
offset int
height int
}
func newAbsorbModel(targetID string) absorbModel {
return absorbModel{targetID: targetID}
}
func (a *absorbModel) setSources(entities []*db.Entity) {
a.sources = nil
for _, e := range entities {
if e.ID != a.targetID {
a.sources = append(a.sources, e)
}
}
a.cursor = 0
a.offset = 0
}
func (a *absorbModel) setHeight(h int) {
a.height = h
}
func (a absorbModel) selectedSource() *db.Entity {
if len(a.sources) == 0 || a.cursor >= len(a.sources) {
return nil
}
return a.sources[a.cursor]
}
func (a absorbModel) update(msg tea.KeyMsg) absorbModel {
switch msg.String() {
case "up", "k":
if a.cursor > 0 {
a.cursor--
if a.cursor < a.offset {
a.offset = a.cursor
}
}
case "down", "j":
if a.cursor < len(a.sources)-1 {
a.cursor++
visible := a.visibleCount()
if a.cursor >= a.offset+visible {
a.offset = a.cursor - visible + 1
}
}
}
return a
}
func (a absorbModel) view(width int) string {
if len(a.sources) == 0 {
return statusStyle.Render("no other entities")
}
var b strings.Builder
b.WriteString(titleStyle.Render("absorb into " + display.FormatID(a.targetID)))
b.WriteString("\n")
b.WriteString(helpStyle.Render("select source to merge"))
b.WriteString("\n\n")
visible := a.visibleCount() - 4
if visible <= 0 {
visible = 10
}
end := min(a.offset+visible, len(a.sources))
for i := a.offset; i < end; i++ {
e := a.sources[i]
line := renderAbsorbSource(e, width-4)
if i == a.cursor {
b.WriteString(selectedItemStyle.Render(" " + line))
} else {
b.WriteString(listItemStyle.Render(line))
}
if i < end-1 {
b.WriteString("\n")
}
}
return b.String()
}
func (a absorbModel) visibleCount() int {
if a.height <= 0 {
return 20
}
return a.height
}
func renderAbsorbSource(e *db.Entity, maxWidth int) string {
glyph := glyphStyle.Render(display.DisplayGlyph(e.Glyph, e.CardType))
id := idStyle.Render("[" + display.FormatID(e.ID) + "]")
body := e.Body
if e.Title != nil {
body = *e.Title
}
var tags string
if len(e.Tags) > 0 {
limit := min(2, len(e.Tags))
tagParts := make([]string, limit)
for i := 0; i < limit; i++ {
tagParts[i] = tagStyle.Render("#" + e.Tags[i])
}
tags = " " + strings.Join(tagParts, " ")
}
line := fmt.Sprintf("%s %s%s %s", glyph, body, tags, id)
if maxWidth > 0 && len(stripAnsi(line)) > maxWidth {
body = truncate(body, maxWidth-20)
line = fmt.Sprintf("%s %s%s %s", glyph, body, tags, id)
}
return line
}
+28
View File
@@ -40,6 +40,15 @@ type entityDemotedMsg struct {
type entityCopiedMsg struct{}
type entityAbsorbedMsg struct {
targetID string
}
type absorbSourcesLoadedMsg struct {
targetID string
entities []*db.Entity
}
type tagsLoadedMsg struct {
tags []db.TagCount
}
@@ -207,3 +216,22 @@ func editInEditor(store *db.Store, e *db.Entity) tea.Cmd {
return editorFinishedMsg{nil}
})
}
func loadAbsorbSources(store *db.Store, targetID string) tea.Cmd {
return func() tea.Msg {
entities, err := store.List(db.DefaultListParams())
if err != nil {
return errMsg{err}
}
return absorbSourcesLoadedMsg{targetID, entities}
}
}
func absorbEntity(store *db.Store, targetID, sourceID string) tea.Cmd {
return func() tea.Msg {
if err := store.Absorb(targetID, sourceID); err != nil {
return errMsg{err}
}
return entityAbsorbedMsg{targetID}
}
}
+2 -1
View File
@@ -21,11 +21,12 @@ func renderHelp(width, height int) string {
{"tab", "cycle intent (cards)"},
}},
{"Actions", [][2]string{
{"a", "add entity"},
{"a", "add entity (or ?query to search)"},
{"d", "delete (with confirm)"},
{"x", "toggle todo completion"},
{"!", "toggle pin"},
{"#", "filter by tag"},
{"m", "absorb (merge into target)"},
{"p", "promote to card"},
}},
{"Detail View", [][2]string{
+17 -2
View File
@@ -8,6 +8,13 @@ import (
"github.com/lerko/nib/internal/parse"
)
type inputResult struct {
entity *db.Entity
query bool
body string
tags []string
}
type inputModel struct {
ti textinput.Model
active bool
@@ -32,7 +39,7 @@ func (i *inputModel) reset() {
i.ti.Blur()
}
func (i inputModel) submit() *db.Entity {
func (i inputModel) submit() *inputResult {
val := i.ti.Value()
if val == "" {
return nil
@@ -43,6 +50,14 @@ func (i inputModel) submit() *db.Entity {
return nil
}
if parsed.Query {
return &inputResult{
query: true,
body: parsed.Body,
tags: parsed.FilterTags,
}
}
e := &db.Entity{
Body: parsed.Body,
Title: parsed.Title,
@@ -63,7 +78,7 @@ func (i inputModel) submit() *db.Entity {
e.Description = parsed.Description
}
return e
return &inputResult{entity: e}
}
func (i inputModel) updateKey(msg tea.KeyMsg) inputModel {
+2
View File
@@ -26,6 +26,7 @@ type keyMap struct {
Cards key.Binding
Sort key.Binding
Intent key.Binding
Absorb key.Binding
}
var keys = keyMap{
@@ -52,4 +53,5 @@ var keys = keyMap{
Cards: key.NewBinding(key.WithKeys("2"), key.WithHelp("2", "cards")),
Sort: key.NewBinding(key.WithKeys("s"), key.WithHelp("s", "sort")),
Intent: key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "intent")),
Absorb: key.NewBinding(key.WithKeys("m"), key.WithHelp("m", "absorb")),
}
+18 -7
View File
@@ -13,6 +13,7 @@ import (
type listModel struct {
entities []*db.Entity
filtered []*db.Entity
cursor int
offset int
height int
@@ -25,6 +26,7 @@ func newListModel() listModel {
func (l *listModel) setEntities(entities []*db.Entity) {
l.entities = entities
l.filtered = nil
if l.cursor >= len(entities) {
l.cursor = max(0, len(entities)-1)
}
@@ -35,11 +37,19 @@ func (l *listModel) setSize(width, height int) {
l.height = height
}
func (l listModel) displayEntities() []*db.Entity {
if l.filtered != nil {
return l.filtered
}
return l.entities
}
func (l listModel) selected() *db.Entity {
if len(l.entities) == 0 || l.cursor >= len(l.entities) {
ents := l.displayEntities()
if len(ents) == 0 || l.cursor >= len(ents) {
return nil
}
return l.entities[l.cursor]
return ents[l.cursor]
}
func (l listModel) update(msg tea.KeyMsg) listModel {
@@ -52,7 +62,7 @@ func (l listModel) update(msg tea.KeyMsg) listModel {
}
}
case "down", "j":
if l.cursor < len(l.entities)-1 {
if l.cursor < len(l.displayEntities())-1 {
l.cursor++
visible := l.visibleCount()
if l.cursor >= l.offset+visible {
@@ -63,7 +73,7 @@ func (l listModel) update(msg tea.KeyMsg) listModel {
l.cursor = 0
l.offset = 0
case "end", "G":
l.cursor = max(0, len(l.entities)-1)
l.cursor = max(0, len(l.displayEntities())-1)
visible := l.visibleCount()
if l.cursor >= visible {
l.offset = l.cursor - visible + 1
@@ -74,7 +84,7 @@ func (l listModel) update(msg tea.KeyMsg) listModel {
l.offset = l.cursor
}
case "pgdown", "ctrl+d":
l.cursor = min(len(l.entities)-1, l.cursor+l.visibleCount())
l.cursor = min(len(l.displayEntities())-1, l.cursor+l.visibleCount())
visible := l.visibleCount()
if l.cursor >= l.offset+visible {
l.offset = l.cursor - visible + 1
@@ -84,11 +94,12 @@ func (l listModel) update(msg tea.KeyMsg) listModel {
}
func (l listModel) view(width int) string {
if len(l.entities) == 0 {
ents := l.displayEntities()
if len(ents) == 0 {
return statusStyle.Render("no entities")
}
groups := groupByDate(l.entities)
groups := groupByDate(ents)
type displayLine struct {
text string
+121 -5
View File
@@ -17,6 +17,7 @@ const (
stateTagFilter
stateConfirm
statePromote
stateAbsorb
)
type viewMode int
@@ -69,11 +70,14 @@ type model struct {
input inputModel
filter filterModel
promote promoteModel
absorb absorbModel
showHelp bool
filterTag string
confirmID string
cardsSort cardsSort
filterTag string
confirmID string
cardsSort cardsSort
searchQuery string
searchTags []string
status string
err error
@@ -118,6 +122,34 @@ func (m model) listParams() db.ListParams {
return p
}
func (m model) hasSearch() bool {
return m.searchQuery != "" || len(m.searchTags) > 0
}
func (m *model) applySearch() {
if m.mode == modeCards {
filtered := filterEntities(m.cards.entities, m.searchQuery, m.searchTags)
m.cards.filtered = nil
var pinned, rest []*db.Entity
for _, e := range filtered {
if !matchesIntent(e, m.cards.intent) {
continue
}
if e.Pinned {
pinned = append(pinned, e)
} else {
rest = append(rest, e)
}
}
m.cards.filtered = append(pinned, rest...)
if m.cards.cursor >= len(m.cards.filtered) {
m.cards.cursor = max(0, len(m.cards.filtered)-1)
}
} else {
m.list.filtered = filterEntities(m.list.entities, m.searchQuery, m.searchTags)
}
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
@@ -135,6 +167,9 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} else {
m.list.setEntities(msg.entities)
}
if m.hasSearch() {
m.applySearch()
}
m.err = nil
return m, nil
@@ -169,6 +204,18 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.status = "copied"
return m, nil
case entityAbsorbedMsg:
m.status = "absorbed"
m.state = stateList
return m, loadEntities(m.store, m.listParams())
case absorbSourcesLoadedMsg:
m.absorb = newAbsorbModel(msg.targetID)
m.absorb.setSources(msg.entities)
m.absorb.setHeight(m.contentHeight())
m.state = stateAbsorb
return m, nil
case tagsLoadedMsg:
m.filter.setTags(msg.tags)
m.state = stateTagFilter
@@ -204,6 +251,8 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m.updateConfirm(msg)
case statePromote:
return m.updatePromote(msg)
case stateAbsorb:
return m.updateAbsorb(msg)
default:
return m.updateKeys(msg)
}
@@ -263,6 +312,9 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
case "tab":
if m.mode == modeCards && m.state == stateList {
m.cards.setIntent(m.cards.intent.next())
if m.hasSearch() {
m.applySearch()
}
return m, nil
}
return m, nil
@@ -279,6 +331,17 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.state = stateList
return m, nil
}
if m.state == stateList && m.hasSearch() {
m.searchQuery = ""
m.searchTags = nil
m.status = ""
if m.mode == modeCards {
m.cards.applyFilter()
} else {
m.list.filtered = nil
}
return m, nil
}
if m.state == stateList && m.filterTag != "" {
m.filterTag = ""
m.status = ""
@@ -331,6 +394,17 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
}
return m, nil
case "m":
e := m.selectedEntity()
if e != nil {
if e.CardType != nil {
m.status = "target must be fluid"
return m, nil
}
return m, loadAbsorbSources(m.store, e.ID)
}
return m, nil
case "p":
e := m.selectedEntity()
if e != nil {
@@ -387,8 +461,20 @@ func (m model) updateInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.input.reset()
return m, nil
case "enter":
if e := m.input.submit(); e != nil {
return m, createEntity(m.store, e)
result := m.input.submit()
if result == nil {
return m, nil
}
if result.query {
m.searchQuery = result.body
m.searchTags = result.tags
m.state = stateList
m.input.reset()
m.applySearch()
return m, nil
}
if result.entity != nil {
return m, createEntity(m.store, result.entity)
}
return m, nil
}
@@ -441,6 +527,23 @@ func (m model) updatePromote(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
}
}
func (m model) updateAbsorb(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "esc", "q":
m.state = stateList
return m, nil
case "enter":
source := m.absorb.selectedSource()
if source != nil {
return m, absorbEntity(m.store, m.absorb.targetID, source.ID)
}
return m, nil
default:
m.absorb = m.absorb.update(msg)
return m, nil
}
}
func (m model) View() string {
if m.showHelp {
return renderHelp(m.width, m.height)
@@ -460,6 +563,8 @@ func (m model) View() string {
content = m.filter.view(m.width)
case statePromote:
content = m.promote.view(m.width)
case stateAbsorb:
content = m.absorb.view(m.width)
}
header := m.headerView()
@@ -481,6 +586,17 @@ func (m model) headerView() string {
header += " " + filterPillStyle.Render("#"+m.filterTag)
}
if m.hasSearch() {
pill := "?"
if m.searchQuery != "" {
pill += m.searchQuery
}
for _, t := range m.searchTags {
pill += " #" + t
}
header += " " + searchPillStyle.Render(pill)
}
if m.mode == modeCards && m.cards.intent != intentAll {
header += " " + affordanceStyle.Render(m.cards.intent.String())
}
+55
View File
@@ -0,0 +1,55 @@
package tui
import (
"strings"
"github.com/lerko/nib/internal/db"
)
func filterEntities(entities []*db.Entity, query string, tags []string) []*db.Entity {
if query == "" && len(tags) == 0 {
return entities
}
query = strings.ToLower(query)
lowerTags := make([]string, len(tags))
for i, t := range tags {
lowerTags[i] = strings.ToLower(t)
}
var result []*db.Entity
for _, e := range entities {
if !matchesSearch(e, query, lowerTags) {
continue
}
result = append(result, e)
}
return result
}
func matchesSearch(e *db.Entity, query string, tags []string) bool {
if len(tags) > 0 {
eTags := make(map[string]bool, len(e.Tags))
for _, t := range e.Tags {
eTags[strings.ToLower(t)] = true
}
for _, t := range tags {
if !eTags[t] {
return false
}
}
}
if query == "" {
return true
}
haystack := strings.ToLower(e.Body)
if e.Title != nil {
haystack += " " + strings.ToLower(*e.Title)
}
if e.Description != nil {
haystack += " " + strings.ToLower(*e.Description)
}
return strings.Contains(haystack, query)
}
+4 -2
View File
@@ -27,7 +27,7 @@ func countText(m model) string {
if m.mode == modeCards {
total = len(m.cards.filtered)
} else {
total = len(m.list.entities)
total = len(m.list.displayEntities())
}
if m.filterTag != "" {
return fmt.Sprintf("%d entities #%s", total, m.filterTag)
@@ -47,10 +47,12 @@ func contextHints(m model) string {
return "y:confirm n:cancel"
case statePromote:
return "j/k:nav enter:select esc:cancel"
case stateAbsorb:
return "j/k:nav enter:absorb esc:cancel"
default:
if m.mode == modeCards {
return "1:stream 2:cards s:sort tab:intent a:add ?:help q:quit"
}
return "1:stream 2:cards a:add d:del x:todo #:filter ?:help q:quit"
return "1:stream 2:cards a:add/?search m:absorb d:del #:filter ?:help q:quit"
}
}
+4
View File
@@ -101,4 +101,8 @@ var (
checkPendingStyle = lipgloss.NewStyle().
Foreground(dim)
searchPillStyle = lipgloss.NewStyle().
Foreground(lipgloss.AdaptiveColor{Light: "#E06C75", Dark: "#E06C75"}).
Bold(true)
)