From a567b2ce73b58c0a2e23b270604f1bbbe9304807 Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Wed, 20 May 2026 16:40:40 -0400 Subject: [PATCH] =?UTF-8?q?feat(tui):=20stumble=20mode=20=E2=80=94=20resur?= =?UTF-8?q?face=20stale=20entries=20card=20by=20card?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Card-by-card walkthrough of entries untouched for 30+ days. Prevents write-mostly decay by bringing old entries back to attention. - S from list triggers stumble, loads entries where modified_at < 30d - Single-card view with markdown body, glyph, tags, age indicator - Actions: n skip, d dismiss, ! pin, p promote, m absorb, esc exit - Progress indicator: stumble [3/12] - After promote/absorb from stumble, returns to deck (not list) - "All caught up" screen when deck exhausted - DB: add ModifiedBefore to ListParams, modified_at sort column --- internal/db/entities.go | 7 ++ internal/tui/commands.go | 38 +++++++++ internal/tui/help.go | 9 +++ internal/tui/keys.go | 2 + internal/tui/model.go | 89 ++++++++++++++++++++- internal/tui/statusbar.go | 2 + internal/tui/stumble.go | 164 ++++++++++++++++++++++++++++++++++++++ internal/tui/styles.go | 3 + 8 files changed, 311 insertions(+), 3 deletions(-) create mode 100644 internal/tui/stumble.go diff --git a/internal/db/entities.go b/internal/db/entities.go index 79dc615..5954b66 100644 --- a/internal/db/entities.go +++ b/internal/db/entities.go @@ -71,6 +71,7 @@ type ListParams struct { From *string To *string Since *time.Time + ModifiedBefore *time.Time CardsOnly bool IncludeDeleted bool CardTypeFilter *CardType @@ -216,6 +217,10 @@ func listWhere(params ListParams) (string, []any) { where = append(where, "e.card_type = ?") args = append(args, string(*params.CardTypeFilter)) } + if params.ModifiedBefore != nil { + where = append(where, "e.modified_at < ?") + args = append(args, params.ModifiedBefore.Format(time.RFC3339)) + } clause := "" if len(where) > 0 { @@ -239,6 +244,8 @@ func (s *Store) List(params ListParams) ([]*Entity, error) { switch params.Sort { case "use_count": orderCol = "e.use_count" + case "modified_at": + orderCol = "e.modified_at" case "created_at", "": orderCol = "e.created_at" default: diff --git a/internal/tui/commands.go b/internal/tui/commands.go index 68e3896..a233f0b 100644 --- a/internal/tui/commands.go +++ b/internal/tui/commands.go @@ -62,6 +62,14 @@ type railTagsLoadedMsg struct { tags []db.TagCount } +type staleEntitiesLoadedMsg struct { + entities []*db.Entity +} + +type stumbleActionMsg struct { + action string +} + type statusClearMsg struct{ seq int } type editorFinishedMsg struct { @@ -289,3 +297,33 @@ func clearStatusAfter(d time.Duration, seq int) tea.Cmd { return statusClearMsg{seq: seq} }) } + +func loadStaleEntities(store *db.Store) tea.Cmd { + return func() tea.Msg { + entities, err := store.List(staleParams()) + if err != nil { + return errMsg{err} + } + return staleEntitiesLoadedMsg{entities} + } +} + +func stumbleDismiss(store *db.Store, id string) tea.Cmd { + return func() tea.Msg { + if _, err := store.SoftDelete(id); err != nil { + return errMsg{err} + } + return stumbleActionMsg{"dismissed"} + } +} + +func stumblePin(store *db.Store, id string) tea.Cmd { + return func() tea.Msg { + pinned := true + update := db.EntityUpdate{Pinned: &pinned} + if err := store.Update(id, &update); err != nil { + return errMsg{err} + } + return stumbleActionMsg{"pinned"} + } +} diff --git a/internal/tui/help.go b/internal/tui/help.go index 84176fb..ef3bd18 100644 --- a/internal/tui/help.go +++ b/internal/tui/help.go @@ -41,6 +41,7 @@ func renderHelp(width, height int) string { {"#", "filter by tag"}, {"m", "absorb (merge into target)"}, {"p", "promote to card"}, + {"S", "stumble (resurface stale entries)"}, }}, {"Detail View", [][2]string{ {"p", "promote to card"}, @@ -51,6 +52,14 @@ func renderHelp(width, height int) string { {"r", "run checklist"}, {"f", "fill template"}, }}, + {"Stumble", [][2]string{ + {"n / →", "skip to next"}, + {"d", "dismiss (soft delete)"}, + {"!", "pin"}, + {"p", "promote to card"}, + {"m", "absorb"}, + {"esc", "exit"}, + }}, {"Run Mode", [][2]string{ {"j/k", "move between steps"}, {"space", "toggle step"}, diff --git a/internal/tui/keys.go b/internal/tui/keys.go index 2ebe7dc..e68b7be 100644 --- a/internal/tui/keys.go +++ b/internal/tui/keys.go @@ -33,6 +33,7 @@ type keyMap struct { FocusRight key.Binding Tab key.Binding ToggleRail key.Binding + Stumble key.Binding } var keys = keyMap{ @@ -66,4 +67,5 @@ var keys = keyMap{ FocusRight: key.NewBinding(key.WithKeys("l"), key.WithHelp("l", "focus detail")), Tab: key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "focus cycle")), ToggleRail: key.NewBinding(key.WithKeys("ctrl+b"), key.WithHelp("ctrl+b", "toggle tag rail")), + Stumble: key.NewBinding(key.WithKeys("S"), key.WithHelp("S", "stumble")), } diff --git a/internal/tui/model.go b/internal/tui/model.go index 3b95780..84a6049 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -22,6 +22,7 @@ const ( stateConfirm statePromote stateAbsorb + stateStumble ) type viewMode int @@ -85,6 +86,7 @@ type model struct { promote promoteModel absorb absorbModel tagRail tagRailModel + stumble stumbleModel showHelp bool focus focusPane @@ -231,8 +233,13 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 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))) + if !m.stumble.done && len(m.stumble.entries) > 0 { + m.stumble.advance() + m.state = stateStumble + } else { + m.state = stateList + } + return m, tea.Batch(loadEntities(m.store, m.listParams()), loadRailTags(m.store), m.setStatus(fmt.Sprintf("promoted → %s", msg.cardType))) case entityDemotedMsg: return m, tea.Batch(m.reloadDetail(msg.id), m.setStatus("demoted → fluid")) @@ -241,7 +248,12 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, m.setStatus("copied") case entityAbsorbedMsg: - m.state = stateList + if !m.stumble.done && len(m.stumble.entries) > 0 { + m.stumble.advance() + m.state = stateStumble + } else { + m.state = stateList + } return m, tea.Batch(loadEntities(m.store, m.listParams()), loadRailTags(m.store), m.setStatus("absorbed")) case absorbSourcesLoadedMsg: @@ -265,6 +277,16 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.state = stateTagFilter return m, nil + case staleEntitiesLoadedMsg: + m.stumble = newStumbleModel() + m.stumble.setEntries(msg.entities) + m.stumble.setSize(m.width, m.contentHeight()) + m.state = stateStumble + return m, nil + + case stumbleActionMsg: + return m, m.setStatus(msg.action) + case editorFinishedMsg: if msg.err != nil { m.err = msg.err @@ -300,6 +322,8 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m.updatePromote(msg) case stateAbsorb: return m.updateAbsorb(msg) + case stateStumble: + return m.updateStumble(msg) default: return m.updateKeys(msg) } @@ -532,6 +556,12 @@ func (m model) updateBrowse(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } return m, nil + case "S": + if m.state == stateList { + return m, loadStaleEntities(m.store) + } + return m, nil + case "i": if m.mode == modeCards && m.state == stateList { m.cards.setIntent(m.cards.intent.next()) @@ -818,6 +848,57 @@ func (m model) updateAbsorb(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } } +func (m model) updateStumble(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + if m.stumble.done { + m.state = stateList + return m, loadEntities(m.store, m.listParams()) + } + + switch msg.String() { + case "esc", "q": + m.state = stateList + return m, loadEntities(m.store, m.listParams()) + case "n", "right": + m.stumble.advance() + if m.stumble.done { + return m, m.setStatus("all caught up") + } + return m, nil + case "d": + e := m.stumble.current() + if e != nil { + m.stumble.removeCurrent() + return m, stumbleDismiss(m.store, e.ID) + } + return m, nil + case "!": + e := m.stumble.current() + if e != nil { + m.stumble.advance() + return m, stumblePin(m.store, e.ID) + } + return m, nil + case "p": + e := m.stumble.current() + if e != nil { + m.promote = newPromoteModel(e.ID, e.Body) + m.state = statePromote + return m, nil + } + return m, nil + case "m": + e := m.stumble.current() + 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 + } + return m, nil +} + func (m model) View() string { if m.showHelp { return renderHelp(m.width, m.height) @@ -852,6 +933,8 @@ func (m model) View() string { content = m.promote.view(m.width) case stateAbsorb: content = m.absorb.view(m.width) + case stateStumble: + content = m.stumble.view() } header := m.headerView() diff --git a/internal/tui/statusbar.go b/internal/tui/statusbar.go index 482ec06..b7d4aa0 100644 --- a/internal/tui/statusbar.go +++ b/internal/tui/statusbar.go @@ -82,6 +82,8 @@ func contextHints(m model) []hint { return []hint{{"j/k", "nav"}, {"enter", "select"}, {"esc", "cancel"}} case stateAbsorb: return []hint{{"j/k", "nav"}, {"enter", "absorb"}, {"esc", "cancel"}} + case stateStumble: + return []hint{{"n", "skip"}, {"d", "dismiss"}, {"!", "pin"}, {"p", "promote"}, {"m", "absorb"}, {"esc", "quit"}} } switch m.focus { diff --git a/internal/tui/stumble.go b/internal/tui/stumble.go new file mode 100644 index 0000000..e35eaf4 --- /dev/null +++ b/internal/tui/stumble.go @@ -0,0 +1,164 @@ +package tui + +import ( + "fmt" + "math" + "strings" + "time" + + "github.com/charmbracelet/glamour" + + "github.com/lerko/nib/internal/db" + "github.com/lerko/nib/internal/display" +) + +const staleThresholdDays = 30 + +type stumbleModel struct { + entries []*db.Entity + cursor int + width int + height int + done bool +} + +func newStumbleModel() stumbleModel { + return stumbleModel{} +} + +func (s *stumbleModel) setEntries(entries []*db.Entity) { + s.entries = entries + s.cursor = 0 + s.done = len(entries) == 0 +} + +func (s *stumbleModel) setSize(width, height int) { + s.width = width + s.height = height +} + +func (s stumbleModel) current() *db.Entity { + if s.done || len(s.entries) == 0 || s.cursor >= len(s.entries) { + return nil + } + return s.entries[s.cursor] +} + +func (s *stumbleModel) advance() { + s.cursor++ + if s.cursor >= len(s.entries) { + s.done = true + } +} + +func (s *stumbleModel) removeCurrent() { + if s.cursor < len(s.entries) { + s.entries = append(s.entries[:s.cursor], s.entries[s.cursor+1:]...) + if s.cursor >= len(s.entries) { + s.done = true + } + } +} + +func (s stumbleModel) total() int { + return len(s.entries) +} + +func (s stumbleModel) view() string { + if s.done { + return s.doneView() + } + + e := s.current() + if e == nil { + return s.doneView() + } + + w := s.width + var b strings.Builder + + progress := fmt.Sprintf("stumble [%d/%d]", s.cursor+1, len(s.entries)) + b.WriteString(detailHeaderStyle.Render(progress)) + b.WriteString("\n") + b.WriteString(separatorStyle.Render(strings.Repeat("─", w))) + b.WriteString("\n\n") + + glyph := display.DisplayGlyph(e.Glyph, e.CardType) + title := e.Body + if e.Title != nil { + title = *e.Title + } + if len(title) > w-6 { + title = title[:w-9] + "…" + } + b.WriteString(" " + glyphStyle.Render(glyph) + " " + title) + b.WriteString("\n") + + var meta []string + meta = append(meta, string(e.Glyph)) + if e.CardType != nil { + meta = append(meta, string(*e.CardType)) + } + for _, t := range e.Tags { + meta = append(meta, tagStyle.Render("#"+t)) + } + meta = append(meta, "captured "+e.CreatedAt.Format("Jan 2")) + b.WriteString(" " + idStyle.Render(strings.Join(meta, " · "))) + b.WriteString("\n\n") + + bodyWidth := w - 6 + if bodyWidth < 20 { + bodyWidth = 20 + } + r, _ := glamour.NewTermRenderer( + glamour.WithStylePath("dark"), + glamour.WithWordWrap(bodyWidth), + ) + rendered, err := r.Render(e.Body) + if err != nil { + rendered = e.Body + } + rendered = strings.TrimRight(rendered, "\n") + b.WriteString(" " + rendered) + b.WriteString("\n\n") + + age := daysAgo(e.ModifiedAt) + ageText := fmt.Sprintf("last touched %d days ago", age) + b.WriteString(" " + stumbleAgeStyle.Render(ageText)) + b.WriteString("\n") + b.WriteString(separatorStyle.Render(strings.Repeat("─", w))) + + lines := strings.Split(b.String(), "\n") + if len(lines) > s.height { + lines = lines[:s.height] + } + return strings.Join(lines, "\n") +} + +func (s stumbleModel) doneView() string { + var b strings.Builder + b.WriteString("\n\n") + b.WriteString(detailHeaderStyle.Render(" all caught up")) + b.WriteString("\n\n") + reviewed := s.total() + if reviewed > 0 { + b.WriteString(idStyle.Render(fmt.Sprintf(" %d entries reviewed", reviewed))) + } else { + b.WriteString(idStyle.Render(" no stale entries found")) + } + return b.String() +} + +func daysAgo(t time.Time) int { + return int(math.Floor(time.Since(t).Hours() / 24)) +} + +func staleParams() db.ListParams { + threshold := time.Now().UTC().AddDate(0, 0, -staleThresholdDays) + return db.ListParams{ + ModifiedBefore: &threshold, + Sort: "modified_at", + Order: "asc", + Limit: 50, + } +} diff --git a/internal/tui/styles.go b/internal/tui/styles.go index 1eccd94..94e31ca 100644 --- a/internal/tui/styles.go +++ b/internal/tui/styles.go @@ -143,4 +143,7 @@ var ( railCountStyle = lipgloss.NewStyle(). Foreground(dim) + + stumbleAgeStyle = lipgloss.NewStyle(). + Foreground(lipgloss.AdaptiveColor{Light: "#cc4400", Dark: "#fab387"}) )