feat(tui): add cards view, mode switching, promote picker, and card detail

Stream/cards toggle with 1/2 keys. Cards view with intent filtering
(tab cycles grab/read/fill/all), sort cycling (s key), pinned-first
ordering, and affordance badges. Promote picker (p key) with card type
selection and auto-detection from body content. Detail view renders
card_data per type: checklist steps, template slots, decision fields,
link URLs.

Extracts generateCardData to internal/carddata for reuse across cmd
and tui packages.
This commit is contained in:
2026-05-17 21:14:14 -04:00
parent c2ea63dd16
commit ce335cabd6
12 changed files with 786 additions and 112 deletions
+167 -18
View File
@@ -1,6 +1,8 @@
package tui
import (
"fmt"
tea "github.com/charmbracelet/bubbletea"
"github.com/lerko/nib/internal/db"
@@ -14,22 +16,64 @@ const (
stateInput
stateTagFilter
stateConfirm
statePromote
)
type viewMode int
const (
modeStream viewMode = iota
modeCards
)
type cardsSort int
const (
sortNewest cardsSort = iota
sortOldest
sortMostUsed
)
func (s cardsSort) String() string {
switch s {
case sortOldest:
return "oldest"
case sortMostUsed:
return "most used"
default:
return "newest"
}
}
func (s cardsSort) next() cardsSort {
switch s {
case sortNewest:
return sortOldest
case sortOldest:
return sortMostUsed
default:
return sortNewest
}
}
type model struct {
store *db.Store
state viewState
mode viewMode
width int
height int
list listModel
cards cardsModel
detail detailModel
input inputModel
filter filterModel
promote promoteModel
showHelp bool
filterTag string
confirmID string
cardsSort cardsSort
status string
err error
@@ -39,7 +83,9 @@ func newModel(store *db.Store) model {
return model{
store: store,
state: stateList,
mode: modeStream,
list: newListModel(),
cards: newCardsModel(),
detail: newDetailModel(),
input: newInputModel(),
filter: newFilterModel(),
@@ -55,6 +101,20 @@ func (m model) listParams() db.ListParams {
if m.filterTag != "" {
p.Tag = &m.filterTag
}
if m.mode == modeCards {
p.CardsOnly = true
switch m.cardsSort {
case sortNewest:
p.Sort = "created"
p.Order = "desc"
case sortOldest:
p.Sort = "created"
p.Order = "asc"
case sortMostUsed:
p.Sort = "use_count"
p.Order = "desc"
}
}
return p
}
@@ -64,12 +124,17 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.width = msg.Width
m.height = msg.Height
m.list.setSize(m.width, m.contentHeight())
m.cards.setSize(m.width, m.contentHeight())
m.detail.setSize(m.width, m.contentHeight())
m.filter.setHeight(m.contentHeight())
return m, nil
case entitiesLoadedMsg:
m.list.setEntities(msg.entities)
if m.mode == modeCards {
m.cards.setEntities(msg.entities)
} else {
m.list.setEntities(msg.entities)
}
m.err = nil
return m, nil
@@ -92,8 +157,9 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, loadEntities(m.store, m.listParams())
case entityPromotedMsg:
m.status = "promoted → snippet"
return m, m.reloadDetail(msg.id)
m.status = fmt.Sprintf("promoted → %s", msg.cardType)
m.state = stateList
return m, loadEntities(m.store, m.listParams())
case entityDemotedMsg:
m.status = "demoted → fluid"
@@ -136,6 +202,8 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m.updateTagFilter(msg)
case stateConfirm:
return m.updateConfirm(msg)
case statePromote:
return m.updatePromote(msg)
default:
return m.updateKeys(msg)
}
@@ -166,6 +234,39 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.showHelp = true
return m, nil
case "1":
if m.mode != modeStream {
m.mode = modeStream
m.state = stateList
m.status = ""
return m, loadEntities(m.store, m.listParams())
}
return m, nil
case "2":
if m.mode != modeCards {
m.mode = modeCards
m.state = stateList
m.status = ""
return m, loadEntities(m.store, m.listParams())
}
return m, nil
case "s":
if m.mode == modeCards && m.state == stateList {
m.cardsSort = m.cardsSort.next()
m.status = "sort: " + m.cardsSort.String()
return m, loadEntities(m.store, m.listParams())
}
return m, nil
case "tab":
if m.mode == modeCards && m.state == stateList {
m.cards.setIntent(m.cards.intent.next())
return m, nil
}
return m, nil
case "a":
if m.state == stateList {
m.state = stateInput
@@ -187,7 +288,7 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
case "enter":
if m.state == stateList {
if e := m.list.selected(); e != nil {
if e := m.selectedEntity(); e != nil {
m.detail.setEntity(e)
m.state = stateDetail
}
@@ -196,7 +297,7 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
case "d":
if m.state == stateList {
if e := m.list.selected(); e != nil {
if e := m.selectedEntity(); e != nil {
m.confirmID = e.ID
m.state = stateConfirm
return m, confirmTimeout()
@@ -206,7 +307,7 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
case "x":
if m.state == stateList {
if e := m.list.selected(); e != nil && e.Glyph == db.GlyphTodo {
if e := m.selectedEntity(); e != nil && e.Glyph == db.GlyphTodo {
return m, toggleTodo(m.store, e)
}
}
@@ -231,12 +332,15 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, nil
case "p":
if m.state == stateDetail && m.detail.entity != nil {
if m.detail.entity.CardType != nil {
e := m.selectedEntity()
if e != nil {
if e.CardType != nil {
m.status = "already a card"
return m, nil
}
return m, promoteEntity(m.store, m.detail.entity.ID)
m.promote = newPromoteModel(e.ID, e.Body)
m.state = statePromote
return m, nil
}
return m, nil
@@ -265,7 +369,11 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch m.state {
case stateList:
m.list = m.list.update(msg)
if m.mode == modeCards {
m.cards = m.cards.update(msg)
} else {
m.list = m.list.update(msg)
}
case stateDetail:
m.detail = m.detail.update(msg)
}
@@ -319,6 +427,20 @@ func (m model) updateConfirm(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, nil
}
func (m model) updatePromote(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "esc", "q":
m.state = stateList
return m, nil
case "enter":
ct := m.promote.selectedType()
return m, promoteEntity(m.store, m.promote.entityID, ct, m.promote.body)
default:
m.promote = m.promote.update(msg.String())
return m, nil
}
}
func (m model) View() string {
if m.showHelp {
return renderHelp(m.width, m.height)
@@ -327,21 +449,47 @@ func (m model) View() string {
var content string
switch m.state {
case stateList, stateInput, stateConfirm:
content = m.list.view(m.width)
if m.mode == modeCards {
content = m.cards.view(m.width)
} else {
content = m.list.view(m.width)
}
case stateDetail:
content = m.detail.view(m.width)
case stateTagFilter:
content = m.filter.view(m.width)
case statePromote:
content = m.promote.view(m.width)
}
header := m.headerView()
footer := m.footerView()
return header + "\n" + content + "\n" + footer
}
func (m model) headerView() string {
header := titleStyle.Render("nib")
modeName := "stream"
if m.mode == modeCards {
modeName = "cards"
}
header += " " + modeStyle.Render(modeName)
if m.filterTag != "" {
header += " " + filterPillStyle.Render("#"+m.filterTag)
}
footer := m.footerView()
if m.mode == modeCards && m.cards.intent != intentAll {
header += " " + affordanceStyle.Render(m.cards.intent.String())
}
return header + "\n" + content + "\n" + footer
if m.mode == modeCards {
header += " " + idStyle.Render("("+m.cardsSort.String()+")")
}
return header
}
func (m model) footerView() string {
@@ -369,13 +517,14 @@ func (m model) contentHeight() int {
}
func (m model) selectedEntity() *db.Entity {
switch m.state {
case stateList:
return m.list.selected()
case stateDetail:
switch {
case m.state == stateDetail:
return m.detail.entity
case m.mode == modeCards:
return m.cards.selected()
default:
return m.list.selected()
}
return nil
}
func (m model) reloadDetail(id string) tea.Cmd {