Browse Source

Initial commit

Co-Authored-By: Claude <noreply@anthropic.com>
main
qiaofu 2 weeks ago
commit
40b78ec3cd
  1. 37
      .gitignore
  2. 354
      PRD.md
  3. 29
      go.mod
  4. 46
      go.sum
  5. 334
      main.go

37
.gitignore

@ -0,0 +1,37 @@
# Binaries for programs and plugins
*.exe
*.exe~
*.dll
*.so
*.dylib
# Test binary, built with `go test -c`
*.test
# Output of the go coverage tool, specifically when used with LiteIDE
*.out
# Dependency directories (remove the comment below to include it)
# vendor/
# Go workspace file
go.work
go.work.sum
# IDE
.idea/
.vscode/
*.swp
*.swo
# Compiled binary
gselect
# OS generated files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db

354
PRD.md

@ -0,0 +1,354 @@
# gselect —— 交互式 CLI 单选器
**Product Requirements Document (PRD)**
---
## 1. 产品概述
### 1.1 产品定位
`gselect` 是一个 **基于 Golang + Bubble Tea 的交互式命令行选择器**
**单选为核心**,用于补全 shell 脚本中缺失的“美观、稳定、可控”的交互选择能力。
*其定位类似于 **`fzf`**,但目标是:*
* **更简单**
* **更容易嵌入 shell 脚本**
* **更容易维护与二次开发**
---
### 1.2 目标用户
* 运维工程师
* 后端 / 工具链开发者
* 编写 shell / bash / zsh / fish 脚本的用户
* CLI 工具作者
---
### 1.3 非目标
* 不做复杂 TUI 框架
* 不做 REPL
* 不做表格 / 分屏 / 树状 UI
* 不支持鼠标操作
* 不做插件系统
---
## 2. 设计原则(强约束)
1. **Unix 风格**
* stdin 输入
* stdout 输出
* exit code 表达状态
2. **单选优先**
* 单选是默认行为
* 多选只是可选扩展
3. **Shell 友好**
* 输出可直接被 `$(...)`、`mapfile`、`read` 使用
* 不输出多余日志
4. **接口极简**
* 参数少
* 行为直观
* 无配置文件依赖
---
## 3. 使用方式总览
### 3.1 基础用法(默认单选)
```bash
printf "apple\nbanana\norange\n" | gselect
```
用户交互:
* ↑ ↓ 移动
* Enter 确认
* Esc / Ctrl+C 取消
stdout:
```text
banana
```
exit code:
* `0`:成功选择
* `1`:用户取消 / 无选择
---
## 4. 输入规范(stdin)
### 4.1 输入格式
* 每行一个选项
* 空行忽略
* 原样显示,不做 trim / escape
```text
option A
option B
option C
```
---
### 4.2 可选 Key-Value 输入(扩展)
```text
Apple|apple
Banana|banana
```
启用参数:
```bash
gselect --kv
```
* UI 显示:`Apple`
* 输出结果:`apple`
---
## 5. 输出规范(stdout)
### 5.1 默认输出
* 输出 **选中项的值**
* 单行文本
* 不包含换行以外的任何字符
```text
banana
```
---
### 5.2 输出索引
```bash
gselect --index
```
stdout:
```text
1
```
(索引从 `0` 开始)
---
## 6. 退出状态(exit code)
| code | 含义 |
| ---- | ------------------ |
| 0 | 用户成功选择 |
| 1 | 用户取消(Esc / Ctrl+C) |
| 2 | 输入为空 / 无可选项 |
| 3 | 参数错误 |
---
## 7. 命令行参数设计
### 7.1 参数一览
| 参数 | 说明 |
| ----------------- | ----------------- |
| `--title` | 顶部标题 |
| `--index` | 输出索引而非值 |
| `--default` | 默认选中值 |
| `--default-index` | 默认选中索引 |
| `--no-search` | 禁用搜索过滤 |
| `--kv` | 启用 label|value 模式 |
| `--version` | 显示版本 |
| `--help` | 帮助信息 |
---
### 7.2 参数示例
#### 设置标题
```bash
gselect --title "Select a branch"
```
#### 默认选中
```bash
gselect --default "banana"
gselect --default-index 1
```
#### 输出索引
```bash
gselect --index
```
#### 禁用搜索
```bash
gselect --no-search
```
---
## 8. 交互设计
### 8.1 键位定义(固定,不可配置)
| 键 | 行为 |
| ------ | --------- |
| ↑ / k | 上移 |
| ↓ / j | 下移 |
| Enter | 确认 |
| Esc | 取消 |
| Ctrl+C | 取消 |
| 字符输入 | 搜索过滤(如启用) |
---
### 8.2 搜索行为
* 默认启用
* 子串匹配(不要求 fuzzy)
* 实时过滤
* 可通过 `--no-search` 禁用
---
## 9. UI 设计规范
### 9.1 技术选型
* `bubbletea`
* `bubbles/list`
* `lipgloss`
---
### 9.2 UI 元素
* 顶部:标题(可选)
* 中部:列表
* 底部:简要提示(↑↓ Enter Esc)
---
### 9.3 风格要求
* 简洁
* 对比清晰
* 不使用 Emoji
* 颜色适配暗色终端
---
## 10. 内部模型(设计指导)
### 10.1 核心数据结构(建议)
```go
type Item struct {
Label string
Value string
Index int
}
```
```go
type Model struct {
List list.Model
Selected *Item
Cancelled bool
}
```
---
### 10.2 退出逻辑
* Enter → 输出 → `os.Exit(0)`
* Esc / Ctrl+C → `os.Exit(1)`
* 无输入 → `os.Exit(2)`
---
## 11. Shell 使用示例
### 11.1 基础
```bash
choice=$(ls | gselect) || exit 1
echo "$choice"
```
---
### 11.2 Git 分支选择
```bash
branch=$(git branch --format='%(refname:short)' | gselect)
git checkout "$branch"
```
---
### 11.3 输出索引
```bash
idx=$(printf "a\nb\nc\n" | gselect --index)
```
---
## 12. 非功能性要求
* 启动时间 < 50ms
* 单文件二进制
* 无运行时依赖
* 支持 Linux / macOS
* Go ≥ 1.21
---
## 13. 未来扩展(不在本期)
* 多选模式
* 自定义分隔符
* 预览窗
* JSON 输入
---
## 14. 成功标准(验收)
* 可在 shell 中稳定使用
* Ctrl+C 不污染终端
* stdout 可直接被脚本消费
* 用户无需文档即可理解基本操作
---
## 15. 一句话总结
> **一个“像 fzf 一样好用,但更简单、更可控、更适合脚本”的 CLI 单选器。**

29
go.mod

@ -0,0 +1,29 @@
module go-gselect
go 1.25
require (
github.com/charmbracelet/bubbles v0.17.1
github.com/charmbracelet/bubbletea v0.25.0
github.com/charmbracelet/lipgloss v0.9.1
)
require (
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.18 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.15 // indirect
github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/reflow v0.3.0 // indirect
github.com/muesli/termenv v0.15.2 // indirect
github.com/rivo/uniseg v0.2.0 // indirect
github.com/sahilm/fuzzy v0.1.1-0.20230530133925-c48e322e2a8f // indirect
golang.org/x/sync v0.1.0 // indirect
golang.org/x/sys v0.12.0 // indirect
golang.org/x/term v0.6.0 // indirect
golang.org/x/text v0.3.8 // indirect
)

46
go.sum

@ -0,0 +1,46 @@
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/charmbracelet/bubbles v0.17.1 h1:0SIyjOnkrsfDo88YvPgAWvZMwXe26TP6drRvmkjyUu4=
github.com/charmbracelet/bubbles v0.17.1/go.mod h1:9HxZWlkCqz2PRwsCbYl7a3KXvGzFaDHpYbSYMJ+nE3o=
github.com/charmbracelet/bubbletea v0.25.0 h1:bAfwk7jRz7FKFl9RzlIULPkStffg5k6pNt5dywy4TcM=
github.com/charmbracelet/bubbletea v0.25.0/go.mod h1:EN3QDR1T5ZdWmdfDzYcqOCAps45+QIJbLOBxmVNWNNg=
github.com/charmbracelet/lipgloss v0.9.1 h1:PNyd3jvaJbg4jRHKWXnCj1akQm4rh8dbEzN1p/u1KWg=
github.com/charmbracelet/lipgloss v0.9.1/go.mod h1:1mPmG4cxScwUQALAAnacHaigiiHB9Pmr+v1VEawJl6I=
github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 h1:q2hJAaP1k2wIvVRd/hEHD7lacgqrCPS+k8g1MndzfWY=
github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.18 h1:DOKFKCQ7FNG2L1rbrmstDN4QVRdS89Nkh85u68Uwp98=
github.com/mattn/go-isatty v0.0.18/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b h1:1XF24mVaiu7u+CFywTdcDo2ie1pzzhwjt6RHqzpMU34=
github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo=
github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8=
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/sahilm/fuzzy v0.1.1-0.20230530133925-c48e322e2a8f h1:MvTmaQdww/z0Q4wrYjDSCcZ78NoftLQyHBSLW/Cx79Y=
github.com/sahilm/fuzzy v0.1.1-0.20230530133925-c48e322e2a8f/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.6.0 h1:clScbb1cHjoCkyRbWwBEUZ5H/tIFu5TAXIqaZD0Gcjw=
golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=

334
main.go

@ -0,0 +1,334 @@
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)
}
Loading…
Cancel
Save