Rewrite TUI as full-screen Bubble Tea two-pane interactive browser
Replace the line-based stdin pagination loop with a charmbracelet/bubbletea
application providing a responsive two-pane layout: a filterable deal list
on the left and a scrollable detail viewport on the right.
Key capabilities:
- Async startup: spinner + skeleton while store/deals are fetched, then
transition to the interactive view. Fatal load errors propagate cleanly.
- Grouped sections: deals are bucketed by category (BOGO first, then by
count descending) with numbered section headers. Jump with [/] for
prev/next and 1-9 for direct section access.
- Inline filter cycling: s (sort: relevance/savings/ending), g (bogo
toggle), c (category cycle), a (department cycle), l (limit cycle),
r (reset to CLI-start defaults). Filters rebuild the list while
preserving cursor position via stable IDs.
- Fuzzy search: / activates bubbletea's built-in list filter. Section
jumps are disabled while a fuzzy filter is active.
- Detail pane: full deal metadata with word-wrapped text, scrollable
via j/k, u/d (half-page), b/f (full page). Tab switches focus.
- Terminal size awareness: minimum 92x24, graceful too-small message.
- JSON mode: tui --json fetches and filters without launching the
interactive UI, consistent with other commands.
New files:
cmd/tui_model.go — Bubble Tea model, view, update, grouping logic
cmd/tui_model_test.go — Tests for sort canonicalization, group ordering,
and category choice building
Dependencies added: charmbracelet/bubbles, charmbracelet/bubbletea.
Transitive deps upgraded: lipgloss, x/ansi, x/cellbuf, x/term, go-colorful,
go-runewidth, sys.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
195
cmd/tui.go
195
cmd/tui.go
@@ -1,13 +1,12 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/tayloree/publix-deals/internal/api"
|
||||
"github.com/tayloree/publix-deals/internal/display"
|
||||
@@ -15,11 +14,9 @@ import (
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
const tuiPageSize = 10
|
||||
|
||||
var tuiCmd = &cobra.Command{
|
||||
Use: "tui",
|
||||
Short: "Browse deals interactively in the terminal",
|
||||
Short: "Browse deals in a full-screen interactive terminal UI",
|
||||
Example: ` pubcli tui --zip 33101
|
||||
pubcli tui --store 1425 --category produce --sort ending`,
|
||||
RunE: runTUI,
|
||||
@@ -34,50 +31,111 @@ func runTUI(cmd *cobra.Command, _ []string) error {
|
||||
if err := validateSortMode(); err != nil {
|
||||
return err
|
||||
}
|
||||
if !flagJSON && !isInteractiveSession(cmd.InOrStdin(), cmd.OutOrStdout()) {
|
||||
return invalidArgsError(
|
||||
"`pubcli tui` requires an interactive terminal",
|
||||
"Use `pubcli --zip 33101 --json` in pipelines.",
|
||||
)
|
||||
}
|
||||
|
||||
client := api.NewClient()
|
||||
storeNumber, err := resolveStore(cmd, client)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resp, err := client.FetchSavings(cmd.Context(), storeNumber)
|
||||
if err != nil {
|
||||
return upstreamError("fetching deals", err)
|
||||
}
|
||||
if len(resp.Savings) == 0 {
|
||||
return notFoundError(
|
||||
fmt.Sprintf("no deals found for store #%s", storeNumber),
|
||||
"Try another store with --store.",
|
||||
)
|
||||
}
|
||||
|
||||
items := filter.Apply(resp.Savings, filter.Options{
|
||||
initialOpts := filter.Options{
|
||||
BOGO: flagBogo,
|
||||
Category: flagCategory,
|
||||
Department: flagDepartment,
|
||||
Query: flagQuery,
|
||||
Sort: flagSort,
|
||||
Limit: flagLimit,
|
||||
})
|
||||
if len(items) == 0 {
|
||||
return notFoundError(
|
||||
"no deals match your filters",
|
||||
"Relax filters like --category/--department/--query.",
|
||||
)
|
||||
}
|
||||
|
||||
if flagJSON {
|
||||
_, _, rawItems, err := loadTUIData(cmd.Context(), flagStore, flagZip)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
items := filter.Apply(rawItems, initialOpts)
|
||||
if len(items) == 0 {
|
||||
return notFoundError(
|
||||
"no deals match your filters",
|
||||
"Relax filters like --category/--department/--query.",
|
||||
)
|
||||
}
|
||||
return display.PrintDealsJSON(cmd.OutOrStdout(), items)
|
||||
}
|
||||
|
||||
return runTUILoop(cmd.OutOrStdout(), cmd.InOrStdin(), storeNumber, items)
|
||||
if !isInteractiveSession(cmd.InOrStdin(), cmd.OutOrStdout()) {
|
||||
return invalidArgsError(
|
||||
"`pubcli tui` requires an interactive terminal",
|
||||
"Use `pubcli --zip 33101 --json` in pipelines.",
|
||||
)
|
||||
}
|
||||
|
||||
model := newLoadingDealsTUIModel(tuiLoadConfig{
|
||||
ctx: cmd.Context(),
|
||||
storeNumber: flagStore,
|
||||
zipCode: flagZip,
|
||||
initialOpts: initialOpts,
|
||||
})
|
||||
|
||||
program := tea.NewProgram(
|
||||
model,
|
||||
tea.WithAltScreen(),
|
||||
tea.WithInput(cmd.InOrStdin()),
|
||||
tea.WithOutput(cmd.OutOrStdout()),
|
||||
)
|
||||
|
||||
finalModel, err := program.Run()
|
||||
if err != nil {
|
||||
return fmt.Errorf("running tui: %w", err)
|
||||
}
|
||||
if finalState, ok := finalModel.(dealsTUIModel); ok && finalState.fatalErr != nil {
|
||||
return finalState.fatalErr
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func resolveStoreForTUI(ctx context.Context, client *api.Client, storeNumber, zipCode string) (resolvedStoreNumber, storeLabel string, err error) {
|
||||
if storeNumber != "" {
|
||||
return storeNumber, "#" + storeNumber, nil
|
||||
}
|
||||
if zipCode == "" {
|
||||
return "", "", invalidArgsError(
|
||||
"please provide --store NUMBER or --zip ZIPCODE",
|
||||
"pubcli tui --zip 33101",
|
||||
"pubcli tui --store 1425",
|
||||
)
|
||||
}
|
||||
|
||||
stores, err := client.FetchStores(ctx, zipCode, 1)
|
||||
if err != nil {
|
||||
return "", "", upstreamError("finding stores", err)
|
||||
}
|
||||
if len(stores) == 0 {
|
||||
return "", "", notFoundError(
|
||||
fmt.Sprintf("no Publix stores found near %s", zipCode),
|
||||
"Try a nearby ZIP code.",
|
||||
)
|
||||
}
|
||||
|
||||
store := stores[0]
|
||||
resolvedStoreNumber = api.StoreNumber(store.Key)
|
||||
storeLabel = fmt.Sprintf("#%s — %s (%s, %s)", resolvedStoreNumber, store.Name, store.City, store.State)
|
||||
return resolvedStoreNumber, storeLabel, nil
|
||||
}
|
||||
|
||||
func loadTUIData(ctx context.Context, storeNumber, zipCode string) (resolvedStoreNumber, storeLabel string, items []api.SavingItem, err error) {
|
||||
client := api.NewClient()
|
||||
|
||||
resolvedStoreNumber, storeLabel, err = resolveStoreForTUI(ctx, client, storeNumber, zipCode)
|
||||
if err != nil {
|
||||
return "", "", nil, err
|
||||
}
|
||||
|
||||
resp, err := client.FetchSavings(ctx, resolvedStoreNumber)
|
||||
if err != nil {
|
||||
return "", "", nil, upstreamError("fetching deals", err)
|
||||
}
|
||||
if len(resp.Savings) == 0 {
|
||||
return "", "", nil, notFoundError(
|
||||
fmt.Sprintf("no deals found for store #%s", resolvedStoreNumber),
|
||||
"Try another store with --store.",
|
||||
)
|
||||
}
|
||||
|
||||
return resolvedStoreNumber, storeLabel, resp.Savings, nil
|
||||
}
|
||||
|
||||
func isInteractiveSession(stdin io.Reader, stdout io.Writer) bool {
|
||||
@@ -90,66 +148,3 @@ func isInteractiveSession(stdin io.Reader, stdout io.Writer) bool {
|
||||
}
|
||||
return isTTY(stdout)
|
||||
}
|
||||
|
||||
func runTUILoop(out io.Writer, in io.Reader, storeNumber string, items []api.SavingItem) error {
|
||||
reader := bufio.NewReader(in)
|
||||
page := 0
|
||||
totalPages := (len(items)-1)/tuiPageSize + 1
|
||||
|
||||
for {
|
||||
renderTUIPage(out, storeNumber, items, page, totalPages)
|
||||
|
||||
line, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
cmd := strings.TrimSpace(strings.ToLower(line))
|
||||
|
||||
switch cmd {
|
||||
case "q", "quit", "exit":
|
||||
return nil
|
||||
case "n", "next":
|
||||
if page < totalPages-1 {
|
||||
page++
|
||||
}
|
||||
case "p", "prev", "previous":
|
||||
if page > 0 {
|
||||
page--
|
||||
}
|
||||
default:
|
||||
idx, convErr := strconv.Atoi(cmd)
|
||||
if convErr != nil || idx < 1 || idx > len(items) {
|
||||
continue
|
||||
}
|
||||
renderDealDetail(out, items[idx-1], idx, len(items))
|
||||
if _, err := reader.ReadString('\n'); err != nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func renderTUIPage(out io.Writer, storeNumber string, items []api.SavingItem, page, totalPages int) {
|
||||
fmt.Fprint(out, "\033[H\033[2J")
|
||||
fmt.Fprintf(out, "pubcli tui | store #%s | %d deals | page %d/%d\n\n", storeNumber, len(items), page+1, totalPages)
|
||||
|
||||
start := page * tuiPageSize
|
||||
end := minInt(start+tuiPageSize, len(items))
|
||||
for i := start; i < end; i++ {
|
||||
item := items[i]
|
||||
title := topDealTitle(item)
|
||||
savings := filter.CleanText(filter.Deref(item.Savings))
|
||||
if savings == "" {
|
||||
savings = "-"
|
||||
}
|
||||
fmt.Fprintf(out, "%2d. %s [%s]\n", i+1, title, savings)
|
||||
}
|
||||
fmt.Fprintf(out, "\ncommands: number=details | n=next | p=prev | q=quit\n> ")
|
||||
}
|
||||
|
||||
func renderDealDetail(out io.Writer, item api.SavingItem, index, total int) {
|
||||
fmt.Fprint(out, "\033[H\033[2J")
|
||||
fmt.Fprintf(out, "deal %d/%d\n\n", index, total)
|
||||
display.PrintDeals(out, []api.SavingItem{item})
|
||||
fmt.Fprint(out, "press Enter to return\n")
|
||||
}
|
||||
|
||||
1128
cmd/tui_model.go
Normal file
1128
cmd/tui_model.go
Normal file
File diff suppressed because it is too large
Load Diff
62
cmd/tui_model_test.go
Normal file
62
cmd/tui_model_test.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/tayloree/publix-deals/internal/api"
|
||||
)
|
||||
|
||||
func strPtr(value string) *string { return &value }
|
||||
|
||||
func TestCanonicalSortMode(t *testing.T) {
|
||||
assert.Equal(t, "savings", canonicalSortMode("savings"))
|
||||
assert.Equal(t, "ending", canonicalSortMode("end"))
|
||||
assert.Equal(t, "ending", canonicalSortMode("expiry"))
|
||||
assert.Equal(t, "ending", canonicalSortMode("expiration"))
|
||||
assert.Equal(t, "", canonicalSortMode("relevance"))
|
||||
assert.Equal(t, "", canonicalSortMode("unknown"))
|
||||
}
|
||||
|
||||
func TestBuildGroupedListItems_BogoFirstAndNumberedHeaders(t *testing.T) {
|
||||
deals := []api.SavingItem{
|
||||
{ID: "1", Title: strPtr("Bananas"), Categories: []string{"produce"}},
|
||||
{ID: "2", Title: strPtr("Chicken"), Categories: []string{"meat", "bogo"}},
|
||||
{ID: "3", Title: strPtr("Apples"), Categories: []string{"produce"}},
|
||||
{ID: "4", Title: strPtr("Ground Beef"), Categories: []string{"meat"}},
|
||||
}
|
||||
|
||||
items, starts := buildGroupedListItems(deals)
|
||||
|
||||
assert.NotEmpty(t, items)
|
||||
assert.Equal(t, []int{0, 2, 5}, starts)
|
||||
|
||||
header, ok := items[0].(tuiGroupItem)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, "BOGO", header.name)
|
||||
assert.Equal(t, 1, header.ordinal)
|
||||
|
||||
header2, ok := items[2].(tuiGroupItem)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, "Produce", header2.name)
|
||||
assert.Equal(t, 2, header2.count)
|
||||
|
||||
header3, ok := items[5].(tuiGroupItem)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, "Meat", header3.name)
|
||||
assert.Equal(t, 1, header3.count)
|
||||
}
|
||||
|
||||
func TestBuildCategoryChoices_AlwaysIncludesCurrent(t *testing.T) {
|
||||
deals := []api.SavingItem{
|
||||
{Categories: []string{"produce"}},
|
||||
{Categories: []string{"meat"}},
|
||||
}
|
||||
|
||||
choices := buildCategoryChoices(deals, "seafood")
|
||||
|
||||
assert.Contains(t, choices, "")
|
||||
assert.Contains(t, choices, "produce")
|
||||
assert.Contains(t, choices, "meat")
|
||||
assert.Contains(t, choices, "seafood")
|
||||
}
|
||||
Reference in New Issue
Block a user