Files
nib-v1/internal/tui/model.go
T
lerko 77222ff1b8 feat(tui): add interactive run mode for checklists and fill mode for templates
Run mode (r key on checklist cards): cursor navigates steps, space
toggles done/undone, r resets all, esc saves changes to DB and exits.
Persists step state — improvement over web which discards on exit.

Fill mode (f key on template cards): tab/shift-tab navigates slots,
type to fill values, enter resolves template and copies to clipboard
with use count increment. Esc cancels without copying.

Both modes are sub-states of detail view, keeping architecture simple.
2026-05-17 21:53:55 -04:00

714 lines
14 KiB
Go

package tui
import (
"fmt"
tea "github.com/charmbracelet/bubbletea"
"github.com/lerko/nib/internal/db"
)
type viewState int
const (
stateList viewState = iota
stateDetail
stateInput
stateTagFilter
stateConfirm
statePromote
stateAbsorb
)
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
absorb absorbModel
showHelp bool
filterTag string
confirmID string
cardsSort cardsSort
searchQuery string
searchTags []string
status string
err error
}
func newModel(store *db.Store) model {
return model{
store: store,
state: stateList,
mode: modeStream,
list: newListModel(),
cards: newCardsModel(),
detail: newDetailModel(),
input: newInputModel(),
filter: newFilterModel(),
}
}
func (m model) Init() tea.Cmd {
return loadEntities(m.store, m.listParams())
}
func (m model) listParams() db.ListParams {
p := db.DefaultListParams()
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
}
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:
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:
if m.mode == modeCards {
m.cards.setEntities(msg.entities)
} else {
m.list.setEntities(msg.entities)
}
if m.hasSearch() {
m.applySearch()
}
m.err = nil
return m, nil
case entityCreatedMsg:
m.state = stateList
m.input.reset()
m.status = "created"
return m, loadEntities(m.store, m.listParams())
case entityDeletedMsg:
m.status = "deleted"
m.state = stateList
return m, loadEntities(m.store, m.listParams())
case entityUpdatedMsg:
m.status = msg.action
if m.state == stateDetail {
m.detail.setEntity(msg.entity)
}
return m, loadEntities(m.store, m.listParams())
case entityPromotedMsg:
m.status = fmt.Sprintf("promoted → %s", msg.cardType)
m.state = stateList
return m, loadEntities(m.store, m.listParams())
case entityDemotedMsg:
m.status = "demoted → fluid"
return m, m.reloadDetail(msg.id)
case entityCopiedMsg:
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 stepsPersistedMsg:
m.status = "steps saved"
m.detail.mode = detailPreview
return m, m.reloadDetail(m.detail.entity.ID)
case templateCopiedMsg:
m.status = "copied resolved"
m.detail.mode = detailPreview
return m, loadEntities(m.store, m.listParams())
case tagsLoadedMsg:
m.filter.setTags(msg.tags)
m.state = stateTagFilter
return m, nil
case editorFinishedMsg:
if msg.err != nil {
m.err = msg.err
} else {
m.status = "updated"
}
return m, m.reloadAfterEdit()
case confirmTimeoutMsg:
if m.state == stateConfirm {
m.state = stateList
m.confirmID = ""
}
return m, nil
case errMsg:
m.err = msg.err
return m, nil
case tea.KeyMsg:
m.err = nil
switch m.state {
case stateInput:
return m.updateInput(msg)
case stateTagFilter:
return m.updateTagFilter(msg)
case stateConfirm:
return m.updateConfirm(msg)
case statePromote:
return m.updatePromote(msg)
case stateAbsorb:
return m.updateAbsorb(msg)
default:
return m.updateKeys(msg)
}
}
return m, nil
}
func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
if m.showHelp {
if msg.String() == "?" || msg.String() == "esc" || msg.String() == "q" {
m.showHelp = false
}
return m, nil
}
switch msg.String() {
case "ctrl+c":
return m, tea.Quit
case "q":
if m.state == stateList {
return m, tea.Quit
}
return m, nil
case "?":
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())
if m.hasSearch() {
m.applySearch()
}
return m, nil
}
return m, nil
case "a":
if m.state == stateList {
m.state = stateInput
m.input.focus()
return m, m.input.ti.Focus()
}
case "esc":
if m.state == stateDetail {
if m.detail.mode == detailRun {
var cmd tea.Cmd
if m.detail.run.dirty {
cmd = persistSteps(m.store, m.detail.run.entityID, m.detail.run.stepsJSON())
}
m.detail.mode = detailPreview
return m, cmd
}
if m.detail.mode == detailFill {
m.detail.mode = detailPreview
return m, nil
}
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 = ""
return m, loadEntities(m.store, m.listParams())
}
return m, nil
case "d":
if m.state == stateList {
if e := m.selectedEntity(); e != nil {
m.confirmID = e.ID
m.state = stateConfirm
return m, confirmTimeout()
}
}
return m, nil
case "x":
if m.state == stateList {
if e := m.selectedEntity(); e != nil && e.Glyph == db.GlyphTodo {
return m, toggleTodo(m.store, e)
}
}
return m, nil
case "!":
e := m.selectedEntity()
if e != nil {
return m, pinEntity(m.store, e)
}
return m, nil
case "#":
if m.state == stateList {
if m.filterTag != "" {
m.filterTag = ""
m.status = ""
return m, loadEntities(m.store, m.listParams())
}
return m, loadTags(m.store)
}
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 {
if e.CardType != nil {
m.status = "already a card"
return m, nil
}
m.promote = newPromoteModel(e.ID, e.Body)
m.state = statePromote
return m, nil
}
return m, nil
case "D":
if m.state == stateDetail && m.detail.entity != nil {
if m.detail.entity.CardType == nil {
m.status = "already fluid"
return m, nil
}
return m, demoteEntity(m.store, m.detail.entity.ID)
}
return m, nil
case "c":
if m.state == stateDetail && m.detail.entity != nil {
return m, copyToClipboard(m.store, m.detail.entity)
}
return m, nil
case "e":
if m.state == stateDetail && m.detail.entity != nil && m.detail.mode == detailPreview {
return m, editInEditor(m.store, m.detail.entity)
}
return m, nil
case "r":
if m.state == stateDetail && m.detail.entity != nil && m.detail.mode == detailPreview {
if m.detail.entity.CardType != nil && *m.detail.entity.CardType == db.CardChecklist {
m.detail.run = newRunModel(m.detail.entity.ID, m.detail.entity.CardData)
m.detail.mode = detailRun
return m, nil
}
}
return m, nil
case "f":
if m.state == stateDetail && m.detail.entity != nil && m.detail.mode == detailPreview {
if m.detail.entity.CardType != nil && *m.detail.entity.CardType == db.CardTemplate {
m.detail.fill = newFillModel(m.detail.entity.ID, m.detail.entity.Body)
m.detail.mode = detailFill
return m, m.detail.fill.ti.Focus()
}
}
return m, nil
case "enter":
if m.state == stateDetail && m.detail.mode == detailFill {
m.detail.fill.commitActive()
resolved := m.detail.fill.resolve()
return m, copyResolved(m.store, m.detail.fill.entityID, resolved)
}
if m.state == stateList {
if e := m.selectedEntity(); e != nil {
m.detail.setEntity(e)
m.state = stateDetail
}
}
return m, nil
}
switch m.state {
case stateList:
if m.mode == modeCards {
m.cards = m.cards.update(msg)
} else {
m.list = m.list.update(msg)
}
case stateDetail:
var cmd tea.Cmd
m.detail, cmd = m.detail.update(msg)
return m, cmd
}
return m, nil
}
func (m model) updateInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "esc":
m.state = stateList
m.input.reset()
return m, nil
case "enter":
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
}
m.input = m.input.updateKey(msg)
return m, nil
}
func (m model) updateTagFilter(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "esc", "q":
m.state = stateList
return m, nil
case "enter":
tag := m.filter.selectedTag()
if tag != "" {
m.filterTag = tag
m.state = stateList
return m, loadEntities(m.store, m.listParams())
}
return m, nil
default:
m.filter = m.filter.update(msg.String())
return m, nil
}
}
func (m model) updateConfirm(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
id := m.confirmID
m.confirmID = ""
m.state = stateList
if msg.String() == "y" && id != "" {
return m, deleteEntity(m.store, id)
}
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) 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)
}
var content string
switch m.state {
case stateList, stateInput, stateConfirm:
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)
case stateAbsorb:
content = m.absorb.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)
}
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())
}
if m.mode == modeCards {
header += " " + idStyle.Render("("+m.cardsSort.String()+")")
}
return header
}
func (m model) footerView() string {
if m.state == stateInput {
return m.input.view(m.width)
}
if m.state == stateConfirm {
return renderConfirm(m.confirmID)
}
if m.err != nil {
return errorStyle.Render("error: " + m.err.Error())
}
if m.status != "" {
return statusStyle.Render(m.status) + " " + helpStyle.Render(contextHints(m))
}
return renderStatusBar(m, m.width)
}
func (m model) contentHeight() int {
return m.height - 3
}
func (m model) selectedEntity() *db.Entity {
switch {
case m.state == stateDetail:
return m.detail.entity
case m.mode == modeCards:
return m.cards.selected()
default:
return m.list.selected()
}
}
func (m model) reloadDetail(id string) tea.Cmd {
return tea.Batch(
loadEntities(m.store, m.listParams()),
func() tea.Msg {
e, err := m.store.Get(id)
if err != nil {
return errMsg{err}
}
return entityUpdatedMsg{e, ""}
},
)
}
func (m model) reloadAfterEdit() tea.Cmd {
if m.detail.entity == nil {
return loadEntities(m.store, m.listParams())
}
return m.reloadDetail(m.detail.entity.ID)
}