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:
2026-02-23 01:32:10 -05:00
parent f486150c06
commit da327fe759
5 changed files with 1352 additions and 125 deletions

View File

@@ -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

File diff suppressed because it is too large Load Diff

62
cmd/tui_model_test.go Normal file
View 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")
}