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>
151 lines
3.7 KiB
Go
151 lines
3.7 KiB
Go
package cmd
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
"github.com/spf13/cobra"
|
|
"github.com/tayloree/publix-deals/internal/api"
|
|
"github.com/tayloree/publix-deals/internal/display"
|
|
"github.com/tayloree/publix-deals/internal/filter"
|
|
"golang.org/x/term"
|
|
)
|
|
|
|
var tuiCmd = &cobra.Command{
|
|
Use: "tui",
|
|
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,
|
|
}
|
|
|
|
func init() {
|
|
rootCmd.AddCommand(tuiCmd)
|
|
registerDealFilterFlags(tuiCmd.Flags())
|
|
}
|
|
|
|
func runTUI(cmd *cobra.Command, _ []string) error {
|
|
if err := validateSortMode(); err != nil {
|
|
return err
|
|
}
|
|
|
|
initialOpts := filter.Options{
|
|
BOGO: flagBogo,
|
|
Category: flagCategory,
|
|
Department: flagDepartment,
|
|
Query: flagQuery,
|
|
Sort: flagSort,
|
|
Limit: flagLimit,
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
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 {
|
|
inputFile, ok := stdin.(*os.File)
|
|
if !ok {
|
|
return false
|
|
}
|
|
if !term.IsTerminal(int(inputFile.Fd())) {
|
|
return false
|
|
}
|
|
return isTTY(stdout)
|
|
}
|