Files
pubcli/cmd/tui.go
teernisse da327fe759 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>
2026-02-23 01:32:10 -05:00

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)
}