Files
nib-v1/internal/tui/statusbar.go
T
lerko b5b7f6b6ee 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
2026-05-20 14:32:32 -04:00

107 lines
3.2 KiB
Go

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 renderTab(label, key string, active bool) string {
if active {
return hintKeyStyle.Render(label) + " " + hintDescStyle.Render(key)
}
return hintDescStyle.Render(label) + " " + hintKeyStyle.Render(key)
}
func renderStatusBar(m model, width int) string {
var leftParts []string
if m.status != "" {
leftParts = append(leftParts, statusStyle.Render(m.status))
} else if preview := m.input.previewText(); m.focus == focusCapture && preview != "" {
leftParts = append(leftParts, drawerPreviewStyle.Render(preview))
} 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 {
gap = 0
}
pad := lipgloss.NewStyle().Width(gap).Render("")
return leftRendered + pad + right
}
func countText(m model) string {
var total int
if m.mode == modeCards {
total = len(m.cards.filtered)
} else {
total = len(m.list.displayEntities())
}
if m.filterTag != "" {
return fmt.Sprintf("%d entities #%s", total, m.filterTag)
}
return fmt.Sprintf("%d entities", total)
}
func contextHints(m model) []hint {
switch m.state {
case stateDetail:
switch m.detail.mode {
case detailRun:
return []hint{{"space", "toggle"}, {"j/k", "nav"}, {"r", "reset"}, {"esc", "save+exit"}}
case detailFill:
return []hint{{"tab", "next"}, {"⇧tab", "prev"}, {"enter", "copy"}, {"esc", "cancel"}}
default:
return []hint{{"p", "promote"}, {"D", "demote"}, {"c", "copy"}, {"e", "edit"}, {"r", "run"}, {"f", "fill"}, {"!", "pin"}, {"esc", "back"}}
}
case stateTagFilter:
return []hint{{"j/k", "nav"}, {"enter", "select"}, {"esc", "cancel"}}
case stateConfirm:
return []hint{{"y", "confirm"}, {"n", "cancel"}}
case statePromote:
return []hint{{"j/k", "nav"}, {"enter", "select"}, {"esc", "cancel"}}
case stateAbsorb:
return []hint{{"j/k", "nav"}, {"enter", "absorb"}, {"esc", "cancel"}}
}
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"}}
}
return []hint{{"c", "copy"}, {"e", "edit"}, {"p", "promote"}, {"!", "pin"}, {"esc", "back"}}
default:
if m.splitDetail {
return []hint{{"l", "detail"}, {"d", "del"}, {"#", "filter"}, {"tab", "capture"}, {"?", "help"}}
}
if m.mode == modeCards {
return []hint{{"s", "sort"}, {"i", "intent"}, {"tab", "capture"}, {"?", "help"}}
}
return []hint{{"m", "absorb"}, {"d", "del"}, {"#", "filter"}, {"tab", "capture"}, {"?", "help"}}
}
}