diff options
| author | Adam Mathes <adam@adammathes.com> | 2026-02-12 20:42:09 -0800 |
|---|---|---|
| committer | Adam Mathes <adam@adammathes.com> | 2026-02-12 20:42:09 -0800 |
| commit | fa037e748bb2ba65f75ba104e18d460a8fbcd49b (patch) | |
| tree | 9a6c33024475ff6fd08013e39aee9717296cc638 | |
| parent | 27d14514ab3c3c11e822fc2c1220072298e85c02 (diff) | |
| download | neko-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.jsonl | 2 | ||||
| -rw-r--r-- | go.mod | 29 | ||||
| -rw-r--r-- | main.go | 12 | ||||
| -rw-r--r-- | tui/style.go | 43 | ||||
| -rw-r--r-- | tui/tui.go | 191 |
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"} @@ -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 ) @@ -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 +} |
