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

Merged
lerko merged 2 commits from feat/tag-rail into main 2026-05-20 19:24:42 +00:00
7 changed files with 305 additions and 31 deletions
Showing only changes of commit b5b7f6b6ee - Show all commits
+14
View File
@@ -58,6 +58,10 @@ type tagsLoadedMsg struct {
tags []db.TagCount
}
type railTagsLoadedMsg struct {
tags []db.TagCount
}
type statusClearMsg struct{ seq int }
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 {
editorEnv := os.Getenv("EDITOR")
if editorEnv == "" {
+2 -1
View File
@@ -8,9 +8,10 @@ func renderHelp(width, height int) string {
binds [][2]string
}{
{"Focus", [][2]string{
{"tab", "cycle focus: capture → list → detail"},
{"tab", "cycle focus: capture → tags → list → detail"},
{"esc", "back / clear filter / to capture"},
{"a", "focus capture bar"},
{"ctrl+b", "toggle tag rail"},
}},
{"Capture Bar", [][2]string{
{"enter", "submit (or browse if empty)"},
+2
View File
@@ -32,6 +32,7 @@ type keyMap struct {
FocusLeft key.Binding
FocusRight key.Binding
Tab key.Binding
ToggleRail key.Binding
}
var keys = keyMap{
@@ -64,4 +65,5 @@ var keys = keyMap{
FocusLeft: key.NewBinding(key.WithKeys("h"), key.WithHelp("h", "focus list")),
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")),
}
+122 -19
View File
@@ -65,6 +65,7 @@ type focusPane int
const (
focusCapture focusPane = iota
focusTagRail
focusList
focusDetail
)
@@ -83,10 +84,12 @@ type model struct {
filter filterModel
promote promoteModel
absorb absorbModel
tagRail tagRailModel
showHelp bool
focus focusPane
splitDetail bool
showTagRail bool
filterTag string
confirmID string
@@ -107,11 +110,13 @@ func newModel(store *db.Store) model {
state: stateList,
mode: modeStream,
focus: focusCapture,
showTagRail: true,
list: newListModel(),
cards: newCardsModel(),
detail: newDetailModel(),
input: inp,
filter: newFilterModel(),
tagRail: newTagRailModel(),
}
}
@@ -131,7 +136,7 @@ func (m *model) setStatus(msg string) 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 {
@@ -188,6 +193,9 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.splitDetail = false
m.focus = focusList
}
if !m.railVisible() && m.focus == focusTagRail {
m.focus = focusList
}
m.recalcSizes()
return m, nil
@@ -203,13 +211,18 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.err = nil
return m, nil
case railTagsLoadedMsg:
m.tagRail.setTags(msg.tags)
m.tagRail.activeTag = m.filterTag
return m, nil
case entityCreatedMsg:
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:
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:
if m.state == stateDetail {
@@ -229,7 +242,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case entityAbsorbedMsg:
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:
m.absorb = newAbsorbModel(msg.targetID)
@@ -248,6 +261,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tagsLoadedMsg:
m.filter.setTags(msg.tags)
m.tagRail.setTags(msg.tags)
m.state = stateTagFilter
return m, nil
@@ -319,6 +333,13 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
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) {
switch msg.String() {
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, nil
case "esc", "tab":
case "esc":
cmd := m.setFocus(focusList)
return m, cmd
case "tab":
cmd := m.setFocus(m.nextFocusFromCapture())
return m, cmd
}
m.input = m.input.updateKey(msg)
return m, nil
}
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 {
switch msg.String() {
case "tab":
@@ -775,6 +843,13 @@ func (m model) View() string {
} else {
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:
content = m.detail.view(m.width)
case stateTagFilter:
@@ -794,18 +869,23 @@ func (m model) View() string {
return header + "\n" + content + "\n" + captureBar + "\n" + statusLine
}
func (m model) listWidth() int {
if m.splitDetail {
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 {
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)
}
@@ -859,26 +939,49 @@ func (m model) contentHeight() int {
func (m *model) recalcSizes() {
ch := m.contentHeight()
lw := m.listWidth()
if m.isSplit() && m.splitDetail {
lw, rw := m.splitWidths()
_, 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.list.setSize(lw, ch)
m.cards.setSize(lw, ch)
m.detail.setSize(lw, ch)
}
m.filter.setHeight(ch)
if m.railVisible() {
m.tagRail.setSize(m.railWidth(), ch)
}
}
func (m model) isSplit() bool {
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) {
left := m.width * 40 / 100
right := m.width - left - 1
avail := m.width - m.railWidth()
if m.railVisible() {
avail--
}
left := avail * 40 / 100
right := avail - left - 1
return left, right
}
+2
View File
@@ -87,6 +87,8 @@ func contextHints(m model) []hint {
switch m.focus {
case focusCapture:
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:
if m.splitDetail {
return []hint{{"h", "list"}, {"c", "copy"}, {"e", "edit"}, {"p", "promote"}, {"!", "pin"}, {"tab", "capture"}}
+14
View File
@@ -129,4 +129,18 @@ var (
hintDescStyle = lipgloss.NewStyle().
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()
}