From 4e0ac8402f51c643f25801959956295da6d3a76a Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Wed, 20 May 2026 11:01:13 -0400 Subject: [PATCH 1/4] fix(tui): pin footer to bottom, style hint bar, auto-clear status Content area now enforces full height so the context help bar stays pinned to the terminal bottom. Hint keys rendered with bold highlight color for scannability. Status messages (created, deleted, etc.) auto-clear after 2 seconds, reverting to the entity count. --- internal/tui/commands.go | 8 +++++++ internal/tui/input.go | 5 +++- internal/tui/model.go | 45 +++++++++++++++++++---------------- internal/tui/statusbar.go | 50 ++++++++++++++++++++++++++------------- internal/tui/styles.go | 7 ++++++ 5 files changed, 77 insertions(+), 38 deletions(-) diff --git a/internal/tui/commands.go b/internal/tui/commands.go index 5e930f7..17155df 100644 --- a/internal/tui/commands.go +++ b/internal/tui/commands.go @@ -58,6 +58,8 @@ type tagsLoadedMsg struct { tags []db.TagCount } +type statusClearMsg struct{} + type editorFinishedMsg struct { err error } @@ -267,3 +269,9 @@ func copyResolved(store *db.Store, entityID string, resolved string) tea.Cmd { return templateCopiedMsg{} } } + +func clearStatusAfter(d time.Duration) tea.Cmd { + return tea.Tick(d, func(time.Time) tea.Msg { + return statusClearMsg{} + }) +} diff --git a/internal/tui/input.go b/internal/tui/input.go index 52027ff..b782107 100644 --- a/internal/tui/input.go +++ b/internal/tui/input.go @@ -107,7 +107,10 @@ func (i inputModel) view(width int) string { b.WriteString("\n") b.WriteString(i.ti.View()) b.WriteString("\n") - b.WriteString(drawerHintsStyle.Render("enter:submit esc:cancel ?:search -:todo @:event !:reminder")) + b.WriteString(drawerHintsStyle.Render(renderHints([]hint{ + {"enter", "submit"}, {"esc", "cancel"}, {"?", "search"}, + {"-", "todo"}, {"@", "event"}, {"!", "reminder"}, + }))) b.WriteString("\n") b.WriteString(i.renderPreview(width)) return b.String() diff --git a/internal/tui/model.go b/internal/tui/model.go index a5e7c5b..cbe06f9 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -3,6 +3,7 @@ package tui import ( "fmt" "strings" + "time" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" @@ -10,6 +11,8 @@ import ( "github.com/lerko/nib/internal/db" ) +const statusTimeout = 2 * time.Second + type viewState int const ( @@ -192,37 +195,37 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.input.reset() m.recalcSizes() m.status = "created" - return m, loadEntities(m.store, m.listParams()) + return m, tea.Batch(loadEntities(m.store, m.listParams()), clearStatusAfter(statusTimeout)) case entityDeletedMsg: m.status = "deleted" m.state = stateList - return m, loadEntities(m.store, m.listParams()) + return m, tea.Batch(loadEntities(m.store, m.listParams()), clearStatusAfter(statusTimeout)) case entityUpdatedMsg: m.status = msg.action if m.state == stateDetail { m.detail.setEntity(msg.entity) } - return m, loadEntities(m.store, m.listParams()) + return m, tea.Batch(loadEntities(m.store, m.listParams()), clearStatusAfter(statusTimeout)) case entityPromotedMsg: m.status = fmt.Sprintf("promoted → %s", msg.cardType) m.state = stateList - return m, loadEntities(m.store, m.listParams()) + return m, tea.Batch(loadEntities(m.store, m.listParams()), clearStatusAfter(statusTimeout)) case entityDemotedMsg: m.status = "demoted → fluid" - return m, m.reloadDetail(msg.id) + return m, tea.Batch(m.reloadDetail(msg.id), clearStatusAfter(statusTimeout)) case entityCopiedMsg: m.status = "copied" - return m, nil + return m, clearStatusAfter(statusTimeout) case entityAbsorbedMsg: m.status = "absorbed" m.state = stateList - return m, loadEntities(m.store, m.listParams()) + return m, tea.Batch(loadEntities(m.store, m.listParams()), clearStatusAfter(statusTimeout)) case absorbSourcesLoadedMsg: m.absorb = newAbsorbModel(msg.targetID) @@ -234,12 +237,12 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case stepsPersistedMsg: m.status = "steps saved" m.detail.mode = detailPreview - return m, m.reloadDetail(m.detail.entity.ID) + return m, tea.Batch(m.reloadDetail(m.detail.entity.ID), clearStatusAfter(statusTimeout)) case templateCopiedMsg: m.status = "copied resolved" m.detail.mode = detailPreview - return m, loadEntities(m.store, m.listParams()) + return m, tea.Batch(loadEntities(m.store, m.listParams()), clearStatusAfter(statusTimeout)) case tagsLoadedMsg: m.filter.setTags(msg.tags) @@ -249,10 +252,10 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case editorFinishedMsg: if msg.err != nil { m.err = msg.err - } else { - m.status = "updated" + return m, m.reloadAfterEdit() } - return m, m.reloadAfterEdit() + m.status = "updated" + return m, tea.Batch(m.reloadAfterEdit(), clearStatusAfter(statusTimeout)) case confirmTimeoutMsg: if m.state == stateConfirm { @@ -261,6 +264,10 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, nil + case statusClearMsg: + m.status = "" + return m, nil + case errMsg: m.err = msg.err return m, nil @@ -415,7 +422,7 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { 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, tea.Batch(loadEntities(m.store, m.listParams()), clearStatusAfter(statusTimeout)) } return m, nil @@ -532,7 +539,7 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if e != nil { if e.CardType != nil { m.status = "target must be fluid" - return m, nil + return m, clearStatusAfter(statusTimeout) } return m, loadAbsorbSources(m.store, e.ID) } @@ -543,7 +550,7 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if e != nil { if e.CardType != nil { m.status = "already a card" - return m, nil + return m, clearStatusAfter(statusTimeout) } m.promote = newPromoteModel(e.ID, e.Body) m.state = statePromote @@ -555,7 +562,7 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if m.state == stateDetail && m.detail.entity != nil { if m.detail.entity.CardType == nil { m.status = "already fluid" - return m, nil + return m, clearStatusAfter(statusTimeout) } return m, demoteEntity(m.store, m.detail.entity.ID) } @@ -758,6 +765,8 @@ func (m model) View() string { header := m.headerView() footer := m.footerView() + content = lipgloss.NewStyle().Width(m.width).Height(m.contentHeight()).Render(content) + return header + "\n" + content + "\n" + footer } @@ -824,10 +833,6 @@ func (m model) footerView() string { return errorStyle.Render("error: " + m.err.Error()) } - if m.status != "" { - return statusStyle.Render(m.status) + " " + helpStyle.Render(contextHints(m)) - } - return renderStatusBar(m, m.width) } diff --git a/internal/tui/statusbar.go b/internal/tui/statusbar.go index bdbfb09..609d7cb 100644 --- a/internal/tui/statusbar.go +++ b/internal/tui/statusbar.go @@ -2,24 +2,40 @@ package tui import ( "fmt" + "strings" "github.com/charmbracelet/lipgloss" ) +type hint struct { + key string + desc string +} + +func renderHints(hints []hint) string { + parts := make([]string, len(hints)) + for i, h := range hints { + parts[i] = hintKeyStyle.Render(h.key) + " " + hintDescStyle.Render(h.desc) + } + return strings.Join(parts, " ") +} + func renderStatusBar(m model, width int) string { left := countText(m) - right := contextHints(m) + if m.status != "" { + left = m.status + } + right := renderHints(contextHints(m)) leftRendered := statusStyle.Render(left) - rightRendered := helpStyle.Render(right) - gap := width - lipgloss.Width(leftRendered) - lipgloss.Width(rightRendered) + gap := width - lipgloss.Width(leftRendered) - lipgloss.Width(right) if gap < 0 { gap = 0 } pad := lipgloss.NewStyle().Width(gap).Render("") - return leftRendered + pad + rightRendered + return leftRendered + pad + right } func countText(m model) string { @@ -35,37 +51,37 @@ func countText(m model) string { return fmt.Sprintf("%d entities", total) } -func contextHints(m model) string { +func contextHints(m model) []hint { switch m.state { case stateDetail: switch m.detail.mode { case detailRun: - return "space:toggle j/k:nav r:reset esc:save+exit" + return []hint{{"space", "toggle"}, {"j/k", "nav"}, {"r", "reset"}, {"esc", "save+exit"}} case detailFill: - return "tab:next shift+tab:prev enter:copy esc:cancel" + return []hint{{"tab", "next"}, {"⇧tab", "prev"}, {"enter", "copy"}, {"esc", "cancel"}} default: - return "p:promote D:demote c:copy e:edit r:run f:fill !:pin esc:back" + return []hint{{"p", "promote"}, {"D", "demote"}, {"c", "copy"}, {"e", "edit"}, {"r", "run"}, {"f", "fill"}, {"!", "pin"}, {"esc", "back"}} } case stateInput: - return "" + return nil case stateTagFilter: - return "j/k:nav enter:select esc:cancel" + return []hint{{"j/k", "nav"}, {"enter", "select"}, {"esc", "cancel"}} case stateConfirm: - return "y:confirm n:cancel" + return []hint{{"y", "confirm"}, {"n", "cancel"}} case statePromote: - return "j/k:nav enter:select esc:cancel" + return []hint{{"j/k", "nav"}, {"enter", "select"}, {"esc", "cancel"}} case stateAbsorb: - return "j/k:nav enter:absorb esc:cancel" + return []hint{{"j/k", "nav"}, {"enter", "absorb"}, {"esc", "cancel"}} default: if m.splitDetail { if m.focus == focusDetail { - return "h:list c:copy e:edit p:promote D:demote !:pin esc:back" + return []hint{{"h", "list"}, {"c", "copy"}, {"e", "edit"}, {"p", "promote"}, {"D", "demote"}, {"!", "pin"}, {"esc", "back"}} } - return "l:detail a:add d:del #:filter esc:close ?:help q:quit" + return []hint{{"l", "detail"}, {"a", "add"}, {"d", "del"}, {"#", "filter"}, {"esc", "close"}, {"?", "help"}, {"q", "quit"}} } if m.mode == modeCards { - return "1:stream 2:cards s:sort tab:intent a:add ?:help q:quit" + return []hint{{"1", "stream"}, {"2", "cards"}, {"s", "sort"}, {"tab", "intent"}, {"a", "add"}, {"?", "help"}, {"q", "quit"}} } - return "1:stream 2:cards a:add/?search m:absorb d:del #:filter ?:help q:quit" + return []hint{{"1", "stream"}, {"2", "cards"}, {"a", "add"}, {"?", "search"}, {"m", "absorb"}, {"d", "del"}, {"#", "filter"}, {"?", "help"}, {"q", "quit"}} } } diff --git a/internal/tui/styles.go b/internal/tui/styles.go index ef837a2..19c1773 100644 --- a/internal/tui/styles.go +++ b/internal/tui/styles.go @@ -122,4 +122,11 @@ var ( separatorStyle = lipgloss.NewStyle(). Foreground(dim) + + hintKeyStyle = lipgloss.NewStyle(). + Foreground(highlight). + Bold(true) + + hintDescStyle = lipgloss.NewStyle(). + Foreground(dim) ) -- 2.52.0 From e20fae3543efb1aba90aa1a15bc3b2356a5317dd Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Wed, 20 May 2026 11:16:58 -0400 Subject: [PATCH 2/4] feat(tui): add broot-style tab affordances to header and footer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Header now renders stream/cards as tabs with keybindings inline — active tab highlighted, inactive shows the key to switch. Footer shows a capture tab affordance when in list state. Redundant mode and capture hints removed from the context hint bar. --- internal/tui/model.go | 13 +++++-------- internal/tui/statusbar.go | 32 +++++++++++++++++++++++--------- 2 files changed, 28 insertions(+), 17 deletions(-) diff --git a/internal/tui/model.go b/internal/tui/model.go index cbe06f9..d0692df 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -786,16 +786,13 @@ func (m model) listContent() string { } func (m model) headerView() string { - header := titleStyle.Render("nib") - - modeName := "stream" - if m.mode == modeCards { - modeName = "cards" - } - header += " " + modeStyle.Render(modeName) + 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) + header += " " + filterPillStyle.Render("#"+m.filterTag) } if m.hasSearch() { diff --git a/internal/tui/statusbar.go b/internal/tui/statusbar.go index 609d7cb..07ecf72 100644 --- a/internal/tui/statusbar.go +++ b/internal/tui/statusbar.go @@ -20,14 +20,28 @@ func renderHints(hints []hint) string { return strings.Join(parts, " ") } -func renderStatusBar(m model, width int) string { - left := countText(m) - if m.status != "" { - left = m.status +func renderTab(label, key string, active bool) string { + if active { + return hintKeyStyle.Render(label) + " " + hintDescStyle.Render(key) } - right := renderHints(contextHints(m)) + return hintDescStyle.Render(label) + " " + hintKeyStyle.Render(key) +} - leftRendered := statusStyle.Render(left) +func renderStatusBar(m model, width int) string { + var leftParts []string + + if m.state == stateList { + leftParts = append(leftParts, renderTab("capture", "a", false)) + } + + if m.status != "" { + leftParts = append(leftParts, statusStyle.Render(m.status)) + } else { + leftParts = append(leftParts, statusStyle.Render(countText(m))) + } + + leftRendered := strings.Join(leftParts, " "+separatorStyle.Render("│")+" ") + right := renderHints(contextHints(m)) gap := width - lipgloss.Width(leftRendered) - lipgloss.Width(right) if gap < 0 { @@ -77,11 +91,11 @@ func contextHints(m model) []hint { if m.focus == focusDetail { return []hint{{"h", "list"}, {"c", "copy"}, {"e", "edit"}, {"p", "promote"}, {"D", "demote"}, {"!", "pin"}, {"esc", "back"}} } - return []hint{{"l", "detail"}, {"a", "add"}, {"d", "del"}, {"#", "filter"}, {"esc", "close"}, {"?", "help"}, {"q", "quit"}} + return []hint{{"l", "detail"}, {"d", "del"}, {"#", "filter"}, {"esc", "close"}, {"?", "help"}, {"q", "quit"}} } if m.mode == modeCards { - return []hint{{"1", "stream"}, {"2", "cards"}, {"s", "sort"}, {"tab", "intent"}, {"a", "add"}, {"?", "help"}, {"q", "quit"}} + return []hint{{"s", "sort"}, {"tab", "intent"}, {"?", "help"}, {"q", "quit"}} } - return []hint{{"1", "stream"}, {"2", "cards"}, {"a", "add"}, {"?", "search"}, {"m", "absorb"}, {"d", "del"}, {"#", "filter"}, {"?", "help"}, {"q", "quit"}} + return []hint{{"?", "search"}, {"m", "absorb"}, {"d", "del"}, {"#", "filter"}, {"?", "help"}, {"q", "quit"}} } } -- 2.52.0 From cb10d1e93d45574e6c25364512c1105e589cb780 Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Wed, 20 May 2026 11:27:40 -0400 Subject: [PATCH 3/4] feat(tui): render entity body as markdown via glamour MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Detail pane now pipes entity body through charmbracelet/glamour for styled markdown output — headers, bold, code blocks, lists. Uses hardcoded dark style to avoid terminal query freeze in alt screen. --- go.mod | 16 ++++++++++++++-- go.sum | 34 ++++++++++++++++++++++++++++++++++ internal/tui/detail.go | 16 +++++++++++++++- 3 files changed, 63 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 58dcc91..c88043c 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ require ( github.com/atotto/clipboard v0.1.4 github.com/charmbracelet/bubbles v1.0.0 github.com/charmbracelet/bubbletea v1.3.10 - github.com/charmbracelet/lipgloss v1.1.0 + github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 github.com/go-chi/chi/v5 v5.2.5 github.com/oklog/ulid/v2 v2.1.1 github.com/spf13/cobra v1.10.2 @@ -14,33 +14,45 @@ require ( ) require ( + github.com/alecthomas/chroma/v2 v2.20.0 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/aymerick/douceur v0.2.0 // indirect github.com/charmbracelet/colorprofile v0.4.1 // indirect + github.com/charmbracelet/glamour v1.0.0 // indirect github.com/charmbracelet/x/ansi v0.11.6 // indirect github.com/charmbracelet/x/cellbuf v0.0.15 // indirect + github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect github.com/charmbracelet/x/term v0.2.2 // indirect github.com/clipperhouse/displaywidth v0.9.0 // indirect github.com/clipperhouse/stringish v0.1.1 // indirect github.com/clipperhouse/uax29/v2 v2.5.0 // indirect + github.com/dlclark/regexp2 v1.11.5 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/google/uuid v1.6.0 // indirect + github.com/gorilla/css v1.0.1 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/microcosm-cc/bluemonday v1.0.27 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/reflow v0.3.0 // indirect github.com/muesli/termenv v0.16.0 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/spf13/pflag v1.0.9 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + github.com/yuin/goldmark v1.7.13 // indirect + github.com/yuin/goldmark-emoji v1.0.6 // indirect golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect + golang.org/x/net v0.38.0 // indirect golang.org/x/sys v0.38.0 // indirect - golang.org/x/text v0.3.8 // indirect + golang.org/x/term v0.36.0 // indirect + golang.org/x/text v0.30.0 // indirect modernc.org/libc v1.65.7 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect diff --git a/go.sum b/go.sum index 7252a8d..31b528c 100644 --- a/go.sum +++ b/go.sum @@ -1,19 +1,29 @@ +github.com/alecthomas/chroma/v2 v2.20.0 h1:sfIHpxPyR07/Oylvmcai3X/exDlE8+FA820NTz+9sGw= +github.com/alecthomas/chroma/v2 v2.20.0/go.mod h1:e7tViK0xh/Nf4BYHl00ycY6rV7b8iXBksI9E359yNmA= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= +github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= +github.com/charmbracelet/glamour v1.0.0 h1:AWMLOVFHTsysl4WV8T8QgkQ0s/ZNZo7CiE4WKhk8l08= +github.com/charmbracelet/glamour v1.0.0/go.mod h1:DSdohgOBkMr2ZQNhw4LZxSGpx3SvpeujNoXrQyH2hxo= github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE= +github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA= github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= +github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI= +github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= @@ -23,6 +33,8 @@ github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEX github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= +github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= @@ -33,6 +45,8 @@ github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17k github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= +github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= @@ -41,12 +55,17 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= +github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= +github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= @@ -56,6 +75,8 @@ github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNs github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= @@ -65,21 +86,34 @@ github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA= +github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= +github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs= +github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM= golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8= golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= +golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U= +golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= +golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q= +golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss= golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= +golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc= golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI= +golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= modernc.org/cc/v4 v4.26.1 h1:+X5NtzVBn0KgsBCBe+xkDC7twLb/jNVj9FPgiwSQO3s= modernc.org/cc/v4 v4.26.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= diff --git a/internal/tui/detail.go b/internal/tui/detail.go index 38e5f46..4e51db8 100644 --- a/internal/tui/detail.go +++ b/internal/tui/detail.go @@ -6,6 +6,7 @@ import ( "time" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/glamour" "github.com/lerko/nib/internal/db" "github.com/lerko/nib/internal/display" @@ -98,7 +99,20 @@ func (d detailModel) previewView(width int) string { b.WriteString("\n") } - b.WriteString(detailBodyStyle.Render(e.Body)) + bodyWidth := width - 4 + 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(detailBodyStyle.Render(rendered)) b.WriteString("\n") if e.CardType != nil { -- 2.52.0 From c26e2d2022f235e32419b29ed96d85257ffc0a2a Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Wed, 20 May 2026 11:49:11 -0400 Subject: [PATCH 4/4] feat(tui): status debounce, scroll indicator, drawer label, card grouping Status messages now use a sequence counter so rapid actions don't cause premature clearing. Detail pane shows scroll position and supports pgup/pgdown/g/G. Capture drawer border includes inline label. Cards view groups by intent (pinned/grab/read/fill) with gutter labels matching the stream view's date grouping pattern. --- internal/tui/cards.go | 130 +++++++++++++++++++++++++++++++++++---- internal/tui/commands.go | 6 +- internal/tui/detail.go | 31 +++++++++- internal/tui/input.go | 11 +++- internal/tui/model.go | 75 ++++++++++------------ 5 files changed, 191 insertions(+), 62 deletions(-) diff --git a/internal/tui/cards.go b/internal/tui/cards.go index f08038a..72416cc 100644 --- a/internal/tui/cards.go +++ b/internal/tui/cards.go @@ -64,9 +64,16 @@ func matchesIntent(e *db.Entity, i intent) bool { return false } +type cardGroup struct { + label string + start int + count int +} + type cardsModel struct { entities []*db.Entity filtered []*db.Entity + groups []cardGroup cursor int offset int height int @@ -91,24 +98,69 @@ func (c *cardsModel) setIntent(i intent) { } func (c *cardsModel) applyFilter() { - c.filtered = nil - var pinned, rest []*db.Entity - for _, e := range c.entities { - if !matchesIntent(e, c.intent) { - continue - } - if e.Pinned { - pinned = append(pinned, e) - } else { - rest = append(rest, e) - } - } - c.filtered = append(pinned, rest...) + c.filtered, c.groups = sortAndGroupCards(c.entities, c.intent) if c.cursor >= len(c.filtered) { c.cursor = max(0, len(c.filtered)-1) } } +func sortAndGroupCards(entities []*db.Entity, intentFilter intent) ([]*db.Entity, []cardGroup) { + if intentFilter != intentAll { + var pinned, rest []*db.Entity + for _, e := range entities { + if !matchesIntent(e, intentFilter) { + continue + } + if e.Pinned { + pinned = append(pinned, e) + } else { + rest = append(rest, e) + } + } + return append(pinned, rest...), nil + } + + var pinned, grab, read, fill []*db.Entity + for _, e := range entities { + if e.Pinned { + pinned = append(pinned, e) + } else { + switch { + case matchesIntent(e, intentGrab): + grab = append(grab, e) + case matchesIntent(e, intentRead): + read = append(read, e) + case matchesIntent(e, intentFill): + fill = append(fill, e) + } + } + } + + var filtered []*db.Entity + var groups []cardGroup + for _, bucket := range []struct { + label string + entities []*db.Entity + }{ + {"pinned", pinned}, + {"grab", grab}, + {"read", read}, + {"fill", fill}, + } { + if len(bucket.entities) == 0 { + continue + } + groups = append(groups, cardGroup{ + label: bucket.label, + start: len(filtered), + count: len(bucket.entities), + }) + filtered = append(filtered, bucket.entities...) + } + + return filtered, groups +} + func (c *cardsModel) setSize(width, height int) { c.width = width c.height = height @@ -166,6 +218,9 @@ func (c cardsModel) view(width int) string { if len(c.filtered) == 0 { return statusStyle.Render("no cards") } + if len(c.groups) > 0 { + return c.groupedView(width) + } var b strings.Builder visible := c.visibleCount() @@ -188,6 +243,55 @@ func (c cardsModel) view(width int) string { return b.String() } +func (c cardsModel) groupedView(width int) string { + entityWidth := width - 4 - dateGutterWidth + + type displayLine struct { + text string + entityIdx int + } + + var lines []displayLine + for _, g := range c.groups { + for i := 0; i < g.count; i++ { + eIdx := g.start + i + var gutter string + if i == 0 { + gutter = gutterStyle.Render(padRight(g.label, 6) + " │ ") + } else { + gutter = gutterStyle.Render(" │ ") + } + line := gutter + renderCard(c.filtered[eIdx], entityWidth) + lines = append(lines, displayLine{text: line, entityIdx: eIdx}) + } + } + + visible := c.visibleCount() + offset := c.offset + if c.cursor < offset { + offset = c.cursor + } + if c.cursor >= offset+visible { + offset = c.cursor - visible + 1 + } + + var b strings.Builder + end := min(offset+visible, len(lines)) + for i := offset; i < end; i++ { + dl := lines[i] + if dl.entityIdx == c.cursor { + b.WriteString(selectedItemStyle.Render(" " + dl.text)) + } else { + b.WriteString(listItemStyle.Render(dl.text)) + } + if i < end-1 { + b.WriteString("\n") + } + } + + return b.String() +} + func (c cardsModel) visibleCount() int { if c.height <= 0 { return 20 diff --git a/internal/tui/commands.go b/internal/tui/commands.go index 17155df..140f773 100644 --- a/internal/tui/commands.go +++ b/internal/tui/commands.go @@ -58,7 +58,7 @@ type tagsLoadedMsg struct { tags []db.TagCount } -type statusClearMsg struct{} +type statusClearMsg struct{ seq int } type editorFinishedMsg struct { err error @@ -270,8 +270,8 @@ func copyResolved(store *db.Store, entityID string, resolved string) tea.Cmd { } } -func clearStatusAfter(d time.Duration) tea.Cmd { +func clearStatusAfter(d time.Duration, seq int) tea.Cmd { return tea.Tick(d, func(time.Time) tea.Msg { - return statusClearMsg{} + return statusClearMsg{seq: seq} }) } diff --git a/internal/tui/detail.go b/internal/tui/detail.go index 4e51db8..2130ddd 100644 --- a/internal/tui/detail.go +++ b/internal/tui/detail.go @@ -62,6 +62,17 @@ func (d detailModel) update(msg tea.KeyMsg) (detailModel, tea.Cmd) { } case "down", "j": d.scroll++ + case "pgdown", "ctrl+d": + d.scroll += d.height + case "pgup", "ctrl+u": + d.scroll -= d.height + if d.scroll < 0 { + d.scroll = 0 + } + case "home", "g": + d.scroll = 0 + case "end", "G": + d.scroll = 1<<31 - 1 } return d, nil } @@ -156,8 +167,24 @@ func (d detailModel) previewView(width int) string { b.WriteString(idStyle.Render(meta)) lines := strings.Split(b.String(), "\n") - if d.scroll > 0 && d.scroll < len(lines) { - lines = lines[d.scroll:] + totalLines := len(lines) + + maxScroll := totalLines - d.height + if maxScroll < 0 { + maxScroll = 0 + } + scroll := d.scroll + if scroll > maxScroll { + scroll = maxScroll + } + + if totalLines > d.height && d.height > 0 && len(lines) > 0 { + indicator := idStyle.Render(fmt.Sprintf(" %d/%d", scroll+1, totalLines)) + lines[0] = lines[0] + indicator + } + + if scroll > 0 && scroll < totalLines { + lines = lines[scroll:] } if d.height > 0 && len(lines) > d.height { lines = lines[:d.height] diff --git a/internal/tui/input.go b/internal/tui/input.go index b782107..5e38948 100644 --- a/internal/tui/input.go +++ b/internal/tui/input.go @@ -103,7 +103,16 @@ func (i inputModel) updateKey(msg tea.KeyMsg) inputModel { func (i inputModel) view(width int) string { var b strings.Builder - b.WriteString(drawerBorderStyle.Render(strings.Repeat("─", width))) + label := "capture" + prefix := "── " + suffix := " " + dashCount := width - len(prefix) - len(label) - len(suffix) + if dashCount < 0 { + dashCount = 0 + } + b.WriteString(drawerBorderStyle.Render(prefix) + + hintDescStyle.Render(label) + + drawerBorderStyle.Render(suffix+strings.Repeat("─", dashCount))) b.WriteString("\n") b.WriteString(i.ti.View()) b.WriteString("\n") diff --git a/internal/tui/model.go b/internal/tui/model.go index d0692df..18c2bb5 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -94,8 +94,9 @@ type model struct { searchQuery string searchTags []string - status string - err error + status string + statusSeq int + err error } func newModel(store *db.Store) model { @@ -111,6 +112,12 @@ func newModel(store *db.Store) model { } } +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()) } @@ -143,20 +150,14 @@ func (m model) hasSearch() bool { 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) + 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 = append(pinned, rest...) + 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) } @@ -194,38 +195,31 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.state = stateList m.input.reset() m.recalcSizes() - m.status = "created" - return m, tea.Batch(loadEntities(m.store, m.listParams()), clearStatusAfter(statusTimeout)) + return m, tea.Batch(loadEntities(m.store, m.listParams()), m.setStatus("created")) case entityDeletedMsg: - m.status = "deleted" m.state = stateList - return m, tea.Batch(loadEntities(m.store, m.listParams()), clearStatusAfter(statusTimeout)) + return m, tea.Batch(loadEntities(m.store, m.listParams()), m.setStatus("deleted")) case entityUpdatedMsg: - m.status = msg.action if m.state == stateDetail { m.detail.setEntity(msg.entity) } - return m, tea.Batch(loadEntities(m.store, m.listParams()), clearStatusAfter(statusTimeout)) + return m, tea.Batch(loadEntities(m.store, m.listParams()), m.setStatus(msg.action)) case entityPromotedMsg: - m.status = fmt.Sprintf("promoted → %s", msg.cardType) m.state = stateList - return m, tea.Batch(loadEntities(m.store, m.listParams()), clearStatusAfter(statusTimeout)) + return m, tea.Batch(loadEntities(m.store, m.listParams()), m.setStatus(fmt.Sprintf("promoted → %s", msg.cardType))) case entityDemotedMsg: - m.status = "demoted → fluid" - return m, tea.Batch(m.reloadDetail(msg.id), clearStatusAfter(statusTimeout)) + return m, tea.Batch(m.reloadDetail(msg.id), m.setStatus("demoted → fluid")) case entityCopiedMsg: - m.status = "copied" - return m, clearStatusAfter(statusTimeout) + return m, m.setStatus("copied") case entityAbsorbedMsg: - m.status = "absorbed" m.state = stateList - return m, tea.Batch(loadEntities(m.store, m.listParams()), clearStatusAfter(statusTimeout)) + return m, tea.Batch(loadEntities(m.store, m.listParams()), m.setStatus("absorbed")) case absorbSourcesLoadedMsg: m.absorb = newAbsorbModel(msg.targetID) @@ -235,14 +229,12 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case stepsPersistedMsg: - m.status = "steps saved" m.detail.mode = detailPreview - return m, tea.Batch(m.reloadDetail(m.detail.entity.ID), clearStatusAfter(statusTimeout)) + return m, tea.Batch(m.reloadDetail(m.detail.entity.ID), m.setStatus("steps saved")) case templateCopiedMsg: - m.status = "copied resolved" m.detail.mode = detailPreview - return m, tea.Batch(loadEntities(m.store, m.listParams()), clearStatusAfter(statusTimeout)) + return m, tea.Batch(loadEntities(m.store, m.listParams()), m.setStatus("copied resolved")) case tagsLoadedMsg: m.filter.setTags(msg.tags) @@ -254,8 +246,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.err = msg.err return m, m.reloadAfterEdit() } - m.status = "updated" - return m, tea.Batch(m.reloadAfterEdit(), clearStatusAfter(statusTimeout)) + return m, tea.Batch(m.reloadAfterEdit(), m.setStatus("updated")) case confirmTimeoutMsg: if m.state == stateConfirm { @@ -265,7 +256,9 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case statusClearMsg: - m.status = "" + if msg.seq == m.statusSeq { + m.status = "" + } return m, nil case errMsg: @@ -421,8 +414,7 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case "s": if m.mode == modeCards && m.state == stateList { m.cardsSort = m.cardsSort.next() - m.status = "sort: " + m.cardsSort.String() - return m, tea.Batch(loadEntities(m.store, m.listParams()), clearStatusAfter(statusTimeout)) + return m, tea.Batch(loadEntities(m.store, m.listParams()), m.setStatus("sort: "+m.cardsSort.String())) } return m, nil @@ -538,8 +530,7 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { e := m.selectedEntity() if e != nil { if e.CardType != nil { - m.status = "target must be fluid" - return m, clearStatusAfter(statusTimeout) + return m, m.setStatus("target must be fluid") } return m, loadAbsorbSources(m.store, e.ID) } @@ -549,8 +540,7 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { e := m.selectedEntity() if e != nil { if e.CardType != nil { - m.status = "already a card" - return m, clearStatusAfter(statusTimeout) + return m, m.setStatus("already a card") } m.promote = newPromoteModel(e.ID, e.Body) m.state = statePromote @@ -561,8 +551,7 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case "D": if m.state == stateDetail && m.detail.entity != nil { if m.detail.entity.CardType == nil { - m.status = "already fluid" - return m, clearStatusAfter(statusTimeout) + return m, m.setStatus("already fluid") } return m, demoteEntity(m.store, m.detail.entity.ID) } -- 2.52.0