package main import ( "bufio" "flag" "fmt" "io" "os" "strconv" "strings" "github.com/charmbracelet/bubbles/list" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" ) const version = "0.1.0" type Item struct { Label string Value string Index int } func (i Item) FilterValue() string { return i.Label } type Config struct { Title string DefaultValue string DefaultIndex int OutputIndex bool NoSearch bool KVMode bool } type Model struct { items []Item list list.Model selected *Item cancelled bool config Config } type itemDelegate struct { selectedStyle lipgloss.Style normalStyle lipgloss.Style } func (d itemDelegate) Height() int { return 1 } func (d itemDelegate) Spacing() int { return 0 } func (d itemDelegate) Update(_ tea.Msg, m *list.Model) tea.Cmd { return nil } func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) { item, ok := listItem.(Item) if !ok { return } cursor := " " style := d.normalStyle if index == m.Index() { cursor = "> " style = d.selectedStyle } fmt.Fprint(w, style.Render(cursor+item.Label)) } func newModel(items []Item, config Config, selectedIndex int) Model { listItems := make([]list.Item, 0, len(items)) for _, item := range items { listItems = append(listItems, item) } delegate := itemDelegate{ selectedStyle: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("2")), normalStyle: lipgloss.NewStyle(), } l := list.New(listItems, delegate, 0, 0) l.SetShowHelp(false) l.SetShowStatusBar(false) l.SetShowPagination(false) l.SetShowTitle(false) l.SetFilteringEnabled(!config.NoSearch) l.SetShowFilter(!config.NoSearch) l.KeyMap.Quit.SetEnabled(false) l.KeyMap.ForceQuit.SetEnabled(false) if selectedIndex >= 0 && selectedIndex < len(items) { l.Select(selectedIndex) } return Model{ items: items, list: l, config: config, } } func (m Model) Init() tea.Cmd { return nil } func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: switch msg.Type { case tea.KeyEnter: selected := m.list.SelectedItem() if selectedItem, ok := selected.(Item); ok { m.selected = &selectedItem return m, tea.Quit } m.cancelled = true return m, tea.Quit case tea.KeyEsc, tea.KeyCtrlC: m.cancelled = true return m, tea.Quit } case tea.WindowSizeMsg: heightUsed := 1 if m.config.Title != "" { heightUsed++ } m.list.SetSize(msg.Width, max(0, msg.Height-heightUsed)) } var cmd tea.Cmd m.list, cmd = m.list.Update(msg) return m, cmd } func (m Model) View() string { var b strings.Builder if m.config.Title != "" { titleStyle := lipgloss.NewStyle().Bold(true) b.WriteString(titleStyle.Render(m.config.Title)) b.WriteString("\n") } b.WriteString(m.list.View()) helpStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("8")) b.WriteString("\n") b.WriteString(helpStyle.Render("↑/↓ (j/k) Enter Esc")) return b.String() } func readItems(r io.Reader, kvMode bool) ([]Item, error) { scanner := bufio.NewScanner(r) items := []Item{} index := 0 for scanner.Scan() { line := scanner.Text() if line == "" { continue } label := line value := line if kvMode { if sep := strings.Index(line, "|"); sep >= 0 { label = line[:sep] value = line[sep+1:] } } items = append(items, Item{Label: label, Value: value, Index: index}) index++ } if err := scanner.Err(); err != nil { return nil, err } return items, nil } func parseFlags() (Config, bool, bool, bool, bool, error) { var cfg Config var showHelp bool var showVersion bool var defaultValueSet bool var defaultIndexSet bool fs := flag.NewFlagSet(os.Args[0], flag.ContinueOnError) fs.SetOutput(io.Discard) fs.StringVar(&cfg.Title, "title", "", "Title shown above the list") fs.BoolVar(&cfg.OutputIndex, "index", false, "Output index instead of value") fs.BoolVar(&cfg.NoSearch, "no-search", false, "Disable search filtering") fs.BoolVar(&cfg.KVMode, "kv", false, "Enable label|value input mode") fs.BoolVar(&showVersion, "version", false, "Show version") fs.BoolVar(&showHelp, "help", false, "Show help") fs.Func("default", "Default selected value", func(value string) error { cfg.DefaultValue = value defaultValueSet = true return nil }) fs.Func("default-index", "Default selected index", func(value string) error { parsed, err := strconv.Atoi(value) if err != nil { return err } cfg.DefaultIndex = parsed defaultIndexSet = true return nil }) if err := fs.Parse(os.Args[1:]); err != nil { return cfg, false, false, false, false, err } return cfg, showHelp, showVersion, defaultValueSet, defaultIndexSet, nil } func printHelp(w io.Writer) { help := `gselect - interactive CLI selector Usage: gselect [flags] Flags: --title string Title shown above the list --index Output index instead of value --default string Default selected value --default-index int Default selected index --no-search Disable search filtering --kv Enable label|value input mode --version Show version --help Show help ` fmt.Fprint(w, help) } func clamp(value, minValue, maxValue int) int { if value < minValue { return minValue } if value > maxValue { return maxValue } return value } func max(a, b int) int { if a > b { return a } return b } func main() { cfg, showHelp, showVersion, defaultValueSet, defaultIndexSet, err := parseFlags() if err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(3) } if showHelp { printHelp(os.Stdout) os.Exit(0) } if showVersion { fmt.Fprintf(os.Stdout, "gselect %s\n", version) os.Exit(0) } if defaultValueSet && defaultIndexSet { fmt.Fprintln(os.Stderr, "--default and --default-index cannot be used together") os.Exit(3) } items, err := readItems(os.Stdin, cfg.KVMode) if err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(3) } if len(items) == 0 { os.Exit(2) } selectedIndex := 0 if defaultValueSet { for i, item := range items { if item.Value == cfg.DefaultValue { selectedIndex = i break } } } else if defaultIndexSet { selectedIndex = clamp(cfg.DefaultIndex, 0, len(items)-1) } model := newModel(items, cfg, selectedIndex) tty, ttyErr := os.OpenFile("/dev/tty", os.O_RDWR, 0) if ttyErr != nil { fmt.Fprintln(os.Stderr, "failed to open /dev/tty for input/output") os.Exit(3) } defer tty.Close() program := tea.NewProgram(model, tea.WithAltScreen(), tea.WithInput(tty), tea.WithOutput(tty)) finalModel, runErr := program.Run() if runErr != nil { if runErr == tea.ErrProgramKilled { os.Exit(1) } fmt.Fprintln(os.Stderr, runErr) os.Exit(1) } result := finalModel.(Model) if result.cancelled || result.selected == nil { os.Exit(1) } if cfg.OutputIndex { fmt.Fprintln(os.Stdout, result.selected.Index) os.Exit(0) } fmt.Fprintln(os.Stdout, result.selected.Value) }