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
+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())
}