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