From 4e0ac8402f51c643f25801959956295da6d3a76a Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Wed, 20 May 2026 11:01:13 -0400 Subject: [PATCH] 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. --- internal/tui/commands.go | 8 +++++++ internal/tui/input.go | 5 +++- internal/tui/model.go | 45 +++++++++++++++++++---------------- internal/tui/statusbar.go | 50 ++++++++++++++++++++++++++------------- internal/tui/styles.go | 7 ++++++ 5 files changed, 77 insertions(+), 38 deletions(-) diff --git a/internal/tui/commands.go b/internal/tui/commands.go index 5e930f7..17155df 100644 --- a/internal/tui/commands.go +++ b/internal/tui/commands.go @@ -58,6 +58,8 @@ type tagsLoadedMsg struct { tags []db.TagCount } +type statusClearMsg struct{} + type editorFinishedMsg struct { err error } @@ -267,3 +269,9 @@ func copyResolved(store *db.Store, entityID string, resolved string) tea.Cmd { return templateCopiedMsg{} } } + +func clearStatusAfter(d time.Duration) tea.Cmd { + return tea.Tick(d, func(time.Time) tea.Msg { + return statusClearMsg{} + }) +} diff --git a/internal/tui/input.go b/internal/tui/input.go index 52027ff..b782107 100644 --- a/internal/tui/input.go +++ b/internal/tui/input.go @@ -107,7 +107,10 @@ func (i inputModel) view(width int) string { b.WriteString("\n") b.WriteString(i.ti.View()) b.WriteString("\n") - b.WriteString(drawerHintsStyle.Render("enter:submit esc:cancel ?:search -:todo @:event !:reminder")) + b.WriteString(drawerHintsStyle.Render(renderHints([]hint{ + {"enter", "submit"}, {"esc", "cancel"}, {"?", "search"}, + {"-", "todo"}, {"@", "event"}, {"!", "reminder"}, + }))) b.WriteString("\n") b.WriteString(i.renderPreview(width)) return b.String() diff --git a/internal/tui/model.go b/internal/tui/model.go index a5e7c5b..cbe06f9 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -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) } diff --git a/internal/tui/statusbar.go b/internal/tui/statusbar.go index bdbfb09..609d7cb 100644 --- a/internal/tui/statusbar.go +++ b/internal/tui/statusbar.go @@ -2,24 +2,40 @@ 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 renderStatusBar(m model, width int) string { left := countText(m) - right := contextHints(m) + if m.status != "" { + left = m.status + } + right := renderHints(contextHints(m)) leftRendered := statusStyle.Render(left) - rightRendered := helpStyle.Render(right) - gap := width - lipgloss.Width(leftRendered) - lipgloss.Width(rightRendered) + gap := width - lipgloss.Width(leftRendered) - lipgloss.Width(right) if gap < 0 { gap = 0 } pad := lipgloss.NewStyle().Width(gap).Render("") - return leftRendered + pad + rightRendered + return leftRendered + pad + right } func countText(m model) string { @@ -35,37 +51,37 @@ func countText(m model) string { return fmt.Sprintf("%d entities", total) } -func contextHints(m model) string { +func contextHints(m model) []hint { switch m.state { case stateDetail: switch m.detail.mode { case detailRun: - return "space:toggle j/k:nav r:reset esc:save+exit" + return []hint{{"space", "toggle"}, {"j/k", "nav"}, {"r", "reset"}, {"esc", "save+exit"}} case detailFill: - return "tab:next shift+tab:prev enter:copy esc:cancel" + return []hint{{"tab", "next"}, {"⇧tab", "prev"}, {"enter", "copy"}, {"esc", "cancel"}} default: - return "p:promote D:demote c:copy e:edit r:run f:fill !:pin esc:back" + return []hint{{"p", "promote"}, {"D", "demote"}, {"c", "copy"}, {"e", "edit"}, {"r", "run"}, {"f", "fill"}, {"!", "pin"}, {"esc", "back"}} } case stateInput: - return "" + return nil case stateTagFilter: - return "j/k:nav enter:select esc:cancel" + return []hint{{"j/k", "nav"}, {"enter", "select"}, {"esc", "cancel"}} case stateConfirm: - return "y:confirm n:cancel" + return []hint{{"y", "confirm"}, {"n", "cancel"}} case statePromote: - return "j/k:nav enter:select esc:cancel" + return []hint{{"j/k", "nav"}, {"enter", "select"}, {"esc", "cancel"}} case stateAbsorb: - return "j/k:nav enter:absorb esc:cancel" + return []hint{{"j/k", "nav"}, {"enter", "absorb"}, {"esc", "cancel"}} default: if m.splitDetail { if m.focus == focusDetail { - return "h:list c:copy e:edit p:promote D:demote !:pin esc:back" + return []hint{{"h", "list"}, {"c", "copy"}, {"e", "edit"}, {"p", "promote"}, {"D", "demote"}, {"!", "pin"}, {"esc", "back"}} } - return "l:detail a:add d:del #:filter esc:close ?:help q:quit" + return []hint{{"l", "detail"}, {"a", "add"}, {"d", "del"}, {"#", "filter"}, {"esc", "close"}, {"?", "help"}, {"q", "quit"}} } if m.mode == modeCards { - return "1:stream 2:cards s:sort tab:intent a:add ?:help q:quit" + return []hint{{"1", "stream"}, {"2", "cards"}, {"s", "sort"}, {"tab", "intent"}, {"a", "add"}, {"?", "help"}, {"q", "quit"}} } - return "1:stream 2:cards a:add/?search m:absorb d:del #:filter ?:help q:quit" + return []hint{{"1", "stream"}, {"2", "cards"}, {"a", "add"}, {"?", "search"}, {"m", "absorb"}, {"d", "del"}, {"#", "filter"}, {"?", "help"}, {"q", "quit"}} } } diff --git a/internal/tui/styles.go b/internal/tui/styles.go index ef837a2..19c1773 100644 --- a/internal/tui/styles.go +++ b/internal/tui/styles.go @@ -122,4 +122,11 @@ var ( separatorStyle = lipgloss.NewStyle(). Foreground(dim) + + hintKeyStyle = lipgloss.NewStyle(). + Foreground(highlight). + Bold(true) + + hintDescStyle = lipgloss.NewStyle(). + Foreground(dim) )