From fcdccdbe87505f5087aa4db053bf24014f60d8d0 Mon Sep 17 00:00:00 2001 From: Snider Date: Sat, 21 Feb 2026 18:12:37 +0000 Subject: [PATCH] feat(cli): add InteractiveList with keyboard navigation and terminal fallback Co-Authored-By: Virgil --- pkg/cli/list.go | 144 +++++++++++++++++++++++++++++++++++++++++++ pkg/cli/list_test.go | 52 ++++++++++++++++ 2 files changed, 196 insertions(+) create mode 100644 pkg/cli/list.go create mode 100644 pkg/cli/list_test.go diff --git a/pkg/cli/list.go b/pkg/cli/list.go new file mode 100644 index 0000000..3c9d7da --- /dev/null +++ b/pkg/cli/list.go @@ -0,0 +1,144 @@ +package cli + +import ( + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "golang.org/x/term" +) + +// listModel is the internal bubbletea model for interactive list selection. +type listModel struct { + items []string + cursor int + title string + selected bool + quitted bool +} + +func newListModel(items []string, title string) *listModel { + return &listModel{ + items: items, + title: title, + } +} + +func (m *listModel) moveDown() { + m.cursor++ + if m.cursor >= len(m.items) { + m.cursor = 0 + } +} + +func (m *listModel) moveUp() { + m.cursor-- + if m.cursor < 0 { + m.cursor = len(m.items) - 1 + } +} + +func (m *listModel) Init() tea.Cmd { + return nil +} + +func (m *listModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.Type { + case tea.KeyUp, tea.KeyShiftTab: + m.moveUp() + case tea.KeyDown, tea.KeyTab: + m.moveDown() + case tea.KeyEnter: + m.selected = true + return m, tea.Quit + case tea.KeyEscape, tea.KeyCtrlC: + m.quitted = true + return m, tea.Quit + case tea.KeyRunes: + switch string(msg.Runes) { + case "j": + m.moveDown() + case "k": + m.moveUp() + } + } + } + return m, nil +} + +func (m *listModel) View() string { + var sb strings.Builder + + if m.title != "" { + sb.WriteString(BoldStyle.Render(m.title) + "\n\n") + } + + for i, item := range m.items { + cursor := " " + style := DimStyle + if i == m.cursor { + cursor = AccentStyle.Render(Glyph(":pointer:")) + " " + style = BoldStyle + } + sb.WriteString(fmt.Sprintf("%s%s\n", cursor, style.Render(item))) + } + + sb.WriteString("\n" + DimStyle.Render("↑/↓ navigate • enter select • esc cancel")) + + return sb.String() +} + +// ListOption configures InteractiveList behaviour. +type ListOption func(*listConfig) + +type listConfig struct { + height int +} + +// WithListHeight sets the visible height of the list (number of items shown). +func WithListHeight(n int) ListOption { + return func(c *listConfig) { + c.height = n + } +} + +// InteractiveList presents an interactive scrollable list and returns the +// selected item's index and value. Returns -1 and empty string if cancelled. +// +// Falls back to numbered Select() when stdin is not a terminal (e.g. piped input). +// +// idx, value := cli.InteractiveList("Pick a repo:", repos) +func InteractiveList(title string, items []string, opts ...ListOption) (int, string) { + if len(items) == 0 { + return -1, "" + } + + // Fall back to simple Select if not a terminal + if !term.IsTerminal(0) { + result, err := Select(title, items) + if err != nil { + return -1, "" + } + for i, item := range items { + if item == result { + return i, result + } + } + return -1, "" + } + + m := newListModel(items, title) + p := tea.NewProgram(m) + finalModel, err := p.Run() + if err != nil { + return -1, "" + } + + final := finalModel.(*listModel) + if final.quitted || !final.selected { + return -1, "" + } + return final.cursor, final.items[final.cursor] +} diff --git a/pkg/cli/list_test.go b/pkg/cli/list_test.go new file mode 100644 index 0000000..202e323 --- /dev/null +++ b/pkg/cli/list_test.go @@ -0,0 +1,52 @@ +package cli + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestListModel_Good_Create(t *testing.T) { + items := []string{"alpha", "beta", "gamma"} + m := newListModel(items, "Pick one:") + assert.Equal(t, 3, len(m.items)) + assert.Equal(t, 0, m.cursor) + assert.Equal(t, "Pick one:", m.title) +} + +func TestListModel_Good_MoveDown(t *testing.T) { + m := newListModel([]string{"a", "b", "c"}, "") + m.moveDown() + assert.Equal(t, 1, m.cursor) + m.moveDown() + assert.Equal(t, 2, m.cursor) +} + +func TestListModel_Good_MoveUp(t *testing.T) { + m := newListModel([]string{"a", "b", "c"}, "") + m.moveDown() + m.moveDown() + m.moveUp() + assert.Equal(t, 1, m.cursor) +} + +func TestListModel_Good_WrapAround(t *testing.T) { + m := newListModel([]string{"a", "b", "c"}, "") + m.moveUp() // Should wrap to bottom + assert.Equal(t, 2, m.cursor) +} + +func TestListModel_Good_View(t *testing.T) { + m := newListModel([]string{"alpha", "beta"}, "Choose:") + view := m.View() + assert.Contains(t, view, "Choose:") + assert.Contains(t, view, "alpha") + assert.Contains(t, view, "beta") +} + +func TestListModel_Good_Selected(t *testing.T) { + m := newListModel([]string{"a", "b", "c"}, "") + m.moveDown() + m.selected = true + assert.Equal(t, "b", m.items[m.cursor]) +}