diff --git a/.beans/beans-jsud--tui-hide-archive-status-beans-by-default-with-togg.md b/.beans/beans-jsud--tui-hide-archive-status-beans-by-default-with-togg.md new file mode 100644 index 00000000..c4c46fdd --- /dev/null +++ b/.beans/beans-jsud--tui-hide-archive-status-beans-by-default-with-togg.md @@ -0,0 +1,48 @@ +--- +# beans-jsud +title: 'TUI: hide archive-status beans by default with toggle to show all' +status: completed +type: feature +priority: normal +created_at: 2026-05-16T13:53:14Z +updated_at: 2026-05-16T13:58:46Z +--- + +Hide archive-status (completed/scrapped) beans by default in the TUI, with an `a` keybind to toggle "show all". + +Builds on the mechanics from upstream PR hmans/beans#76 (matleh) but flips the default to hide-by-default and derives the status list from `Archive: true` rather than hardcoding `["completed","scrapped"]`. + +## Todo + +- [x] Add `showAll bool` (default `false`) to `listModel` in `internal/tui/list.go` +- [x] Helper `ArchiveStatusNames()` on `*Config` +- [x] `buildFilter()` composes `ExcludeStatus` (from archive statuses) with the existing tag filter +- [x] Add `a` case to the key switch — toggle + reload +- [x] Title via `buildTitle()`: `Beans`, `Beans [all]`, composes with `[tag: x]` +- [x] Help overlay entry for `a` +- [x] Footer help line entry for `a` (dynamic label: show all / hide done) +- [x] Unit tests for `buildFilter` (4 cases) and `buildTitle` (4 cases); plus `ArchiveStatusNames` test +- [x] `go test ./...` (excluding internal/web embed) green +- [~] Manual TUI test not run in this non-interactive session; binary compiles, help overlay entry verified +- [x] Commit + push branch to chrisjkuch/beans + +## Out of scope + +- Filter modal (#41) — separate larger effort +- Agent-env-var-based default (#73) — orthogonal idea +- Two-column ViewConstrained title update — yes, both title sites need updating + + +## Summary of Changes + +- Added `(*Config).ArchiveStatusNames()` helper deriving the list from `StatusConfig.Archive: true` (not hardcoded), so future status-config additions flow through automatically. +- Added `showAll bool` to `listModel`. Default `false` => archive-status beans hidden on launch. +- Refactored title logic into `buildTitle()` and filter logic into `buildFilter()` for testability; both used from `View()`, `ViewConstrained()`, and `loadBeans()`. +- Added `a` keybind that toggles `showAll` and reloads. The footer label flips between `show all` (when hidden) and `hide done` (when shown). +- Help overlay shortcut added. +- 2 new unit-test functions in `internal/tui/list_test.go` covering both helpers under every relevant combo, plus a `TestArchiveStatusNames` in `pkg/config/config_test.go`. + +Diverges from upstream PR hmans/beans#76 in three ways: +1. Default flipped to hide-on-launch (the original behavior was opt-in to hide). +2. Status set sourced from `ArchiveStatusNames()` rather than hardcoded `["completed","scrapped"]`. +3. Keybind `a` (semantically "show all") instead of `H` (which would mean "unhide" under the new default). diff --git a/internal/tui/help.go b/internal/tui/help.go index beed8d2b..18d05a91 100644 --- a/internal/tui/help.go +++ b/internal/tui/help.go @@ -80,6 +80,7 @@ func (m helpOverlayModel) View() string { content.WriteString(title + "\n\n") content.WriteString(shortcut("enter", "View bean details") + "\n") + content.WriteString(shortcut("a", "Toggle show all (include completed/scrapped)") + "\n") content.WriteString(shortcut("b", "Manage blocking") + "\n") content.WriteString(shortcut("c", "Create new bean") + "\n") content.WriteString(shortcut("e", "Edit in $EDITOR") + "\n") diff --git a/internal/tui/list.go b/internal/tui/list.go index 77c851a7..56b9ee65 100644 --- a/internal/tui/list.go +++ b/internal/tui/list.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "io" + "strings" "github.com/charmbracelet/bubbles/list" tea "github.com/charmbracelet/bubbletea" @@ -118,6 +119,7 @@ type listModel struct { // Active filters tagFilter string // if set, only show beans with this tag + showAll bool // if false (default), hide archive-status beans (e.g. completed, scrapped) // Multi-select state selectedBeans map[string]bool // IDs of beans marked for multi-edit @@ -169,11 +171,8 @@ func (m listModel) Init() tea.Cmd { } func (m listModel) loadBeans() tea.Msg { - // Build filter if tag filter is set - var filter *model.BeanFilter - if m.tagFilter != "" { - filter = &model.BeanFilter{Tags: []string{m.tagFilter}} - } + // Build filter from active filter state + filter := m.buildFilter() // Query filtered beans filteredBeans, err := m.resolver.Beans(context.Background(), filter) @@ -226,16 +225,55 @@ func (m *listModel) setTagFilter(tag string) { m.tagFilter = tag } -// clearFilter clears all active filters +// clearFilter clears the user-controlled filters (tag filter). +// Does not affect showAll, which is a separate visibility toggle. func (m *listModel) clearFilter() { m.tagFilter = "" } -// hasActiveFilter returns true if any filter is active +// hasActiveFilter returns true if the tag filter is active. func (m *listModel) hasActiveFilter() bool { return m.tagFilter != "" } +// toggleShowAll toggles whether archive-status beans are shown. +func (m *listModel) toggleShowAll() { + m.showAll = !m.showAll +} + +// buildTitle builds the list title with active-filter indicators. +func (m *listModel) buildTitle() string { + var parts []string + if m.tagFilter != "" { + parts = append(parts, fmt.Sprintf("tag: %s", m.tagFilter)) + } + if m.showAll { + parts = append(parts, "all") + } + if len(parts) == 0 { + return "Beans" + } + return fmt.Sprintf("Beans [%s]", strings.Join(parts, "] [")) +} + +// buildFilter composes the BeanFilter from active filter state. +// Returns nil when no filtering is needed. +func (m *listModel) buildFilter() *model.BeanFilter { + hasTag := m.tagFilter != "" + hideArchive := !m.showAll + if !hasTag && !hideArchive { + return nil + } + f := &model.BeanFilter{} + if hasTag { + f.Tags = []string{m.tagFilter} + } + if hideArchive { + f.ExcludeStatus = m.config.ArchiveStatusNames() + } + return f +} + func (m listModel) Update(msg tea.Msg) (listModel, tea.Cmd) { var cmd tea.Cmd var cmds []tea.Cmd @@ -413,6 +451,10 @@ func (m listModel) Update(msg tea.Msg) (listModel, tea.Cmd) { } } } + case "a": + // Toggle showing all beans (include archive-status when on) + m.toggleShowAll() + return m, m.loadBeans case "c": // Open create modal return m, func() tea.Msg { @@ -501,12 +543,7 @@ func (m listModel) View() string { return "Loading..." } - // Update title based on active filter - if m.tagFilter != "" { - m.list.Title = fmt.Sprintf("Beans [tag: %s]", m.tagFilter) - } else { - m.list.Title = "Beans" - } + m.list.Title = m.buildTitle() // Inner height: total height minus border (2) minus footer (1) minus padding (1) return m.viewContent(m.height-4) + "\n" + m.Footer() @@ -528,6 +565,12 @@ func (m listModel) viewContent(innerHeight int) string { func (m listModel) Footer() string { var help string + // "a" key label flips based on current state + aLabel := "show all" + if m.showAll { + aLabel = "hide done" + } + // Show selection count if any beans are selected var selectionPrefix string if len(m.selectedBeans) > 0 { @@ -548,6 +591,7 @@ func (m listModel) Footer() string { } else if m.hasActiveFilter() { help = helpKeyStyle.Render("space") + " " + helpStyle.Render("select") + " " + helpKeyStyle.Render("enter") + " " + helpStyle.Render("view") + " " + + helpKeyStyle.Render("a") + " " + helpStyle.Render(aLabel) + " " + helpKeyStyle.Render("b") + " " + helpStyle.Render("blocking") + " " + helpKeyStyle.Render("c") + " " + helpStyle.Render("create") + " " + helpKeyStyle.Render("e") + " " + helpStyle.Render("edit") + " " + @@ -562,6 +606,7 @@ func (m listModel) Footer() string { } else { help = helpKeyStyle.Render("space") + " " + helpStyle.Render("select") + " " + helpKeyStyle.Render("enter") + " " + helpStyle.Render("view") + " " + + helpKeyStyle.Render("a") + " " + helpStyle.Render(aLabel) + " " + helpKeyStyle.Render("b") + " " + helpStyle.Render("blocking") + " " + helpKeyStyle.Render("c") + " " + helpStyle.Render("create") + " " + helpKeyStyle.Render("e") + " " + helpStyle.Render("edit") + " " + @@ -603,12 +648,7 @@ func (m listModel) ViewConstrained(width, height int) string { m.cols = ui.CalculateResponsiveColumns(width, m.hasTags) m.updateDelegate() - // Update title based on active filter - if m.tagFilter != "" { - m.list.Title = fmt.Sprintf("Beans [tag: %s]", m.tagFilter) - } else { - m.list.Title = "Beans" - } + m.list.Title = m.buildTitle() return m.viewContent(innerHeight) } diff --git a/internal/tui/list_test.go b/internal/tui/list_test.go index 913e9803..deeac7df 100644 --- a/internal/tui/list_test.go +++ b/internal/tui/list_test.go @@ -1,9 +1,11 @@ package tui import ( + "reflect" "testing" "github.com/hmans/beans/pkg/bean" + "github.com/hmans/beans/pkg/config" ) func TestSortBeans(t *testing.T) { @@ -281,3 +283,81 @@ func TestCompareBeansByStatusPriorityAndType(t *testing.T) { } }) } + +func TestListModelBuildFilter(t *testing.T) { + cfg := config.Default() + archive := cfg.ArchiveStatusNames() + + t.Run("default hides archive statuses", func(t *testing.T) { + m := &listModel{config: cfg} + got := m.buildFilter() + if got == nil { + t.Fatal("expected non-nil filter when hiding archive") + } + if !reflect.DeepEqual(got.ExcludeStatus, archive) { + t.Errorf("ExcludeStatus = %v, want %v", got.ExcludeStatus, archive) + } + if len(got.Tags) != 0 { + t.Errorf("Tags = %v, want empty", got.Tags) + } + }) + + t.Run("show all with no tag returns nil filter", func(t *testing.T) { + m := &listModel{config: cfg, showAll: true} + if got := m.buildFilter(); got != nil { + t.Errorf("expected nil filter, got %+v", got) + } + }) + + t.Run("show all + tag filter", func(t *testing.T) { + m := &listModel{config: cfg, showAll: true, tagFilter: "idea"} + got := m.buildFilter() + if got == nil { + t.Fatal("expected non-nil filter when tag filter set") + } + if !reflect.DeepEqual(got.Tags, []string{"idea"}) { + t.Errorf("Tags = %v, want [idea]", got.Tags) + } + if len(got.ExcludeStatus) != 0 { + t.Errorf("ExcludeStatus = %v, want empty", got.ExcludeStatus) + } + }) + + t.Run("default + tag filter composes both", func(t *testing.T) { + m := &listModel{config: cfg, tagFilter: "idea"} + got := m.buildFilter() + if got == nil { + t.Fatal("expected non-nil filter") + } + if !reflect.DeepEqual(got.Tags, []string{"idea"}) { + t.Errorf("Tags = %v, want [idea]", got.Tags) + } + if !reflect.DeepEqual(got.ExcludeStatus, archive) { + t.Errorf("ExcludeStatus = %v, want %v", got.ExcludeStatus, archive) + } + }) +} + +func TestListModelBuildTitle(t *testing.T) { + cfg := config.Default() + + tests := []struct { + name string + tagFilter string + showAll bool + want string + }{ + {"default", "", false, "Beans"}, + {"showAll", "", true, "Beans [all]"}, + {"tagOnly", "idea", false, "Beans [tag: idea]"}, + {"tagAndShowAll", "idea", true, "Beans [tag: idea] [all]"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m := &listModel{config: cfg, tagFilter: tt.tagFilter, showAll: tt.showAll} + if got := m.buildTitle(); got != tt.want { + t.Errorf("buildTitle() = %q, want %q", got, tt.want) + } + }) + } +} diff --git a/pkg/config/config.go b/pkg/config/config.go index 0324034a..3f5e7f34 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -556,6 +556,18 @@ func (c *Config) IsArchiveStatus(name string) bool { return false } +// ArchiveStatusNames returns the names of all statuses marked for archiving. +// Statuses are hardcoded and not configurable. +func (c *Config) ArchiveStatusNames() []string { + var names []string + for _, s := range DefaultStatuses { + if s.Archive { + names = append(names, s.Name) + } + } + return names +} + // GetType returns the TypeConfig for a given type name, or nil if not found. // Types are hardcoded and not configurable. func (c *Config) GetType(name string) *TypeConfig { diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index f9f7d2fc..83d535d7 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -184,6 +184,19 @@ func TestIsArchiveStatus(t *testing.T) { } } +func TestArchiveStatusNames(t *testing.T) { + got := Default().ArchiveStatusNames() + want := []string{"completed", "scrapped"} + if len(got) != len(want) { + t.Fatalf("ArchiveStatusNames() len = %d, want %d (%v)", len(got), len(want), got) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("ArchiveStatusNames()[%d] = %q, want %q", i, got[i], want[i]) + } + } +} + func TestLoadNonExistent(t *testing.T) { // Load from non-existent directory should return defaults cfg, err := Load("/nonexistent/path/that/does/not/exist")