fix(tui): pin footer to bottom, style hint bar, auto-clear status

Content area now enforces full height so the context help bar stays
pinned to the terminal bottom. Hint keys rendered with bold highlight
color for scannability. Status messages (created, deleted, etc.)
auto-clear after 2 seconds, reverting to the entity count.
This commit is contained in:
2026-05-20 11:01:13 -04:00
parent e2d0f3e997
commit 4e0ac8402f
5 changed files with 77 additions and 38 deletions
+25 -20
View File
@@ -3,6 +3,7 @@ package tui
import (
"fmt"
"strings"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
@@ -10,6 +11,8 @@ import (
"github.com/lerko/nib/internal/db"
)
const statusTimeout = 2 * time.Second
type viewState int
const (
@@ -192,37 +195,37 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.input.reset()
m.recalcSizes()
m.status = "created"
return m, loadEntities(m.store, m.listParams())
return m, tea.Batch(loadEntities(m.store, m.listParams()), clearStatusAfter(statusTimeout))
case entityDeletedMsg:
m.status = "deleted"
m.state = stateList
return m, loadEntities(m.store, m.listParams())
return m, tea.Batch(loadEntities(m.store, m.listParams()), clearStatusAfter(statusTimeout))
case entityUpdatedMsg:
m.status = msg.action
if m.state == stateDetail {
m.detail.setEntity(msg.entity)
}
return m, loadEntities(m.store, m.listParams())
return m, tea.Batch(loadEntities(m.store, m.listParams()), clearStatusAfter(statusTimeout))
case entityPromotedMsg:
m.status = fmt.Sprintf("promoted → %s", msg.cardType)
m.state = stateList
return m, loadEntities(m.store, m.listParams())
return m, tea.Batch(loadEntities(m.store, m.listParams()), clearStatusAfter(statusTimeout))
case entityDemotedMsg:
m.status = "demoted → fluid"
return m, m.reloadDetail(msg.id)
return m, tea.Batch(m.reloadDetail(msg.id), clearStatusAfter(statusTimeout))
case entityCopiedMsg:
m.status = "copied"
return m, nil
return m, clearStatusAfter(statusTimeout)
case entityAbsorbedMsg:
m.status = "absorbed"
m.state = stateList
return m, loadEntities(m.store, m.listParams())
return m, tea.Batch(loadEntities(m.store, m.listParams()), clearStatusAfter(statusTimeout))
case absorbSourcesLoadedMsg:
m.absorb = newAbsorbModel(msg.targetID)
@@ -234,12 +237,12 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case stepsPersistedMsg:
m.status = "steps saved"
m.detail.mode = detailPreview
return m, m.reloadDetail(m.detail.entity.ID)
return m, tea.Batch(m.reloadDetail(m.detail.entity.ID), clearStatusAfter(statusTimeout))
case templateCopiedMsg:
m.status = "copied resolved"
m.detail.mode = detailPreview
return m, loadEntities(m.store, m.listParams())
return m, tea.Batch(loadEntities(m.store, m.listParams()), clearStatusAfter(statusTimeout))
case tagsLoadedMsg:
m.filter.setTags(msg.tags)
@@ -249,10 +252,10 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case editorFinishedMsg:
if msg.err != nil {
m.err = msg.err
} else {
m.status = "updated"
return m, m.reloadAfterEdit()
}
return m, m.reloadAfterEdit()
m.status = "updated"
return m, tea.Batch(m.reloadAfterEdit(), clearStatusAfter(statusTimeout))
case confirmTimeoutMsg:
if m.state == stateConfirm {
@@ -261,6 +264,10 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
return m, nil
case statusClearMsg:
m.status = ""
return m, nil
case errMsg:
m.err = msg.err
return m, nil
@@ -415,7 +422,7 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
if m.mode == modeCards && m.state == stateList {
m.cardsSort = m.cardsSort.next()
m.status = "sort: " + m.cardsSort.String()
return m, loadEntities(m.store, m.listParams())
return m, tea.Batch(loadEntities(m.store, m.listParams()), clearStatusAfter(statusTimeout))
}
return m, nil
@@ -532,7 +539,7 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
if e != nil {
if e.CardType != nil {
m.status = "target must be fluid"
return m, nil
return m, clearStatusAfter(statusTimeout)
}
return m, loadAbsorbSources(m.store, e.ID)
}
@@ -543,7 +550,7 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
if e != nil {
if e.CardType != nil {
m.status = "already a card"
return m, nil
return m, clearStatusAfter(statusTimeout)
}
m.promote = newPromoteModel(e.ID, e.Body)
m.state = statePromote
@@ -555,7 +562,7 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
if m.state == stateDetail && m.detail.entity != nil {
if m.detail.entity.CardType == nil {
m.status = "already fluid"
return m, nil
return m, clearStatusAfter(statusTimeout)
}
return m, demoteEntity(m.store, m.detail.entity.ID)
}
@@ -758,6 +765,8 @@ func (m model) View() string {
header := m.headerView()
footer := m.footerView()
content = lipgloss.NewStyle().Width(m.width).Height(m.contentHeight()).Render(content)
return header + "\n" + content + "\n" + footer
}
@@ -824,10 +833,6 @@ func (m model) footerView() string {
return errorStyle.Render("error: " + m.err.Error())
}
if m.status != "" {
return statusStyle.Render(m.status) + " " + helpStyle.Render(contextHints(m))
}
return renderStatusBar(m, m.width)
}