From 40b78ec3cd47a0b9f8d2fb9969ce04cae3d77027 Mon Sep 17 00:00:00 2001 From: qiaofu Date: Sat, 24 Jan 2026 11:53:56 +0800 Subject: [PATCH] Initial commit Co-Authored-By: Claude --- .gitignore | 37 ++++++ PRD.md | 354 +++++++++++++++++++++++++++++++++++++++++++++++++++++ go.mod | 29 +++++ go.sum | 46 +++++++ main.go | 334 ++++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 800 insertions(+) create mode 100644 .gitignore create mode 100644 PRD.md create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c8d5556 --- /dev/null +++ b/.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 \ No newline at end of file diff --git a/PRD.md b/PRD.md new file mode 100644 index 0000000..79a27f9 --- /dev/null +++ b/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 单选器。** diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..51dc68f --- /dev/null +++ b/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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..4e1e933 --- /dev/null +++ b/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= diff --git a/main.go b/main.go new file mode 100644 index 0000000..efccf3d --- /dev/null +++ b/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) +}