返回 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 )
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
177 lines GO