feat(tui): add status bar, help overlay, tag filter, and entity actions

Status bar with entity count and context-sensitive key hints. Help
overlay via ? key. Tag filter via # with cursor-navigable tag list.
Todo toggle (x), pin (!), promote (p), demote (D), copy (c), edit (e)
via $EDITOR. Delete confirmation with 3s timeout. Date-grouped list
with completed todo and pinned indicators. Esc clears active tag filter.

Adds CompletedAt/ClearCompleted to EntityUpdate for todo toggling.
This commit is contained in:
2026-05-17 20:33:34 -04:00
parent 36999cd825
commit c2ea63dd16
10 changed files with 804 additions and 77 deletions
+253 -30
View File
@@ -12,6 +12,8 @@ const (
stateList viewState = iota
stateDetail
stateInput
stateTagFilter
stateConfirm
)
type model struct {
@@ -20,9 +22,14 @@ type model struct {
width int
height int
list listModel
detail detailModel
input inputModel
list listModel
detail detailModel
input inputModel
filter filterModel
showHelp bool
filterTag string
confirmID string
status string
err error
@@ -35,11 +42,20 @@ func newModel(store *db.Store) model {
list: newListModel(),
detail: newDetailModel(),
input: newInputModel(),
filter: newFilterModel(),
}
}
func (m model) Init() tea.Cmd {
return loadEntities(m.store, db.DefaultListParams())
return loadEntities(m.store, m.listParams())
}
func (m model) listParams() db.ListParams {
p := db.DefaultListParams()
if m.filterTag != "" {
p.Tag = &m.filterTag
}
return p
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
@@ -49,11 +65,11 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.height = msg.Height
m.list.setSize(m.width, m.contentHeight())
m.detail.setSize(m.width, m.contentHeight())
m.filter.setHeight(m.contentHeight())
return m, nil
case entitiesLoadedMsg:
m.list.setEntities(msg.entities)
m.status = ""
m.err = nil
return m, nil
@@ -61,52 +77,188 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.state = stateList
m.input.reset()
m.status = "created"
return m, loadEntities(m.store, db.DefaultListParams())
return m, loadEntities(m.store, m.listParams())
case entityDeletedMsg:
m.status = "deleted"
return m, loadEntities(m.store, db.DefaultListParams())
m.state = stateList
return m, loadEntities(m.store, m.listParams())
case entityUpdatedMsg:
m.status = msg.action
if m.state == stateDetail {
m.detail.setEntity(msg.entity)
}
return m, loadEntities(m.store, m.listParams())
case entityPromotedMsg:
m.status = "promoted → snippet"
return m, m.reloadDetail(msg.id)
case entityDemotedMsg:
m.status = "demoted → fluid"
return m, m.reloadDetail(msg.id)
case entityCopiedMsg:
m.status = "copied"
return m, nil
case tagsLoadedMsg:
m.filter.setTags(msg.tags)
m.state = stateTagFilter
return m, nil
case editorFinishedMsg:
if msg.err != nil {
m.err = msg.err
} else {
m.status = "updated"
}
return m, m.reloadAfterEdit()
case confirmTimeoutMsg:
if m.state == stateConfirm {
m.state = stateList
m.confirmID = ""
}
return m, nil
case errMsg:
m.err = msg.err
return m, nil
case tea.KeyMsg:
if m.state == stateInput {
m.err = nil
switch m.state {
case stateInput:
return m.updateInput(msg)
case stateTagFilter:
return m.updateTagFilter(msg)
case stateConfirm:
return m.updateConfirm(msg)
default:
return m.updateKeys(msg)
}
return m.updateKeys(msg)
}
return m, nil
}
func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch {
case msg.String() == "q" || msg.String() == "ctrl+c":
if m.showHelp {
if msg.String() == "?" || msg.String() == "esc" || msg.String() == "q" {
m.showHelp = false
}
return m, nil
}
switch msg.String() {
case "ctrl+c":
return m, tea.Quit
case msg.String() == "a" && m.state == stateList:
m.state = stateInput
m.input.focus()
return m, m.input.ti.Focus()
case "q":
if m.state == stateList {
return m, tea.Quit
}
return m, nil
case msg.String() == "esc":
case "?":
m.showHelp = true
return m, nil
case "a":
if m.state == stateList {
m.state = stateInput
m.input.focus()
return m, m.input.ti.Focus()
}
case "esc":
if m.state == stateDetail {
m.state = stateList
return m, nil
}
if m.state == stateList && m.filterTag != "" {
m.filterTag = ""
m.status = ""
return m, loadEntities(m.store, m.listParams())
}
return m, nil
case msg.String() == "enter" && m.state == stateList:
if e := m.list.selected(); e != nil {
m.detail.setEntity(e)
m.state = stateDetail
case "enter":
if m.state == stateList {
if e := m.list.selected(); e != nil {
m.detail.setEntity(e)
m.state = stateDetail
}
}
return m, nil
case msg.String() == "d" && m.state == stateList:
if e := m.list.selected(); e != nil {
return m, deleteEntity(m.store, e.ID)
case "d":
if m.state == stateList {
if e := m.list.selected(); e != nil {
m.confirmID = e.ID
m.state = stateConfirm
return m, confirmTimeout()
}
}
return m, nil
case "x":
if m.state == stateList {
if e := m.list.selected(); e != nil && e.Glyph == db.GlyphTodo {
return m, toggleTodo(m.store, e)
}
}
return m, nil
case "!":
e := m.selectedEntity()
if e != nil {
return m, pinEntity(m.store, e)
}
return m, nil
case "#":
if m.state == stateList {
if m.filterTag != "" {
m.filterTag = ""
m.status = ""
return m, loadEntities(m.store, m.listParams())
}
return m, loadTags(m.store)
}
return m, nil
case "p":
if m.state == stateDetail && m.detail.entity != nil {
if m.detail.entity.CardType != nil {
m.status = "already a card"
return m, nil
}
return m, promoteEntity(m.store, m.detail.entity.ID)
}
return m, nil
case "D":
if m.state == stateDetail && m.detail.entity != nil {
if m.detail.entity.CardType == nil {
m.status = "already fluid"
return m, nil
}
return m, demoteEntity(m.store, m.detail.entity.ID)
}
return m, nil
case "c":
if m.state == stateDetail && m.detail.entity != nil {
return m, copyToClipboard(m.store, m.detail.entity)
}
return m, nil
case "e":
if m.state == stateDetail && m.detail.entity != nil {
return m, editInEditor(m.store, m.detail.entity)
}
return m, nil
}
@@ -137,19 +289,56 @@ func (m model) updateInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, nil
}
func (m model) View() string {
var content string
func (m model) updateTagFilter(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "esc", "q":
m.state = stateList
return m, nil
case "enter":
tag := m.filter.selectedTag()
if tag != "" {
m.filterTag = tag
m.state = stateList
return m, loadEntities(m.store, m.listParams())
}
return m, nil
default:
m.filter = m.filter.update(msg.String())
return m, nil
}
}
func (m model) updateConfirm(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
id := m.confirmID
m.confirmID = ""
m.state = stateList
if msg.String() == "y" && id != "" {
return m, deleteEntity(m.store, id)
}
return m, nil
}
func (m model) View() string {
if m.showHelp {
return renderHelp(m.width, m.height)
}
var content string
switch m.state {
case stateList:
case stateList, stateInput, stateConfirm:
content = m.list.view(m.width)
case stateDetail:
content = m.detail.view(m.width)
case stateInput:
content = m.list.view(m.width)
case stateTagFilter:
content = m.filter.view(m.width)
}
header := titleStyle.Render("nib")
if m.filterTag != "" {
header += " " + filterPillStyle.Render("#"+m.filterTag)
}
footer := m.footerView()
return header + "\n" + content + "\n" + footer
@@ -160,17 +349,51 @@ func (m model) footerView() string {
return m.input.view(m.width)
}
if m.state == stateConfirm {
return renderConfirm(m.confirmID)
}
if m.err != nil {
return errorStyle.Render("error: " + m.err.Error())
}
if m.status != "" {
return statusStyle.Render(m.status)
return statusStyle.Render(m.status) + " " + helpStyle.Render(contextHints(m))
}
return helpStyle.Render("a:add enter:view d:delete q:quit ?:help")
return renderStatusBar(m, m.width)
}
func (m model) contentHeight() int {
return m.height - 3
}
func (m model) selectedEntity() *db.Entity {
switch m.state {
case stateList:
return m.list.selected()
case stateDetail:
return m.detail.entity
}
return nil
}
func (m model) reloadDetail(id string) tea.Cmd {
return tea.Batch(
loadEntities(m.store, m.listParams()),
func() tea.Msg {
e, err := m.store.Get(id)
if err != nil {
return errMsg{err}
}
return entityUpdatedMsg{e, ""}
},
)
}
func (m model) reloadAfterEdit() tea.Cmd {
if m.detail.entity == nil {
return loadEntities(m.store, m.listParams())
}
return m.reloadDetail(m.detail.entity.ID)
}