feat(tui): add bubbletea terminal UI with entity list, detail, and capture

Adds `nib tui` command and `make tui` target. Scrollable entity list
with j/k navigation, enter for detail view, `a` to capture new entries
using the existing parse grammar, and `d` to delete.
This commit is contained in:
2026-05-17 20:07:45 -04:00
parent d995d1e708
commit 36999cd825
12 changed files with 789 additions and 2 deletions
+176
View File
@@ -0,0 +1,176 @@
package tui
import (
tea "github.com/charmbracelet/bubbletea"
"github.com/lerko/nib/internal/db"
)
type viewState int
const (
stateList viewState = iota
stateDetail
stateInput
)
type model struct {
store *db.Store
state viewState
width int
height int
list listModel
detail detailModel
input inputModel
status string
err error
}
func newModel(store *db.Store) model {
return model{
store: store,
state: stateList,
list: newListModel(),
detail: newDetailModel(),
input: newInputModel(),
}
}
func (m model) Init() tea.Cmd {
return loadEntities(m.store, db.DefaultListParams())
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
m.list.setSize(m.width, m.contentHeight())
m.detail.setSize(m.width, m.contentHeight())
return m, nil
case entitiesLoadedMsg:
m.list.setEntities(msg.entities)
m.status = ""
m.err = nil
return m, nil
case entityCreatedMsg:
m.state = stateList
m.input.reset()
m.status = "created"
return m, loadEntities(m.store, db.DefaultListParams())
case entityDeletedMsg:
m.status = "deleted"
return m, loadEntities(m.store, db.DefaultListParams())
case errMsg:
m.err = msg.err
return m, nil
case tea.KeyMsg:
if m.state == stateInput {
return m.updateInput(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":
return m, tea.Quit
case msg.String() == "a" && m.state == stateList:
m.state = stateInput
m.input.focus()
return m, m.input.ti.Focus()
case msg.String() == "esc":
if m.state == stateDetail {
m.state = stateList
}
return m, nil
case msg.String() == "enter" && 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)
}
return m, nil
}
switch m.state {
case stateList:
m.list = m.list.update(msg)
case stateDetail:
m.detail = m.detail.update(msg)
}
return m, nil
}
func (m model) updateInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "esc":
m.state = stateList
m.input.reset()
return m, nil
case "enter":
if e := m.input.submit(); e != nil {
return m, createEntity(m.store, e)
}
return m, nil
}
m.input = m.input.updateKey(msg)
return m, nil
}
func (m model) View() string {
var content string
switch m.state {
case stateList:
content = m.list.view(m.width)
case stateDetail:
content = m.detail.view(m.width)
case stateInput:
content = m.list.view(m.width)
}
header := titleStyle.Render("nib")
footer := m.footerView()
return header + "\n" + content + "\n" + footer
}
func (m model) footerView() string {
if m.state == stateInput {
return m.input.view(m.width)
}
if m.err != nil {
return errorStyle.Render("error: " + m.err.Error())
}
if m.status != "" {
return statusStyle.Render(m.status)
}
return helpStyle.Render("a:add enter:view d:delete q:quit ?:help")
}
func (m model) contentHeight() int {
return m.height - 3
}