feat(tui): collapsible tag rail with ambient tag awareness

Persistent left panel showing tags with counts. Provides ambient
awareness of tag landscape without requiring a modal.

- New tagRailModel in tagrail.go: tag list with cursor, scroll, counts
- Rail visible at >=100 cols width, 18% width (min 16 chars)
- ctrl+b toggles rail visibility
- focusTagRail added to focus cycle: capture → tags → list → detail
- j/k navigates, enter filters/unfilters by tag
- Active filter tag highlighted bold in rail
- Tags refresh after entity create/delete/absorb
- Rail auto-hides on narrow terminals, # modal still works as fallback
- Width allocation accounts for rail in split and non-split layouts
This commit is contained in:
2026-05-20 14:32:32 -04:00
parent 3f57531995
commit b5b7f6b6ee
7 changed files with 305 additions and 31 deletions
+14
View File
@@ -58,6 +58,10 @@ type tagsLoadedMsg struct {
tags []db.TagCount tags []db.TagCount
} }
type railTagsLoadedMsg struct {
tags []db.TagCount
}
type statusClearMsg struct{ seq int } type statusClearMsg struct{ seq int }
type editorFinishedMsg struct { type editorFinishedMsg struct {
@@ -181,6 +185,16 @@ func loadTags(store *db.Store) tea.Cmd {
} }
} }
func loadRailTags(store *db.Store) tea.Cmd {
return func() tea.Msg {
tags, err := store.ListTags(false)
if err != nil {
return errMsg{err}
}
return railTagsLoadedMsg{tags}
}
}
func editInEditor(store *db.Store, e *db.Entity) tea.Cmd { func editInEditor(store *db.Store, e *db.Entity) tea.Cmd {
editorEnv := os.Getenv("EDITOR") editorEnv := os.Getenv("EDITOR")
if editorEnv == "" { if editorEnv == "" {
+2 -1
View File
@@ -8,9 +8,10 @@ func renderHelp(width, height int) string {
binds [][2]string binds [][2]string
}{ }{
{"Focus", [][2]string{ {"Focus", [][2]string{
{"tab", "cycle focus: capture → list → detail"}, {"tab", "cycle focus: capture → tags → list → detail"},
{"esc", "back / clear filter / to capture"}, {"esc", "back / clear filter / to capture"},
{"a", "focus capture bar"}, {"a", "focus capture bar"},
{"ctrl+b", "toggle tag rail"},
}}, }},
{"Capture Bar", [][2]string{ {"Capture Bar", [][2]string{
{"enter", "submit (or browse if empty)"}, {"enter", "submit (or browse if empty)"},
+2
View File
@@ -32,6 +32,7 @@ type keyMap struct {
FocusLeft key.Binding FocusLeft key.Binding
FocusRight key.Binding FocusRight key.Binding
Tab key.Binding Tab key.Binding
ToggleRail key.Binding
} }
var keys = keyMap{ var keys = keyMap{
@@ -64,4 +65,5 @@ var keys = keyMap{
FocusLeft: key.NewBinding(key.WithKeys("h"), key.WithHelp("h", "focus list")), FocusLeft: key.NewBinding(key.WithKeys("h"), key.WithHelp("h", "focus list")),
FocusRight: key.NewBinding(key.WithKeys("l"), key.WithHelp("l", "focus detail")), FocusRight: key.NewBinding(key.WithKeys("l"), key.WithHelp("l", "focus detail")),
Tab: key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "focus cycle")), 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")),
} }
+133 -30
View File
@@ -65,6 +65,7 @@ type focusPane int
const ( const (
focusCapture focusPane = iota focusCapture focusPane = iota
focusTagRail
focusList focusList
focusDetail focusDetail
) )
@@ -83,10 +84,12 @@ type model struct {
filter filterModel filter filterModel
promote promoteModel promote promoteModel
absorb absorbModel absorb absorbModel
tagRail tagRailModel
showHelp bool showHelp bool
focus focusPane focus focusPane
splitDetail bool splitDetail bool
showTagRail bool
filterTag string filterTag string
confirmID string confirmID string
@@ -103,15 +106,17 @@ func newModel(store *db.Store) model {
inp := newInputModel() inp := newInputModel()
inp.ti.Focus() inp.ti.Focus()
return model{ return model{
store: store, store: store,
state: stateList, state: stateList,
mode: modeStream, mode: modeStream,
focus: focusCapture, focus: focusCapture,
list: newListModel(), showTagRail: true,
cards: newCardsModel(), list: newListModel(),
detail: newDetailModel(), cards: newCardsModel(),
input: inp, detail: newDetailModel(),
filter: newFilterModel(), input: inp,
filter: newFilterModel(),
tagRail: newTagRailModel(),
} }
} }
@@ -131,7 +136,7 @@ func (m *model) setStatus(msg string) tea.Cmd {
} }
func (m model) Init() tea.Cmd { func (m model) Init() tea.Cmd {
return tea.Batch(loadEntities(m.store, m.listParams()), m.input.ti.Focus()) return tea.Batch(loadEntities(m.store, m.listParams()), loadRailTags(m.store), m.input.ti.Focus())
} }
func (m model) listParams() db.ListParams { func (m model) listParams() db.ListParams {
@@ -188,6 +193,9 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.splitDetail = false m.splitDetail = false
m.focus = focusList m.focus = focusList
} }
if !m.railVisible() && m.focus == focusTagRail {
m.focus = focusList
}
m.recalcSizes() m.recalcSizes()
return m, nil return m, nil
@@ -203,13 +211,18 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.err = nil m.err = nil
return m, nil return m, nil
case railTagsLoadedMsg:
m.tagRail.setTags(msg.tags)
m.tagRail.activeTag = m.filterTag
return m, nil
case entityCreatedMsg: case entityCreatedMsg:
m.input.clearText() m.input.clearText()
return m, tea.Batch(loadEntities(m.store, m.listParams()), m.setStatus("created")) return m, tea.Batch(loadEntities(m.store, m.listParams()), loadRailTags(m.store), m.setStatus("created"))
case entityDeletedMsg: case entityDeletedMsg:
m.state = stateList m.state = stateList
return m, tea.Batch(loadEntities(m.store, m.listParams()), m.setStatus("deleted")) return m, tea.Batch(loadEntities(m.store, m.listParams()), loadRailTags(m.store), m.setStatus("deleted"))
case entityUpdatedMsg: case entityUpdatedMsg:
if m.state == stateDetail { if m.state == stateDetail {
@@ -229,7 +242,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case entityAbsorbedMsg: case entityAbsorbedMsg:
m.state = stateList m.state = stateList
return m, tea.Batch(loadEntities(m.store, m.listParams()), m.setStatus("absorbed")) return m, tea.Batch(loadEntities(m.store, m.listParams()), loadRailTags(m.store), m.setStatus("absorbed"))
case absorbSourcesLoadedMsg: case absorbSourcesLoadedMsg:
m.absorb = newAbsorbModel(msg.targetID) m.absorb = newAbsorbModel(msg.targetID)
@@ -248,6 +261,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tagsLoadedMsg: case tagsLoadedMsg:
m.filter.setTags(msg.tags) m.filter.setTags(msg.tags)
m.tagRail.setTags(msg.tags)
m.state = stateTagFilter m.state = stateTagFilter
return m, nil return m, nil
@@ -319,6 +333,13 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m.updateBrowse(msg) return m.updateBrowse(msg)
} }
func (m model) nextFocusFromCapture() focusPane {
if m.railVisible() {
return focusTagRail
}
return focusList
}
func (m model) updateCapture(msg tea.KeyMsg) (tea.Model, tea.Cmd) { func (m model) updateCapture(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() { switch msg.String() {
case "enter": case "enter":
@@ -343,15 +364,62 @@ func (m model) updateCapture(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, createEntity(m.store, result.entity) return m, createEntity(m.store, result.entity)
} }
return m, nil return m, nil
case "esc", "tab": case "esc":
cmd := m.setFocus(focusList) cmd := m.setFocus(focusList)
return m, cmd return m, cmd
case "tab":
cmd := m.setFocus(m.nextFocusFromCapture())
return m, cmd
} }
m.input = m.input.updateKey(msg) m.input = m.input.updateKey(msg)
return m, nil return m, nil
} }
func (m model) updateBrowse(msg tea.KeyMsg) (tea.Model, tea.Cmd) { func (m model) updateBrowse(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
// Tag rail focus handling
if m.focus == focusTagRail && m.state == stateList {
switch msg.String() {
case "j", "k", "up", "down":
m.tagRail = m.tagRail.update(msg.String())
return m, nil
case "enter":
tag := m.tagRail.selectedTag()
if tag != "" {
if tag == m.filterTag {
m.filterTag = ""
m.tagRail.activeTag = ""
} else {
m.filterTag = tag
m.tagRail.activeTag = tag
}
m.focus = focusList
return m, loadEntities(m.store, m.listParams())
}
return m, nil
case "tab":
m.focus = focusList
return m, nil
case "esc":
m.focus = focusList
return m, nil
case "ctrl+b":
m.showTagRail = false
m.focus = focusList
m.recalcSizes()
return m, nil
}
}
// ctrl+b toggle from any browse focus
if msg.String() == "ctrl+b" && m.state == stateList {
m.showTagRail = !m.showTagRail
if !m.railVisible() && m.focus == focusTagRail {
m.focus = focusList
}
m.recalcSizes()
return m, nil
}
if m.splitDetail && m.state == stateList { if m.splitDetail && m.state == stateList {
switch msg.String() { switch msg.String() {
case "tab": case "tab":
@@ -775,6 +843,13 @@ func (m model) View() string {
} else { } else {
content = listContent content = listContent
} }
if m.railVisible() {
rw := m.railWidth()
ch := m.contentHeight()
rail := lipgloss.NewStyle().Width(rw).Height(ch).Render(m.tagRail.view(m.focus == focusTagRail))
sep := m.renderSeparator()
content = lipgloss.JoinHorizontal(lipgloss.Top, rail, sep, content)
}
case stateDetail: case stateDetail:
content = m.detail.view(m.width) content = m.detail.view(m.width)
case stateTagFilter: case stateTagFilter:
@@ -794,17 +869,22 @@ func (m model) View() string {
return header + "\n" + content + "\n" + captureBar + "\n" + statusLine return header + "\n" + content + "\n" + captureBar + "\n" + statusLine
} }
func (m model) listContent() string { func (m model) listWidth() int {
if m.mode == modeCards {
lw := m.width
if m.splitDetail {
lw, _ = m.splitWidths()
}
return m.cards.view(lw)
}
lw := m.width
if m.splitDetail { if m.splitDetail {
lw, _ = m.splitWidths() lw, _ := m.splitWidths()
return lw
}
w := m.width - m.railWidth()
if m.railVisible() {
w--
}
return w
}
func (m model) listContent() string {
lw := m.listWidth()
if m.mode == modeCards {
return m.cards.view(lw)
} }
return m.list.view(lw) return m.list.view(lw)
} }
@@ -859,26 +939,49 @@ func (m model) contentHeight() int {
func (m *model) recalcSizes() { func (m *model) recalcSizes() {
ch := m.contentHeight() ch := m.contentHeight()
lw := m.listWidth()
if m.isSplit() && m.splitDetail { if m.isSplit() && m.splitDetail {
lw, rw := m.splitWidths() _, rw := m.splitWidths()
m.list.setSize(lw, ch) m.list.setSize(lw, ch)
m.cards.setSize(lw, ch) m.cards.setSize(lw, ch)
m.detail.setSize(rw, ch) m.detail.setSize(rw, ch)
} else { } else {
m.list.setSize(m.width, ch) m.list.setSize(lw, ch)
m.cards.setSize(m.width, ch) m.cards.setSize(lw, ch)
m.detail.setSize(m.width, ch) m.detail.setSize(lw, ch)
} }
m.filter.setHeight(ch) m.filter.setHeight(ch)
if m.railVisible() {
m.tagRail.setSize(m.railWidth(), ch)
}
} }
func (m model) isSplit() bool { func (m model) isSplit() bool {
return m.width >= 100 return m.width >= 100
} }
func (m model) railVisible() bool {
return m.showTagRail && m.width >= 100
}
func (m model) railWidth() int {
if !m.railVisible() {
return 0
}
w := m.width * 18 / 100
if w < 16 {
w = 16
}
return w
}
func (m model) splitWidths() (int, int) { func (m model) splitWidths() (int, int) {
left := m.width * 40 / 100 avail := m.width - m.railWidth()
right := m.width - left - 1 if m.railVisible() {
avail--
}
left := avail * 40 / 100
right := avail - left - 1
return left, right return left, right
} }
+2
View File
@@ -87,6 +87,8 @@ func contextHints(m model) []hint {
switch m.focus { switch m.focus {
case focusCapture: case focusCapture:
return []hint{{"enter", "submit"}, {"esc", "browse"}, {"?…", "search"}, {"-", "todo"}, {"@", "event"}} return []hint{{"enter", "submit"}, {"esc", "browse"}, {"?…", "search"}, {"-", "todo"}, {"@", "event"}}
case focusTagRail:
return []hint{{"j/k", "nav"}, {"enter", "filter"}, {"ctrl+b", "hide"}, {"tab", "list"}, {"esc", "list"}}
case focusDetail: case focusDetail:
if m.splitDetail { if m.splitDetail {
return []hint{{"h", "list"}, {"c", "copy"}, {"e", "edit"}, {"p", "promote"}, {"!", "pin"}, {"tab", "capture"}} return []hint{{"h", "list"}, {"c", "copy"}, {"e", "edit"}, {"p", "promote"}, {"!", "pin"}, {"tab", "capture"}}
+14
View File
@@ -129,4 +129,18 @@ var (
hintDescStyle = lipgloss.NewStyle(). hintDescStyle = lipgloss.NewStyle().
Foreground(dim) Foreground(dim)
railHeaderStyle = lipgloss.NewStyle().
Bold(true).
Foreground(dim)
railTagStyle = lipgloss.NewStyle().
Foreground(lipgloss.AdaptiveColor{Light: "#43BF6D", Dark: "#73F59F"})
railActiveTagStyle = lipgloss.NewStyle().
Foreground(lipgloss.AdaptiveColor{Light: "#43BF6D", Dark: "#73F59F"}).
Bold(true)
railCountStyle = lipgloss.NewStyle().
Foreground(dim)
) )
+138
View File
@@ -0,0 +1,138 @@
package tui
import (
"fmt"
"strings"
"github.com/lerko/nib/internal/db"
)
type tagRailModel struct {
tags []db.TagCount
cursor int
offset int
height int
width int
activeTag string
}
func newTagRailModel() tagRailModel {
return tagRailModel{}
}
func (r *tagRailModel) setTags(tags []db.TagCount) {
r.tags = tags
if r.cursor >= len(tags) {
r.cursor = max(0, len(tags)-1)
}
}
func (r *tagRailModel) setSize(width, height int) {
r.width = width
r.height = height
}
func (r tagRailModel) selectedTag() string {
if len(r.tags) == 0 || r.cursor >= len(r.tags) {
return ""
}
return r.tags[r.cursor].Tag
}
func (r tagRailModel) update(key string) tagRailModel {
switch key {
case "up", "k":
if r.cursor > 0 {
r.cursor--
if r.cursor < r.offset {
r.offset = r.cursor
}
}
case "down", "j":
if r.cursor < len(r.tags)-1 {
r.cursor++
visible := r.visibleCount()
if r.cursor >= r.offset+visible {
r.offset = r.cursor - visible + 1
}
}
}
return r
}
func (r tagRailModel) visibleCount() int {
v := r.height - 2
if v <= 0 {
return 10
}
return v
}
func (r tagRailModel) view(focused bool) string {
w := r.width
if w <= 0 {
return ""
}
var b strings.Builder
headerStyle := railHeaderStyle
if focused {
headerStyle = headerStyle.Foreground(highlight)
}
b.WriteString(headerStyle.Render("tags"))
b.WriteString("\n")
b.WriteString(separatorStyle.Render(strings.Repeat("─", w)))
b.WriteString("\n")
if len(r.tags) == 0 {
b.WriteString(hintDescStyle.Render(" no tags"))
return b.String()
}
visible := r.visibleCount()
end := min(r.offset+visible, len(r.tags))
countW := 0
for _, tc := range r.tags {
cw := len(fmt.Sprintf("%d", tc.Count))
if cw > countW {
countW = cw
}
}
nameW := w - countW - 3
if nameW < 4 {
nameW = 4
}
for i := r.offset; i < end; i++ {
tc := r.tags[i]
name := "#" + tc.Tag
if len(name) > nameW {
name = name[:nameW-1] + "…"
}
count := fmt.Sprintf("%*d", countW, tc.Count)
gap := w - len(name) - len(count) - 1
if gap < 1 {
gap = 1
}
var line string
if i == r.cursor && focused {
line = selectedItemStyle.Render(" " + name + strings.Repeat(" ", gap) + railCountStyle.Render(count))
} else if tc.Tag == r.activeTag {
line = " " + railActiveTagStyle.Render(name) + strings.Repeat(" ", gap) + railCountStyle.Render(count)
} else {
line = " " + railTagStyle.Render(name) + strings.Repeat(" ", gap) + railCountStyle.Render(count)
}
b.WriteString(line)
if i < end-1 {
b.WriteString("\n")
}
}
return b.String()
}