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
+133 -30
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
@@ -103,15 +106,17 @@ func newModel(store *db.Store) model {
inp := newInputModel()
inp.ti.Focus()
return model{
store: store,
state: stateList,
mode: modeStream,
focus: focusCapture,
list: newListModel(),
cards: newCardsModel(),
detail: newDetailModel(),
input: inp,
filter: newFilterModel(),
store: store,
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,17 +869,22 @@ func (m model) View() string {
return header + "\n" + content + "\n" + captureBar + "\n" + statusLine
}
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
func (m model) listWidth() int {
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)
}
@@ -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
}