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