From 36999cd825b28e1796656496887de46d643fd46a Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Sun, 17 May 2026 20:07:45 -0400 Subject: [PATCH 01/11] 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. --- Makefile | 5 +- cmd/tui.go | 25 ++++++ go.mod | 23 ++++- go.sum | 45 ++++++++++ internal/tui/commands.go | 51 ++++++++++++ internal/tui/detail.go | 106 +++++++++++++++++++++++ internal/tui/input.go | 76 +++++++++++++++++ internal/tui/keys.go | 33 ++++++++ internal/tui/list.go | 174 ++++++++++++++++++++++++++++++++++++++ internal/tui/model.go | 176 +++++++++++++++++++++++++++++++++++++++ internal/tui/styles.go | 57 +++++++++++++ internal/tui/tui.go | 20 +++++ 12 files changed, 789 insertions(+), 2 deletions(-) create mode 100644 cmd/tui.go create mode 100644 internal/tui/commands.go create mode 100644 internal/tui/detail.go create mode 100644 internal/tui/input.go create mode 100644 internal/tui/keys.go create mode 100644 internal/tui/list.go create mode 100644 internal/tui/model.go create mode 100644 internal/tui/styles.go create mode 100644 internal/tui/tui.go diff --git a/Makefile b/Makefile index f4e1981..fe9c81b 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,7 @@ BINARY := nib MODULE := github.com/lerko/nib GOFLAGS := -trimpath -.PHONY: build dev watch test lint fmt vet clean run cert help +.PHONY: build dev tui watch test lint fmt vet clean run cert help ## —— Build —————————————————————————————————— @@ -12,6 +12,9 @@ build: ## Build production binary dev: ## Build and run with default serve go run . serve +tui: ## Launch the terminal UI + go run . tui + watch: ## Live-reload dev server (requires air) air diff --git a/cmd/tui.go b/cmd/tui.go new file mode 100644 index 0000000..9d43eab --- /dev/null +++ b/cmd/tui.go @@ -0,0 +1,25 @@ +package cmd + +import ( + "github.com/lerko/nib/internal/tui" + "github.com/spf13/cobra" +) + +var tuiCmd = &cobra.Command{ + Use: "tui", + Short: "launch the terminal UI", + RunE: runTUI, +} + +func init() { + rootCmd.AddCommand(tuiCmd) +} + +func runTUI(_ *cobra.Command, _ []string) error { + store, err := openStore() + if err != nil { + return err + } + defer store.Close() + return tui.Run(store) +} diff --git a/go.mod b/go.mod index 9972a46..ec47185 100644 --- a/go.mod +++ b/go.mod @@ -11,15 +11,36 @@ require ( ) require ( + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/bubbles v1.0.0 // indirect + github.com/charmbracelet/bubbletea v1.3.10 // indirect + github.com/charmbracelet/colorprofile v0.4.1 // indirect + github.com/charmbracelet/lipgloss v1.1.0 // indirect + github.com/charmbracelet/x/ansi v0.11.6 // indirect + github.com/charmbracelet/x/cellbuf v0.0.15 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.9.0 // indirect + github.com/clipperhouse/stringish v0.1.1 // indirect + github.com/clipperhouse/uax29/v2 v2.5.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/google/uuid v1.6.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/rivo/uniseg v0.4.7 // indirect github.com/spf13/pflag v1.0.9 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect - golang.org/x/sys v0.33.0 // indirect + golang.org/x/sys v0.38.0 // indirect + golang.org/x/text v0.3.8 // indirect modernc.org/libc v1.65.7 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect diff --git a/go.sum b/go.sum index d394f26..696989a 100644 --- a/go.sum +++ b/go.sum @@ -1,8 +1,32 @@ github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= +github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= +github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= +github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= +github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= +github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= +github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= +github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= +github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= +github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= +github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= @@ -11,8 +35,20 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s= @@ -20,11 +56,15 @@ github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNs github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM= golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8= @@ -32,9 +72,14 @@ golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc= golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/tui/commands.go b/internal/tui/commands.go new file mode 100644 index 0000000..7a14e8d --- /dev/null +++ b/internal/tui/commands.go @@ -0,0 +1,51 @@ +package tui + +import ( + tea "github.com/charmbracelet/bubbletea" + + "github.com/lerko/nib/internal/db" +) + +type entitiesLoadedMsg struct { + entities []*db.Entity +} + +type entityCreatedMsg struct { + entity *db.Entity +} + +type entityDeletedMsg struct { + id string +} + +type errMsg struct { + err error +} + +func loadEntities(store *db.Store, params db.ListParams) tea.Cmd { + return func() tea.Msg { + entities, err := store.List(params) + if err != nil { + return errMsg{err} + } + return entitiesLoadedMsg{entities} + } +} + +func createEntity(store *db.Store, e *db.Entity) tea.Cmd { + return func() tea.Msg { + if err := store.Create(e); err != nil { + return errMsg{err} + } + return entityCreatedMsg{e} + } +} + +func deleteEntity(store *db.Store, id string) tea.Cmd { + return func() tea.Msg { + if _, err := store.SoftDelete(id); err != nil { + return errMsg{err} + } + return entityDeletedMsg{id} + } +} diff --git a/internal/tui/detail.go b/internal/tui/detail.go new file mode 100644 index 0000000..2bf1073 --- /dev/null +++ b/internal/tui/detail.go @@ -0,0 +1,106 @@ +package tui + +import ( + "fmt" + "strings" + "time" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/lerko/nib/internal/db" + "github.com/lerko/nib/internal/display" +) + +type detailModel struct { + entity *db.Entity + scroll int + height int + width int +} + +func newDetailModel() detailModel { + return detailModel{} +} + +func (d *detailModel) setEntity(e *db.Entity) { + d.entity = e + d.scroll = 0 +} + +func (d *detailModel) setSize(width, height int) { + d.width = width + d.height = height +} + +func (d detailModel) update(msg tea.KeyMsg) detailModel { + switch msg.String() { + case "up", "k": + if d.scroll > 0 { + d.scroll-- + } + case "down", "j": + d.scroll++ + } + return d +} + +func (d detailModel) view(width int) string { + if d.entity == nil { + return "" + } + + e := d.entity + var b strings.Builder + + glyph := display.DisplayGlyph(e.Glyph, e.CardType) + header := fmt.Sprintf("%s %s", glyph, display.FormatID(e.ID)) + b.WriteString(detailHeaderStyle.Render(header)) + b.WriteString("\n\n") + + if e.Title != nil { + b.WriteString(detailBodyStyle.Render("title: " + *e.Title)) + b.WriteString("\n") + } + + b.WriteString(detailBodyStyle.Render(e.Body)) + b.WriteString("\n") + + if len(e.Tags) > 0 { + tagParts := make([]string, len(e.Tags)) + for i, t := range e.Tags { + tagParts[i] = tagStyle.Render("#" + t) + } + b.WriteString("\n") + b.WriteString(detailBodyStyle.Render(strings.Join(tagParts, " "))) + b.WriteString("\n") + } + + b.WriteString("\n") + meta := fmt.Sprintf("created %s", e.CreatedAt.Format(time.DateTime)) + if e.ModifiedAt != e.CreatedAt { + meta += fmt.Sprintf("\nmodified %s", e.ModifiedAt.Format(time.DateTime)) + } + if e.TimeAnchor != nil { + meta += fmt.Sprintf("\nanchored @%s", *e.TimeAnchor) + } + if e.Pinned { + meta += "\npinned" + } + if e.CardType != nil { + meta += fmt.Sprintf("\ncard %s", *e.CardType) + } + if e.CompletedAt != nil { + meta += fmt.Sprintf("\ndone %s", e.CompletedAt.Format(time.DateTime)) + } + b.WriteString(idStyle.Render(meta)) + + lines := strings.Split(b.String(), "\n") + if d.scroll > 0 && d.scroll < len(lines) { + lines = lines[d.scroll:] + } + if d.height > 0 && len(lines) > d.height { + lines = lines[:d.height] + } + + return strings.Join(lines, "\n") +} diff --git a/internal/tui/input.go b/internal/tui/input.go new file mode 100644 index 0000000..485f937 --- /dev/null +++ b/internal/tui/input.go @@ -0,0 +1,76 @@ +package tui + +import ( + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + + "github.com/lerko/nib/internal/db" + "github.com/lerko/nib/internal/parse" +) + +type inputModel struct { + ti textinput.Model + active bool +} + +func newInputModel() inputModel { + ti := textinput.New() + ti.Placeholder = "capture a thought…" + ti.Prompt = inputPromptStyle.Render("› ") + ti.CharLimit = 500 + return inputModel{ti: ti} +} + +func (i *inputModel) focus() { + i.active = true + i.ti.Focus() +} + +func (i *inputModel) reset() { + i.active = false + i.ti.SetValue("") + i.ti.Blur() +} + +func (i inputModel) submit() *db.Entity { + val := i.ti.Value() + if val == "" { + return nil + } + + parsed, err := parse.Parse(val) + if err != nil { + return nil + } + + e := &db.Entity{ + Body: parsed.Body, + Title: parsed.Title, + Glyph: db.Glyph(parsed.Glyph), + Tags: parsed.Tags, + } + if parsed.TimeAnchor != nil { + e.TimeAnchor = parsed.TimeAnchor + } + if parsed.CardSuffix != nil { + ct := db.CardType(*parsed.CardSuffix) + e.CardType = &ct + } + if parsed.Pin { + e.Pinned = true + } + if parsed.Description != nil { + e.Description = parsed.Description + } + + return e +} + +func (i inputModel) updateKey(msg tea.KeyMsg) inputModel { + i.ti, _ = i.ti.Update(msg) + return i +} + +func (i inputModel) view(width int) string { + return i.ti.View() +} diff --git a/internal/tui/keys.go b/internal/tui/keys.go new file mode 100644 index 0000000..b779956 --- /dev/null +++ b/internal/tui/keys.go @@ -0,0 +1,33 @@ +package tui + +import "github.com/charmbracelet/bubbles/key" + +type keyMap struct { + Up key.Binding + Down key.Binding + Enter key.Binding + Back key.Binding + Add key.Binding + Delete key.Binding + Quit key.Binding + Help key.Binding + PageUp key.Binding + PageDn key.Binding + Top key.Binding + Bottom key.Binding +} + +var keys = keyMap{ + Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "up")), + Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down")), + Enter: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "view")), + Back: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "back")), + Add: key.NewBinding(key.WithKeys("a"), key.WithHelp("a", "add")), + Delete: key.NewBinding(key.WithKeys("d"), key.WithHelp("d", "delete")), + Quit: key.NewBinding(key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "quit")), + Help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "help")), + PageUp: key.NewBinding(key.WithKeys("pgup", "ctrl+u"), key.WithHelp("pgup", "page up")), + PageDn: key.NewBinding(key.WithKeys("pgdown", "ctrl+d"), key.WithHelp("pgdn", "page down")), + Top: key.NewBinding(key.WithKeys("home", "g"), key.WithHelp("home", "top")), + Bottom: key.NewBinding(key.WithKeys("end", "G"), key.WithHelp("end", "bottom")), +} diff --git a/internal/tui/list.go b/internal/tui/list.go new file mode 100644 index 0000000..03f615f --- /dev/null +++ b/internal/tui/list.go @@ -0,0 +1,174 @@ +package tui + +import ( + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/lerko/nib/internal/db" + "github.com/lerko/nib/internal/display" +) + +type listModel struct { + entities []*db.Entity + cursor int + offset int + height int + width int +} + +func newListModel() listModel { + return listModel{} +} + +func (l *listModel) setEntities(entities []*db.Entity) { + l.entities = entities + if l.cursor >= len(entities) { + l.cursor = max(0, len(entities)-1) + } +} + +func (l *listModel) setSize(width, height int) { + l.width = width + l.height = height +} + +func (l listModel) selected() *db.Entity { + if len(l.entities) == 0 || l.cursor >= len(l.entities) { + return nil + } + return l.entities[l.cursor] +} + +func (l listModel) update(msg tea.KeyMsg) listModel { + switch msg.String() { + case "up", "k": + if l.cursor > 0 { + l.cursor-- + if l.cursor < l.offset { + l.offset = l.cursor + } + } + case "down", "j": + if l.cursor < len(l.entities)-1 { + l.cursor++ + visible := l.visibleCount() + if l.cursor >= l.offset+visible { + l.offset = l.cursor - visible + 1 + } + } + case "home", "g": + l.cursor = 0 + l.offset = 0 + case "end", "G": + l.cursor = max(0, len(l.entities)-1) + visible := l.visibleCount() + if l.cursor >= visible { + l.offset = l.cursor - visible + 1 + } + case "pgup", "ctrl+u": + l.cursor = max(0, l.cursor-l.visibleCount()) + if l.cursor < l.offset { + l.offset = l.cursor + } + case "pgdown", "ctrl+d": + l.cursor = min(len(l.entities)-1, l.cursor+l.visibleCount()) + visible := l.visibleCount() + if l.cursor >= l.offset+visible { + l.offset = l.cursor - visible + 1 + } + } + return l +} + +func (l listModel) view(width int) string { + if len(l.entities) == 0 { + return statusStyle.Render("no entities") + } + + var b strings.Builder + visible := l.visibleCount() + end := min(l.offset+visible, len(l.entities)) + + for i := l.offset; i < end; i++ { + e := l.entities[i] + line := renderEntity(e, width-4) + + if i == l.cursor { + b.WriteString(selectedItemStyle.Render(" " + line)) + } else { + b.WriteString(listItemStyle.Render(line)) + } + if i < end-1 { + b.WriteString("\n") + } + } + + return b.String() +} + +func (l listModel) visibleCount() int { + if l.height <= 0 { + return 20 + } + return l.height +} + +func renderEntity(e *db.Entity, maxWidth int) string { + glyph := glyphStyle.Render(display.DisplayGlyph(e.Glyph, e.CardType)) + id := idStyle.Render("[" + display.FormatID(e.ID) + "]") + + body := e.Body + if e.Title != nil { + body = *e.Title + } + + var tags string + if len(e.Tags) > 0 { + tagParts := make([]string, len(e.Tags)) + for i, t := range e.Tags { + tagParts[i] = tagStyle.Render("#" + t) + } + tags = " " + strings.Join(tagParts, " ") + } + + line := fmt.Sprintf("%s %s%s %s", glyph, body, tags, id) + + if maxWidth > 0 && len(stripAnsi(line)) > maxWidth { + body = truncate(body, maxWidth-20) + line = fmt.Sprintf("%s %s%s %s", glyph, body, tags, id) + } + + return line +} + +func truncate(s string, maxLen int) string { + if maxLen <= 3 { + return "…" + } + runes := []rune(s) + if len(runes) <= maxLen { + return s + } + return string(runes[:maxLen-1]) + "…" +} + +func stripAnsi(s string) string { + var b strings.Builder + inEsc := false + for _, r := range s { + if r == '\x1b' { + inEsc = true + continue + } + if inEsc { + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') { + inEsc = false + } + continue + } + b.WriteRune(r) + } + return b.String() +} diff --git a/internal/tui/model.go b/internal/tui/model.go new file mode 100644 index 0000000..30a6571 --- /dev/null +++ b/internal/tui/model.go @@ -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 +} diff --git a/internal/tui/styles.go b/internal/tui/styles.go new file mode 100644 index 0000000..97432ac --- /dev/null +++ b/internal/tui/styles.go @@ -0,0 +1,57 @@ +package tui + +import "github.com/charmbracelet/lipgloss" + +var ( + subtle = lipgloss.AdaptiveColor{Light: "#D9DCCF", Dark: "#383838"} + highlight = lipgloss.AdaptiveColor{Light: "#874BFD", Dark: "#7D56F4"} + dim = lipgloss.AdaptiveColor{Light: "#A49FA5", Dark: "#777777"} + + titleStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(highlight). + PaddingLeft(1) + + statusStyle = lipgloss.NewStyle(). + Foreground(dim). + PaddingLeft(1) + + listItemStyle = lipgloss.NewStyle(). + PaddingLeft(2) + + selectedItemStyle = lipgloss.NewStyle(). + PaddingLeft(1). + Bold(true). + Foreground(highlight). + SetString("›") + + glyphStyle = lipgloss.NewStyle(). + Width(2) + + tagStyle = lipgloss.NewStyle(). + Foreground(lipgloss.AdaptiveColor{Light: "#43BF6D", Dark: "#73F59F"}) + + idStyle = lipgloss.NewStyle(). + Foreground(dim) + + inputPromptStyle = lipgloss.NewStyle(). + Foreground(highlight). + Bold(true) + + detailHeaderStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(highlight). + MarginBottom(1) + + detailBodyStyle = lipgloss.NewStyle(). + PaddingLeft(2). + PaddingTop(1) + + helpStyle = lipgloss.NewStyle(). + Foreground(dim). + PaddingLeft(1) + + errorStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#FF0000")). + PaddingLeft(1) +) diff --git a/internal/tui/tui.go b/internal/tui/tui.go new file mode 100644 index 0000000..c29d615 --- /dev/null +++ b/internal/tui/tui.go @@ -0,0 +1,20 @@ +package tui + +import ( + "fmt" + "os" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/lerko/nib/internal/db" +) + +func Run(store *db.Store) error { + m := newModel(store) + p := tea.NewProgram(m, tea.WithAltScreen(), tea.WithMouseCellMotion()) + if _, err := p.Run(); err != nil { + fmt.Fprintln(os.Stderr, err) + return err + } + return nil +} From c2ea63dd160ce58612636ca02ca6f2ab066b75fa Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Sun, 17 May 2026 20:33:34 -0400 Subject: [PATCH 02/11] 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. --- internal/db/entities.go | 28 ++-- internal/tui/commands.go | 155 +++++++++++++++++++++ internal/tui/confirm.go | 23 ++++ internal/tui/filter.go | 84 +++++++++++ internal/tui/help.go | 59 ++++++++ internal/tui/keys.go | 62 +++++---- internal/tui/list.go | 118 ++++++++++++++-- internal/tui/model.go | 283 ++++++++++++++++++++++++++++++++++---- internal/tui/statusbar.go | 46 +++++++ internal/tui/styles.go | 23 ++++ 10 files changed, 804 insertions(+), 77 deletions(-) create mode 100644 internal/tui/confirm.go create mode 100644 internal/tui/filter.go create mode 100644 internal/tui/help.go create mode 100644 internal/tui/statusbar.go diff --git a/internal/db/entities.go b/internal/db/entities.go index c257d7a..c3488f1 100644 --- a/internal/db/entities.go +++ b/internal/db/entities.go @@ -89,16 +89,18 @@ func DefaultListParams() ListParams { } type EntityUpdate struct { - Body *string - Title *string - Description *string - Glyph *Glyph - TimeAnchor *string - ClearTime bool - Pinned *bool - CardType *CardType - CardData *string - Tags *[]string + Body *string + Title *string + Description *string + Glyph *Glyph + TimeAnchor *string + ClearTime bool + CompletedAt *time.Time + ClearCompleted bool + Pinned *bool + CardType *CardType + CardData *string + Tags *[]string } func (s *Store) Create(e *Entity) error { @@ -311,6 +313,12 @@ func (s *Store) Update(id string, u *EntityUpdate) error { sets = append(sets, "time_anchor = ?") args = append(args, *u.TimeAnchor) } + if u.ClearCompleted { + sets = append(sets, "completed_at = NULL") + } else if u.CompletedAt != nil { + sets = append(sets, "completed_at = ?") + args = append(args, u.CompletedAt.Format(time.RFC3339)) + } if u.Pinned != nil { sets = append(sets, "pinned = ?") args = append(args, boolToInt(*u.Pinned)) diff --git a/internal/tui/commands.go b/internal/tui/commands.go index 7a14e8d..5b0ae8e 100644 --- a/internal/tui/commands.go +++ b/internal/tui/commands.go @@ -1,6 +1,11 @@ package tui import ( + "os" + "os/exec" + "time" + + "github.com/atotto/clipboard" tea "github.com/charmbracelet/bubbletea" "github.com/lerko/nib/internal/db" @@ -18,6 +23,29 @@ type entityDeletedMsg struct { id string } +type entityUpdatedMsg struct { + entity *db.Entity + action string +} + +type entityPromotedMsg struct { + id string +} + +type entityDemotedMsg struct { + id string +} + +type entityCopiedMsg struct{} + +type tagsLoadedMsg struct { + tags []db.TagCount +} + +type editorFinishedMsg struct { + err error +} + type errMsg struct { err error } @@ -49,3 +77,130 @@ func deleteEntity(store *db.Store, id string) tea.Cmd { return entityDeletedMsg{id} } } + +func toggleTodo(store *db.Store, e *db.Entity) tea.Cmd { + return func() tea.Msg { + var update db.EntityUpdate + if e.CompletedAt == nil { + now := time.Now().UTC() + update = db.EntityUpdate{CompletedAt: &now} + } else { + update = db.EntityUpdate{ClearCompleted: true} + } + + if err := store.Update(e.ID, &update); err != nil { + return errMsg{err} + } + updated, err := store.Get(e.ID) + if err != nil { + return errMsg{err} + } + action := "completed" + if e.CompletedAt != nil { + action = "reopened" + } + return entityUpdatedMsg{updated, action} + } +} + +func pinEntity(store *db.Store, e *db.Entity) tea.Cmd { + return func() tea.Msg { + newPinned := !e.Pinned + update := db.EntityUpdate{Pinned: &newPinned} + if err := store.Update(e.ID, &update); err != nil { + return errMsg{err} + } + updated, err := store.Get(e.ID) + if err != nil { + return errMsg{err} + } + action := "pinned" + if !newPinned { + action = "unpinned" + } + return entityUpdatedMsg{updated, action} + } +} + +func promoteEntity(store *db.Store, id string) tea.Cmd { + return func() tea.Msg { + if err := store.Promote(id, db.CardSnippet, nil); err != nil { + return errMsg{err} + } + return entityPromotedMsg{id} + } +} + +func demoteEntity(store *db.Store, id string) tea.Cmd { + return func() tea.Msg { + if err := store.Demote(id); err != nil { + return errMsg{err} + } + return entityDemotedMsg{id} + } +} + +func copyToClipboard(store *db.Store, e *db.Entity) tea.Cmd { + return func() tea.Msg { + if err := clipboard.WriteAll(e.Body); err != nil { + return errMsg{err} + } + if err := store.IncrementUse(e.ID); err != nil { + return errMsg{err} + } + return entityCopiedMsg{} + } +} + +func loadTags(store *db.Store) tea.Cmd { + return func() tea.Msg { + tags, err := store.ListTags(false) + if err != nil { + return errMsg{err} + } + return tagsLoadedMsg{tags} + } +} + +func editInEditor(store *db.Store, e *db.Entity) tea.Cmd { + editor := os.Getenv("EDITOR") + if editor == "" { + editor = "vi" + } + + f, err := os.CreateTemp("", "nib-edit-*.md") + if err != nil { + return func() tea.Msg { return errMsg{err} } + } + if _, err := f.WriteString(e.Body); err != nil { + f.Close() + os.Remove(f.Name()) + return func() tea.Msg { return errMsg{err} } + } + f.Close() + + c := exec.Command(editor, f.Name()) + return tea.ExecProcess(c, func(err error) tea.Msg { + defer os.Remove(f.Name()) + if err != nil { + return editorFinishedMsg{err} + } + + content, readErr := os.ReadFile(f.Name()) + if readErr != nil { + return editorFinishedMsg{readErr} + } + + newBody := string(content) + if newBody == e.Body { + return editorFinishedMsg{nil} + } + + update := db.EntityUpdate{Body: &newBody} + if updateErr := store.Update(e.ID, &update); updateErr != nil { + return editorFinishedMsg{updateErr} + } + + return editorFinishedMsg{nil} + }) +} diff --git a/internal/tui/confirm.go b/internal/tui/confirm.go new file mode 100644 index 0000000..84746f0 --- /dev/null +++ b/internal/tui/confirm.go @@ -0,0 +1,23 @@ +package tui + +import ( + "fmt" + "time" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/lerko/nib/internal/display" +) + +type confirmTimeoutMsg struct{} + +func confirmTimeout() tea.Cmd { + return tea.Tick(3*time.Second, func(time.Time) tea.Msg { + return confirmTimeoutMsg{} + }) +} + +func renderConfirm(entityID string) string { + short := display.FormatID(entityID) + return errorStyle.Render(fmt.Sprintf("delete %s? y to confirm, any key to cancel", short)) +} diff --git a/internal/tui/filter.go b/internal/tui/filter.go new file mode 100644 index 0000000..cb01f8e --- /dev/null +++ b/internal/tui/filter.go @@ -0,0 +1,84 @@ +package tui + +import ( + "fmt" + "strings" + + "github.com/lerko/nib/internal/db" +) + +type filterModel struct { + tags []db.TagCount + cursor int + height int +} + +func newFilterModel() filterModel { + return filterModel{} +} + +func (f *filterModel) setTags(tags []db.TagCount) { + f.tags = tags + f.cursor = 0 +} + +func (f *filterModel) setHeight(h int) { + f.height = h +} + +func (f filterModel) selectedTag() string { + if len(f.tags) == 0 || f.cursor >= len(f.tags) { + return "" + } + return f.tags[f.cursor].Tag +} + +func (f filterModel) update(key string) filterModel { + switch key { + case "up", "k": + if f.cursor > 0 { + f.cursor-- + } + case "down", "j": + if f.cursor < len(f.tags)-1 { + f.cursor++ + } + } + return f +} + +func (f filterModel) view(width int) string { + if len(f.tags) == 0 { + return statusStyle.Render("no tags") + } + + var b strings.Builder + b.WriteString(titleStyle.Render("filter by tag")) + b.WriteString("\n\n") + + visible := f.height - 4 + if visible <= 0 { + visible = 10 + } + + offset := 0 + if f.cursor >= visible { + offset = f.cursor - visible + 1 + } + end := min(offset+visible, len(f.tags)) + + for i := offset; i < end; i++ { + tc := f.tags[i] + tag := fmt.Sprintf("#%-20s %d", tc.Tag, tc.Count) + if i == f.cursor { + b.WriteString(selectedItemStyle.Render(" " + tagStyle.Render(tag))) + } else { + b.WriteString(listItemStyle.Render(tagStyle.Render(tag))) + } + if i < end-1 { + b.WriteString("\n") + } + } + + return b.String() +} diff --git a/internal/tui/help.go b/internal/tui/help.go new file mode 100644 index 0000000..74d565c --- /dev/null +++ b/internal/tui/help.go @@ -0,0 +1,59 @@ +package tui + +import "strings" + +func renderHelp(width, height int) string { + sections := []struct { + title string + binds [][2]string + }{ + {"Navigation", [][2]string{ + {"j/k ↑/↓", "move cursor"}, + {"g/G home/end", "top / bottom"}, + {"pgup/pgdn", "page up / down"}, + {"enter", "view detail"}, + {"esc", "back / cancel"}, + }}, + {"Actions", [][2]string{ + {"a", "add entity"}, + {"d", "delete (with confirm)"}, + {"x", "toggle todo completion"}, + {"!", "toggle pin"}, + {"#", "filter by tag"}, + }}, + {"Detail View", [][2]string{ + {"p", "promote to card"}, + {"D", "demote to fluid"}, + {"c", "copy to clipboard"}, + {"e", "edit in $EDITOR"}, + {"!", "toggle pin"}, + }}, + {"Global", [][2]string{ + {"?", "toggle help"}, + {"q / ctrl+c", "quit"}, + }}, + } + + var b strings.Builder + b.WriteString(detailHeaderStyle.Render("keybindings")) + b.WriteString("\n\n") + + for _, s := range sections { + b.WriteString(titleStyle.Render(s.title)) + b.WriteString("\n") + for _, bind := range s.binds { + key := helpKeyStyle.Render(bind[0]) + desc := helpDescStyle.Render(bind[1]) + b.WriteString(" " + key + " " + desc + "\n") + } + b.WriteString("\n") + } + + b.WriteString(helpStyle.Render("press ? or esc to close")) + + lines := strings.Split(b.String(), "\n") + if len(lines) > height { + lines = lines[:height] + } + return strings.Join(lines, "\n") +} diff --git a/internal/tui/keys.go b/internal/tui/keys.go index b779956..708599c 100644 --- a/internal/tui/keys.go +++ b/internal/tui/keys.go @@ -3,31 +3,45 @@ package tui import "github.com/charmbracelet/bubbles/key" type keyMap struct { - Up key.Binding - Down key.Binding - Enter key.Binding - Back key.Binding - Add key.Binding - Delete key.Binding - Quit key.Binding - Help key.Binding - PageUp key.Binding - PageDn key.Binding - Top key.Binding - Bottom key.Binding + Up key.Binding + Down key.Binding + Enter key.Binding + Back key.Binding + Add key.Binding + Delete key.Binding + Quit key.Binding + Help key.Binding + PageUp key.Binding + PageDn key.Binding + Top key.Binding + Bottom key.Binding + Todo key.Binding + Pin key.Binding + Filter key.Binding + Promote key.Binding + Demote key.Binding + Copy key.Binding + Edit key.Binding } var keys = keyMap{ - Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "up")), - Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down")), - Enter: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "view")), - Back: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "back")), - Add: key.NewBinding(key.WithKeys("a"), key.WithHelp("a", "add")), - Delete: key.NewBinding(key.WithKeys("d"), key.WithHelp("d", "delete")), - Quit: key.NewBinding(key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "quit")), - Help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "help")), - PageUp: key.NewBinding(key.WithKeys("pgup", "ctrl+u"), key.WithHelp("pgup", "page up")), - PageDn: key.NewBinding(key.WithKeys("pgdown", "ctrl+d"), key.WithHelp("pgdn", "page down")), - Top: key.NewBinding(key.WithKeys("home", "g"), key.WithHelp("home", "top")), - Bottom: key.NewBinding(key.WithKeys("end", "G"), key.WithHelp("end", "bottom")), + Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "up")), + Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down")), + Enter: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "view")), + Back: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "back")), + Add: key.NewBinding(key.WithKeys("a"), key.WithHelp("a", "add")), + Delete: key.NewBinding(key.WithKeys("d"), key.WithHelp("d", "delete")), + Quit: key.NewBinding(key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "quit")), + Help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "help")), + PageUp: key.NewBinding(key.WithKeys("pgup", "ctrl+u"), key.WithHelp("pgup", "page up")), + PageDn: key.NewBinding(key.WithKeys("pgdown", "ctrl+d"), key.WithHelp("pgdn", "page down")), + Top: key.NewBinding(key.WithKeys("home", "g"), key.WithHelp("home", "top")), + Bottom: key.NewBinding(key.WithKeys("end", "G"), key.WithHelp("end", "bottom")), + Todo: key.NewBinding(key.WithKeys("x"), key.WithHelp("x", "toggle todo")), + Pin: key.NewBinding(key.WithKeys("!"), key.WithHelp("!", "toggle pin")), + Filter: key.NewBinding(key.WithKeys("#"), key.WithHelp("#", "filter tag")), + Promote: key.NewBinding(key.WithKeys("p"), key.WithHelp("p", "promote")), + Demote: key.NewBinding(key.WithKeys("D"), key.WithHelp("D", "demote")), + Copy: key.NewBinding(key.WithKeys("c"), key.WithHelp("c", "copy")), + Edit: key.NewBinding(key.WithKeys("e"), key.WithHelp("e", "edit")), } diff --git a/internal/tui/list.go b/internal/tui/list.go index 03f615f..57462e7 100644 --- a/internal/tui/list.go +++ b/internal/tui/list.go @@ -3,6 +3,7 @@ package tui import ( "fmt" "strings" + "time" tea "github.com/charmbracelet/bubbletea" @@ -87,18 +88,49 @@ func (l listModel) view(width int) string { return statusStyle.Render("no entities") } - var b strings.Builder + groups := groupByDate(l.entities) + + type displayLine struct { + text string + entityIdx int + isHeader bool + } + + var lines []displayLine + entityIdx := 0 + for _, g := range groups { + lines = append(lines, displayLine{ + text: dateHeaderStyle.Render("── " + g.label + " ──"), + isHeader: true, + }) + for _, e := range g.entities { + line := renderEntity(e, width-4) + lines = append(lines, displayLine{ + text: line, + entityIdx: entityIdx, + }) + entityIdx++ + } + } + + cursorLine := l.cursorDisplayLine(groups) visible := l.visibleCount() - end := min(l.offset+visible, len(l.entities)) - for i := l.offset; i < end; i++ { - e := l.entities[i] - line := renderEntity(e, width-4) + offset := 0 + if cursorLine >= visible { + offset = cursorLine - visible + 1 + } - if i == l.cursor { - b.WriteString(selectedItemStyle.Render(" " + line)) + var b strings.Builder + end := min(offset+visible, len(lines)) + for i := offset; i < end; i++ { + dl := lines[i] + if dl.isHeader { + b.WriteString(dl.text) + } else if dl.entityIdx == l.cursor { + b.WriteString(selectedItemStyle.Render(" " + dl.text)) } else { - b.WriteString(listItemStyle.Render(line)) + b.WriteString(listItemStyle.Render(dl.text)) } if i < end-1 { b.WriteString("\n") @@ -108,6 +140,22 @@ func (l listModel) view(width int) 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 { if l.height <= 0 { return 20 @@ -115,8 +163,44 @@ func (l listModel) visibleCount() int { return l.height } +type dateGroup struct { + label string + entities []*db.Entity +} + +func groupByDate(entities []*db.Entity) []dateGroup { + var groups []dateGroup + var current *dateGroup + + for _, e := range entities { + label := formatDateLabel(e.CreatedAt) + if current == nil || current.label != label { + if current != nil { + groups = append(groups, *current) + } + current = &dateGroup{label: label} + } + current.entities = append(current.entities, e) + } + if current != nil { + groups = append(groups, *current) + } + return groups +} + +func formatDateLabel(t time.Time) string { + return strings.ToLower(t.Format("Jan 2")) +} + func renderEntity(e *db.Entity, maxWidth int) string { - glyph := glyphStyle.Render(display.DisplayGlyph(e.Glyph, e.CardType)) + glyphStr := display.DisplayGlyph(e.Glyph, e.CardType) + style := glyphStyle + if e.Glyph == db.GlyphTodo && e.CompletedAt != nil { + glyphStr = "●" + style = completedGlyphStyle + } + glyph := style.Render(glyphStr) + id := idStyle.Render("[" + display.FormatID(e.ID) + "]") body := e.Body @@ -124,20 +208,28 @@ func renderEntity(e *db.Entity, maxWidth int) string { body = *e.Title } - var tags string + var extras []string + if e.Pinned { + extras = append(extras, pinnedStyle.Render("•")) + } if len(e.Tags) > 0 { tagParts := make([]string, len(e.Tags)) for i, t := range e.Tags { tagParts[i] = tagStyle.Render("#" + t) } - tags = " " + strings.Join(tagParts, " ") + extras = append(extras, strings.Join(tagParts, " ")) } - line := fmt.Sprintf("%s %s%s %s", glyph, body, tags, id) + extraStr := "" + if len(extras) > 0 { + extraStr = " " + strings.Join(extras, " ") + } + + line := fmt.Sprintf("%s %s%s %s", glyph, body, extraStr, id) if maxWidth > 0 && len(stripAnsi(line)) > maxWidth { body = truncate(body, maxWidth-20) - line = fmt.Sprintf("%s %s%s %s", glyph, body, tags, id) + line = fmt.Sprintf("%s %s%s %s", glyph, body, extraStr, id) } return line diff --git a/internal/tui/model.go b/internal/tui/model.go index 30a6571..8696fbe 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -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) +} diff --git a/internal/tui/statusbar.go b/internal/tui/statusbar.go new file mode 100644 index 0000000..fca7441 --- /dev/null +++ b/internal/tui/statusbar.go @@ -0,0 +1,46 @@ +package tui + +import ( + "fmt" + + "github.com/charmbracelet/lipgloss" +) + +func renderStatusBar(m model, width int) string { + left := countText(m) + right := contextHints(m) + + leftRendered := statusStyle.Render(left) + rightRendered := helpStyle.Render(right) + + gap := width - lipgloss.Width(leftRendered) - lipgloss.Width(rightRendered) + if gap < 0 { + gap = 0 + } + + pad := lipgloss.NewStyle().Width(gap).Render("") + return leftRendered + pad + rightRendered +} + +func countText(m model) string { + total := len(m.list.entities) + if m.filterTag != "" { + return fmt.Sprintf("%d entities #%s", total, m.filterTag) + } + return fmt.Sprintf("%d entities", total) +} + +func contextHints(m model) string { + switch m.state { + case stateDetail: + return "p:promote D:demote c:copy e:edit !:pin esc:back" + case stateInput: + return "enter:submit esc:cancel" + case stateTagFilter: + return "j/k:nav enter:select esc:cancel" + case stateConfirm: + return "y:confirm n:cancel" + default: + return "a:add d:del x:todo #:filter ?:help q:quit" + } +} diff --git a/internal/tui/styles.go b/internal/tui/styles.go index 97432ac..e4f31d7 100644 --- a/internal/tui/styles.go +++ b/internal/tui/styles.go @@ -28,6 +28,10 @@ var ( glyphStyle = lipgloss.NewStyle(). Width(2) + completedGlyphStyle = lipgloss.NewStyle(). + Width(2). + Foreground(dim) + tagStyle = lipgloss.NewStyle(). Foreground(lipgloss.AdaptiveColor{Light: "#43BF6D", Dark: "#73F59F"}) @@ -54,4 +58,23 @@ var ( errorStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("#FF0000")). PaddingLeft(1) + + dateHeaderStyle = lipgloss.NewStyle(). + Foreground(dim). + PaddingLeft(1) + + pinnedStyle = lipgloss.NewStyle(). + Foreground(lipgloss.AdaptiveColor{Light: "#D4A017", Dark: "#FFD700"}) + + filterPillStyle = lipgloss.NewStyle(). + Foreground(lipgloss.AdaptiveColor{Light: "#43BF6D", Dark: "#73F59F"}). + Bold(true) + + helpKeyStyle = lipgloss.NewStyle(). + Foreground(highlight). + Bold(true). + Width(18) + + helpDescStyle = lipgloss.NewStyle(). + Foreground(dim) ) From ce335cabd6a5b541119933f4206ee33c8254a1be Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Sun, 17 May 2026 21:14:14 -0400 Subject: [PATCH 03/11] feat(tui): add cards view, mode switching, promote picker, and card detail Stream/cards toggle with 1/2 keys. Cards view with intent filtering (tab cycles grab/read/fill/all), sort cycling (s key), pinned-first ordering, and affordance badges. Promote picker (p key) with card type selection and auto-detection from body content. Detail view renders card_data per type: checklist steps, template slots, decision fields, link URLs. Extracts generateCardData to internal/carddata for reuse across cmd and tui packages. --- cmd/promote.go | 79 +----- internal/carddata/carddata.go | 102 +++++++ .../carddata/carddata_test.go | 20 +- internal/tui/cards.go | 262 ++++++++++++++++++ internal/tui/commands.go | 11 +- internal/tui/detail.go | 100 ++++++- internal/tui/help.go | 9 +- internal/tui/keys.go | 8 + internal/tui/model.go | 185 +++++++++++-- internal/tui/promote.go | 84 ++++++ internal/tui/statusbar.go | 14 +- internal/tui/styles.go | 24 ++ 12 files changed, 786 insertions(+), 112 deletions(-) create mode 100644 internal/carddata/carddata.go rename cmd/promote_test.go => internal/carddata/carddata_test.go (86%) create mode 100644 internal/tui/cards.go create mode 100644 internal/tui/promote.go diff --git a/cmd/promote.go b/cmd/promote.go index 37d60b0..7b99927 100644 --- a/cmd/promote.go +++ b/cmd/promote.go @@ -1,11 +1,9 @@ package cmd import ( - "encoding/json" "fmt" - "regexp" - "strings" + "github.com/lerko/nib/internal/carddata" "github.com/lerko/nib/internal/db" "github.com/lerko/nib/internal/display" "github.com/spf13/cobra" @@ -47,9 +45,9 @@ func runPromote(_ *cobra.Command, args []string) error { return err } - cardData := generateCardData(cardType, e.Body) + cd := carddata.GenerateCardData(cardType, e.Body) - if err := store.Promote(id, cardType, cardData); err != nil { + if err := store.Promote(id, cardType, cd); err != nil { if err == db.ErrAlreadyPromoted { return fmt.Errorf("invalid_promote — entity %s is already a %s", display.FormatID(id), *e.CardType) @@ -60,74 +58,3 @@ func runPromote(_ *cobra.Command, args []string) error { fmt.Printf("promoted %s → %s\n", display.FormatID(id), cardType) return nil } - -var templateSlotRe = regexp.MustCompile(`\$\{(\w+)\}`) - -func generateCardData(ct db.CardType, body string) *string { - var data string - switch ct { - case db.CardTemplate: - matches := templateSlotRe.FindAllStringSubmatch(body, -1) - type slot struct { - Name string `json:"name"` - Default string `json:"default"` - } - var slots []slot - seen := map[string]bool{} - for _, m := range matches { - name := m[1] - if !seen[name] { - slots = append(slots, slot{Name: name, Default: ""}) - seen[name] = true - } - } - if slots == nil { - slots = []slot{} - } - b, _ := json.Marshal(map[string]any{"slots": slots}) - data = string(b) - - case db.CardChecklist: - type step struct { - Text string `json:"text"` - Done bool `json:"done"` - } - var steps []step - for _, line := range strings.Split(body, "\n") { - line = strings.TrimSpace(line) - if strings.HasPrefix(line, "[ ]") || strings.HasPrefix(line, "[x]") { - text := strings.TrimSpace(line[3:]) - done := strings.HasPrefix(line, "[x]") - steps = append(steps, step{Text: text, Done: done}) - } - } - if steps == nil { - steps = []step{{Text: body, Done: false}} - } - b, _ := json.Marshal(map[string]any{"steps": steps}) - data = string(b) - - case db.CardDecision: - b, _ := json.Marshal(map[string]any{ - "chose": "", - "why": "", - "rejected": []string{}, - }) - data = string(b) - - case db.CardLink: - url := "" - for _, word := range strings.Fields(body) { - if strings.HasPrefix(word, "http://") || strings.HasPrefix(word, "https://") { - url = word - break - } - } - b, _ := json.Marshal(map[string]any{"url": url}) - data = string(b) - - default: - data = "{}" - } - return &data -} diff --git a/internal/carddata/carddata.go b/internal/carddata/carddata.go new file mode 100644 index 0000000..aba9607 --- /dev/null +++ b/internal/carddata/carddata.go @@ -0,0 +1,102 @@ +package carddata + +import ( + "encoding/json" + "regexp" + "strings" + + "github.com/lerko/nib/internal/db" +) + +var TemplateSlotRe = regexp.MustCompile(`\$\{(\w+)\}`) + +func GenerateCardData(ct db.CardType, body string) *string { + var data string + switch ct { + case db.CardTemplate: + matches := TemplateSlotRe.FindAllStringSubmatch(body, -1) + type slot struct { + Name string `json:"name"` + Default string `json:"default"` + } + var slots []slot + seen := map[string]bool{} + for _, m := range matches { + name := m[1] + if !seen[name] { + slots = append(slots, slot{Name: name, Default: ""}) + seen[name] = true + } + } + if slots == nil { + slots = []slot{} + } + b, _ := json.Marshal(map[string]any{"slots": slots}) + data = string(b) + + case db.CardChecklist: + type step struct { + Text string `json:"text"` + Done bool `json:"done"` + } + var steps []step + for _, line := range strings.Split(body, "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "[ ]") || strings.HasPrefix(line, "[x]") { + text := strings.TrimSpace(line[3:]) + done := strings.HasPrefix(line, "[x]") + steps = append(steps, step{Text: text, Done: done}) + } + } + if steps == nil { + steps = []step{{Text: body, Done: false}} + } + b, _ := json.Marshal(map[string]any{"steps": steps}) + data = string(b) + + case db.CardDecision: + b, _ := json.Marshal(map[string]any{ + "chose": "", + "why": "", + "rejected": []string{}, + }) + data = string(b) + + case db.CardLink: + url := "" + for _, word := range strings.Fields(body) { + if strings.HasPrefix(word, "http://") || strings.HasPrefix(word, "https://") { + url = word + break + } + } + b, _ := json.Marshal(map[string]any{"url": url}) + data = string(b) + + default: + data = "{}" + } + return &data +} + +func DetectCardType(body string) *db.CardType { + if TemplateSlotRe.MatchString(body) { + ct := db.CardTemplate + return &ct + } + if strings.Contains(body, "chose:") || strings.Contains(body, "why:") { + ct := db.CardDecision + return &ct + } + if strings.Contains(body, "[ ]") || strings.Contains(body, "[x]") { + ct := db.CardChecklist + return &ct + } + for _, word := range strings.Fields(body) { + if strings.HasPrefix(word, "http://") || strings.HasPrefix(word, "https://") { + ct := db.CardLink + return &ct + } + } + return nil +} diff --git a/cmd/promote_test.go b/internal/carddata/carddata_test.go similarity index 86% rename from cmd/promote_test.go rename to internal/carddata/carddata_test.go index b200278..86a7964 100644 --- a/cmd/promote_test.go +++ b/internal/carddata/carddata_test.go @@ -1,4 +1,4 @@ -package cmd +package carddata import ( "encoding/json" @@ -8,14 +8,14 @@ import ( ) func TestGenerateCardData_Snippet(t *testing.T) { - data := generateCardData(db.CardSnippet, "some snippet") + data := GenerateCardData(db.CardSnippet, "some snippet") if data == nil || *data != "{}" { t.Errorf("snippet should produce {}, got %v", data) } } func TestGenerateCardData_Template(t *testing.T) { - data := generateCardData(db.CardTemplate, "deploy ${host} to ${env}") + data := GenerateCardData(db.CardTemplate, "deploy ${host} to ${env}") if data == nil { t.Fatal("expected non-nil data") } @@ -41,7 +41,7 @@ func TestGenerateCardData_Template(t *testing.T) { } func TestGenerateCardData_TemplateDedupe(t *testing.T) { - data := generateCardData(db.CardTemplate, "${x} and ${x}") + data := GenerateCardData(db.CardTemplate, "${x} and ${x}") var parsed struct { Slots []struct { Name string `json:"name"` @@ -54,7 +54,7 @@ func TestGenerateCardData_TemplateDedupe(t *testing.T) { } func TestGenerateCardData_TemplateNoSlots(t *testing.T) { - data := generateCardData(db.CardTemplate, "no placeholders here") + data := GenerateCardData(db.CardTemplate, "no placeholders here") var parsed struct { Slots []struct { Name string `json:"name"` @@ -68,7 +68,7 @@ func TestGenerateCardData_TemplateNoSlots(t *testing.T) { func TestGenerateCardData_Checklist(t *testing.T) { body := "[ ] step one\n[x] step two\n[ ] step three" - data := generateCardData(db.CardChecklist, body) + data := GenerateCardData(db.CardChecklist, body) if data == nil { t.Fatal("expected non-nil data") } @@ -94,7 +94,7 @@ func TestGenerateCardData_Checklist(t *testing.T) { } func TestGenerateCardData_ChecklistFallback(t *testing.T) { - data := generateCardData(db.CardChecklist, "no checkbox syntax") + data := GenerateCardData(db.CardChecklist, "no checkbox syntax") var parsed struct { Steps []struct { Text string `json:"text"` @@ -111,7 +111,7 @@ func TestGenerateCardData_ChecklistFallback(t *testing.T) { } func TestGenerateCardData_Decision(t *testing.T) { - data := generateCardData(db.CardDecision, "which db?") + data := GenerateCardData(db.CardDecision, "which db?") var parsed struct { Chose string `json:"chose"` Why string `json:"why"` @@ -129,7 +129,7 @@ func TestGenerateCardData_Decision(t *testing.T) { } func TestGenerateCardData_Link(t *testing.T) { - data := generateCardData(db.CardLink, "check https://example.com/path for details") + data := GenerateCardData(db.CardLink, "check https://example.com/path for details") var parsed struct { URL string `json:"url"` } @@ -140,7 +140,7 @@ func TestGenerateCardData_Link(t *testing.T) { } func TestGenerateCardData_LinkNoURL(t *testing.T) { - data := generateCardData(db.CardLink, "no url here") + data := GenerateCardData(db.CardLink, "no url here") var parsed struct { URL string `json:"url"` } diff --git a/internal/tui/cards.go b/internal/tui/cards.go new file mode 100644 index 0000000..f08038a --- /dev/null +++ b/internal/tui/cards.go @@ -0,0 +1,262 @@ +package tui + +import ( + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/lerko/nib/internal/db" + "github.com/lerko/nib/internal/display" +) + +type intent int + +const ( + intentAll intent = iota + intentGrab + intentRead + intentFill +) + +func (i intent) String() string { + switch i { + case intentGrab: + return "grab" + case intentRead: + return "read" + case intentFill: + return "fill" + default: + return "all" + } +} + +func (i intent) next() intent { + switch i { + case intentAll: + return intentGrab + case intentGrab: + return intentRead + case intentRead: + return intentFill + default: + return intentAll + } +} + +func matchesIntent(e *db.Entity, i intent) bool { + if i == intentAll { + return true + } + ct := e.CardType + if ct == nil { + return i == intentGrab + } + switch i { + case intentGrab: + return *ct == db.CardSnippet + case intentRead: + return *ct == db.CardNote || *ct == db.CardLink || *ct == db.CardDecision + case intentFill: + return *ct == db.CardTemplate || *ct == db.CardChecklist + } + return false +} + +type cardsModel struct { + entities []*db.Entity + filtered []*db.Entity + cursor int + offset int + height int + width int + intent intent +} + +func newCardsModel() cardsModel { + return cardsModel{} +} + +func (c *cardsModel) setEntities(entities []*db.Entity) { + c.entities = entities + c.applyFilter() +} + +func (c *cardsModel) setIntent(i intent) { + c.intent = i + c.cursor = 0 + c.offset = 0 + c.applyFilter() +} + +func (c *cardsModel) applyFilter() { + c.filtered = nil + var pinned, rest []*db.Entity + for _, e := range c.entities { + if !matchesIntent(e, c.intent) { + continue + } + if e.Pinned { + pinned = append(pinned, e) + } else { + rest = append(rest, e) + } + } + c.filtered = append(pinned, rest...) + if c.cursor >= len(c.filtered) { + c.cursor = max(0, len(c.filtered)-1) + } +} + +func (c *cardsModel) setSize(width, height int) { + c.width = width + c.height = height +} + +func (c cardsModel) selected() *db.Entity { + if len(c.filtered) == 0 || c.cursor >= len(c.filtered) { + return nil + } + return c.filtered[c.cursor] +} + +func (c cardsModel) update(msg tea.KeyMsg) cardsModel { + switch msg.String() { + case "up", "k": + if c.cursor > 0 { + c.cursor-- + if c.cursor < c.offset { + c.offset = c.cursor + } + } + case "down", "j": + if c.cursor < len(c.filtered)-1 { + c.cursor++ + visible := c.visibleCount() + if c.cursor >= c.offset+visible { + c.offset = c.cursor - visible + 1 + } + } + case "home", "g": + c.cursor = 0 + c.offset = 0 + case "end", "G": + c.cursor = max(0, len(c.filtered)-1) + visible := c.visibleCount() + if c.cursor >= visible { + c.offset = c.cursor - visible + 1 + } + case "pgup", "ctrl+u": + c.cursor = max(0, c.cursor-c.visibleCount()) + if c.cursor < c.offset { + c.offset = c.cursor + } + case "pgdown", "ctrl+d": + c.cursor = min(len(c.filtered)-1, c.cursor+c.visibleCount()) + visible := c.visibleCount() + if c.cursor >= c.offset+visible { + c.offset = c.cursor - visible + 1 + } + } + return c +} + +func (c cardsModel) view(width int) string { + if len(c.filtered) == 0 { + return statusStyle.Render("no cards") + } + + var b strings.Builder + visible := c.visibleCount() + end := min(c.offset+visible, len(c.filtered)) + + for i := c.offset; i < end; i++ { + e := c.filtered[i] + line := renderCard(e, width-4) + + if i == c.cursor { + b.WriteString(selectedItemStyle.Render(" " + line)) + } else { + b.WriteString(listItemStyle.Render(line)) + } + if i < end-1 { + b.WriteString("\n") + } + } + + return b.String() +} + +func (c cardsModel) visibleCount() int { + if c.height <= 0 { + return 20 + } + return c.height +} + +func renderCard(e *db.Entity, maxWidth int) string { + glyph := glyphStyle.Render(display.DisplayGlyph(e.Glyph, e.CardType)) + id := idStyle.Render("[" + display.FormatID(e.ID) + "]") + + body := e.Body + if e.Title != nil { + body = *e.Title + } + + affordance := detectAffordance(e) + affordStr := "" + if affordance != "" { + affordStr = " " + affordanceStyle.Render(affordance) + } + + var extras []string + if e.Pinned { + extras = append(extras, pinnedStyle.Render("•")) + } + if len(e.Tags) > 0 { + limit := min(2, len(e.Tags)) + for _, t := range e.Tags[:limit] { + extras = append(extras, tagStyle.Render("#"+t)) + } + } + + extraStr := "" + if len(extras) > 0 { + extraStr = " " + strings.Join(extras, " ") + } + + useStr := "" + if e.UseCount > 0 { + useStr = " " + useCountStyle.Render(fmt.Sprintf("%d×", e.UseCount)) + } + + line := fmt.Sprintf("%s %s%s%s%s %s", glyph, body, affordStr, extraStr, useStr, id) + + if maxWidth > 0 && len(stripAnsi(line)) > maxWidth { + body = truncate(body, maxWidth-30) + line = fmt.Sprintf("%s %s%s%s%s %s", glyph, body, affordStr, extraStr, useStr, id) + } + + return line +} + +func detectAffordance(e *db.Entity) string { + if e.CardType == nil { + return "" + } + switch *e.CardType { + case db.CardSnippet: + return "code" + case db.CardTemplate: + return "fill" + case db.CardChecklist: + return "steps" + case db.CardDecision: + return "decide" + case db.CardLink: + return "link" + default: + return "" + } +} diff --git a/internal/tui/commands.go b/internal/tui/commands.go index 5b0ae8e..ee98959 100644 --- a/internal/tui/commands.go +++ b/internal/tui/commands.go @@ -8,6 +8,7 @@ import ( "github.com/atotto/clipboard" tea "github.com/charmbracelet/bubbletea" + "github.com/lerko/nib/internal/carddata" "github.com/lerko/nib/internal/db" ) @@ -29,7 +30,8 @@ type entityUpdatedMsg struct { } type entityPromotedMsg struct { - id string + id string + cardType db.CardType } type entityDemotedMsg struct { @@ -122,12 +124,13 @@ func pinEntity(store *db.Store, e *db.Entity) tea.Cmd { } } -func promoteEntity(store *db.Store, id string) tea.Cmd { +func promoteEntity(store *db.Store, id string, ct db.CardType, body string) tea.Cmd { return func() tea.Msg { - if err := store.Promote(id, db.CardSnippet, nil); err != nil { + cd := carddata.GenerateCardData(ct, body) + if err := store.Promote(id, ct, cd); err != nil { return errMsg{err} } - return entityPromotedMsg{id} + return entityPromotedMsg{id, ct} } } diff --git a/internal/tui/detail.go b/internal/tui/detail.go index 2bf1073..1cc8376 100644 --- a/internal/tui/detail.go +++ b/internal/tui/detail.go @@ -54,6 +54,9 @@ func (d detailModel) view(width int) string { glyph := display.DisplayGlyph(e.Glyph, e.CardType) header := fmt.Sprintf("%s %s", glyph, display.FormatID(e.ID)) + if e.CardType != nil { + header += " " + affordanceStyle.Render(string(*e.CardType)) + } b.WriteString(detailHeaderStyle.Render(header)) b.WriteString("\n\n") @@ -65,6 +68,14 @@ func (d detailModel) view(width int) string { b.WriteString(detailBodyStyle.Render(e.Body)) b.WriteString("\n") + if e.CardType != nil { + cardSection := renderCardData(e) + if cardSection != "" { + b.WriteString("\n") + b.WriteString(cardSection) + } + } + if len(e.Tags) > 0 { tagParts := make([]string, len(e.Tags)) for i, t := range e.Tags { @@ -84,11 +95,14 @@ func (d detailModel) view(width int) string { meta += fmt.Sprintf("\nanchored @%s", *e.TimeAnchor) } if e.Pinned { - meta += "\npinned" + meta += "\n" + pinnedStyle.Render("pinned") } if e.CardType != nil { meta += fmt.Sprintf("\ncard %s", *e.CardType) } + if e.UseCount > 0 { + meta += fmt.Sprintf("\nused %d×", e.UseCount) + } if e.CompletedAt != nil { meta += fmt.Sprintf("\ndone %s", e.CompletedAt.Format(time.DateTime)) } @@ -104,3 +118,87 @@ func (d detailModel) view(width int) string { return strings.Join(lines, "\n") } + +func renderCardData(e *db.Entity) string { + if e.CardData == nil { + return "" + } + + data, err := e.CardDataJSON() + if err != nil || data == nil { + return "" + } + + var b strings.Builder + + switch *e.CardType { + case db.CardChecklist: + steps, ok := data["steps"].([]interface{}) + if !ok { + break + } + done := 0 + for _, s := range steps { + step, ok := s.(map[string]interface{}) + if !ok { + continue + } + text, _ := step["text"].(string) + isDone, _ := step["done"].(bool) + if isDone { + done++ + b.WriteString(" " + checkDoneStyle.Render("[✓] "+text) + "\n") + } else { + b.WriteString(" " + checkPendingStyle.Render("[ ] "+text) + "\n") + } + } + progress := fmt.Sprintf(" %d/%d steps", done, len(steps)) + b.WriteString(detailLabelStyle.Render(progress)) + + case db.CardTemplate: + slots, ok := data["slots"].([]interface{}) + if !ok { + break + } + b.WriteString(detailLabelStyle.Render(" slots:") + "\n") + for _, s := range slots { + slot, ok := s.(map[string]interface{}) + if !ok { + continue + } + name, _ := slot["name"].(string) + def, _ := slot["default"].(string) + line := " ${" + name + "}" + if def != "" { + line += " " + detailValueStyle.Render("default: "+def) + } + b.WriteString(line + "\n") + } + + case db.CardDecision: + if chose, ok := data["chose"].(string); ok && chose != "" { + b.WriteString(" " + detailLabelStyle.Render("chose: ") + detailValueStyle.Render(chose) + "\n") + } + if why, ok := data["why"].(string); ok && why != "" { + b.WriteString(" " + detailLabelStyle.Render("why: ") + detailValueStyle.Render(why) + "\n") + } + if rejected, ok := data["rejected"].([]interface{}); ok && len(rejected) > 0 { + items := make([]string, 0, len(rejected)) + for _, r := range rejected { + if s, ok := r.(string); ok { + items = append(items, s) + } + } + if len(items) > 0 { + b.WriteString(" " + detailLabelStyle.Render("rejected: ") + detailValueStyle.Render(strings.Join(items, ", ")) + "\n") + } + } + + case db.CardLink: + if url, ok := data["url"].(string); ok && url != "" { + b.WriteString(" " + detailLabelStyle.Render("↗ ") + detailValueStyle.Render(url) + "\n") + } + } + + return b.String() +} diff --git a/internal/tui/help.go b/internal/tui/help.go index 74d565c..bc50754 100644 --- a/internal/tui/help.go +++ b/internal/tui/help.go @@ -12,7 +12,13 @@ func renderHelp(width, height int) string { {"g/G home/end", "top / bottom"}, {"pgup/pgdn", "page up / down"}, {"enter", "view detail"}, - {"esc", "back / cancel"}, + {"esc", "back / clear filter"}, + }}, + {"Views", [][2]string{ + {"1", "stream view"}, + {"2", "cards view"}, + {"s", "cycle sort (cards)"}, + {"tab", "cycle intent (cards)"}, }}, {"Actions", [][2]string{ {"a", "add entity"}, @@ -20,6 +26,7 @@ func renderHelp(width, height int) string { {"x", "toggle todo completion"}, {"!", "toggle pin"}, {"#", "filter by tag"}, + {"p", "promote to card"}, }}, {"Detail View", [][2]string{ {"p", "promote to card"}, diff --git a/internal/tui/keys.go b/internal/tui/keys.go index 708599c..19b26fb 100644 --- a/internal/tui/keys.go +++ b/internal/tui/keys.go @@ -22,6 +22,10 @@ type keyMap struct { Demote key.Binding Copy key.Binding Edit key.Binding + Stream key.Binding + Cards key.Binding + Sort key.Binding + Intent key.Binding } var keys = keyMap{ @@ -44,4 +48,8 @@ var keys = keyMap{ Demote: key.NewBinding(key.WithKeys("D"), key.WithHelp("D", "demote")), Copy: key.NewBinding(key.WithKeys("c"), key.WithHelp("c", "copy")), Edit: key.NewBinding(key.WithKeys("e"), key.WithHelp("e", "edit")), + Stream: key.NewBinding(key.WithKeys("1"), key.WithHelp("1", "stream")), + Cards: key.NewBinding(key.WithKeys("2"), key.WithHelp("2", "cards")), + Sort: key.NewBinding(key.WithKeys("s"), key.WithHelp("s", "sort")), + Intent: key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "intent")), } diff --git a/internal/tui/model.go b/internal/tui/model.go index 8696fbe..90240ab 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -1,6 +1,8 @@ package tui import ( + "fmt" + tea "github.com/charmbracelet/bubbletea" "github.com/lerko/nib/internal/db" @@ -14,22 +16,64 @@ const ( stateInput stateTagFilter stateConfirm + statePromote ) +type viewMode int + +const ( + modeStream viewMode = iota + modeCards +) + +type cardsSort int + +const ( + sortNewest cardsSort = iota + sortOldest + sortMostUsed +) + +func (s cardsSort) String() string { + switch s { + case sortOldest: + return "oldest" + case sortMostUsed: + return "most used" + default: + return "newest" + } +} + +func (s cardsSort) next() cardsSort { + switch s { + case sortNewest: + return sortOldest + case sortOldest: + return sortMostUsed + default: + return sortNewest + } +} + type model struct { store *db.Store state viewState + mode viewMode width int height int list listModel + cards cardsModel detail detailModel input inputModel filter filterModel + promote promoteModel showHelp bool filterTag string confirmID string + cardsSort cardsSort status string err error @@ -39,7 +83,9 @@ func newModel(store *db.Store) model { return model{ store: store, state: stateList, + mode: modeStream, list: newListModel(), + cards: newCardsModel(), detail: newDetailModel(), input: newInputModel(), filter: newFilterModel(), @@ -55,6 +101,20 @@ func (m model) listParams() db.ListParams { if m.filterTag != "" { p.Tag = &m.filterTag } + if m.mode == modeCards { + p.CardsOnly = true + switch m.cardsSort { + case sortNewest: + p.Sort = "created" + p.Order = "desc" + case sortOldest: + p.Sort = "created" + p.Order = "asc" + case sortMostUsed: + p.Sort = "use_count" + p.Order = "desc" + } + } return p } @@ -64,12 +124,17 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.width = msg.Width m.height = msg.Height m.list.setSize(m.width, m.contentHeight()) + m.cards.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) + if m.mode == modeCards { + m.cards.setEntities(msg.entities) + } else { + m.list.setEntities(msg.entities) + } m.err = nil return m, nil @@ -92,8 +157,9 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, loadEntities(m.store, m.listParams()) case entityPromotedMsg: - m.status = "promoted → snippet" - return m, m.reloadDetail(msg.id) + m.status = fmt.Sprintf("promoted → %s", msg.cardType) + m.state = stateList + return m, loadEntities(m.store, m.listParams()) case entityDemotedMsg: m.status = "demoted → fluid" @@ -136,6 +202,8 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m.updateTagFilter(msg) case stateConfirm: return m.updateConfirm(msg) + case statePromote: + return m.updatePromote(msg) default: return m.updateKeys(msg) } @@ -166,6 +234,39 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.showHelp = true return m, nil + case "1": + if m.mode != modeStream { + m.mode = modeStream + m.state = stateList + m.status = "" + return m, loadEntities(m.store, m.listParams()) + } + return m, nil + + case "2": + if m.mode != modeCards { + m.mode = modeCards + m.state = stateList + m.status = "" + return m, loadEntities(m.store, m.listParams()) + } + return m, nil + + case "s": + 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, nil + + case "tab": + if m.mode == modeCards && m.state == stateList { + m.cards.setIntent(m.cards.intent.next()) + return m, nil + } + return m, nil + case "a": if m.state == stateList { m.state = stateInput @@ -187,7 +288,7 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case "enter": if m.state == stateList { - if e := m.list.selected(); e != nil { + if e := m.selectedEntity(); e != nil { m.detail.setEntity(e) m.state = stateDetail } @@ -196,7 +297,7 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case "d": if m.state == stateList { - if e := m.list.selected(); e != nil { + if e := m.selectedEntity(); e != nil { m.confirmID = e.ID m.state = stateConfirm return m, confirmTimeout() @@ -206,7 +307,7 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case "x": if m.state == stateList { - if e := m.list.selected(); e != nil && e.Glyph == db.GlyphTodo { + if e := m.selectedEntity(); e != nil && e.Glyph == db.GlyphTodo { return m, toggleTodo(m.store, e) } } @@ -231,12 +332,15 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil case "p": - if m.state == stateDetail && m.detail.entity != nil { - if m.detail.entity.CardType != nil { + e := m.selectedEntity() + if e != nil { + if e.CardType != nil { m.status = "already a card" return m, nil } - return m, promoteEntity(m.store, m.detail.entity.ID) + m.promote = newPromoteModel(e.ID, e.Body) + m.state = statePromote + return m, nil } return m, nil @@ -265,7 +369,11 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch m.state { case stateList: - m.list = m.list.update(msg) + if m.mode == modeCards { + m.cards = m.cards.update(msg) + } else { + m.list = m.list.update(msg) + } case stateDetail: m.detail = m.detail.update(msg) } @@ -319,6 +427,20 @@ func (m model) updateConfirm(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil } +func (m model) updatePromote(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "esc", "q": + m.state = stateList + return m, nil + case "enter": + ct := m.promote.selectedType() + return m, promoteEntity(m.store, m.promote.entityID, ct, m.promote.body) + default: + m.promote = m.promote.update(msg.String()) + return m, nil + } +} + func (m model) View() string { if m.showHelp { return renderHelp(m.width, m.height) @@ -327,21 +449,47 @@ func (m model) View() string { var content string switch m.state { case stateList, stateInput, stateConfirm: - content = m.list.view(m.width) + if m.mode == modeCards { + content = m.cards.view(m.width) + } else { + content = m.list.view(m.width) + } case stateDetail: content = m.detail.view(m.width) case stateTagFilter: content = m.filter.view(m.width) + case statePromote: + content = m.promote.view(m.width) } + header := m.headerView() + footer := m.footerView() + + return header + "\n" + content + "\n" + footer +} + +func (m model) headerView() string { header := titleStyle.Render("nib") + + modeName := "stream" + if m.mode == modeCards { + modeName = "cards" + } + header += " " + modeStyle.Render(modeName) + if m.filterTag != "" { header += " " + filterPillStyle.Render("#"+m.filterTag) } - footer := m.footerView() + if m.mode == modeCards && m.cards.intent != intentAll { + header += " " + affordanceStyle.Render(m.cards.intent.String()) + } - return header + "\n" + content + "\n" + footer + if m.mode == modeCards { + header += " " + idStyle.Render("("+m.cardsSort.String()+")") + } + + return header } func (m model) footerView() string { @@ -369,13 +517,14 @@ func (m model) contentHeight() int { } func (m model) selectedEntity() *db.Entity { - switch m.state { - case stateList: - return m.list.selected() - case stateDetail: + switch { + case m.state == stateDetail: return m.detail.entity + case m.mode == modeCards: + return m.cards.selected() + default: + return m.list.selected() } - return nil } func (m model) reloadDetail(id string) tea.Cmd { diff --git a/internal/tui/promote.go b/internal/tui/promote.go new file mode 100644 index 0000000..ed47598 --- /dev/null +++ b/internal/tui/promote.go @@ -0,0 +1,84 @@ +package tui + +import ( + "github.com/lerko/nib/internal/carddata" + "github.com/lerko/nib/internal/db" + "github.com/lerko/nib/internal/display" +) + +type promoteOption struct { + cardType db.CardType + label string + group string +} + +var promoteOptions = []promoteOption{ + {db.CardSnippet, "snippet", "grab"}, + {db.CardNote, "note", "read"}, + {db.CardLink, "link", "read"}, + {db.CardDecision, "decision", "read"}, + {db.CardTemplate, "template", "fill"}, + {db.CardChecklist, "checklist", "fill"}, +} + +type promoteModel struct { + cursor int + entityID string + body string + suggested *db.CardType +} + +func newPromoteModel(entityID, body string) promoteModel { + return promoteModel{ + entityID: entityID, + body: body, + suggested: carddata.DetectCardType(body), + } +} + +func (p promoteModel) selectedType() db.CardType { + return promoteOptions[p.cursor].cardType +} + +func (p promoteModel) update(key string) promoteModel { + switch key { + case "up", "k": + if p.cursor > 0 { + p.cursor-- + } + case "down", "j": + if p.cursor < len(promoteOptions)-1 { + p.cursor++ + } + } + return p +} + +func (p promoteModel) view(width int) string { + var b string + b += titleStyle.Render("promote to card") + "\n\n" + + currentGroup := "" + for i, opt := range promoteOptions { + if opt.group != currentGroup { + currentGroup = opt.group + b += dateHeaderStyle.Render("── "+currentGroup+" ──") + "\n" + } + + glyph := display.DisplayGlyph(db.GlyphNote, &opt.cardType) + label := glyph + " " + opt.label + + if p.suggested != nil && *p.suggested == opt.cardType { + label += " " + affordanceStyle.Render("*") + } + + if i == p.cursor { + b += selectedItemStyle.Render(" "+label) + "\n" + } else { + b += listItemStyle.Render(label) + "\n" + } + } + + b += "\n" + helpStyle.Render("enter:select esc:cancel") + return b +} diff --git a/internal/tui/statusbar.go b/internal/tui/statusbar.go index fca7441..d102bf2 100644 --- a/internal/tui/statusbar.go +++ b/internal/tui/statusbar.go @@ -23,7 +23,12 @@ func renderStatusBar(m model, width int) string { } func countText(m model) string { - total := len(m.list.entities) + var total int + if m.mode == modeCards { + total = len(m.cards.filtered) + } else { + total = len(m.list.entities) + } if m.filterTag != "" { return fmt.Sprintf("%d entities #%s", total, m.filterTag) } @@ -40,7 +45,12 @@ func contextHints(m model) string { return "j/k:nav enter:select esc:cancel" case stateConfirm: return "y:confirm n:cancel" + case statePromote: + return "j/k:nav enter:select esc:cancel" default: - return "a:add d:del x:todo #:filter ?:help q:quit" + if m.mode == modeCards { + return "1:stream 2:cards s:sort tab:intent a:add ?:help q:quit" + } + return "1:stream 2:cards a:add d:del x:todo #:filter ?:help q:quit" } } diff --git a/internal/tui/styles.go b/internal/tui/styles.go index e4f31d7..fbad368 100644 --- a/internal/tui/styles.go +++ b/internal/tui/styles.go @@ -77,4 +77,28 @@ var ( helpDescStyle = lipgloss.NewStyle(). Foreground(dim) + + affordanceStyle = lipgloss.NewStyle(). + Foreground(lipgloss.AdaptiveColor{Light: "#5B8EF0", Dark: "#7AAFFF"}). + Bold(true) + + useCountStyle = lipgloss.NewStyle(). + Foreground(lipgloss.AdaptiveColor{Light: "#B07D3A", Dark: "#D4A54A"}) + + modeStyle = lipgloss.NewStyle(). + Foreground(dim). + Bold(true) + + detailLabelStyle = lipgloss.NewStyle(). + Foreground(highlight). + Bold(true) + + detailValueStyle = lipgloss.NewStyle(). + Foreground(lipgloss.AdaptiveColor{Light: "#555555", Dark: "#BBBBBB"}) + + checkDoneStyle = lipgloss.NewStyle(). + Foreground(lipgloss.AdaptiveColor{Light: "#43BF6D", Dark: "#73F59F"}) + + checkPendingStyle = lipgloss.NewStyle(). + Foreground(dim) ) From 1066c0bc7d8b6e47cd58ca1c3dcaca348f25ca29 Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Sun, 17 May 2026 21:35:44 -0400 Subject: [PATCH 04/11] feat(tui): add search via capture bar and absorb flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Search uses existing parse grammar ?prefix — type `?query #tag` in capture bar to filter entities client-side. Substring match on body+title+description with AND tag filtering. Esc clears search. Absorb via m key on fluid entities — opens source picker showing all other entities, enter merges source into target. Uses existing store.Absorb() backend. --- internal/tui/absorb.go | 136 ++++++++++++++++++++++++++++++++++++++ internal/tui/commands.go | 28 ++++++++ internal/tui/help.go | 3 +- internal/tui/input.go | 19 +++++- internal/tui/keys.go | 2 + internal/tui/list.go | 25 +++++-- internal/tui/model.go | 126 +++++++++++++++++++++++++++++++++-- internal/tui/search.go | 55 +++++++++++++++ internal/tui/statusbar.go | 6 +- internal/tui/styles.go | 4 ++ 10 files changed, 387 insertions(+), 17 deletions(-) create mode 100644 internal/tui/absorb.go create mode 100644 internal/tui/search.go diff --git a/internal/tui/absorb.go b/internal/tui/absorb.go new file mode 100644 index 0000000..eb51ce3 --- /dev/null +++ b/internal/tui/absorb.go @@ -0,0 +1,136 @@ +package tui + +import ( + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/lerko/nib/internal/db" + "github.com/lerko/nib/internal/display" +) + +type absorbModel struct { + targetID string + sources []*db.Entity + cursor int + offset int + height int +} + +func newAbsorbModel(targetID string) absorbModel { + return absorbModel{targetID: targetID} +} + +func (a *absorbModel) setSources(entities []*db.Entity) { + a.sources = nil + for _, e := range entities { + if e.ID != a.targetID { + a.sources = append(a.sources, e) + } + } + a.cursor = 0 + a.offset = 0 +} + +func (a *absorbModel) setHeight(h int) { + a.height = h +} + +func (a absorbModel) selectedSource() *db.Entity { + if len(a.sources) == 0 || a.cursor >= len(a.sources) { + return nil + } + return a.sources[a.cursor] +} + +func (a absorbModel) update(msg tea.KeyMsg) absorbModel { + switch msg.String() { + case "up", "k": + if a.cursor > 0 { + a.cursor-- + if a.cursor < a.offset { + a.offset = a.cursor + } + } + case "down", "j": + if a.cursor < len(a.sources)-1 { + a.cursor++ + visible := a.visibleCount() + if a.cursor >= a.offset+visible { + a.offset = a.cursor - visible + 1 + } + } + } + return a +} + +func (a absorbModel) view(width int) string { + if len(a.sources) == 0 { + return statusStyle.Render("no other entities") + } + + var b strings.Builder + b.WriteString(titleStyle.Render("absorb into " + display.FormatID(a.targetID))) + b.WriteString("\n") + b.WriteString(helpStyle.Render("select source to merge")) + b.WriteString("\n\n") + + visible := a.visibleCount() - 4 + if visible <= 0 { + visible = 10 + } + end := min(a.offset+visible, len(a.sources)) + + for i := a.offset; i < end; i++ { + e := a.sources[i] + line := renderAbsorbSource(e, width-4) + + if i == a.cursor { + b.WriteString(selectedItemStyle.Render(" " + line)) + } else { + b.WriteString(listItemStyle.Render(line)) + } + if i < end-1 { + b.WriteString("\n") + } + } + + return b.String() +} + +func (a absorbModel) visibleCount() int { + if a.height <= 0 { + return 20 + } + return a.height +} + +func renderAbsorbSource(e *db.Entity, maxWidth int) string { + glyph := glyphStyle.Render(display.DisplayGlyph(e.Glyph, e.CardType)) + id := idStyle.Render("[" + display.FormatID(e.ID) + "]") + + body := e.Body + if e.Title != nil { + body = *e.Title + } + + var tags string + if len(e.Tags) > 0 { + limit := min(2, len(e.Tags)) + tagParts := make([]string, limit) + for i := 0; i < limit; i++ { + tagParts[i] = tagStyle.Render("#" + e.Tags[i]) + } + tags = " " + strings.Join(tagParts, " ") + } + + line := fmt.Sprintf("%s %s%s %s", glyph, body, tags, id) + + if maxWidth > 0 && len(stripAnsi(line)) > maxWidth { + body = truncate(body, maxWidth-20) + line = fmt.Sprintf("%s %s%s %s", glyph, body, tags, id) + } + + return line +} diff --git a/internal/tui/commands.go b/internal/tui/commands.go index ee98959..d93281e 100644 --- a/internal/tui/commands.go +++ b/internal/tui/commands.go @@ -40,6 +40,15 @@ type entityDemotedMsg struct { type entityCopiedMsg struct{} +type entityAbsorbedMsg struct { + targetID string +} + +type absorbSourcesLoadedMsg struct { + targetID string + entities []*db.Entity +} + type tagsLoadedMsg struct { tags []db.TagCount } @@ -207,3 +216,22 @@ func editInEditor(store *db.Store, e *db.Entity) tea.Cmd { return editorFinishedMsg{nil} }) } + +func loadAbsorbSources(store *db.Store, targetID string) tea.Cmd { + return func() tea.Msg { + entities, err := store.List(db.DefaultListParams()) + if err != nil { + return errMsg{err} + } + return absorbSourcesLoadedMsg{targetID, entities} + } +} + +func absorbEntity(store *db.Store, targetID, sourceID string) tea.Cmd { + return func() tea.Msg { + if err := store.Absorb(targetID, sourceID); err != nil { + return errMsg{err} + } + return entityAbsorbedMsg{targetID} + } +} diff --git a/internal/tui/help.go b/internal/tui/help.go index bc50754..bfec228 100644 --- a/internal/tui/help.go +++ b/internal/tui/help.go @@ -21,11 +21,12 @@ func renderHelp(width, height int) string { {"tab", "cycle intent (cards)"}, }}, {"Actions", [][2]string{ - {"a", "add entity"}, + {"a", "add entity (or ?query to search)"}, {"d", "delete (with confirm)"}, {"x", "toggle todo completion"}, {"!", "toggle pin"}, {"#", "filter by tag"}, + {"m", "absorb (merge into target)"}, {"p", "promote to card"}, }}, {"Detail View", [][2]string{ diff --git a/internal/tui/input.go b/internal/tui/input.go index 485f937..8b46272 100644 --- a/internal/tui/input.go +++ b/internal/tui/input.go @@ -8,6 +8,13 @@ import ( "github.com/lerko/nib/internal/parse" ) +type inputResult struct { + entity *db.Entity + query bool + body string + tags []string +} + type inputModel struct { ti textinput.Model active bool @@ -32,7 +39,7 @@ func (i *inputModel) reset() { i.ti.Blur() } -func (i inputModel) submit() *db.Entity { +func (i inputModel) submit() *inputResult { val := i.ti.Value() if val == "" { return nil @@ -43,6 +50,14 @@ func (i inputModel) submit() *db.Entity { return nil } + if parsed.Query { + return &inputResult{ + query: true, + body: parsed.Body, + tags: parsed.FilterTags, + } + } + e := &db.Entity{ Body: parsed.Body, Title: parsed.Title, @@ -63,7 +78,7 @@ func (i inputModel) submit() *db.Entity { e.Description = parsed.Description } - return e + return &inputResult{entity: e} } func (i inputModel) updateKey(msg tea.KeyMsg) inputModel { diff --git a/internal/tui/keys.go b/internal/tui/keys.go index 19b26fb..5fae286 100644 --- a/internal/tui/keys.go +++ b/internal/tui/keys.go @@ -26,6 +26,7 @@ type keyMap struct { Cards key.Binding Sort key.Binding Intent key.Binding + Absorb key.Binding } var keys = keyMap{ @@ -52,4 +53,5 @@ var keys = keyMap{ Cards: key.NewBinding(key.WithKeys("2"), key.WithHelp("2", "cards")), Sort: key.NewBinding(key.WithKeys("s"), key.WithHelp("s", "sort")), Intent: key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "intent")), + Absorb: key.NewBinding(key.WithKeys("m"), key.WithHelp("m", "absorb")), } diff --git a/internal/tui/list.go b/internal/tui/list.go index 57462e7..6ba4a2c 100644 --- a/internal/tui/list.go +++ b/internal/tui/list.go @@ -13,6 +13,7 @@ import ( type listModel struct { entities []*db.Entity + filtered []*db.Entity cursor int offset int height int @@ -25,6 +26,7 @@ func newListModel() listModel { func (l *listModel) setEntities(entities []*db.Entity) { l.entities = entities + l.filtered = nil if l.cursor >= len(entities) { l.cursor = max(0, len(entities)-1) } @@ -35,11 +37,19 @@ func (l *listModel) setSize(width, height int) { l.height = height } +func (l listModel) displayEntities() []*db.Entity { + if l.filtered != nil { + return l.filtered + } + return l.entities +} + func (l listModel) selected() *db.Entity { - if len(l.entities) == 0 || l.cursor >= len(l.entities) { + ents := l.displayEntities() + if len(ents) == 0 || l.cursor >= len(ents) { return nil } - return l.entities[l.cursor] + return ents[l.cursor] } func (l listModel) update(msg tea.KeyMsg) listModel { @@ -52,7 +62,7 @@ func (l listModel) update(msg tea.KeyMsg) listModel { } } case "down", "j": - if l.cursor < len(l.entities)-1 { + if l.cursor < len(l.displayEntities())-1 { l.cursor++ visible := l.visibleCount() if l.cursor >= l.offset+visible { @@ -63,7 +73,7 @@ func (l listModel) update(msg tea.KeyMsg) listModel { l.cursor = 0 l.offset = 0 case "end", "G": - l.cursor = max(0, len(l.entities)-1) + l.cursor = max(0, len(l.displayEntities())-1) visible := l.visibleCount() if l.cursor >= visible { l.offset = l.cursor - visible + 1 @@ -74,7 +84,7 @@ func (l listModel) update(msg tea.KeyMsg) listModel { l.offset = l.cursor } case "pgdown", "ctrl+d": - l.cursor = min(len(l.entities)-1, l.cursor+l.visibleCount()) + l.cursor = min(len(l.displayEntities())-1, l.cursor+l.visibleCount()) visible := l.visibleCount() if l.cursor >= l.offset+visible { l.offset = l.cursor - visible + 1 @@ -84,11 +94,12 @@ func (l listModel) update(msg tea.KeyMsg) listModel { } func (l listModel) view(width int) string { - if len(l.entities) == 0 { + ents := l.displayEntities() + if len(ents) == 0 { return statusStyle.Render("no entities") } - groups := groupByDate(l.entities) + groups := groupByDate(ents) type displayLine struct { text string diff --git a/internal/tui/model.go b/internal/tui/model.go index 90240ab..bd1be9a 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -17,6 +17,7 @@ const ( stateTagFilter stateConfirm statePromote + stateAbsorb ) type viewMode int @@ -69,11 +70,14 @@ type model struct { input inputModel filter filterModel promote promoteModel + absorb absorbModel showHelp bool - filterTag string - confirmID string - cardsSort cardsSort + filterTag string + confirmID string + cardsSort cardsSort + searchQuery string + searchTags []string status string err error @@ -118,6 +122,34 @@ func (m model) listParams() db.ListParams { return p } +func (m model) hasSearch() bool { + return m.searchQuery != "" || len(m.searchTags) > 0 +} + +func (m *model) applySearch() { + if m.mode == modeCards { + filtered := filterEntities(m.cards.entities, m.searchQuery, m.searchTags) + m.cards.filtered = nil + var pinned, rest []*db.Entity + for _, e := range filtered { + if !matchesIntent(e, m.cards.intent) { + continue + } + if e.Pinned { + pinned = append(pinned, e) + } else { + rest = append(rest, e) + } + } + m.cards.filtered = append(pinned, rest...) + if m.cards.cursor >= len(m.cards.filtered) { + m.cards.cursor = max(0, len(m.cards.filtered)-1) + } + } else { + m.list.filtered = filterEntities(m.list.entities, m.searchQuery, m.searchTags) + } +} + func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: @@ -135,6 +167,9 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } else { m.list.setEntities(msg.entities) } + if m.hasSearch() { + m.applySearch() + } m.err = nil return m, nil @@ -169,6 +204,18 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.status = "copied" return m, nil + case entityAbsorbedMsg: + m.status = "absorbed" + m.state = stateList + return m, loadEntities(m.store, m.listParams()) + + case absorbSourcesLoadedMsg: + m.absorb = newAbsorbModel(msg.targetID) + m.absorb.setSources(msg.entities) + m.absorb.setHeight(m.contentHeight()) + m.state = stateAbsorb + return m, nil + case tagsLoadedMsg: m.filter.setTags(msg.tags) m.state = stateTagFilter @@ -204,6 +251,8 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m.updateConfirm(msg) case statePromote: return m.updatePromote(msg) + case stateAbsorb: + return m.updateAbsorb(msg) default: return m.updateKeys(msg) } @@ -263,6 +312,9 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case "tab": if m.mode == modeCards && m.state == stateList { m.cards.setIntent(m.cards.intent.next()) + if m.hasSearch() { + m.applySearch() + } return m, nil } return m, nil @@ -279,6 +331,17 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.state = stateList return m, nil } + if m.state == stateList && m.hasSearch() { + m.searchQuery = "" + m.searchTags = nil + m.status = "" + if m.mode == modeCards { + m.cards.applyFilter() + } else { + m.list.filtered = nil + } + return m, nil + } if m.state == stateList && m.filterTag != "" { m.filterTag = "" m.status = "" @@ -331,6 +394,17 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } return m, nil + case "m": + e := m.selectedEntity() + if e != nil { + if e.CardType != nil { + m.status = "target must be fluid" + return m, nil + } + return m, loadAbsorbSources(m.store, e.ID) + } + return m, nil + case "p": e := m.selectedEntity() if e != nil { @@ -387,8 +461,20 @@ func (m model) updateInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.input.reset() return m, nil case "enter": - if e := m.input.submit(); e != nil { - return m, createEntity(m.store, e) + result := m.input.submit() + if result == nil { + return m, nil + } + if result.query { + m.searchQuery = result.body + m.searchTags = result.tags + m.state = stateList + m.input.reset() + m.applySearch() + return m, nil + } + if result.entity != nil { + return m, createEntity(m.store, result.entity) } return m, nil } @@ -441,6 +527,23 @@ func (m model) updatePromote(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } } +func (m model) updateAbsorb(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "esc", "q": + m.state = stateList + return m, nil + case "enter": + source := m.absorb.selectedSource() + if source != nil { + return m, absorbEntity(m.store, m.absorb.targetID, source.ID) + } + return m, nil + default: + m.absorb = m.absorb.update(msg) + return m, nil + } +} + func (m model) View() string { if m.showHelp { return renderHelp(m.width, m.height) @@ -460,6 +563,8 @@ func (m model) View() string { content = m.filter.view(m.width) case statePromote: content = m.promote.view(m.width) + case stateAbsorb: + content = m.absorb.view(m.width) } header := m.headerView() @@ -481,6 +586,17 @@ func (m model) headerView() string { header += " " + filterPillStyle.Render("#"+m.filterTag) } + if m.hasSearch() { + pill := "?" + if m.searchQuery != "" { + pill += m.searchQuery + } + for _, t := range m.searchTags { + pill += " #" + t + } + header += " " + searchPillStyle.Render(pill) + } + if m.mode == modeCards && m.cards.intent != intentAll { header += " " + affordanceStyle.Render(m.cards.intent.String()) } diff --git a/internal/tui/search.go b/internal/tui/search.go new file mode 100644 index 0000000..abb2577 --- /dev/null +++ b/internal/tui/search.go @@ -0,0 +1,55 @@ +package tui + +import ( + "strings" + + "github.com/lerko/nib/internal/db" +) + +func filterEntities(entities []*db.Entity, query string, tags []string) []*db.Entity { + if query == "" && len(tags) == 0 { + return entities + } + + query = strings.ToLower(query) + lowerTags := make([]string, len(tags)) + for i, t := range tags { + lowerTags[i] = strings.ToLower(t) + } + + var result []*db.Entity + for _, e := range entities { + if !matchesSearch(e, query, lowerTags) { + continue + } + result = append(result, e) + } + return result +} + +func matchesSearch(e *db.Entity, query string, tags []string) bool { + if len(tags) > 0 { + eTags := make(map[string]bool, len(e.Tags)) + for _, t := range e.Tags { + eTags[strings.ToLower(t)] = true + } + for _, t := range tags { + if !eTags[t] { + return false + } + } + } + + if query == "" { + return true + } + + haystack := strings.ToLower(e.Body) + if e.Title != nil { + haystack += " " + strings.ToLower(*e.Title) + } + if e.Description != nil { + haystack += " " + strings.ToLower(*e.Description) + } + return strings.Contains(haystack, query) +} diff --git a/internal/tui/statusbar.go b/internal/tui/statusbar.go index d102bf2..bfde093 100644 --- a/internal/tui/statusbar.go +++ b/internal/tui/statusbar.go @@ -27,7 +27,7 @@ func countText(m model) string { if m.mode == modeCards { total = len(m.cards.filtered) } else { - total = len(m.list.entities) + total = len(m.list.displayEntities()) } if m.filterTag != "" { return fmt.Sprintf("%d entities #%s", total, m.filterTag) @@ -47,10 +47,12 @@ func contextHints(m model) string { return "y:confirm n:cancel" case statePromote: return "j/k:nav enter:select esc:cancel" + case stateAbsorb: + return "j/k:nav enter:absorb esc:cancel" default: if m.mode == modeCards { return "1:stream 2:cards s:sort tab:intent a:add ?:help q:quit" } - return "1:stream 2:cards a:add d:del x:todo #:filter ?:help q:quit" + return "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 fbad368..9c896b8 100644 --- a/internal/tui/styles.go +++ b/internal/tui/styles.go @@ -101,4 +101,8 @@ var ( checkPendingStyle = lipgloss.NewStyle(). Foreground(dim) + + searchPillStyle = lipgloss.NewStyle(). + Foreground(lipgloss.AdaptiveColor{Light: "#E06C75", Dark: "#E06C75"}). + Bold(true) ) From 77222ff1b81d48fb8422f76b347cebfd5fe177fc Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Sun, 17 May 2026 21:53:55 -0400 Subject: [PATCH 05/11] feat(tui): add interactive run mode for checklists and fill mode for templates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Run mode (r key on checklist cards): cursor navigates steps, space toggles done/undone, r resets all, esc saves changes to DB and exits. Persists step state — improvement over web which discards on exit. Fill mode (f key on template cards): tab/shift-tab navigates slots, type to fill values, enter resolves template and copies to clipboard with use count increment. Esc cancels without copying. Both modes are sub-states of detail view, keeping architecture simple. --- internal/tui/commands.go | 26 +++++++ internal/tui/detail.go | 51 +++++++++++-- internal/tui/fill.go | 152 ++++++++++++++++++++++++++++++++++++++ internal/tui/help.go | 14 ++++ internal/tui/keys.go | 4 + internal/tui/model.go | 71 +++++++++++++++--- internal/tui/run.go | 116 +++++++++++++++++++++++++++++ internal/tui/statusbar.go | 9 ++- 8 files changed, 423 insertions(+), 20 deletions(-) create mode 100644 internal/tui/fill.go create mode 100644 internal/tui/run.go diff --git a/internal/tui/commands.go b/internal/tui/commands.go index d93281e..a247cd3 100644 --- a/internal/tui/commands.go +++ b/internal/tui/commands.go @@ -49,6 +49,10 @@ type absorbSourcesLoadedMsg struct { entities []*db.Entity } +type stepsPersistedMsg struct{} + +type templateCopiedMsg struct{} + type tagsLoadedMsg struct { tags []db.TagCount } @@ -235,3 +239,25 @@ func absorbEntity(store *db.Store, targetID, sourceID string) tea.Cmd { return entityAbsorbedMsg{targetID} } } + +func persistSteps(store *db.Store, entityID string, stepsJSON string) tea.Cmd { + return func() tea.Msg { + update := db.EntityUpdate{CardData: &stepsJSON} + if err := store.Update(entityID, &update); err != nil { + return errMsg{err} + } + return stepsPersistedMsg{} + } +} + +func copyResolved(store *db.Store, entityID string, resolved string) tea.Cmd { + return func() tea.Msg { + if err := clipboard.WriteAll(resolved); err != nil { + return errMsg{err} + } + if err := store.IncrementUse(entityID); err != nil { + return errMsg{err} + } + return templateCopiedMsg{} + } +} diff --git a/internal/tui/detail.go b/internal/tui/detail.go index 1cc8376..38e5f46 100644 --- a/internal/tui/detail.go +++ b/internal/tui/detail.go @@ -11,11 +11,22 @@ import ( "github.com/lerko/nib/internal/display" ) +type detailMode int + +const ( + detailPreview detailMode = iota + detailRun + detailFill +) + type detailModel struct { entity *db.Entity scroll int height int width int + mode detailMode + run runModel + fill fillModel } func newDetailModel() detailModel { @@ -25,6 +36,7 @@ func newDetailModel() detailModel { func (d *detailModel) setEntity(e *db.Entity) { d.entity = e d.scroll = 0 + d.mode = detailPreview } func (d *detailModel) setSize(width, height int) { @@ -32,19 +44,40 @@ func (d *detailModel) setSize(width, height int) { d.height = height } -func (d detailModel) update(msg tea.KeyMsg) detailModel { - switch msg.String() { - case "up", "k": - if d.scroll > 0 { - d.scroll-- +func (d detailModel) update(msg tea.KeyMsg) (detailModel, tea.Cmd) { + switch d.mode { + case detailRun: + d.run = d.run.update(msg.String()) + return d, nil + case detailFill: + var cmd tea.Cmd + d.fill, cmd = d.fill.update(msg) + return d, cmd + default: + switch msg.String() { + case "up", "k": + if d.scroll > 0 { + d.scroll-- + } + case "down", "j": + d.scroll++ } - case "down", "j": - d.scroll++ + return d, nil } - return d } func (d detailModel) view(width int) string { + switch d.mode { + case detailRun: + return d.run.view(width) + case detailFill: + return d.fill.view(width) + default: + return d.previewView(width) + } +} + +func (d detailModel) previewView(width int) string { if d.entity == nil { return "" } @@ -154,6 +187,7 @@ func renderCardData(e *db.Entity) string { } progress := fmt.Sprintf(" %d/%d steps", done, len(steps)) b.WriteString(detailLabelStyle.Render(progress)) + b.WriteString(" " + helpStyle.Render("r:run")) case db.CardTemplate: slots, ok := data["slots"].([]interface{}) @@ -174,6 +208,7 @@ func renderCardData(e *db.Entity) string { } b.WriteString(line + "\n") } + b.WriteString(" " + helpStyle.Render("f:fill")) case db.CardDecision: if chose, ok := data["chose"].(string); ok && chose != "" { diff --git a/internal/tui/fill.go b/internal/tui/fill.go new file mode 100644 index 0000000..ec9d2f8 --- /dev/null +++ b/internal/tui/fill.go @@ -0,0 +1,152 @@ +package tui + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + + "github.com/lerko/nib/internal/carddata" +) + +type fillSlot struct { + Name string + Default string + Value string +} + +type fillModel struct { + slots []fillSlot + active int + body string + entityID string + ti textinput.Model +} + +func newFillModel(entityID, body string) fillModel { + slots := discoverSlots(body) + m := fillModel{ + slots: slots, + body: body, + entityID: entityID, + } + m.ti = textinput.New() + m.ti.CharLimit = 200 + if len(slots) > 0 { + m.ti.Placeholder = slots[0].Name + m.ti.Focus() + if slots[0].Default != "" { + m.ti.SetValue(slots[0].Default) + } + } + return m +} + +func discoverSlots(body string) []fillSlot { + matches := carddata.TemplateSlotRe.FindAllStringSubmatch(body, -1) + seen := map[string]bool{} + var slots []fillSlot + for _, m := range matches { + name := m[1] + if !seen[name] { + seen[name] = true + slots = append(slots, fillSlot{Name: name}) + } + } + return slots +} + +func (f fillModel) resolve() string { + result := f.body + for _, s := range f.slots { + val := s.Value + if val == "" { + val = "${" + s.Name + "}" + } + result = strings.ReplaceAll(result, "${"+s.Name+"}", val) + } + return result +} + +func (f fillModel) update(msg tea.KeyMsg) (fillModel, tea.Cmd) { + switch msg.String() { + case "tab": + f.commitActive() + if f.active < len(f.slots)-1 { + f.active++ + } else { + f.active = 0 + } + f.loadActive() + return f, nil + case "shift+tab": + f.commitActive() + if f.active > 0 { + f.active-- + } else { + f.active = len(f.slots) - 1 + } + f.loadActive() + return f, nil + } + + f.ti, _ = f.ti.Update(msg) + return f, nil +} + +func (f *fillModel) commitActive() { + if f.active < len(f.slots) { + f.slots[f.active].Value = f.ti.Value() + } +} + +func (f *fillModel) loadActive() { + if f.active < len(f.slots) { + s := f.slots[f.active] + f.ti.SetValue(s.Value) + f.ti.Placeholder = s.Name + f.ti.Focus() + } +} + +func (f fillModel) view(width int) string { + if len(f.slots) == 0 { + return statusStyle.Render("no slots") + } + + var b strings.Builder + header := fmt.Sprintf("⤓ filling slot %d/%d", f.active+1, len(f.slots)) + b.WriteString(detailHeaderStyle.Render(header)) + b.WriteString("\n\n") + + for i, slot := range f.slots { + name := detailLabelStyle.Render(slot.Name) + var val string + if i == f.active { + val = f.ti.View() + } else if slot.Value != "" { + val = detailValueStyle.Render(slot.Value) + } else { + val = idStyle.Render("(empty)") + } + + if i == f.active { + b.WriteString(selectedItemStyle.Render(" " + name + " " + val)) + } else { + b.WriteString(listItemStyle.Render(name + " " + val)) + } + b.WriteString("\n") + } + + b.WriteString("\n") + preview := f.resolve() + if len(preview) > width-4 { + preview = preview[:width-7] + "…" + } + b.WriteString(detailBodyStyle.Render(preview)) + b.WriteString("\n\n") + b.WriteString(helpStyle.Render("tab:next shift+tab:prev enter:copy esc:cancel")) + + return b.String() +} diff --git a/internal/tui/help.go b/internal/tui/help.go index bfec228..ea3e080 100644 --- a/internal/tui/help.go +++ b/internal/tui/help.go @@ -35,6 +35,20 @@ func renderHelp(width, height int) string { {"c", "copy to clipboard"}, {"e", "edit in $EDITOR"}, {"!", "toggle pin"}, + {"r", "run checklist"}, + {"f", "fill template"}, + }}, + {"Run Mode", [][2]string{ + {"j/k", "move between steps"}, + {"space", "toggle step"}, + {"r", "reset all steps"}, + {"esc", "save + exit"}, + }}, + {"Fill Mode", [][2]string{ + {"tab", "next slot"}, + {"shift+tab", "prev slot"}, + {"enter", "copy resolved"}, + {"esc", "cancel"}, }}, {"Global", [][2]string{ {"?", "toggle help"}, diff --git a/internal/tui/keys.go b/internal/tui/keys.go index 5fae286..e755232 100644 --- a/internal/tui/keys.go +++ b/internal/tui/keys.go @@ -27,6 +27,8 @@ type keyMap struct { Sort key.Binding Intent key.Binding Absorb key.Binding + Run key.Binding + Fill key.Binding } var keys = keyMap{ @@ -54,4 +56,6 @@ var keys = keyMap{ Sort: key.NewBinding(key.WithKeys("s"), key.WithHelp("s", "sort")), Intent: key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "intent")), Absorb: key.NewBinding(key.WithKeys("m"), key.WithHelp("m", "absorb")), + Run: key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "run checklist")), + Fill: key.NewBinding(key.WithKeys("f"), key.WithHelp("f", "fill template")), } diff --git a/internal/tui/model.go b/internal/tui/model.go index bd1be9a..bf8c39a 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -216,6 +216,16 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.state = stateAbsorb return m, nil + case stepsPersistedMsg: + m.status = "steps saved" + m.detail.mode = detailPreview + return m, m.reloadDetail(m.detail.entity.ID) + + case templateCopiedMsg: + m.status = "copied resolved" + m.detail.mode = detailPreview + return m, loadEntities(m.store, m.listParams()) + case tagsLoadedMsg: m.filter.setTags(msg.tags) m.state = stateTagFilter @@ -328,6 +338,18 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case "esc": if m.state == stateDetail { + if m.detail.mode == detailRun { + var cmd tea.Cmd + if m.detail.run.dirty { + cmd = persistSteps(m.store, m.detail.run.entityID, m.detail.run.stepsJSON()) + } + m.detail.mode = detailPreview + return m, cmd + } + if m.detail.mode == detailFill { + m.detail.mode = detailPreview + return m, nil + } m.state = stateList return m, nil } @@ -349,15 +371,6 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } return m, nil - case "enter": - if m.state == stateList { - if e := m.selectedEntity(); e != nil { - m.detail.setEntity(e) - m.state = stateDetail - } - } - return m, nil - case "d": if m.state == stateList { if e := m.selectedEntity(); e != nil { @@ -435,10 +448,44 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil case "e": - if m.state == stateDetail && m.detail.entity != nil { + if m.state == stateDetail && m.detail.entity != nil && m.detail.mode == detailPreview { return m, editInEditor(m.store, m.detail.entity) } return m, nil + + case "r": + if m.state == stateDetail && 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 + return m, nil + } + } + return m, nil + + case "f": + if m.state == stateDetail && 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 + return m, m.detail.fill.ti.Focus() + } + } + return m, nil + + case "enter": + if m.state == stateDetail && m.detail.mode == detailFill { + m.detail.fill.commitActive() + resolved := m.detail.fill.resolve() + return m, copyResolved(m.store, m.detail.fill.entityID, resolved) + } + if m.state == stateList { + if e := m.selectedEntity(); e != nil { + m.detail.setEntity(e) + m.state = stateDetail + } + } + return m, nil } switch m.state { @@ -449,7 +496,9 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.list = m.list.update(msg) } case stateDetail: - m.detail = m.detail.update(msg) + var cmd tea.Cmd + m.detail, cmd = m.detail.update(msg) + return m, cmd } return m, nil } diff --git a/internal/tui/run.go b/internal/tui/run.go new file mode 100644 index 0000000..9d37013 --- /dev/null +++ b/internal/tui/run.go @@ -0,0 +1,116 @@ +package tui + +import ( + "encoding/json" + "fmt" + "strings" +) + +type runStep struct { + Text string `json:"text"` + Done bool `json:"done"` +} + +type runModel struct { + steps []runStep + cursor int + entityID string + dirty bool +} + +func newRunModel(entityID string, cardData *string) runModel { + m := runModel{entityID: entityID} + m.steps = parseChecklist(cardData) + return m +} + +func parseChecklist(cardData *string) []runStep { + if cardData == nil { + return nil + } + var data struct { + Steps []runStep `json:"steps"` + } + if err := json.Unmarshal([]byte(*cardData), &data); err != nil { + return nil + } + return data.Steps +} + +func (r runModel) stepsJSON() string { + b, _ := json.Marshal(map[string]any{"steps": r.steps}) + return string(b) +} + +func (r runModel) doneCount() int { + n := 0 + for _, s := range r.steps { + if s.Done { + n++ + } + } + return n +} + +func (r runModel) update(key string) runModel { + switch key { + case "up", "k": + if r.cursor > 0 { + r.cursor-- + } + case "down", "j": + if r.cursor < len(r.steps)-1 { + r.cursor++ + } + case " ": + if r.cursor < len(r.steps) { + r.steps[r.cursor].Done = !r.steps[r.cursor].Done + r.dirty = true + } + case "r": + for i := range r.steps { + r.steps[i].Done = false + } + r.dirty = true + } + return r +} + +func (r runModel) view(width int) string { + if len(r.steps) == 0 { + return statusStyle.Render("no steps") + } + + var b strings.Builder + done := r.doneCount() + total := len(r.steps) + pct := 0 + if total > 0 { + pct = done * 100 / total + } + + header := fmt.Sprintf("▶ running %d/%d done (%d%%)", done, total, pct) + b.WriteString(detailHeaderStyle.Render(header)) + b.WriteString("\n\n") + + for i, step := range r.steps { + var line string + if step.Done { + line = checkDoneStyle.Render("[✓] " + step.Text) + } else { + line = checkPendingStyle.Render("[ ] " + step.Text) + } + + if i == r.cursor { + b.WriteString(selectedItemStyle.Render(" " + line)) + } else { + b.WriteString(listItemStyle.Render(line)) + } + b.WriteString("\n") + } + + b.WriteString("\n") + b.WriteString(helpStyle.Render("space:toggle r:reset esc:save+exit")) + + return b.String() +} diff --git a/internal/tui/statusbar.go b/internal/tui/statusbar.go index bfde093..03eff2b 100644 --- a/internal/tui/statusbar.go +++ b/internal/tui/statusbar.go @@ -38,7 +38,14 @@ func countText(m model) string { func contextHints(m model) string { switch m.state { case stateDetail: - return "p:promote D:demote c:copy e:edit !:pin esc:back" + switch m.detail.mode { + case detailRun: + return "space:toggle j/k:nav r:reset esc:save+exit" + case detailFill: + return "tab:next shift+tab:prev enter:copy esc:cancel" + default: + return "p:promote D:demote c:copy e:edit r:run f:fill !:pin esc:back" + } case stateInput: return "enter:submit esc:cancel" case stateTagFilter: From babf1d6620d858c30f5204e17768037e83eb3346 Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Sun, 17 May 2026 23:24:58 -0400 Subject: [PATCH 06/11] fix(tui): harden EDITOR handling and SQL sort/order validation Split EDITOR env var on whitespace so multi-word values like "code --wait" work correctly. Add allow-list switch for sort column and order direction at the query boundary to prevent future callers from passing unsanitized values into SQL. --- internal/db/entities.go | 12 ++++++++++-- internal/tui/commands.go | 11 +++++++---- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/internal/db/entities.go b/internal/db/entities.go index c3488f1..d73f654 100644 --- a/internal/db/entities.go +++ b/internal/db/entities.go @@ -220,12 +220,20 @@ func (s *Store) List(params ListParams) ([]*Entity, error) { } orderCol := "e.created_at" - if params.Sort == "use_count" { + switch params.Sort { + case "use_count": orderCol = "e.use_count" + case "created_at", "": + orderCol = "e.created_at" + default: + orderCol = "e.created_at" } orderDir := "DESC" - if strings.EqualFold(params.Order, "asc") { + switch strings.ToLower(params.Order) { + case "asc": orderDir = "ASC" + default: + orderDir = "DESC" } limit := params.Limit diff --git a/internal/tui/commands.go b/internal/tui/commands.go index a247cd3..d916465 100644 --- a/internal/tui/commands.go +++ b/internal/tui/commands.go @@ -3,6 +3,7 @@ package tui import ( "os" "os/exec" + "strings" "time" "github.com/atotto/clipboard" @@ -179,10 +180,12 @@ func loadTags(store *db.Store) tea.Cmd { } func editInEditor(store *db.Store, e *db.Entity) tea.Cmd { - editor := os.Getenv("EDITOR") - if editor == "" { - editor = "vi" + editorEnv := os.Getenv("EDITOR") + if editorEnv == "" { + editorEnv = "vi" } + parts := strings.Fields(editorEnv) + editor, editorArgs := parts[0], parts[1:] f, err := os.CreateTemp("", "nib-edit-*.md") if err != nil { @@ -195,7 +198,7 @@ func editInEditor(store *db.Store, e *db.Entity) tea.Cmd { } f.Close() - c := exec.Command(editor, f.Name()) + c := exec.Command(editor, append(editorArgs, f.Name())...) return tea.ExecProcess(c, func(err error) tea.Msg { defer os.Remove(f.Name()) if err != nil { From e09919b6794e107933b6fcb22dedf82359e9de10 Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Tue, 19 May 2026 18:30:17 -0400 Subject: [PATCH 07/11] fix: harden API, DB schema, and CLI safety - Add 'reminder' to glyph CHECK constraint (was accepted by parser but rejected by DB) - Default serve bind to 127.0.0.1, add --host flag for LAN access - Validate card_data as JSON in Store.Create/Update/Promote - Return pagination envelope {data,total,limit,offset} from list endpoint - Append absorb breadcrumb to source entity before soft-delete - Add Levenshtein fuzzy match to catch command typos before routing to add - Replace DDL string-matching migrations with versioned schema_version table - Update web UI and API tests for envelope response format --- cmd/root.go | 55 +++++++++++++++ cmd/serve.go | 8 ++- go.mod | 6 +- go.sum | 2 - internal/api/api_test.go | 35 +++++---- internal/api/entities.go | 32 ++++++++- internal/db/db.go | 149 +++++++++++++++++++++++++-------------- internal/db/entities.go | 33 +++++++-- web/app.js | 12 ++-- 9 files changed, 243 insertions(+), 89 deletions(-) diff --git a/cmd/root.go b/cmd/root.go index 7424fac..067fa09 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -1,6 +1,7 @@ package cmd import ( + "fmt" "os" "strings" @@ -26,6 +27,10 @@ func Execute() error { isFlag := strings.HasPrefix(first, "-") && !strings.Contains(first, " ") if first != "help" && first != "completion" && !isFlag && !isSubcommand(first) { + if near := nearSubcommand(first); near != "" { + fmt.Fprintf(os.Stderr, "unknown command %q — did you mean %q?\n", first, near) + os.Exit(1) + } // "--" stops cobra from parsing glyph prefixes like "-" as flags rootCmd.SetArgs(append([]string{"add", "--"}, os.Args[1:]...)) } @@ -47,6 +52,56 @@ func isSubcommand(name string) bool { return false } +func nearSubcommand(name string) string { + for _, c := range rootCmd.Commands() { + if d := editDist(name, c.Name()); d > 0 && d <= 2 { + return c.Name() + } + for _, alias := range c.Aliases { + if d := editDist(name, alias); d > 0 && d <= 2 { + return alias + } + } + } + return "" +} + +func editDist(a, b string) int { + la, lb := len(a), len(b) + if la == 0 { + return lb + } + if lb == 0 { + return la + } + prev := make([]int, lb+1) + for j := range prev { + prev[j] = j + } + for i := 1; i <= la; i++ { + curr := make([]int, lb+1) + curr[0] = i + for j := 1; j <= lb; j++ { + cost := 1 + if a[i-1] == b[j-1] { + cost = 0 + } + ins := curr[j-1] + 1 + del := prev[j] + 1 + sub := prev[j-1] + cost + curr[j] = ins + if del < curr[j] { + curr[j] = del + } + if sub < curr[j] { + curr[j] = sub + } + } + prev = curr + } + return prev[lb] +} + func init() { rootCmd.AddCommand(addCmd) rootCmd.AddCommand(lsCmd) diff --git a/cmd/serve.go b/cmd/serve.go index 157548a..2fabf53 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -19,6 +19,7 @@ var WebFS fs.FS var ( servePort int + serveHost string serveDev bool tlsCert string tlsKey string @@ -32,6 +33,7 @@ var serveCmd = &cobra.Command{ func init() { serveCmd.Flags().IntVar(&servePort, "port", 0, "port to listen on (default 4444, or 4443 with TLS)") + serveCmd.Flags().StringVar(&serveHost, "host", "127.0.0.1", "address to bind to (default localhost only)") serveCmd.Flags().BoolVar(&serveDev, "dev", false, "enable CORS for development") serveCmd.Flags().StringVar(&tlsCert, "tls-cert", "", "path to TLS certificate file") serveCmd.Flags().StringVar(&tlsKey, "tls-key", "", "path to TLS private key file") @@ -70,7 +72,7 @@ func runServe(_ *cobra.Command, _ []string) error { router = api.NewRouter(store, serveDev, WebFS) } - addr := fmt.Sprintf(":%d", port) + addr := fmt.Sprintf("%s:%d", serveHost, port) srv := &http.Server{ Addr: addr, Handler: router, @@ -81,9 +83,9 @@ func runServe(_ *cobra.Command, _ []string) error { go func() { if useTLS { - fmt.Printf("nib serving on https://localhost%s\n", addr) + fmt.Printf("nib serving on https://%s\n", addr) } else { - fmt.Printf("nib serving on http://localhost%s\n", addr) + fmt.Printf("nib serving on http://%s\n", addr) } if serveDev { fmt.Println(" CORS enabled (dev mode)") diff --git a/go.mod b/go.mod index ec47185..58dcc91 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,9 @@ go 1.24.4 require ( github.com/atotto/clipboard v0.1.4 + github.com/charmbracelet/bubbles v1.0.0 + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/lipgloss v1.1.0 github.com/go-chi/chi/v5 v5.2.5 github.com/oklog/ulid/v2 v2.1.1 github.com/spf13/cobra v1.10.2 @@ -12,10 +15,7 @@ require ( require ( github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect - github.com/charmbracelet/bubbles v1.0.0 // indirect - github.com/charmbracelet/bubbletea v1.3.10 // indirect github.com/charmbracelet/colorprofile v0.4.1 // indirect - github.com/charmbracelet/lipgloss v1.1.0 // indirect github.com/charmbracelet/x/ansi v0.11.6 // indirect github.com/charmbracelet/x/cellbuf v0.0.15 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect diff --git a/go.sum b/go.sum index 696989a..7252a8d 100644 --- a/go.sum +++ b/go.sum @@ -74,8 +74,6 @@ golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= -golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= diff --git a/internal/api/api_test.go b/internal/api/api_test.go index 7f10aa1..397aeff 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -25,6 +25,20 @@ func testServer(t *testing.T) (*httptest.Server, *db.Store) { return srv, store } +type listEnvelope struct { + Data []EntityResponse `json:"data"` + Total int `json:"total"` + Limit int `json:"limit"` + Offset int `json:"offset"` +} + +func decodeList(t *testing.T, resp *http.Response) []EntityResponse { + t.Helper() + var env listEnvelope + json.NewDecoder(resp.Body).Decode(&env) + return env.Data +} + func postJSON(t *testing.T, srv *httptest.Server, path string, body any) *http.Response { t.Helper() b, err := json.Marshal(body) @@ -157,8 +171,7 @@ func TestListEntities_Default(t *testing.T) { } defer resp.Body.Close() - var entities []EntityResponse - json.NewDecoder(resp.Body).Decode(&entities) + entities := decodeList(t, resp) if len(entities) != 2 { t.Fatalf("expected 2, got %d", len(entities)) } @@ -175,8 +188,7 @@ func TestListEntities_FilterTag(t *testing.T) { } defer resp.Body.Close() - var entities []EntityResponse - json.NewDecoder(resp.Body).Decode(&entities) + entities := decodeList(t, resp) if len(entities) != 1 { t.Fatalf("expected 1, got %d", len(entities)) } @@ -198,8 +210,7 @@ func TestListEntities_CardsOnly(t *testing.T) { } defer resp.Body.Close() - var entities []EntityResponse - json.NewDecoder(resp.Body).Decode(&entities) + entities := decodeList(t, resp) if len(entities) != 1 { t.Fatalf("expected 1 card, got %d", len(entities)) } @@ -215,16 +226,14 @@ func TestListEntities_Pagination(t *testing.T) { if err != nil { t.Fatal(err) } - var page1 []EntityResponse - json.NewDecoder(resp.Body).Decode(&page1) + page1 := decodeList(t, resp) resp.Body.Close() resp, err = http.Get(srv.URL + "/api/entities?limit=2&offset=2") if err != nil { t.Fatal(err) } - var page2 []EntityResponse - json.NewDecoder(resp.Body).Decode(&page2) + page2 := decodeList(t, resp) resp.Body.Close() if len(page1) != 2 || len(page2) != 2 { @@ -517,8 +526,7 @@ func TestAbsorbEntity_Success(t *testing.T) { if err != nil { t.Fatal(err) } - var entities []EntityResponse - json.NewDecoder(listResp.Body).Decode(&entities) + entities := decodeList(t, listResp) listResp.Body.Close() for _, ent := range entities { if ent.ID == source.ID { @@ -686,8 +694,7 @@ func TestListEntities_TitleInResponse(t *testing.T) { } defer resp.Body.Close() - var entities []EntityResponse - json.NewDecoder(resp.Body).Decode(&entities) + entities := decodeList(t, resp) if len(entities) != 1 { t.Fatalf("expected 1, got %d", len(entities)) } diff --git a/internal/api/entities.go b/internal/api/entities.go index 6e78e2e..b4058d4 100644 --- a/internal/api/entities.go +++ b/internal/api/entities.go @@ -102,6 +102,15 @@ func listEntities(store *db.Store) http.HandlerFunc { } p.Offset = offset } + if p.Limit <= 0 { + p.Limit = 50 + } + + total, err := store.Count(p) + if err != nil { + writeInternalError(w, err) + return + } entities, err := store.List(p) if err != nil { @@ -109,11 +118,16 @@ func listEntities(store *db.Store) http.HandlerFunc { return } - resp := make([]EntityResponse, len(entities)) + items := make([]EntityResponse, len(entities)) for i, e := range entities { - resp[i] = entityToResponse(e) + items[i] = entityToResponse(e) } - writeJSON(w, http.StatusOK, resp) + writeJSON(w, http.StatusOK, map[string]any{ + "data": items, + "total": total, + "limit": p.Limit, + "offset": p.Offset, + }) } } @@ -161,6 +175,10 @@ func createEntity(store *db.Store) http.HandlerFunc { } if err := store.Create(e); err != nil { + if err == db.ErrInvalidCardData { + writeError(w, http.StatusBadRequest, "invalid_card_data", "card_data must be valid JSON") + return + } writeInternalError(w, err) return } @@ -227,6 +245,10 @@ func updateEntity(store *db.Store) http.HandlerFunc { writeError(w, http.StatusNotFound, "not_found", "no entity with id "+id) return } + if err == db.ErrInvalidCardData { + writeError(w, http.StatusBadRequest, "invalid_card_data", "card_data must be valid JSON") + return + } writeInternalError(w, err) return } @@ -291,6 +313,10 @@ func promoteEntity(store *db.Store) http.HandlerFunc { writeError(w, http.StatusBadRequest, "invalid_promote", "entity is already crystallized") return } + if err == db.ErrInvalidCardData { + writeError(w, http.StatusBadRequest, "invalid_card_data", "card_data must be valid JSON") + return + } writeInternalError(w, err) return } diff --git a/internal/db/db.go b/internal/db/db.go index fc389ea..982e072 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -6,7 +6,6 @@ import ( "fmt" "os" "path/filepath" - "strings" _ "modernc.org/sqlite" ) @@ -16,6 +15,7 @@ var ( ErrAlreadyPromoted = errors.New("invalid_promote") ErrAlreadyFluid = errors.New("invalid_demote") ErrTargetCrystallized = errors.New("invalid_absorb") + ErrInvalidCardData = errors.New("invalid_card_data") ) type Store struct { @@ -51,64 +51,65 @@ func (s *Store) Close() error { return s.db.Close() } -func (s *Store) migrate() error { - _, err := s.db.Exec(` - CREATE TABLE IF NOT EXISTS entities ( - id TEXT PRIMARY KEY, - created_at TEXT NOT NULL, - modified_at TEXT NOT NULL, - body TEXT NOT NULL, - glyph TEXT NOT NULL - CHECK (glyph IN ('todo', 'event', 'note')), - time_anchor TEXT, - completed_at TEXT, - pinned INTEGER NOT NULL DEFAULT 0, - deleted_at TEXT, - card_type TEXT - CHECK (card_type IN ('snippet', 'template', 'checklist', 'decision', 'link', 'note') - OR card_type IS NULL), - card_data TEXT, - use_count INTEGER NOT NULL DEFAULT 0, - last_used_at TEXT - ); +const currentSchema = 3 - CREATE TABLE IF NOT EXISTS entity_tags ( - entity_id TEXT NOT NULL REFERENCES entities(id) ON DELETE CASCADE, - tag TEXT NOT NULL, - PRIMARY KEY (entity_id, tag) - ); +var migrations = []func(db *sql.DB) error{ + // v1: initial schema + func(db *sql.DB) error { + _, err := db.Exec(` + CREATE TABLE IF NOT EXISTS entities ( + id TEXT PRIMARY KEY, + created_at TEXT NOT NULL, + modified_at TEXT NOT NULL, + body TEXT NOT NULL, + glyph TEXT NOT NULL, + time_anchor TEXT, + completed_at TEXT, + pinned INTEGER NOT NULL DEFAULT 0, + deleted_at TEXT, + card_type TEXT, + card_data TEXT, + use_count INTEGER NOT NULL DEFAULT 0, + last_used_at TEXT + ); - CREATE INDEX IF NOT EXISTS idx_entities_created - ON entities(created_at DESC) WHERE deleted_at IS NULL; - CREATE INDEX IF NOT EXISTS idx_entities_card_use - ON entities(use_count DESC) - WHERE card_type IS NOT NULL AND deleted_at IS NULL; - CREATE INDEX IF NOT EXISTS idx_entity_tags_tag - ON entity_tags(tag); - `) - if err != nil { + CREATE TABLE IF NOT EXISTS entity_tags ( + entity_id TEXT NOT NULL REFERENCES entities(id) ON DELETE CASCADE, + tag TEXT NOT NULL, + PRIMARY KEY (entity_id, tag) + ); + + CREATE INDEX IF NOT EXISTS idx_entities_created + ON entities(created_at DESC) WHERE deleted_at IS NULL; + CREATE INDEX IF NOT EXISTS idx_entities_card_use + ON entities(use_count DESC) + WHERE card_type IS NOT NULL AND deleted_at IS NULL; + CREATE INDEX IF NOT EXISTS idx_entity_tags_tag + ON entity_tags(tag); + `) return err - } + }, - s.db.Exec(`ALTER TABLE entities ADD COLUMN title TEXT`) - s.db.Exec(`ALTER TABLE entities ADD COLUMN description TEXT`) + // v2: add title and description columns + func(db *sql.DB) error { + db.Exec(`ALTER TABLE entities ADD COLUMN title TEXT`) + db.Exec(`ALTER TABLE entities ADD COLUMN description TEXT`) + return nil + }, - // Migrate CHECK constraint to include 'note' card type - var needsMigrate bool - row := s.db.QueryRow(`SELECT sql FROM sqlite_master WHERE type='table' AND name='entities'`) - var ddl string - if row.Scan(&ddl) == nil { - hasNote := strings.Contains(ddl, "'link', 'note'") - hasModified := strings.Contains(ddl, "modified_at") - needsMigrate = !hasNote || !hasModified - } - if needsMigrate { - tx, err := s.db.Begin() + // v3: rebuild table with CHECK constraints (card_type 'note', glyph 'reminder') + func(db *sql.DB) error { + tx, err := db.Begin() if err != nil { return err } defer tx.Rollback() + // Disable FK checks during rebuild to avoid dangling references + if _, err := tx.Exec(`PRAGMA foreign_keys = OFF`); err != nil { + return fmt.Errorf("migrate fk off: %w", err) + } + if _, err := tx.Exec(`ALTER TABLE entities RENAME TO _entities_migrate`); err != nil { return fmt.Errorf("migrate rename: %w", err) } @@ -118,7 +119,7 @@ func (s *Store) migrate() error { modified_at TEXT NOT NULL, body TEXT NOT NULL, glyph TEXT NOT NULL - CHECK (glyph IN ('todo', 'event', 'note')), + CHECK (glyph IN ('todo', 'event', 'note', 'reminder')), time_anchor TEXT, completed_at TEXT, pinned INTEGER NOT NULL DEFAULT 0, @@ -140,12 +141,54 @@ func (s *Store) migrate() error { if _, err := tx.Exec(`DROP TABLE _entities_migrate`); err != nil { return fmt.Errorf("migrate drop: %w", err) } - if err := tx.Commit(); err != nil { - return fmt.Errorf("migrate commit: %w", err) + + // Rebuild entity_tags to point FK at new entities table + if _, err := tx.Exec(`ALTER TABLE entity_tags RENAME TO _tags_migrate`); err != nil { + return fmt.Errorf("migrate tags rename: %w", err) + } + if _, err := tx.Exec(`CREATE TABLE entity_tags ( + entity_id TEXT NOT NULL REFERENCES entities(id) ON DELETE CASCADE, + tag TEXT NOT NULL, + PRIMARY KEY (entity_id, tag) + )`); err != nil { + return fmt.Errorf("migrate tags create: %w", err) + } + if _, err := tx.Exec(`INSERT INTO entity_tags SELECT * FROM _tags_migrate`); err != nil { + return fmt.Errorf("migrate tags copy: %w", err) + } + if _, err := tx.Exec(`DROP TABLE _tags_migrate`); err != nil { + return fmt.Errorf("migrate tags drop: %w", err) + } + + if _, err := tx.Exec(`PRAGMA foreign_keys = ON`); err != nil { + return fmt.Errorf("migrate fk on: %w", err) + } + + return tx.Commit() + }, +} + +func (s *Store) migrate() error { + s.db.Exec(`CREATE TABLE IF NOT EXISTS schema_version (version INTEGER NOT NULL)`) + + var version int + err := s.db.QueryRow(`SELECT version FROM schema_version`).Scan(&version) + if err != nil { + version = 0 + } + + for i := version; i < len(migrations); i++ { + if err := migrations[i](s.db); err != nil { + return fmt.Errorf("migration %d: %w", i+1, err) } } - return nil + if version == 0 { + _, err = s.db.Exec(`INSERT INTO schema_version (version) VALUES (?)`, len(migrations)) + } else if len(migrations) > version { + _, err = s.db.Exec(`UPDATE schema_version SET version = ?`, len(migrations)) + } + return err } func DefaultPath() (string, error) { diff --git a/internal/db/entities.go b/internal/db/entities.go index d73f654..79dc615 100644 --- a/internal/db/entities.go +++ b/internal/db/entities.go @@ -104,6 +104,9 @@ type EntityUpdate struct { } func (s *Store) Create(e *Entity) error { + if e.CardData != nil && !json.Valid([]byte(*e.CardData)) { + return ErrInvalidCardData + } now := time.Now().UTC() e.ID = nibulid.New() e.CreatedAt = now @@ -179,7 +182,7 @@ func (s *Store) Get(id string) (*Entity, error) { return e, nil } -func (s *Store) List(params ListParams) ([]*Entity, error) { +func listWhere(params ListParams) (string, []any) { var where []string var args []any @@ -214,10 +217,23 @@ func (s *Store) List(params ListParams) ([]*Entity, error) { args = append(args, string(*params.CardTypeFilter)) } - whereClause := "" + clause := "" if len(where) > 0 { - whereClause = "WHERE " + strings.Join(where, " AND ") + clause = "WHERE " + strings.Join(where, " AND ") } + return clause, args +} + +func (s *Store) Count(params ListParams) (int, error) { + whereClause, args := listWhere(params) + query := fmt.Sprintf("SELECT COUNT(*) FROM entities e %s", whereClause) + var count int + err := s.db.QueryRow(query, args...).Scan(&count) + return count, err +} + +func (s *Store) List(params ListParams) ([]*Entity, error) { + whereClause, args := listWhere(params) orderCol := "e.created_at" switch params.Sort { @@ -336,6 +352,9 @@ func (s *Store) Update(id string, u *EntityUpdate) error { args = append(args, string(*u.CardType)) } if u.CardData != nil { + if !json.Valid([]byte(*u.CardData)) { + return ErrInvalidCardData + } sets = append(sets, "card_data = ?") args = append(args, *u.CardData) } @@ -370,6 +389,9 @@ func (s *Store) Promote(id string, cardType CardType, cardData *string) error { dataVal := "{}" if cardData != nil { + if !json.Valid([]byte(*cardData)) { + return ErrInvalidCardData + } dataVal = *cardData } @@ -473,8 +495,9 @@ func (s *Store) Absorb(targetID, sourceID string) error { } } - if _, err := tx.Exec("UPDATE entities SET deleted_at = ? WHERE id = ?", - now, sourceID); err != nil { + absorbNote := source.Body + "\n\n[absorbed into " + targetID + "]" + if _, err := tx.Exec("UPDATE entities SET body = ?, deleted_at = ?, modified_at = ? WHERE id = ?", + absorbNote, now, now, sourceID); err != nil { return err } diff --git a/web/app.js b/web/app.js index 9a24b48..25b07e7 100644 --- a/web/app.js +++ b/web/app.js @@ -1247,9 +1247,9 @@ async function loadEntities() { const params = buildListParams(0); - const results = await api.listEntities(params); - state.entities = results; - state.hasMore = results.length === PAGE_SIZE; + const resp = await api.listEntities(params); + state.entities = resp.data; + state.hasMore = (resp.offset + resp.data.length) < resp.total; state.selectedIndex = -1; renderEntityList(); renderDetailPane(); @@ -1258,9 +1258,9 @@ async function loadMore() { const params = buildListParams(state.entities.length); - const results = await api.listEntities(params); - state.entities = state.entities.concat(results); - state.hasMore = results.length === PAGE_SIZE; + const resp = await api.listEntities(params); + state.entities = state.entities.concat(resp.data); + state.hasMore = (resp.offset + resp.data.length) < resp.total; renderEntityList(); } From f89ca8acb94efd8c7cac1606366c600923ba9301 Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Tue, 19 May 2026 19:55:37 -0400 Subject: [PATCH 08/11] feat(tui): add split-pane detail, compact date headers, and input drawer Three layout improvements for better space utilization: - Compact date headers: date labels render as left gutter column instead of standalone lines, saving one line per date group in stream view - Input drawer: capture bar expands to 4-line drawer with border, hints, and live preview of parsed entity/search query - Split-pane detail: wide terminals (>=100 cols) show list and detail side-by-side with h/l focus switching, falling back to full-screen detail on narrow terminals --- internal/tui/help.go | 5 + internal/tui/input.go | 105 ++++++++++++++++++- internal/tui/keys.go | 108 +++++++++---------- internal/tui/list.go | 52 ++++------ internal/tui/model.go | 213 ++++++++++++++++++++++++++++++++++++-- internal/tui/statusbar.go | 8 +- internal/tui/styles.go | 17 +++ 7 files changed, 413 insertions(+), 95 deletions(-) diff --git a/internal/tui/help.go b/internal/tui/help.go index ea3e080..35cdda0 100644 --- a/internal/tui/help.go +++ b/internal/tui/help.go @@ -50,6 +50,11 @@ func renderHelp(width, height int) string { {"enter", "copy resolved"}, {"esc", "cancel"}, }}, + {"Split View", [][2]string{ + {"l", "focus detail pane"}, + {"h", "focus list pane"}, + {"esc", "close detail / back"}, + }}, {"Global", [][2]string{ {"?", "toggle help"}, {"q / ctrl+c", "quit"}, diff --git a/internal/tui/input.go b/internal/tui/input.go index 8b46272..786b8eb 100644 --- a/internal/tui/input.go +++ b/internal/tui/input.go @@ -1,6 +1,9 @@ package tui import ( + "fmt" + "strings" + "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" @@ -16,8 +19,9 @@ type inputResult struct { } type inputModel struct { - ti textinput.Model - active bool + ti textinput.Model + active bool + preview *parse.Result } func newInputModel() inputModel { @@ -37,6 +41,7 @@ func (i *inputModel) reset() { i.active = false i.ti.SetValue("") i.ti.Blur() + i.preview = nil } func (i inputModel) submit() *inputResult { @@ -83,9 +88,103 @@ func (i inputModel) submit() *inputResult { func (i inputModel) updateKey(msg tea.KeyMsg) inputModel { 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 } 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) } diff --git a/internal/tui/keys.go b/internal/tui/keys.go index e755232..1bb1a31 100644 --- a/internal/tui/keys.go +++ b/internal/tui/keys.go @@ -3,59 +3,63 @@ package tui import "github.com/charmbracelet/bubbles/key" type keyMap struct { - Up key.Binding - Down key.Binding - Enter key.Binding - Back key.Binding - Add key.Binding - Delete key.Binding - Quit key.Binding - Help key.Binding - PageUp key.Binding - PageDn key.Binding - Top key.Binding - Bottom key.Binding - Todo key.Binding - Pin key.Binding - Filter key.Binding - Promote key.Binding - Demote key.Binding - Copy key.Binding - Edit key.Binding - Stream key.Binding - Cards key.Binding - Sort key.Binding - Intent key.Binding - Absorb key.Binding - Run key.Binding - Fill key.Binding + Up key.Binding + Down key.Binding + Enter key.Binding + Back key.Binding + Add key.Binding + Delete key.Binding + Quit key.Binding + Help key.Binding + PageUp key.Binding + PageDn key.Binding + Top key.Binding + Bottom key.Binding + Todo key.Binding + Pin key.Binding + Filter key.Binding + Promote key.Binding + Demote key.Binding + Copy key.Binding + Edit key.Binding + Stream key.Binding + Cards key.Binding + Sort key.Binding + Intent key.Binding + Absorb key.Binding + Run key.Binding + Fill key.Binding + FocusLeft key.Binding + FocusRight key.Binding } var keys = keyMap{ - Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "up")), - Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down")), - Enter: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "view")), - Back: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "back")), - Add: key.NewBinding(key.WithKeys("a"), key.WithHelp("a", "add")), - Delete: key.NewBinding(key.WithKeys("d"), key.WithHelp("d", "delete")), - Quit: key.NewBinding(key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "quit")), - Help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "help")), - PageUp: key.NewBinding(key.WithKeys("pgup", "ctrl+u"), key.WithHelp("pgup", "page up")), - PageDn: key.NewBinding(key.WithKeys("pgdown", "ctrl+d"), key.WithHelp("pgdn", "page down")), - Top: key.NewBinding(key.WithKeys("home", "g"), key.WithHelp("home", "top")), - Bottom: key.NewBinding(key.WithKeys("end", "G"), key.WithHelp("end", "bottom")), - Todo: key.NewBinding(key.WithKeys("x"), key.WithHelp("x", "toggle todo")), - Pin: key.NewBinding(key.WithKeys("!"), key.WithHelp("!", "toggle pin")), - Filter: key.NewBinding(key.WithKeys("#"), key.WithHelp("#", "filter tag")), - Promote: key.NewBinding(key.WithKeys("p"), key.WithHelp("p", "promote")), - Demote: key.NewBinding(key.WithKeys("D"), key.WithHelp("D", "demote")), - Copy: key.NewBinding(key.WithKeys("c"), key.WithHelp("c", "copy")), - Edit: key.NewBinding(key.WithKeys("e"), key.WithHelp("e", "edit")), - Stream: key.NewBinding(key.WithKeys("1"), key.WithHelp("1", "stream")), - Cards: key.NewBinding(key.WithKeys("2"), key.WithHelp("2", "cards")), - Sort: key.NewBinding(key.WithKeys("s"), key.WithHelp("s", "sort")), - Intent: key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "intent")), - Absorb: key.NewBinding(key.WithKeys("m"), key.WithHelp("m", "absorb")), - Run: key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "run checklist")), - Fill: key.NewBinding(key.WithKeys("f"), key.WithHelp("f", "fill template")), + Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "up")), + Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down")), + Enter: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "view")), + Back: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "back")), + Add: key.NewBinding(key.WithKeys("a"), key.WithHelp("a", "add")), + Delete: key.NewBinding(key.WithKeys("d"), key.WithHelp("d", "delete")), + Quit: key.NewBinding(key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "quit")), + Help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "help")), + PageUp: key.NewBinding(key.WithKeys("pgup", "ctrl+u"), key.WithHelp("pgup", "page up")), + PageDn: key.NewBinding(key.WithKeys("pgdown", "ctrl+d"), key.WithHelp("pgdn", "page down")), + Top: key.NewBinding(key.WithKeys("home", "g"), key.WithHelp("home", "top")), + Bottom: key.NewBinding(key.WithKeys("end", "G"), key.WithHelp("end", "bottom")), + Todo: key.NewBinding(key.WithKeys("x"), key.WithHelp("x", "toggle todo")), + Pin: key.NewBinding(key.WithKeys("!"), key.WithHelp("!", "toggle pin")), + Filter: key.NewBinding(key.WithKeys("#"), key.WithHelp("#", "filter tag")), + Promote: key.NewBinding(key.WithKeys("p"), key.WithHelp("p", "promote")), + Demote: key.NewBinding(key.WithKeys("D"), key.WithHelp("D", "demote")), + Copy: key.NewBinding(key.WithKeys("c"), key.WithHelp("c", "copy")), + Edit: key.NewBinding(key.WithKeys("e"), key.WithHelp("e", "edit")), + Stream: key.NewBinding(key.WithKeys("1"), key.WithHelp("1", "stream")), + Cards: key.NewBinding(key.WithKeys("2"), key.WithHelp("2", "cards")), + Sort: key.NewBinding(key.WithKeys("s"), key.WithHelp("s", "sort")), + Intent: key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "intent")), + Absorb: key.NewBinding(key.WithKeys("m"), key.WithHelp("m", "absorb")), + Run: key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "run checklist")), + 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")), } diff --git a/internal/tui/list.go b/internal/tui/list.go index 6ba4a2c..8c95c96 100644 --- a/internal/tui/list.go +++ b/internal/tui/list.go @@ -93,6 +93,8 @@ func (l listModel) update(msg tea.KeyMsg) listModel { return l } +const dateGutterWidth = 9 + func (l listModel) view(width int) string { ents := l.displayEntities() if len(ents) == 0 { @@ -100,22 +102,24 @@ func (l listModel) view(width int) string { } groups := groupByDate(ents) + entityWidth := width - 4 - dateGutterWidth type displayLine struct { text string entityIdx int - isHeader bool } var lines []displayLine entityIdx := 0 for _, g := range groups { - lines = append(lines, displayLine{ - text: dateHeaderStyle.Render("── " + g.label + " ──"), - isHeader: true, - }) - for _, e := range g.entities { - line := renderEntity(e, width-4) + for i, e := range g.entities { + var gutter string + if i == 0 { + gutter = gutterStyle.Render(padRight(g.label, 6) + " │ ") + } else { + gutter = gutterStyle.Render(" │ ") + } + line := gutter + renderEntity(e, entityWidth) lines = append(lines, displayLine{ text: line, entityIdx: entityIdx, @@ -124,21 +128,17 @@ func (l listModel) view(width int) string { } } - cursorLine := l.cursorDisplayLine(groups) visible := l.visibleCount() - offset := 0 - if cursorLine >= visible { - offset = cursorLine - visible + 1 + if l.cursor >= visible { + offset = l.cursor - visible + 1 } var b strings.Builder end := min(offset+visible, len(lines)) for i := offset; i < end; i++ { dl := lines[i] - if dl.isHeader { - b.WriteString(dl.text) - } else if dl.entityIdx == l.cursor { + if dl.entityIdx == l.cursor { b.WriteString(selectedItemStyle.Render(" " + dl.text)) } else { b.WriteString(listItemStyle.Render(dl.text)) @@ -151,22 +151,6 @@ func (l listModel) view(width int) 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 { if l.height <= 0 { return 20 @@ -203,6 +187,14 @@ func formatDateLabel(t time.Time) string { 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 { glyphStr := display.DisplayGlyph(e.Glyph, e.CardType) style := glyphStyle diff --git a/internal/tui/model.go b/internal/tui/model.go index bf8c39a..a5e7c5b 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -2,8 +2,10 @@ package tui import ( "fmt" + "strings" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" "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 { store *db.Store state viewState @@ -73,6 +82,9 @@ type model struct { absorb absorbModel showHelp bool + focus focusPane + splitDetail bool + filterTag string confirmID string cardsSort cardsSort @@ -155,10 +167,12 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height - m.list.setSize(m.width, m.contentHeight()) - m.cards.setSize(m.width, m.contentHeight()) - m.detail.setSize(m.width, m.contentHeight()) - m.filter.setHeight(m.contentHeight()) + if !m.isSplit() && m.splitDetail { + m.state = stateDetail + m.splitDetail = false + m.focus = focusList + } + m.recalcSizes() return m, nil case entitiesLoadedMsg: @@ -176,6 +190,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case entityCreatedMsg: m.state = stateList m.input.reset() + m.recalcSizes() m.status = "created" 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 } + 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() { case "ctrl+c": return m, tea.Quit @@ -333,6 +433,7 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if m.state == stateList { m.state = stateInput m.input.focus() + m.recalcSizes() 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()) } m.detail.mode = detailPreview + if m.isSplit() { + m.state = stateList + m.splitDetail = true + m.focus = focusList + m.recalcSizes() + } return m, cmd } if m.detail.mode == detailFill { 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 } m.state = stateList @@ -482,7 +602,13 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if m.state == stateList { if e := m.selectedEntity(); e != nil { m.detail.setEntity(e) - m.state = stateDetail + if m.isSplit() { + m.splitDetail = true + m.focus = focusDetail + m.recalcSizes() + } else { + m.state = stateDetail + } } } return m, nil @@ -495,6 +621,11 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } else { m.list = m.list.update(msg) } + if m.splitDetail { + if e := m.selectedEntity(); e != nil { + m.detail.setEntity(e) + } + } case stateDetail: var cmd tea.Cmd m.detail, cmd = m.detail.update(msg) @@ -508,6 +639,7 @@ func (m model) updateInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case "esc": m.state = stateList m.input.reset() + m.recalcSizes() return m, nil case "enter": result := m.input.submit() @@ -519,6 +651,7 @@ func (m model) updateInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.searchTags = result.tags m.state = stateList m.input.reset() + m.recalcSizes() m.applySearch() return m, nil } @@ -601,10 +734,16 @@ func (m model) View() string { var content string switch m.state { case stateList, stateInput, stateConfirm: - if m.mode == modeCards { - content = m.cards.view(m.width) + listContent := m.listContent() + 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 { - content = m.list.view(m.width) + content = listContent } case stateDetail: content = m.detail.view(m.width) @@ -622,6 +761,21 @@ func (m model) View() string { 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 { header := titleStyle.Render("nib") @@ -678,7 +832,48 @@ func (m model) footerView() string { } 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 { diff --git a/internal/tui/statusbar.go b/internal/tui/statusbar.go index 03eff2b..bdbfb09 100644 --- a/internal/tui/statusbar.go +++ b/internal/tui/statusbar.go @@ -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" } case stateInput: - return "enter:submit esc:cancel" + return "" case stateTagFilter: return "j/k:nav enter:select esc:cancel" case stateConfirm: @@ -57,6 +57,12 @@ func contextHints(m model) string { case stateAbsorb: return "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 "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" } diff --git a/internal/tui/styles.go b/internal/tui/styles.go index 9c896b8..ef837a2 100644 --- a/internal/tui/styles.go +++ b/internal/tui/styles.go @@ -105,4 +105,21 @@ var ( searchPillStyle = lipgloss.NewStyle(). Foreground(lipgloss.AdaptiveColor{Light: "#E06C75", Dark: "#E06C75"}). 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) ) From 778fab3edded68a6aad4a1caa9cc53628815556e Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Tue, 19 May 2026 20:56:29 -0400 Subject: [PATCH 09/11] docs: add terminal UI to README and development guide --- README.md | 16 ++++++++++++++++ docs/development.md | 10 ++++++++++ 2 files changed, 26 insertions(+) diff --git a/README.md b/README.md index 0380ff8..b0fd2da 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,9 @@ nib ls --tag ops # list a specific month nib ls --month 2026-05 +# terminal UI +nib tui + # start the web UI nib serve ``` @@ -122,9 +125,22 @@ Or use `^type` inline: `nib "proxy trick #nginx ^card"` | `nib absorb ` | Merge source into target | | `nib delete ` | Soft delete (repeat to hard delete) | | `nib serve` | Start web UI on `:4444` (or `--port`) | +| `nib tui` | Launch the terminal UI | IDs are prefix-matchable. If `01KRQ4` is unique, that's enough. +## Terminal UI + +`nib tui` launches a keyboard-driven interface in your terminal. + +- **Stream view** — entries grouped by date with compact gutter headers +- **Cards view** — promoted cards filtered by intent (grab/read/fill), sorted by usage +- **Split-pane detail** — wide terminals (100+ cols) show list and detail side-by-side +- **Capture drawer** — inline add with live preview of parsed entity +- **Search** — type `?query #tag` in the capture bar to filter + +Navigation: `j`/`k` move, `enter` opens detail, `h`/`l` switch panes, `a` to add, `?` for full keybindings. + ## Web UI `nib serve` starts a local web interface with: diff --git a/docs/development.md b/docs/development.md index 3005e4e..352af66 100644 --- a/docs/development.md +++ b/docs/development.md @@ -77,6 +77,16 @@ For production, use a reverse proxy (Caddy, nginx) with real certificates in fro Override with `--port` or the `NIB_PORT` environment variable. +## Terminal UI + +Run the TUI directly from source: + +```sh +go run . tui +``` + +TUI code lives in `internal/tui/`. No live-reload — restart manually after changes. + ## Typical Workflow 1. `make cert` — once, generates dev TLS cert From 39975a678765ad4fe2a924eca8632ffb87edf798 Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Tue, 19 May 2026 21:01:26 -0400 Subject: [PATCH 10/11] chore(tui): remove dead formatPreviewEntity function --- internal/tui/input.go | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/internal/tui/input.go b/internal/tui/input.go index 786b8eb..52027ff 100644 --- a/internal/tui/input.go +++ b/internal/tui/input.go @@ -1,7 +1,6 @@ package tui import ( - "fmt" "strings" "github.com/charmbracelet/bubbles/textinput" @@ -175,16 +174,3 @@ func glyphForParsed(glyph string) string { 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) -} From 476abbed0047be26cae6a1247bc7e8802abdbf33 Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Tue, 19 May 2026 21:10:51 -0400 Subject: [PATCH 11/11] test(tui): add tier 1 unit tests for pure logic functions Cover search filtering, intent matching, card affordances, checklist parsing, template slot discovery/resolve, date grouping, and truncation. --- internal/tui/cards_test.go | 131 ++++++++++++++++++++++++++++++++++++ internal/tui/fill_test.go | 81 ++++++++++++++++++++++ internal/tui/list_test.go | 94 ++++++++++++++++++++++++++ internal/tui/run_test.go | 65 ++++++++++++++++++ internal/tui/search_test.go | 70 +++++++++++++++++++ 5 files changed, 441 insertions(+) create mode 100644 internal/tui/cards_test.go create mode 100644 internal/tui/fill_test.go create mode 100644 internal/tui/list_test.go create mode 100644 internal/tui/run_test.go create mode 100644 internal/tui/search_test.go diff --git a/internal/tui/cards_test.go b/internal/tui/cards_test.go new file mode 100644 index 0000000..30fd714 --- /dev/null +++ b/internal/tui/cards_test.go @@ -0,0 +1,131 @@ +package tui + +import ( + "testing" + + "github.com/lerko/nib/internal/db" +) + +func TestMatchesIntent(t *testing.T) { + snippet := db.CardSnippet + template := db.CardTemplate + checklist := db.CardChecklist + note := db.CardNote + link := db.CardLink + decision := db.CardDecision + + tests := []struct { + name string + cardType *db.CardType + intent intent + want bool + }{ + {"all matches nil", nil, intentAll, true}, + {"all matches snippet", &snippet, intentAll, true}, + {"all matches template", &template, intentAll, true}, + + {"grab matches nil", nil, intentGrab, true}, + {"grab matches snippet", &snippet, intentGrab, true}, + {"grab rejects template", &template, intentGrab, false}, + {"grab rejects note", ¬e, intentGrab, false}, + + {"read matches note", ¬e, intentRead, true}, + {"read matches link", &link, intentRead, true}, + {"read matches decision", &decision, intentRead, true}, + {"read rejects snippet", &snippet, intentRead, false}, + {"read rejects nil", nil, intentRead, false}, + + {"fill matches template", &template, intentFill, true}, + {"fill matches checklist", &checklist, intentFill, true}, + {"fill rejects snippet", &snippet, intentFill, false}, + {"fill rejects nil", nil, intentFill, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + e := &db.Entity{CardType: tt.cardType} + if got := matchesIntent(e, tt.intent); got != tt.want { + t.Fatalf("matchesIntent(%v, %v) = %v, want %v", tt.cardType, tt.intent, got, tt.want) + } + }) + } +} + +func TestDetectAffordance(t *testing.T) { + snippet := db.CardSnippet + template := db.CardTemplate + checklist := db.CardChecklist + decision := db.CardDecision + link := db.CardLink + note := db.CardNote + + tests := []struct { + name string + cardType *db.CardType + want string + }{ + {"nil", nil, ""}, + {"snippet", &snippet, "code"}, + {"template", &template, "fill"}, + {"checklist", &checklist, "steps"}, + {"decision", &decision, "decide"}, + {"link", &link, "link"}, + {"note", ¬e, ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + e := &db.Entity{CardType: tt.cardType} + if got := detectAffordance(e); got != tt.want { + t.Fatalf("detectAffordance(%v) = %q, want %q", tt.cardType, got, tt.want) + } + }) + } +} + +func TestIntentCycle(t *testing.T) { + order := []intent{intentAll, intentGrab, intentRead, intentFill, intentAll} + for i := 0; i < len(order)-1; i++ { + got := order[i].next() + if got != order[i+1] { + t.Fatalf("%v.next() = %v, want %v", order[i], got, order[i+1]) + } + } +} + +func TestApplyFilter_PinnedFirst(t *testing.T) { + snippet := db.CardSnippet + c := newCardsModel() + c.setEntities([]*db.Entity{ + {ID: "1", Body: "a", CardType: &snippet}, + {ID: "2", Body: "b", Pinned: true, CardType: &snippet}, + {ID: "3", Body: "c", CardType: &snippet}, + }) + + if len(c.filtered) != 3 { + t.Fatalf("expected 3 filtered, got %d", len(c.filtered)) + } + if c.filtered[0].ID != "2" { + t.Fatalf("pinned entity should be first, got %s", c.filtered[0].ID) + } +} + +func TestApplyFilter_CursorClamps(t *testing.T) { + snippet := db.CardSnippet + template := db.CardTemplate + c := newCardsModel() + c.setEntities([]*db.Entity{ + {ID: "1", Body: "a", CardType: &snippet}, + {ID: "2", Body: "b", CardType: &snippet}, + {ID: "3", Body: "c", CardType: &template}, + }) + c.cursor = 2 + + c.setIntent(intentFill) + if len(c.filtered) != 1 { + t.Fatalf("expected 1 fill entity, got %d", len(c.filtered)) + } + if c.cursor != 0 { + t.Fatalf("cursor should clamp to 0, got %d", c.cursor) + } +} diff --git a/internal/tui/fill_test.go b/internal/tui/fill_test.go new file mode 100644 index 0000000..c0617df --- /dev/null +++ b/internal/tui/fill_test.go @@ -0,0 +1,81 @@ +package tui + +import ( + "testing" +) + +func TestDiscoverSlots(t *testing.T) { + tests := []struct { + name string + body string + wantNames []string + }{ + {"no slots", "plain text", nil}, + {"single slot", "Hello ${name}", []string{"name"}}, + {"multiple slots", "${greeting} ${name}, welcome to ${place}", []string{"greeting", "name", "place"}}, + {"duplicate slot deduped", "${x} and ${x} again", []string{"x"}}, + {"adjacent slots", "${a}${b}", []string{"a", "b"}}, + {"nested braces ignored", "${{bad}}", nil}, + {"empty body", "", nil}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := discoverSlots(tt.body) + if len(tt.wantNames) == 0 && len(got) == 0 { + return + } + if len(got) != len(tt.wantNames) { + t.Fatalf("got %d slots, want %d", len(got), len(tt.wantNames)) + } + for i, s := range got { + if s.Name != tt.wantNames[i] { + t.Fatalf("slot[%d].Name = %q, want %q", i, s.Name, tt.wantNames[i]) + } + } + }) + } +} + +func TestResolve(t *testing.T) { + tests := []struct { + name string + body string + slots []fillSlot + want string + }{ + { + "all filled", + "Hello ${name}, welcome to ${place}", + []fillSlot{{Name: "name", Value: "Alice"}, {Name: "place", Value: "Nib"}}, + "Hello Alice, welcome to Nib", + }, + { + "unfilled stays as placeholder", + "${greeting} ${name}", + []fillSlot{{Name: "greeting", Value: "Hi"}, {Name: "name"}}, + "Hi ${name}", + }, + { + "no slots", + "plain text", + nil, + "plain text", + }, + { + "repeated slot filled everywhere", + "${x} and ${x}", + []fillSlot{{Name: "x", Value: "Y"}}, + "Y and Y", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f := fillModel{body: tt.body, slots: tt.slots} + if got := f.resolve(); got != tt.want { + t.Fatalf("resolve() = %q, want %q", got, tt.want) + } + }) + } +} diff --git a/internal/tui/list_test.go b/internal/tui/list_test.go new file mode 100644 index 0000000..bd90954 --- /dev/null +++ b/internal/tui/list_test.go @@ -0,0 +1,94 @@ +package tui + +import ( + "testing" + "time" + + "github.com/lerko/nib/internal/db" +) + +func TestGroupByDate(t *testing.T) { + may19 := time.Date(2026, 5, 19, 10, 0, 0, 0, time.UTC) + may19b := time.Date(2026, 5, 19, 14, 0, 0, 0, time.UTC) + may18 := time.Date(2026, 5, 18, 9, 0, 0, 0, time.UTC) + + entities := []*db.Entity{ + {ID: "1", CreatedAt: may19, Body: "a"}, + {ID: "2", CreatedAt: may19b, Body: "b"}, + {ID: "3", CreatedAt: may18, Body: "c"}, + } + + groups := groupByDate(entities) + if len(groups) != 2 { + t.Fatalf("expected 2 groups, got %d", len(groups)) + } + if len(groups[0].entities) != 2 { + t.Fatalf("first group should have 2 entities, got %d", len(groups[0].entities)) + } + if len(groups[1].entities) != 1 { + t.Fatalf("second group should have 1 entity, got %d", len(groups[1].entities)) + } + if groups[0].label != "may 19" { + t.Fatalf("first group label = %q, want %q", groups[0].label, "may 19") + } +} + +func TestGroupByDate_Empty(t *testing.T) { + groups := groupByDate(nil) + if len(groups) != 0 { + t.Fatalf("expected 0 groups, got %d", len(groups)) + } +} + +func TestGroupByDate_SingleEntity(t *testing.T) { + e := []*db.Entity{{ID: "1", CreatedAt: time.Now(), Body: "solo"}} + groups := groupByDate(e) + if len(groups) != 1 || len(groups[0].entities) != 1 { + t.Fatal("single entity should produce 1 group with 1 entity") + } +} + +func TestTruncate(t *testing.T) { + tests := []struct { + name string + input string + maxLen int + want string + }{ + {"short enough", "hello", 10, "hello"}, + {"exact length", "hello", 5, "hello"}, + {"truncated", "hello world", 6, "hello…"}, + {"very short max", "hello", 3, "…"}, + {"unicode", "héllo wörld", 7, "héllo …"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := truncate(tt.input, tt.maxLen) + if got != tt.want { + t.Fatalf("truncate(%q, %d) = %q, want %q", tt.input, tt.maxLen, got, tt.want) + } + }) + } +} + +func TestStripAnsi(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + {"no ansi", "hello", "hello"}, + {"with color", "\x1b[31mred\x1b[0m", "red"}, + {"empty", "", ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := stripAnsi(tt.input) + if got != tt.want { + t.Fatalf("stripAnsi(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} diff --git a/internal/tui/run_test.go b/internal/tui/run_test.go new file mode 100644 index 0000000..5edf803 --- /dev/null +++ b/internal/tui/run_test.go @@ -0,0 +1,65 @@ +package tui + +import ( + "encoding/json" + "testing" +) + +func TestParseChecklist(t *testing.T) { + tests := []struct { + name string + cardData *string + wantLen int + }{ + {"nil data", nil, 0}, + {"empty JSON", ptr("{}"), 0}, + {"malformed JSON", ptr("{bad"), 0}, + {"valid steps", ptr(`{"steps":[{"text":"step 1","done":false},{"text":"step 2","done":true}]}`), 2}, + {"empty steps array", ptr(`{"steps":[]}`), 0}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := parseChecklist(tt.cardData) + if len(got) != tt.wantLen { + t.Fatalf("parseChecklist() returned %d steps, want %d", len(got), tt.wantLen) + } + }) + } +} + +func TestParseChecklist_PreservesDoneState(t *testing.T) { + data := `{"steps":[{"text":"first","done":false},{"text":"second","done":true}]}` + steps := parseChecklist(&data) + if steps[0].Done { + t.Fatal("step 0 should not be done") + } + if !steps[1].Done { + t.Fatal("step 1 should be done") + } + if steps[0].Text != "first" || steps[1].Text != "second" { + t.Fatalf("texts wrong: %q, %q", steps[0].Text, steps[1].Text) + } +} + +func TestDoneCount(t *testing.T) { + r := newRunModel("id", ptr(`{"steps":[{"label":"a","done":true},{"label":"b","done":false},{"label":"c","done":true}]}`)) + if got := r.doneCount(); got != 2 { + t.Fatalf("doneCount() = %d, want 2", got) + } +} + +func TestStepsJSON_Roundtrip(t *testing.T) { + r := newRunModel("id", ptr(`{"steps":[{"text":"test","done":false}]}`)) + out := r.stepsJSON() + + var parsed struct { + Steps []runStep `json:"steps"` + } + if err := json.Unmarshal([]byte(out), &parsed); err != nil { + t.Fatalf("stepsJSON() produced invalid JSON: %v", err) + } + if len(parsed.Steps) != 1 || parsed.Steps[0].Text != "test" { + t.Fatalf("roundtrip failed: %+v", parsed.Steps) + } +} diff --git a/internal/tui/search_test.go b/internal/tui/search_test.go new file mode 100644 index 0000000..d805c91 --- /dev/null +++ b/internal/tui/search_test.go @@ -0,0 +1,70 @@ +package tui + +import ( + "testing" + + "github.com/lerko/nib/internal/db" +) + +func ptr[T any](v T) *T { return &v } + +func TestFilterEntities(t *testing.T) { + entities := []*db.Entity{ + {ID: "1", Body: "buy groceries", Tags: []string{"errand", "food"}}, + {ID: "2", Body: "read chapter 5", Title: ptr("Go Book"), Tags: []string{"study"}}, + {ID: "3", Body: "fix login bug", Description: ptr("auth middleware broken"), Tags: []string{"work", "urgent"}}, + {ID: "4", Body: "empty tags"}, + } + + tests := []struct { + name string + query string + tags []string + wantIDs []string + }{ + {"no filter returns all", "", nil, []string{"1", "2", "3", "4"}}, + {"query matches body", "groceries", nil, []string{"1"}}, + {"query case insensitive", "GROCERIES", nil, []string{"1"}}, + {"query matches title", "go book", nil, []string{"2"}}, + {"query matches description", "middleware", nil, []string{"3"}}, + {"query no match", "nonexistent", nil, nil}, + {"single tag filter", "", []string{"study"}, []string{"2"}}, + {"multi tag filter all present", "", []string{"work", "urgent"}, []string{"3"}}, + {"multi tag filter partial miss", "", []string{"work", "food"}, nil}, + {"tag filter no match", "", []string{"missing"}, nil}, + {"query plus tag", "fix", []string{"work"}, []string{"3"}}, + {"query plus tag mismatch", "groceries", []string{"work"}, nil}, + {"entity with nil title and description", "empty", nil, []string{"4"}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := filterEntities(entities, tt.query, tt.tags) + gotIDs := make([]string, len(got)) + for i, e := range got { + gotIDs[i] = e.ID + } + if len(tt.wantIDs) == 0 && len(gotIDs) == 0 { + return + } + if len(gotIDs) != len(tt.wantIDs) { + t.Fatalf("got %v, want %v", gotIDs, tt.wantIDs) + } + for i := range gotIDs { + if gotIDs[i] != tt.wantIDs[i] { + t.Fatalf("got %v, want %v", gotIDs, tt.wantIDs) + } + } + }) + } +} + +func TestMatchesSearch_NilFields(t *testing.T) { + e := &db.Entity{Body: "hello world"} + if !matchesSearch(e, "hello", nil) { + t.Fatal("should match body") + } + if matchesSearch(e, "title", nil) { + t.Fatal("should not match nil title") + } +}