Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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).
1 change: 1 addition & 0 deletions internal/tui/help.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
78 changes: 59 additions & 19 deletions internal/tui/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"fmt"
"io"
"strings"

"github.com/charmbracelet/bubbles/list"
tea "github.com/charmbracelet/bubbletea"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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()
Expand All @@ -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 {
Expand All @@ -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") + " " +
Expand All @@ -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") + " " +
Expand Down Expand Up @@ -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)
}
Expand Down
80 changes: 80 additions & 0 deletions internal/tui/list_test.go
Original file line number Diff line number Diff line change
@@ -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) {
Expand Down Expand Up @@ -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)
}
})
}
}
12 changes: 12 additions & 0 deletions pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
13 changes: 13 additions & 0 deletions pkg/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down