package tui import ( "fmt" "strings" "time" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/lerko/nib/internal/db" ) const statusTimeout = 2 * time.Second 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 focusPane int const ( focusList focusPane = iota focusDetail ) 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 focus focusPane splitDetail bool filterTag string confirmID string cardsSort cardsSort searchQuery string searchTags []string status string statusSeq int 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) setStatus(msg string) tea.Cmd { m.statusSeq++ m.status = msg return clearStatusAfter(statusTimeout, m.statusSeq) } 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 { searchFiltered := filterEntities(m.cards.entities, m.searchQuery, m.searchTags) var intentFiltered []*db.Entity for _, e := range searchFiltered { if matchesIntent(e, m.cards.intent) { intentFiltered = append(intentFiltered, e) } } m.cards.filtered, m.cards.groups = sortAndGroupCards(intentFiltered, m.cards.intent) 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 if !m.isSplit() && m.splitDetail { m.state = stateDetail m.splitDetail = false m.focus = focusList } m.recalcSizes() 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.recalcSizes() return m, tea.Batch(loadEntities(m.store, m.listParams()), m.setStatus("created")) case entityDeletedMsg: m.state = stateList return m, tea.Batch(loadEntities(m.store, m.listParams()), m.setStatus("deleted")) case entityUpdatedMsg: if m.state == stateDetail { m.detail.setEntity(msg.entity) } return m, tea.Batch(loadEntities(m.store, m.listParams()), m.setStatus(msg.action)) case entityPromotedMsg: m.state = stateList return m, tea.Batch(loadEntities(m.store, m.listParams()), m.setStatus(fmt.Sprintf("promoted → %s", msg.cardType))) case entityDemotedMsg: return m, tea.Batch(m.reloadDetail(msg.id), m.setStatus("demoted → fluid")) case entityCopiedMsg: return m, m.setStatus("copied") case entityAbsorbedMsg: m.state = stateList return m, tea.Batch(loadEntities(m.store, m.listParams()), m.setStatus("absorbed")) 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.detail.mode = detailPreview return m, tea.Batch(m.reloadDetail(m.detail.entity.ID), m.setStatus("steps saved")) case templateCopiedMsg: m.detail.mode = detailPreview return m, tea.Batch(loadEntities(m.store, m.listParams()), m.setStatus("copied resolved")) case tagsLoadedMsg: m.filter.setTags(msg.tags) m.state = stateTagFilter return m, nil case editorFinishedMsg: if msg.err != nil { m.err = msg.err return m, m.reloadAfterEdit() } return m, tea.Batch(m.reloadAfterEdit(), m.setStatus("updated")) case confirmTimeoutMsg: if m.state == stateConfirm { m.state = stateList m.confirmID = "" } return m, nil case statusClearMsg: if msg.seq == m.statusSeq { m.status = "" } 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 } if m.splitDetail && m.state == stateList { switch msg.String() { case "l": if m.focus == focusList { m.focus = focusDetail return m, nil } case "h": if m.focus == focusDetail { m.focus = focusList return m, nil } case "esc": if m.focus == focusDetail { m.focus = focusList return m, nil } m.splitDetail = false m.recalcSizes() return m, nil } if m.focus == focusDetail { switch msg.String() { case "j", "k", "up", "down", "pgup", "pgdown", "ctrl+u", "ctrl+d": var cmd tea.Cmd m.detail, cmd = m.detail.update(msg) return m, cmd case "c": if m.detail.entity != nil { return m, copyToClipboard(m.store, m.detail.entity) } return m, nil case "e": if m.detail.entity != nil && m.detail.mode == detailPreview { return m, editInEditor(m.store, m.detail.entity) } return m, nil case "p": if m.detail.entity != nil && m.detail.entity.CardType == nil { m.promote = newPromoteModel(m.detail.entity.ID, m.detail.entity.Body) m.state = statePromote m.splitDetail = false m.recalcSizes() return m, nil } return m, nil case "D": if m.detail.entity != nil && m.detail.entity.CardType != nil { return m, demoteEntity(m.store, m.detail.entity.ID) } return m, nil case "!": if m.detail.entity != nil { return m, pinEntity(m.store, m.detail.entity) } return m, nil case "r": if 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 m.splitDetail = false m.state = stateDetail m.recalcSizes() return m, nil } } return m, nil case "f": if 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 m.splitDetail = false m.state = stateDetail m.recalcSizes() return m, m.detail.fill.ti.Focus() } } 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() return m, tea.Batch(loadEntities(m.store, m.listParams()), m.setStatus("sort: "+m.cardsSort.String())) } 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() m.recalcSizes() 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 if m.isSplit() { m.state = stateList m.splitDetail = true m.focus = focusList m.recalcSizes() } return m, cmd } if m.detail.mode == detailFill { m.detail.mode = detailPreview if m.isSplit() { m.state = stateList m.splitDetail = true m.focus = focusList m.recalcSizes() } return m, nil } if m.isSplit() { m.state = stateList m.splitDetail = true m.focus = focusList m.recalcSizes() 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 { return m, m.setStatus("target must be fluid") } return m, loadAbsorbSources(m.store, e.ID) } return m, nil case "p": e := m.selectedEntity() if e != nil { if e.CardType != nil { return m, m.setStatus("already a card") } 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 { return m, m.setStatus("already fluid") } 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) if m.isSplit() { m.splitDetail = true m.focus = focusDetail m.recalcSizes() } else { 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) } if m.splitDetail { if e := m.selectedEntity(); e != nil { m.detail.setEntity(e) } } 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() m.recalcSizes() 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.recalcSizes() 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: listContent := m.listContent() if m.splitDetail { lw, rw := m.splitWidths() ch := m.contentHeight() left := lipgloss.NewStyle().Width(lw).Height(ch).Render(listContent) sep := m.renderSeparator() right := lipgloss.NewStyle().Width(rw).Height(ch).Render(m.detail.view(rw)) content = lipgloss.JoinHorizontal(lipgloss.Top, left, sep, right) } else { content = listContent } 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() content = lipgloss.NewStyle().Width(m.width).Height(m.contentHeight()).Render(content) return header + "\n" + content + "\n" + footer } func (m model) listContent() string { if m.mode == modeCards { lw := m.width if m.splitDetail { lw, _ = m.splitWidths() } return m.cards.view(lw) } lw := m.width if m.splitDetail { lw, _ = m.splitWidths() } return m.list.view(lw) } func (m model) headerView() string { header := titleStyle.Render("nib") + " " header += renderTab("stream", "1", m.mode == modeStream) header += " " + separatorStyle.Render("│") + " " header += renderTab("cards", "2", m.mode == modeCards) 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()) } return renderStatusBar(m, m.width) } func (m model) contentHeight() int { return m.height - 3 - m.drawerHeight() } func (m model) drawerHeight() int { if m.state == stateInput { return drawerLines() } return 0 } func (m *model) recalcSizes() { ch := m.contentHeight() if m.isSplit() && m.splitDetail { lw, rw := m.splitWidths() m.list.setSize(lw, ch) m.cards.setSize(lw, ch) m.detail.setSize(rw, ch) } else { m.list.setSize(m.width, ch) m.cards.setSize(m.width, ch) m.detail.setSize(m.width, ch) } m.filter.setHeight(ch) } func (m model) isSplit() bool { return m.width >= 100 } func (m model) splitWidths() (int, int) { left := m.width * 40 / 100 right := m.width - left - 1 return left, right } func (m model) renderSeparator() string { ch := m.contentHeight() lines := make([]string, ch) for i := range lines { lines[i] = "│" } return separatorStyle.Render(strings.Join(lines, "\n")) } 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) }