feat(cli): add InteractiveList with keyboard navigation and terminal fallback

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Snider 2026-02-21 18:12:37 +00:00
parent c2418a2737
commit fcdccdbe87
2 changed files with 196 additions and 0 deletions

144
pkg/cli/list.go Normal file
View file

@ -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]
}

52
pkg/cli/list_test.go Normal file
View file

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