feat(tui): add bubbletea terminal UI #30

Merged
lerko merged 12 commits from feat/tui into main 2026-05-20 01:16:57 +00:00
7 changed files with 413 additions and 95 deletions
Showing only changes of commit f89ca8acb9 - Show all commits
+5
View File
@@ -50,6 +50,11 @@ func renderHelp(width, height int) string {
{"enter", "copy resolved"}, {"enter", "copy resolved"},
{"esc", "cancel"}, {"esc", "cancel"},
}}, }},
{"Split View", [][2]string{
{"l", "focus detail pane"},
{"h", "focus list pane"},
{"esc", "close detail / back"},
}},
{"Global", [][2]string{ {"Global", [][2]string{
{"?", "toggle help"}, {"?", "toggle help"},
{"q / ctrl+c", "quit"}, {"q / ctrl+c", "quit"},
+100 -1
View File
@@ -1,6 +1,9 @@
package tui package tui
import ( import (
"fmt"
"strings"
"github.com/charmbracelet/bubbles/textinput" "github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
@@ -18,6 +21,7 @@ type inputResult struct {
type inputModel struct { type inputModel struct {
ti textinput.Model ti textinput.Model
active bool active bool
preview *parse.Result
} }
func newInputModel() inputModel { func newInputModel() inputModel {
@@ -37,6 +41,7 @@ func (i *inputModel) reset() {
i.active = false i.active = false
i.ti.SetValue("") i.ti.SetValue("")
i.ti.Blur() i.ti.Blur()
i.preview = nil
} }
func (i inputModel) submit() *inputResult { func (i inputModel) submit() *inputResult {
@@ -83,9 +88,103 @@ func (i inputModel) submit() *inputResult {
func (i inputModel) updateKey(msg tea.KeyMsg) inputModel { func (i inputModel) updateKey(msg tea.KeyMsg) inputModel {
i.ti, _ = i.ti.Update(msg) i.ti, _ = i.ti.Update(msg)
val := i.ti.Value()
if val != "" {
parsed, err := parse.Parse(val)
if err == nil {
i.preview = parsed
} else {
i.preview = nil
}
} else {
i.preview = nil
}
return i return i
} }
func (i inputModel) view(width int) string { func (i inputModel) view(width int) string {
return i.ti.View() var b strings.Builder
b.WriteString(drawerBorderStyle.Render(strings.Repeat("─", width)))
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("\n")
b.WriteString(i.renderPreview(width))
return b.String()
}
func (i inputModel) renderPreview(width int) string {
if i.preview == nil {
return drawerPreviewStyle.Render("")
}
p := i.preview
if p.Query {
q := "?"
if p.Body != "" {
q += p.Body
}
for _, t := range p.FilterTags {
q += " #" + t
}
return drawerPreviewStyle.Render("search: " + q)
}
glyph := glyphForParsed(p.Glyph)
body := p.Body
if p.Title != nil {
body = *p.Title
}
var parts []string
parts = append(parts, glyph, body)
for _, t := range p.Tags {
parts = append(parts, tagStyle.Render("#"+t))
}
if p.Pin {
parts = append(parts, pinnedStyle.Render("•"))
}
if p.CardSuffix != nil {
parts = append(parts, affordanceStyle.Render(*p.CardSuffix))
}
line := strings.Join(parts, " ")
maxW := width - 4
if maxW > 0 && len(stripAnsi(line)) > maxW {
line = truncate(line, maxW)
}
return drawerPreviewStyle.Render(line)
}
func glyphForParsed(glyph string) string {
switch glyph {
case "todo":
return "○"
case "event":
return "◇"
case "reminder":
return "△"
default:
return "—"
}
}
func drawerLines() int {
return 3
}
// formatPreviewEntity builds a preview string showing how the entity will appear
func formatPreviewEntity(p *parse.Result) string {
if p == nil {
return ""
}
glyph := glyphForParsed(p.Glyph)
body := p.Body
if p.Title != nil {
body = *p.Title
}
return fmt.Sprintf("%s %s", glyph, body)
} }
+4
View File
@@ -29,6 +29,8 @@ type keyMap struct {
Absorb key.Binding Absorb key.Binding
Run key.Binding Run key.Binding
Fill key.Binding Fill key.Binding
FocusLeft key.Binding
FocusRight key.Binding
} }
var keys = keyMap{ var keys = keyMap{
@@ -58,4 +60,6 @@ var keys = keyMap{
Absorb: key.NewBinding(key.WithKeys("m"), key.WithHelp("m", "absorb")), Absorb: key.NewBinding(key.WithKeys("m"), key.WithHelp("m", "absorb")),
Run: key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "run checklist")), Run: key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "run checklist")),
Fill: key.NewBinding(key.WithKeys("f"), key.WithHelp("f", "fill template")), Fill: key.NewBinding(key.WithKeys("f"), key.WithHelp("f", "fill template")),
FocusLeft: key.NewBinding(key.WithKeys("h"), key.WithHelp("h", "focus list")),
FocusRight: key.NewBinding(key.WithKeys("l"), key.WithHelp("l", "focus detail")),
} }
+22 -30
View File
@@ -93,6 +93,8 @@ func (l listModel) update(msg tea.KeyMsg) listModel {
return l return l
} }
const dateGutterWidth = 9
func (l listModel) view(width int) string { func (l listModel) view(width int) string {
ents := l.displayEntities() ents := l.displayEntities()
if len(ents) == 0 { if len(ents) == 0 {
@@ -100,22 +102,24 @@ func (l listModel) view(width int) string {
} }
groups := groupByDate(ents) groups := groupByDate(ents)
entityWidth := width - 4 - dateGutterWidth
type displayLine struct { type displayLine struct {
text string text string
entityIdx int entityIdx int
isHeader bool
} }
var lines []displayLine var lines []displayLine
entityIdx := 0 entityIdx := 0
for _, g := range groups { for _, g := range groups {
lines = append(lines, displayLine{ for i, e := range g.entities {
text: dateHeaderStyle.Render("── " + g.label + " ──"), var gutter string
isHeader: true, if i == 0 {
}) gutter = gutterStyle.Render(padRight(g.label, 6) + " │ ")
for _, e := range g.entities { } else {
line := renderEntity(e, width-4) gutter = gutterStyle.Render(" │ ")
}
line := gutter + renderEntity(e, entityWidth)
lines = append(lines, displayLine{ lines = append(lines, displayLine{
text: line, text: line,
entityIdx: entityIdx, entityIdx: entityIdx,
@@ -124,21 +128,17 @@ func (l listModel) view(width int) string {
} }
} }
cursorLine := l.cursorDisplayLine(groups)
visible := l.visibleCount() visible := l.visibleCount()
offset := 0 offset := 0
if cursorLine >= visible { if l.cursor >= visible {
offset = cursorLine - visible + 1 offset = l.cursor - visible + 1
} }
var b strings.Builder var b strings.Builder
end := min(offset+visible, len(lines)) end := min(offset+visible, len(lines))
for i := offset; i < end; i++ { for i := offset; i < end; i++ {
dl := lines[i] dl := lines[i]
if dl.isHeader { if dl.entityIdx == l.cursor {
b.WriteString(dl.text)
} else if dl.entityIdx == l.cursor {
b.WriteString(selectedItemStyle.Render(" " + dl.text)) b.WriteString(selectedItemStyle.Render(" " + dl.text))
} else { } else {
b.WriteString(listItemStyle.Render(dl.text)) b.WriteString(listItemStyle.Render(dl.text))
@@ -151,22 +151,6 @@ func (l listModel) view(width int) string {
return b.String() return b.String()
} }
func (l listModel) cursorDisplayLine(groups []dateGroup) int {
line := 0
entityIdx := 0
for _, g := range groups {
line++
for range g.entities {
if entityIdx == l.cursor {
return line
}
line++
entityIdx++
}
}
return 0
}
func (l listModel) visibleCount() int { func (l listModel) visibleCount() int {
if l.height <= 0 { if l.height <= 0 {
return 20 return 20
@@ -203,6 +187,14 @@ func formatDateLabel(t time.Time) string {
return strings.ToLower(t.Format("Jan 2")) return strings.ToLower(t.Format("Jan 2"))
} }
func padRight(s string, n int) string {
r := []rune(s)
if len(r) >= n {
return string(r[:n])
}
return s + strings.Repeat(" ", n-len(r))
}
func renderEntity(e *db.Entity, maxWidth int) string { func renderEntity(e *db.Entity, maxWidth int) string {
glyphStr := display.DisplayGlyph(e.Glyph, e.CardType) glyphStr := display.DisplayGlyph(e.Glyph, e.CardType)
style := glyphStyle style := glyphStyle
+203 -8
View File
@@ -2,8 +2,10 @@ package tui
import ( import (
"fmt" "fmt"
"strings"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/lerko/nib/internal/db" "github.com/lerko/nib/internal/db"
) )
@@ -57,6 +59,13 @@ func (s cardsSort) next() cardsSort {
} }
} }
type focusPane int
const (
focusList focusPane = iota
focusDetail
)
type model struct { type model struct {
store *db.Store store *db.Store
state viewState state viewState
@@ -73,6 +82,9 @@ type model struct {
absorb absorbModel absorb absorbModel
showHelp bool showHelp bool
focus focusPane
splitDetail bool
filterTag string filterTag string
confirmID string confirmID string
cardsSort cardsSort cardsSort cardsSort
@@ -155,10 +167,12 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tea.WindowSizeMsg: case tea.WindowSizeMsg:
m.width = msg.Width m.width = msg.Width
m.height = msg.Height m.height = msg.Height
m.list.setSize(m.width, m.contentHeight()) if !m.isSplit() && m.splitDetail {
m.cards.setSize(m.width, m.contentHeight()) m.state = stateDetail
m.detail.setSize(m.width, m.contentHeight()) m.splitDetail = false
m.filter.setHeight(m.contentHeight()) m.focus = focusList
}
m.recalcSizes()
return m, nil return m, nil
case entitiesLoadedMsg: case entitiesLoadedMsg:
@@ -176,6 +190,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case entityCreatedMsg: case entityCreatedMsg:
m.state = stateList m.state = stateList
m.input.reset() m.input.reset()
m.recalcSizes()
m.status = "created" m.status = "created"
return m, loadEntities(m.store, m.listParams()) return m, loadEntities(m.store, m.listParams())
@@ -279,6 +294,91 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, nil return m, nil
} }
if m.splitDetail && m.state == stateList {
switch msg.String() {
case "l":
if m.focus == focusList {
m.focus = focusDetail
return m, nil
}
case "h":
if m.focus == focusDetail {
m.focus = focusList
return m, nil
}
case "esc":
if m.focus == focusDetail {
m.focus = focusList
return m, nil
}
m.splitDetail = false
m.recalcSizes()
return m, nil
}
if m.focus == focusDetail {
switch msg.String() {
case "j", "k", "up", "down", "pgup", "pgdown", "ctrl+u", "ctrl+d":
var cmd tea.Cmd
m.detail, cmd = m.detail.update(msg)
return m, cmd
case "c":
if m.detail.entity != nil {
return m, copyToClipboard(m.store, m.detail.entity)
}
return m, nil
case "e":
if m.detail.entity != nil && m.detail.mode == detailPreview {
return m, editInEditor(m.store, m.detail.entity)
}
return m, nil
case "p":
if m.detail.entity != nil && m.detail.entity.CardType == nil {
m.promote = newPromoteModel(m.detail.entity.ID, m.detail.entity.Body)
m.state = statePromote
m.splitDetail = false
m.recalcSizes()
return m, nil
}
return m, nil
case "D":
if m.detail.entity != nil && m.detail.entity.CardType != nil {
return m, demoteEntity(m.store, m.detail.entity.ID)
}
return m, nil
case "!":
if m.detail.entity != nil {
return m, pinEntity(m.store, m.detail.entity)
}
return m, nil
case "r":
if m.detail.entity != nil && m.detail.mode == detailPreview {
if m.detail.entity.CardType != nil && *m.detail.entity.CardType == db.CardChecklist {
m.detail.run = newRunModel(m.detail.entity.ID, m.detail.entity.CardData)
m.detail.mode = detailRun
m.splitDetail = false
m.state = stateDetail
m.recalcSizes()
return m, nil
}
}
return m, nil
case "f":
if m.detail.entity != nil && m.detail.mode == detailPreview {
if m.detail.entity.CardType != nil && *m.detail.entity.CardType == db.CardTemplate {
m.detail.fill = newFillModel(m.detail.entity.ID, m.detail.entity.Body)
m.detail.mode = detailFill
m.splitDetail = false
m.state = stateDetail
m.recalcSizes()
return m, m.detail.fill.ti.Focus()
}
}
return m, nil
}
}
}
switch msg.String() { switch msg.String() {
case "ctrl+c": case "ctrl+c":
return m, tea.Quit return m, tea.Quit
@@ -333,6 +433,7 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
if m.state == stateList { if m.state == stateList {
m.state = stateInput m.state = stateInput
m.input.focus() m.input.focus()
m.recalcSizes()
return m, m.input.ti.Focus() return m, m.input.ti.Focus()
} }
@@ -344,10 +445,29 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
cmd = persistSteps(m.store, m.detail.run.entityID, m.detail.run.stepsJSON()) cmd = persistSteps(m.store, m.detail.run.entityID, m.detail.run.stepsJSON())
} }
m.detail.mode = detailPreview m.detail.mode = detailPreview
if m.isSplit() {
m.state = stateList
m.splitDetail = true
m.focus = focusList
m.recalcSizes()
}
return m, cmd return m, cmd
} }
if m.detail.mode == detailFill { if m.detail.mode == detailFill {
m.detail.mode = detailPreview m.detail.mode = detailPreview
if m.isSplit() {
m.state = stateList
m.splitDetail = true
m.focus = focusList
m.recalcSizes()
}
return m, nil
}
if m.isSplit() {
m.state = stateList
m.splitDetail = true
m.focus = focusList
m.recalcSizes()
return m, nil return m, nil
} }
m.state = stateList m.state = stateList
@@ -482,9 +602,15 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
if m.state == stateList { if m.state == stateList {
if e := m.selectedEntity(); e != nil { if e := m.selectedEntity(); e != nil {
m.detail.setEntity(e) m.detail.setEntity(e)
if m.isSplit() {
m.splitDetail = true
m.focus = focusDetail
m.recalcSizes()
} else {
m.state = stateDetail m.state = stateDetail
} }
} }
}
return m, nil return m, nil
} }
@@ -495,6 +621,11 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
} else { } else {
m.list = m.list.update(msg) m.list = m.list.update(msg)
} }
if m.splitDetail {
if e := m.selectedEntity(); e != nil {
m.detail.setEntity(e)
}
}
case stateDetail: case stateDetail:
var cmd tea.Cmd var cmd tea.Cmd
m.detail, cmd = m.detail.update(msg) m.detail, cmd = m.detail.update(msg)
@@ -508,6 +639,7 @@ func (m model) updateInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
case "esc": case "esc":
m.state = stateList m.state = stateList
m.input.reset() m.input.reset()
m.recalcSizes()
return m, nil return m, nil
case "enter": case "enter":
result := m.input.submit() result := m.input.submit()
@@ -519,6 +651,7 @@ func (m model) updateInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.searchTags = result.tags m.searchTags = result.tags
m.state = stateList m.state = stateList
m.input.reset() m.input.reset()
m.recalcSizes()
m.applySearch() m.applySearch()
return m, nil return m, nil
} }
@@ -601,10 +734,16 @@ func (m model) View() string {
var content string var content string
switch m.state { switch m.state {
case stateList, stateInput, stateConfirm: case stateList, stateInput, stateConfirm:
if m.mode == modeCards { listContent := m.listContent()
content = m.cards.view(m.width) if m.splitDetail {
lw, rw := m.splitWidths()
ch := m.contentHeight()
left := lipgloss.NewStyle().Width(lw).Height(ch).Render(listContent)
sep := m.renderSeparator()
right := lipgloss.NewStyle().Width(rw).Height(ch).Render(m.detail.view(rw))
content = lipgloss.JoinHorizontal(lipgloss.Top, left, sep, right)
} else { } else {
content = m.list.view(m.width) content = listContent
} }
case stateDetail: case stateDetail:
content = m.detail.view(m.width) content = m.detail.view(m.width)
@@ -622,6 +761,21 @@ func (m model) View() string {
return header + "\n" + content + "\n" + footer return header + "\n" + content + "\n" + footer
} }
func (m model) listContent() string {
if m.mode == modeCards {
lw := m.width
if m.splitDetail {
lw, _ = m.splitWidths()
}
return m.cards.view(lw)
}
lw := m.width
if m.splitDetail {
lw, _ = m.splitWidths()
}
return m.list.view(lw)
}
func (m model) headerView() string { func (m model) headerView() string {
header := titleStyle.Render("nib") header := titleStyle.Render("nib")
@@ -678,7 +832,48 @@ func (m model) footerView() string {
} }
func (m model) contentHeight() int { func (m model) contentHeight() int {
return m.height - 3 return m.height - 3 - m.drawerHeight()
}
func (m model) drawerHeight() int {
if m.state == stateInput {
return drawerLines()
}
return 0
}
func (m *model) recalcSizes() {
ch := m.contentHeight()
if m.isSplit() && m.splitDetail {
lw, rw := m.splitWidths()
m.list.setSize(lw, ch)
m.cards.setSize(lw, ch)
m.detail.setSize(rw, ch)
} else {
m.list.setSize(m.width, ch)
m.cards.setSize(m.width, ch)
m.detail.setSize(m.width, ch)
}
m.filter.setHeight(ch)
}
func (m model) isSplit() bool {
return m.width >= 100
}
func (m model) splitWidths() (int, int) {
left := m.width * 40 / 100
right := m.width - left - 1
return left, right
}
func (m model) renderSeparator() string {
ch := m.contentHeight()
lines := make([]string, ch)
for i := range lines {
lines[i] = "│"
}
return separatorStyle.Render(strings.Join(lines, "\n"))
} }
func (m model) selectedEntity() *db.Entity { func (m model) selectedEntity() *db.Entity {
+7 -1
View File
@@ -47,7 +47,7 @@ func contextHints(m model) string {
return "p:promote D:demote c:copy e:edit r:run f:fill !:pin esc:back" return "p:promote D:demote c:copy e:edit r:run f:fill !:pin esc:back"
} }
case stateInput: case stateInput:
return "enter:submit esc:cancel" return ""
case stateTagFilter: case stateTagFilter:
return "j/k:nav enter:select esc:cancel" return "j/k:nav enter:select esc:cancel"
case stateConfirm: case stateConfirm:
@@ -57,6 +57,12 @@ func contextHints(m model) string {
case stateAbsorb: case stateAbsorb:
return "j/k:nav enter:absorb esc:cancel" return "j/k:nav enter:absorb esc:cancel"
default: default:
if m.splitDetail {
if m.focus == focusDetail {
return "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"
}
if m.mode == modeCards { if m.mode == modeCards {
return "1:stream 2:cards s:sort tab:intent a:add ?:help q:quit" return "1:stream 2:cards s:sort tab:intent a:add ?:help q:quit"
} }
+17
View File
@@ -105,4 +105,21 @@ var (
searchPillStyle = lipgloss.NewStyle(). searchPillStyle = lipgloss.NewStyle().
Foreground(lipgloss.AdaptiveColor{Light: "#E06C75", Dark: "#E06C75"}). Foreground(lipgloss.AdaptiveColor{Light: "#E06C75", Dark: "#E06C75"}).
Bold(true) Bold(true)
gutterStyle = lipgloss.NewStyle().
Foreground(dim)
drawerBorderStyle = lipgloss.NewStyle().
Foreground(dim)
drawerHintsStyle = lipgloss.NewStyle().
Foreground(dim).
PaddingLeft(2)
drawerPreviewStyle = lipgloss.NewStyle().
Foreground(lipgloss.AdaptiveColor{Light: "#555555", Dark: "#AAAAAA"}).
PaddingLeft(2)
separatorStyle = lipgloss.NewStyle().
Foreground(dim)
) )