| 1 | package cli |
| 2 | |
| 3 | import ( |
| 4 | "strings" |
| 5 | "unicode/utf8" |
| 6 | |
| 7 | tea "charm.land/bubbletea/v2" |
| 8 | "github.com/charmbracelet/x/ansi" |
| 9 | ) |
| 10 | |
| 11 | const quickPickerMaxVisible = 8 |
| 12 | |
| 13 | type quickPickerKind string |
| 14 | |
| 15 | const ( |
| 16 | quickPickerModel quickPickerKind = "model" |
| 17 | quickPickerProvider quickPickerKind = "provider" |
| 18 | quickPickerProviderModel quickPickerKind = "provider-model" |
| 19 | quickPickerResume quickPickerKind = "resume" |
| 20 | ) |
| 21 | |
| 22 | type quickPickerItem struct { |
| 23 | ID string |
| 24 | Label string |
| 25 | Description string |
| 26 | Status string |
| 27 | } |
| 28 | |
| 29 | // quickPicker is the shared single-choice overlay used by commands that need |
| 30 | // Claude Code-style searchable lists inside Bubble Tea's event loop. |
| 31 | type quickPicker struct { |
| 32 | kind quickPickerKind |
| 33 | title string |
| 34 | hint string |
| 35 | items []quickPickerItem |
| 36 | query string |
| 37 | selected int // index in filteredItems, not items |
| 38 | } |
| 39 | |
| 40 | type quickPickerResult struct { |
| 41 | choice *quickPickerItem |
| 42 | cancelled bool |
| 43 | } |
| 44 | |
| 45 | func (p *quickPicker) filteredItems() []quickPickerItem { |
| 46 | if p == nil || strings.TrimSpace(p.query) == "" { |
| 47 | if p == nil { |
| 48 | return nil |
| 49 | } |
| 50 | return p.items |
| 51 | } |
| 52 | query := strings.ToLower(strings.TrimSpace(p.query)) |
| 53 | out := make([]quickPickerItem, 0, len(p.items)) |
| 54 | for _, item := range p.items { |
| 55 | haystack := strings.ToLower(item.Label + " " + item.Description + " " + item.Status) |
| 56 | if strings.Contains(haystack, query) { |
| 57 | out = append(out, item) |
| 58 | } |
| 59 | } |
| 60 | return out |
| 61 | } |
| 62 | |
| 63 | func (p *quickPicker) handleKey(msg tea.KeyPressMsg) quickPickerResult { |
| 64 | if p == nil { |
| 65 | return quickPickerResult{} |
| 66 | } |
| 67 | items := p.filteredItems() |
| 68 | key := msg.String() |
| 69 | // Match Claude Code's searchable menus: bare j/k navigate until a search |
| 70 | // starts, then become ordinary query characters. Arrows and Ctrl+P/N always |
| 71 | // remain available for navigation. |
| 72 | if p.query == "" { |
| 73 | switch key { |
| 74 | case "k": |
| 75 | key = "up" |
| 76 | case "j": |
| 77 | key = "down" |
| 78 | } |
| 79 | } |
| 80 | switch key { |
| 81 | case "esc": |
| 82 | return quickPickerResult{cancelled: true} |
| 83 | case "up", "ctrl+p": |
| 84 | if p.selected > 0 { |
| 85 | p.selected-- |
| 86 | } |
| 87 | case "down", "ctrl+n": |
| 88 | if p.selected < len(items)-1 { |
| 89 | p.selected++ |
| 90 | } |
| 91 | case "enter": |
| 92 | if p.selected >= 0 && p.selected < len(items) { |
| 93 | choice := items[p.selected] |
| 94 | return quickPickerResult{choice: &choice} |
| 95 | } |
| 96 | case "backspace": |
| 97 | if p.query != "" { |
| 98 | _, n := utf8.DecodeLastRuneInString(p.query) |
| 99 | p.query = p.query[:len(p.query)-n] |
| 100 | p.selected = 0 |
| 101 | } |
| 102 | default: |
| 103 | text := msg.Text |
| 104 | if text == "" { |
| 105 | s := msg.String() |
| 106 | if len(s) == 1 && s[0] >= 32 && s[0] < 127 { |
| 107 | text = s |
| 108 | } |
| 109 | } |
| 110 | if text != "" { |
| 111 | p.query += text |
| 112 | p.selected = 0 |
| 113 | } |
| 114 | } |
| 115 | return quickPickerResult{} |
| 116 | } |
| 117 | |
| 118 | func (p *quickPicker) render(width int) string { |
| 119 | if p == nil { |
| 120 | return "" |
| 121 | } |
| 122 | w := max(width, 10) |
| 123 | contentWidth := max(w-8, 12) |
| 124 | items := p.filteredItems() |
| 125 | if p.selected >= len(items) { |
| 126 | p.selected = max(len(items)-1, 0) |
| 127 | } |
| 128 | |
| 129 | var b strings.Builder |
| 130 | b.WriteString(accent(p.title) + "\n") |
| 131 | if p.query != "" { |
| 132 | b.WriteString(" " + dim("Search: ") + p.query + "\n") |
| 133 | } |
| 134 | if len(items) == 0 { |
| 135 | b.WriteString(dim(" No matches") + "\n") |
| 136 | } else { |
| 137 | start, end := quickPickerWindow(len(items), p.selected) |
| 138 | if start > 0 { |
| 139 | b.WriteString(dim(" ↑ more") + "\n") |
| 140 | } |
| 141 | for i := start; i < end; i++ { |
| 142 | item := items[i] |
| 143 | label := ansi.Truncate(item.Label, contentWidth, "…") |
| 144 | if item.Status != "" { |
| 145 | label += " " + dim("("+item.Status+")") |
| 146 | } |
| 147 | b.WriteString(rowLine(i == p.selected, i+1, "", label, item.Status == "active") + "\n") |
| 148 | if item.Description != "" { |
| 149 | b.WriteString(dim(" "+ansi.Truncate(item.Description, contentWidth, "…")) + "\n") |
| 150 | } |
| 151 | } |
| 152 | if end < len(items) { |
| 153 | b.WriteString(dim(" ↓ more") + "\n") |
| 154 | } |
| 155 | } |
| 156 | hint := p.hint |
| 157 | if hint == "" { |
| 158 | hint = "Type to filter · ↑/↓ navigate · Enter select · Esc cancel" |
| 159 | } |
| 160 | b.WriteString(dim(hint)) |
| 161 | return choicePanelStyle.Width(w).Render(b.String()) |
| 162 | } |
| 163 | |
| 164 | func quickPickerWindow(total, selected int) (int, int) { |
| 165 | if total <= quickPickerMaxVisible { |
| 166 | return 0, total |
| 167 | } |
| 168 | start := selected - quickPickerMaxVisible/2 |
| 169 | if start < 0 { |
| 170 | start = 0 |
| 171 | } |
| 172 | if maxStart := total - quickPickerMaxVisible; start > maxStart { |
| 173 | start = maxStart |
| 174 | } |
| 175 | return start, start + quickPickerMaxVisible |
| 176 | } |
| 177 |