feat(tui): add bubbletea terminal UI #30

Merged
lerko merged 12 commits from feat/tui into main 2026-05-20 01:16:57 +00:00
10 changed files with 387 additions and 17 deletions
Showing only changes of commit 1066c0bc7d - Show all commits
+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 entityCopiedMsg struct{}
type entityAbsorbedMsg struct {
targetID string
}
type absorbSourcesLoadedMsg struct {
targetID string
entities []*db.Entity
}
type tagsLoadedMsg struct { type tagsLoadedMsg struct {
tags []db.TagCount tags []db.TagCount
} }
@@ -207,3 +216,22 @@ func editInEditor(store *db.Store, e *db.Entity) tea.Cmd {
return editorFinishedMsg{nil} 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)"}, {"tab", "cycle intent (cards)"},
}}, }},
{"Actions", [][2]string{ {"Actions", [][2]string{
{"a", "add entity"}, {"a", "add entity (or ?query to search)"},
{"d", "delete (with confirm)"}, {"d", "delete (with confirm)"},
{"x", "toggle todo completion"}, {"x", "toggle todo completion"},
{"!", "toggle pin"}, {"!", "toggle pin"},
{"#", "filter by tag"}, {"#", "filter by tag"},
{"m", "absorb (merge into target)"},
{"p", "promote to card"}, {"p", "promote to card"},
}}, }},
{"Detail View", [][2]string{ {"Detail View", [][2]string{
+17 -2
View File
@@ -8,6 +8,13 @@ import (
"github.com/lerko/nib/internal/parse" "github.com/lerko/nib/internal/parse"
) )
type inputResult struct {
entity *db.Entity
query bool
body string
tags []string
}
type inputModel struct { type inputModel struct {
ti textinput.Model ti textinput.Model
active bool active bool
@@ -32,7 +39,7 @@ func (i *inputModel) reset() {
i.ti.Blur() i.ti.Blur()
} }
func (i inputModel) submit() *db.Entity { func (i inputModel) submit() *inputResult {
val := i.ti.Value() val := i.ti.Value()
if val == "" { if val == "" {
return nil return nil
@@ -43,6 +50,14 @@ func (i inputModel) submit() *db.Entity {
return nil return nil
} }
if parsed.Query {
return &inputResult{
query: true,
body: parsed.Body,
tags: parsed.FilterTags,
}
}
e := &db.Entity{ e := &db.Entity{
Body: parsed.Body, Body: parsed.Body,
Title: parsed.Title, Title: parsed.Title,
@@ -63,7 +78,7 @@ func (i inputModel) submit() *db.Entity {
e.Description = parsed.Description e.Description = parsed.Description
} }
return e return &inputResult{entity: e}
} }
func (i inputModel) updateKey(msg tea.KeyMsg) inputModel { func (i inputModel) updateKey(msg tea.KeyMsg) inputModel {
+2
View File
@@ -26,6 +26,7 @@ type keyMap struct {
Cards key.Binding Cards key.Binding
Sort key.Binding Sort key.Binding
Intent key.Binding Intent key.Binding
Absorb key.Binding
} }
var keys = keyMap{ var keys = keyMap{
@@ -52,4 +53,5 @@ var keys = keyMap{
Cards: key.NewBinding(key.WithKeys("2"), key.WithHelp("2", "cards")), Cards: key.NewBinding(key.WithKeys("2"), key.WithHelp("2", "cards")),
Sort: key.NewBinding(key.WithKeys("s"), key.WithHelp("s", "sort")), Sort: key.NewBinding(key.WithKeys("s"), key.WithHelp("s", "sort")),
Intent: key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "intent")), 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 { type listModel struct {
entities []*db.Entity entities []*db.Entity
filtered []*db.Entity
cursor int cursor int
offset int offset int
height int height int
@@ -25,6 +26,7 @@ func newListModel() listModel {
func (l *listModel) setEntities(entities []*db.Entity) { func (l *listModel) setEntities(entities []*db.Entity) {
l.entities = entities l.entities = entities
l.filtered = nil
if l.cursor >= len(entities) { if l.cursor >= len(entities) {
l.cursor = max(0, len(entities)-1) l.cursor = max(0, len(entities)-1)
} }
@@ -35,11 +37,19 @@ func (l *listModel) setSize(width, height int) {
l.height = height 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 { 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 nil
} }
return l.entities[l.cursor] return ents[l.cursor]
} }
func (l listModel) update(msg tea.KeyMsg) listModel { func (l listModel) update(msg tea.KeyMsg) listModel {
@@ -52,7 +62,7 @@ func (l listModel) update(msg tea.KeyMsg) listModel {
} }
} }
case "down", "j": case "down", "j":
if l.cursor < len(l.entities)-1 { if l.cursor < len(l.displayEntities())-1 {
l.cursor++ l.cursor++
visible := l.visibleCount() visible := l.visibleCount()
if l.cursor >= l.offset+visible { if l.cursor >= l.offset+visible {
@@ -63,7 +73,7 @@ func (l listModel) update(msg tea.KeyMsg) listModel {
l.cursor = 0 l.cursor = 0
l.offset = 0 l.offset = 0
case "end", "G": case "end", "G":
l.cursor = max(0, len(l.entities)-1) l.cursor = max(0, len(l.displayEntities())-1)
visible := l.visibleCount() visible := l.visibleCount()
if l.cursor >= visible { if l.cursor >= visible {
l.offset = l.cursor - visible + 1 l.offset = l.cursor - visible + 1
@@ -74,7 +84,7 @@ func (l listModel) update(msg tea.KeyMsg) listModel {
l.offset = l.cursor l.offset = l.cursor
} }
case "pgdown", "ctrl+d": 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() visible := l.visibleCount()
if l.cursor >= l.offset+visible { if l.cursor >= l.offset+visible {
l.offset = l.cursor - visible + 1 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 { func (l listModel) view(width int) string {
if len(l.entities) == 0 { ents := l.displayEntities()
if len(ents) == 0 {
return statusStyle.Render("no entities") return statusStyle.Render("no entities")
} }
groups := groupByDate(l.entities) groups := groupByDate(ents)
type displayLine struct { type displayLine struct {
text string text string
+118 -2
View File
@@ -17,6 +17,7 @@ const (
stateTagFilter stateTagFilter
stateConfirm stateConfirm
statePromote statePromote
stateAbsorb
) )
type viewMode int type viewMode int
@@ -69,11 +70,14 @@ type model struct {
input inputModel input inputModel
filter filterModel filter filterModel
promote promoteModel promote promoteModel
absorb absorbModel
showHelp bool showHelp bool
filterTag string filterTag string
confirmID string confirmID string
cardsSort cardsSort cardsSort cardsSort
searchQuery string
searchTags []string
status string status string
err error err error
@@ -118,6 +122,34 @@ func (m model) listParams() db.ListParams {
return p 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) { func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) { switch msg := msg.(type) {
case tea.WindowSizeMsg: case tea.WindowSizeMsg:
@@ -135,6 +167,9 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} else { } else {
m.list.setEntities(msg.entities) m.list.setEntities(msg.entities)
} }
if m.hasSearch() {
m.applySearch()
}
m.err = nil m.err = nil
return m, nil return m, nil
@@ -169,6 +204,18 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.status = "copied" m.status = "copied"
return m, nil 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: case tagsLoadedMsg:
m.filter.setTags(msg.tags) m.filter.setTags(msg.tags)
m.state = stateTagFilter m.state = stateTagFilter
@@ -204,6 +251,8 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m.updateConfirm(msg) return m.updateConfirm(msg)
case statePromote: case statePromote:
return m.updatePromote(msg) return m.updatePromote(msg)
case stateAbsorb:
return m.updateAbsorb(msg)
default: default:
return m.updateKeys(msg) return m.updateKeys(msg)
} }
@@ -263,6 +312,9 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
case "tab": case "tab":
if m.mode == modeCards && m.state == stateList { if m.mode == modeCards && m.state == stateList {
m.cards.setIntent(m.cards.intent.next()) m.cards.setIntent(m.cards.intent.next())
if m.hasSearch() {
m.applySearch()
}
return m, nil return m, nil
} }
return m, nil return m, nil
@@ -279,6 +331,17 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.state = stateList m.state = stateList
return m, nil 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 != "" { if m.state == stateList && m.filterTag != "" {
m.filterTag = "" m.filterTag = ""
m.status = "" m.status = ""
@@ -331,6 +394,17 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
} }
return m, nil 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": case "p":
e := m.selectedEntity() e := m.selectedEntity()
if e != nil { if e != nil {
@@ -387,8 +461,20 @@ func (m model) updateInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.input.reset() m.input.reset()
return m, nil return m, nil
case "enter": case "enter":
if e := m.input.submit(); e != nil { result := m.input.submit()
return m, createEntity(m.store, e) 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 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 { func (m model) View() string {
if m.showHelp { if m.showHelp {
return renderHelp(m.width, m.height) return renderHelp(m.width, m.height)
@@ -460,6 +563,8 @@ func (m model) View() string {
content = m.filter.view(m.width) content = m.filter.view(m.width)
case statePromote: case statePromote:
content = m.promote.view(m.width) content = m.promote.view(m.width)
case stateAbsorb:
content = m.absorb.view(m.width)
} }
header := m.headerView() header := m.headerView()
@@ -481,6 +586,17 @@ func (m model) headerView() string {
header += " " + filterPillStyle.Render("#"+m.filterTag) 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 { if m.mode == modeCards && m.cards.intent != intentAll {
header += " " + affordanceStyle.Render(m.cards.intent.String()) 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 { if m.mode == modeCards {
total = len(m.cards.filtered) total = len(m.cards.filtered)
} else { } else {
total = len(m.list.entities) total = len(m.list.displayEntities())
} }
if m.filterTag != "" { if m.filterTag != "" {
return fmt.Sprintf("%d entities #%s", total, 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" return "y:confirm n:cancel"
case statePromote: case statePromote:
return "j/k:nav enter:select esc:cancel" return "j/k:nav enter:select esc:cancel"
case stateAbsorb:
return "j/k:nav enter:absorb esc:cancel"
default: default:
if m.mode == modeCards { if m.mode == modeCards {
return "1:stream 2:cards s:sort tab:intent a:add ?:help q:quit" 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(). checkPendingStyle = lipgloss.NewStyle().
Foreground(dim) Foreground(dim)
searchPillStyle = lipgloss.NewStyle().
Foreground(lipgloss.AdaptiveColor{Light: "#E06C75", Dark: "#E06C75"}).
Bold(true)
) )