aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Mathes <adam@adammathes.com>2026-02-12 20:42:09 -0800
committerAdam Mathes <adam@adammathes.com>2026-02-12 20:42:09 -0800
commitfa037e748bb2ba65f75ba104e18d460a8fbcd49b (patch)
tree9a6c33024475ff6fd08013e39aee9717296cc638
parent27d14514ab3c3c11e822fc2c1220072298e85c02 (diff)
downloadneko-fa037e748bb2ba65f75ba104e18d460a8fbcd49b.tar.gz
neko-fa037e748bb2ba65f75ba104e18d460a8fbcd49b.tar.bz2
neko-fa037e748bb2ba65f75ba104e18d460a8fbcd49b.zip
Implement Bubble Tea Terminal UI (TUI)
- Added Bubble Tea, Lipgloss, and Bubbles dependencies - Implemented TUI package in tui/ for browsing feeds and items - Added --tui flag to main command - Verified build and existing tests
-rw-r--r--.thicket/tickets.jsonl2
-rw-r--r--go.mod29
-rw-r--r--main.go12
-rw-r--r--tui/style.go43
-rw-r--r--tui/tui.go191
5 files changed, 272 insertions, 5 deletions
diff --git a/.thicket/tickets.jsonl b/.thicket/tickets.jsonl
index 9cfa0e0..6f62686 100644
--- a/.thicket/tickets.jsonl
+++ b/.thicket/tickets.jsonl
@@ -1,6 +1,6 @@
{"id":"NK-1phdpf","title":"refactor backend to have a clean API","description":"create a nice clean API for the backend GO code that is more independent of the frontend\n\nensure that it is working with good tests","type":"task","status":"closed","priority":1,"labels":null,"assignee":"","created":"2026-02-13T01:52:49.8322638Z","updated":"2026-02-13T04:26:47.517515371Z"}
{"id":"NK-6q9nyg","title":"Refactor HTTP-dependent functions for testability","description":"Several functions use http.Get or external libraries directly (GetFullContent uses goose, ResolveFeedURL uses http.Get + goquery, imageProxyHandler uses http.Client). Refactor these to accept interfaces for HTTP fetching so they can be unit tested with mocks. This is the primary blocker for reaching 90% coverage.","type":"task","status":"open","priority":3,"labels":null,"assignee":"","created":"2026-02-13T03:54:37.630148644Z","updated":"2026-02-13T03:54:37.630148644Z"}
-{"id":"NK-bsdwqz","title":"terminal UI","description":"once there is good test coverage and a clean backend API, work on a nice efficient TUI with https://github.com/charmbracelet/bubbletea","type":"task","status":"open","priority":1,"labels":null,"assignee":"","created":"2026-02-13T01:54:02.285738454Z","updated":"2026-02-13T01:54:02.285738454Z"}
+{"id":"NK-bsdwqz","title":"terminal UI","description":"once there is good test coverage and a clean backend API, work on a nice efficient TUI with https://github.com/charmbracelet/bubbletea","type":"task","status":"closed","priority":1,"labels":null,"assignee":"","created":"2026-02-13T01:54:02.285738454Z","updated":"2026-02-13T04:42:09.824268427Z"}
{"id":"NK-ed1iah","title":"Make feed crawling async in API","description":"Currently, POST /api/feed triggers an immediate crawl which blocks the response (or at least keeps the goroutine alive). Refactor the crawling architecture to be truly async with a job queue or status updates, improving API responsiveness and reliability.","type":"task","status":"open","priority":2,"labels":null,"assignee":"","created":"2026-02-13T04:26:55.908243985Z","updated":"2026-02-13T04:26:55.908243985Z"}
{"id":"NK-gqkh96","title":"Remaining test coverage gaps","description":"Cross-package test coverage is at 81.2%. The remaining untested functions are: GetFullContent (goose HTTP extraction), indexHandler/serveBoxedFile (rice.MustFindBox), Serve (starts HTTP server), main, util.init. To reach 90%, consider: (1) refactoring GetFullContent to accept an interface for HTTP fetching, (2) refactoring Serve to extract route setup into a testable function, (3) mocking rice.MustFindBox, (4) using feeds from https://trenchant.org/feeds.txt as static test fixtures for integration tests.","type":"task","status":"open","priority":3,"labels":null,"assignee":"","created":"2026-02-13T03:54:30.298141982Z","updated":"2026-02-13T03:54:30.298141982Z"}
{"id":"NK-ric1zs","title":"Migrate frontend to /api/ endpoints","description":"The backend now provides a clean REST API at /api/. Update the frontend UI to use these new endpoints instead of the legacy backward-compatibility routes (/stream/, /feed/, etc.). This will allow for cleaner separation and better utilization of proper REST patterns.","type":"task","status":"open","priority":2,"labels":null,"assignee":"","created":"2026-02-13T04:26:55.864725765Z","updated":"2026-02-13T04:26:55.864725765Z"}
diff --git a/go.mod b/go.mod
index 0f60034..4da9b78 100644
--- a/go.mod
+++ b/go.mod
@@ -1,13 +1,16 @@
module adammathes.com/neko
-go 1.23.0
+go 1.24.2
-toolchain go1.24.3
+toolchain go1.24.4
require (
github.com/GeertJohan/go.rice v1.0.3
github.com/PuerkitoBio/goquery v1.8.1
github.com/advancedlogic/GoOse v0.0.0-20200830213114-1225d531e0ad
+ github.com/charmbracelet/bubbles v1.0.0
+ github.com/charmbracelet/bubbletea v1.3.10
+ github.com/charmbracelet/lipgloss v1.1.0
github.com/mattn/go-sqlite3 v1.14.16
github.com/microcosm-cc/bluemonday v1.0.26
github.com/mmcdole/gofeed v1.1.0
@@ -20,22 +23,42 @@ require (
require (
github.com/andybalholm/cascadia v1.3.2 // indirect
github.com/araddon/dateparse v0.0.0-20201001162425-8aadafed4dc4 // indirect
+ github.com/atotto/clipboard v0.1.4 // indirect
+ github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
+ github.com/charmbracelet/colorprofile v0.4.1 // 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/daaku/go.zipexe v1.0.2 // indirect
+ github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/fatih/set v0.2.1 // indirect
github.com/gigawattio/window v0.0.0-20180317192513-0f5467e35573 // indirect
github.com/go-resty/resty/v2 v2.3.0 // indirect
github.com/gorilla/css v1.0.0 // indirect
github.com/jaytaylor/html2text v0.0.0-20200412013138-3577fbdbcff7 // indirect
github.com/json-iterator/go v1.1.10 // indirect
- github.com/mattn/go-runewidth v0.0.9 // 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/mmcdole/goxpp v0.0.0-20200921145534-2f3784f67354 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.1 // 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/olekukonko/tablewriter v0.0.4 // indirect
github.com/pkg/errors v0.9.1 // indirect
+ github.com/rivo/uniseg v0.4.7 // indirect
+ github.com/sahilm/fuzzy v0.1.1 // indirect
github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf // indirect
+ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/net v0.21.0 // indirect
+ golang.org/x/sys v0.38.0 // indirect
golang.org/x/text v0.22.0 // indirect
)
diff --git a/main.go b/main.go
index 0ddc39c..432e4d3 100644
--- a/main.go
+++ b/main.go
@@ -2,6 +2,7 @@ package main
import (
"fmt"
+ "log"
"time"
"adammathes.com/neko/config"
@@ -9,6 +10,7 @@ import (
"adammathes.com/neko/exporter"
"adammathes.com/neko/models"
"adammathes.com/neko/models/feed"
+ "adammathes.com/neko/tui"
"adammathes.com/neko/vlog"
"adammathes.com/neko/web"
flag "github.com/ogier/pflag"
@@ -17,7 +19,7 @@ import (
var Version, Build string
func main() {
- var help, update, verbose, proxyImages bool
+ var help, update, verbose, proxyImages, tuiMode bool
var configFile, dbfile, newFeed, export, password string
var port, minutes int
@@ -35,6 +37,7 @@ func main() {
flag.IntVarP(&port, "http", "s", 0, "HTTP port to serve on")
flag.IntVarP(&minutes, "minutes", "m", 0, "minutes between crawling feeds")
flag.BoolVarP(&proxyImages, "imageproxy", "i", false, "rewrite and proxy all image requests for privacy (experimental)")
+ flag.BoolVarP(&tuiMode, "tui", "t", false, "launch terminal UI")
flag.BoolVarP(&verbose, "verbose", "v", false, "verbose output")
// passwords on command line are bad, you should use the config file
@@ -92,6 +95,13 @@ func main() {
return
}
+ if tuiMode {
+ if err := tui.Run(); err != nil {
+ log.Fatal(err)
+ }
+ return
+ }
+
go backgroundCrawl(config.Config.CrawlMinutes)
vlog.Printf("starting web server at 127.0.0.1:%d\n",
config.Config.Port)
diff --git a/tui/style.go b/tui/style.go
new file mode 100644
index 0000000..7b21c78
--- /dev/null
+++ b/tui/style.go
@@ -0,0 +1,43 @@
+package tui
+
+import "github.com/charmbracelet/lipgloss"
+
+var (
+ // Colors
+ maroon = lipgloss.Color("#800000")
+ lavender = lipgloss.Color("#E6E6FA")
+ gray = lipgloss.Color("#808080")
+ darkGray = lipgloss.Color("#404040")
+
+ // Styles
+ TitleStyle = lipgloss.NewStyle().
+ Bold(true).
+ Foreground(lavender).
+ Background(maroon).
+ Padding(0, 1)
+
+ ListStyle = lipgloss.NewStyle().
+ Padding(1, 2)
+
+ SelectedItemStyle = lipgloss.NewStyle().
+ PaddingLeft(2).
+ Foreground(lavender).
+ Background(darkGray).
+ Bold(true)
+
+ ItemStyle = lipgloss.NewStyle().
+ PaddingLeft(2)
+
+ HeaderStyle = lipgloss.NewStyle().
+ Bold(true).
+ Foreground(maroon).
+ MarginBottom(1)
+
+ ContentStyle = lipgloss.NewStyle().
+ Padding(1, 4)
+
+ StatusStyle = lipgloss.NewStyle().
+ Foreground(gray).
+ Italic(true).
+ MarginTop(1)
+)
diff --git a/tui/tui.go b/tui/tui.go
new file mode 100644
index 0000000..54e654b
--- /dev/null
+++ b/tui/tui.go
@@ -0,0 +1,191 @@
+package tui
+
+import (
+ "fmt"
+ "strings"
+
+ "adammathes.com/neko/models/feed"
+ "adammathes.com/neko/models/item"
+ "github.com/charmbracelet/bubbles/list"
+ tea "github.com/charmbracelet/bubbletea"
+)
+
+type viewState int
+
+const (
+ viewFeeds viewState = iota
+ viewItems
+ viewContent
+)
+
+type itemDelegate struct{}
+
+func (d itemDelegate) Height() int { return 1 }
+func (d itemDelegate) Spacing() int { return 0 }
+func (d itemDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd { return nil }
+func (d itemDelegate) Render(w strings.Builder, m list.Model, index int, listItem list.Item) {
+ str, ok := listItem.(itemString)
+ if !ok {
+ return
+ }
+
+ if index == m.Index() {
+ fmt.Fprint(&w, SelectedItemStyle.Render(string(str)))
+ } else {
+ fmt.Fprint(&w, ItemStyle.Render(string(str)))
+ }
+}
+
+type itemString string
+
+func (i itemString) FilterValue() string { return string(i) }
+
+type Model struct {
+ state viewState
+ feedList list.Model
+ itemList list.Model
+ feeds []*feed.Feed
+ items []*item.Item
+ selectedFeed *feed.Feed
+ selectedItem *item.Item
+ err error
+ width int
+ height int
+}
+
+func NewModel() Model {
+ return Model{
+ state: viewFeeds,
+ }
+}
+
+func (m Model) Init() tea.Cmd {
+ return loadFeeds
+}
+
+type (
+ feedsMsg []*feed.Feed
+ itemsMsg []*item.Item
+ errMsg error
+)
+
+func loadFeeds() tea.Msg {
+ feeds, err := feed.All()
+ if err != nil {
+ return errMsg(err)
+ }
+ return feedsMsg(feeds)
+}
+
+func loadItems(feedID int64) tea.Cmd {
+ return func() tea.Msg {
+ items, err := item.Filter(0, feedID, "", false, false, 0, "")
+ if err != nil {
+ return errMsg(err)
+ }
+ return itemsMsg(items)
+ }
+}
+
+func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ var cmd tea.Cmd
+
+ switch msg := msg.(type) {
+ case tea.WindowSizeMsg:
+ m.width = msg.Width
+ m.height = msg.Height
+ m.feedList.SetSize(msg.Width, msg.Height-4)
+ m.itemList.SetSize(msg.Width, msg.Height-4)
+
+ case feedsMsg:
+ m.feeds = msg
+ items := make([]list.Item, len(m.feeds))
+ for i, f := range m.feeds {
+ items[i] = itemString(f.Title)
+ }
+ m.feedList = list.New(items, list.NewDefaultDelegate(), m.width, m.height-4)
+ m.feedList.Title = "Feeds"
+
+ case itemsMsg:
+ m.items = msg
+ items := make([]list.Item, len(m.items))
+ for i, it := range m.items {
+ items[i] = itemString(it.Title)
+ }
+ m.itemList = list.New(items, list.NewDefaultDelegate(), m.width, m.height-4)
+ m.itemList.Title = m.selectedFeed.Title
+ m.state = viewItems
+
+ case errMsg:
+ m.err = msg
+ return m, tea.Quit
+
+ case tea.KeyMsg:
+ switch msg.String() {
+ case "ctrl+c", "q":
+ if m.state == viewFeeds {
+ return m, tea.Quit
+ }
+ if m.state == viewItems {
+ m.state = viewFeeds
+ } else {
+ m.state = viewItems
+ }
+
+ case "enter":
+ if m.state == viewFeeds {
+ idx := m.feedList.Index()
+ if idx >= 0 && idx < len(m.feeds) {
+ m.selectedFeed = m.feeds[idx]
+ return m, loadItems(m.selectedFeed.Id)
+ }
+ } else if m.state == viewItems {
+ idx := m.itemList.Index()
+ if idx >= 0 && idx < len(m.items) {
+ m.selectedItem = m.items[idx]
+ m.state = viewContent
+ }
+ }
+ }
+ }
+
+ if m.state == viewFeeds {
+ m.feedList, cmd = m.feedList.Update(msg)
+ } else if m.state == viewItems {
+ m.itemList, cmd = m.itemList.Update(msg)
+ }
+
+ return m, cmd
+}
+
+func (m Model) View() string {
+ if m.err != nil {
+ return fmt.Sprintf("Error: %v", m.err)
+ }
+
+ var s strings.Builder
+ s.WriteString(TitleStyle.Render("NEKO TUI") + "\n\n")
+
+ switch m.state {
+ case viewFeeds:
+ s.WriteString(m.feedList.View())
+ case viewItems:
+ s.WriteString(m.itemList.View())
+ case viewContent:
+ if m.selectedItem != nil {
+ s.WriteString(HeaderStyle.Render(m.selectedItem.Title) + "\n")
+ s.WriteString(ContentStyle.Render(m.selectedItem.Description) + "\n\n")
+ s.WriteString(StatusStyle.Render("Press 'q' or 'esc' to go back"))
+ }
+ }
+
+ return s.String()
+}
+
+func Run() error {
+ p := tea.NewProgram(NewModel(), tea.WithAltScreen())
+ if _, err := p.Run(); err != nil {
+ return err
+ }
+ return nil
+}