返回 DeepSeek-Reasonix
select.go
根目录 / internal / cli / select.go
1 package cli
2
3 import (
4 "errors"
5 "fmt"
6 "os"
7 "strings"
8
9 "golang.org/x/term"
10
11 "reasonix/internal/i18n"
12 )
13
14 // errCancelled is returned by selectOne when the user aborts (q or Ctrl-C).
15 var errCancelled = errors.New("selection cancelled")
16
17 type menuItem struct {
18 name string
19 desc string
20 }
21
22 // termHeight returns the terminal's row count, falling back to 24 on error.
23 func termHeight(fd int) int {
24 _, h, err := term.GetSize(fd)
25 if err != nil || h <= 0 {
26 return 24
27 }
28 return h
29 }
30
31 // fixedLines returns the number of non-item lines rendered each frame:
32 // header label, blank separator, scroll-up indicator, scroll-down indicator.
33 // When searching is true the search bar adds one more line.
34 func fixedLines(searching bool) int {
35 n := 4 // header + blank + scroll-up + scroll-down
36 if searching {
37 n++ // search bar
38 }
39 return n
40 }
41
42 // maxViewport calculates how many menu item rows fit after subtracting the
43 // fixed lines from the available terminal rows, leaving at least 1 row.
44 func maxViewport(totalItems, termRows int, searching bool) int {
45 avail := max(termRows-fixedLines(searching), 1)
46 if totalItems < avail {
47 return totalItems
48 }
49 return avail
50 }
51
52 // renderSearchBar draws the search input line when searching is active.
53 func renderSearchBar(w *os.File, query string) {
54 fmt.Fprintf(w, "\r\033[K%s %s\n", accent("🔍"), query+"_")
55 }
56
57 // filterMenuItems returns items whose name or desc contain the query (case-insensitive).
58 func filterMenuItems(items []menuItem, query string) []menuItem {
59 if query == "" {
60 return items
61 }
62 lq := strings.ToLower(query)
63 var out []menuItem
64 for _, it := range items {
65 if strings.Contains(strings.ToLower(it.name), lq) || strings.Contains(strings.ToLower(it.desc), lq) {
66 out = append(out, it)
67 }
68 }
69 return out
70 }
71
72 // selectOne renders an interactive single-choice menu navigated with the arrow
73 // keys (or j/k), confirmed with Enter, aborted with q or Ctrl-C. It puts the
74 // terminal in raw mode, so it requires a TTY (callers gate on isInteractive).
75 // When the item list exceeds the terminal height, only a viewport-sized window
76 // is shown, with scroll indicators. Pressing '/' enters search mode to filter
77 // items by keyword.
78 func selectOne(label string, items []menuItem) (int, error) {
79 fd := int(os.Stdin.Fd())
80 old, err := term.MakeRaw(fd)
81 if err != nil {
82 return 0, err
83 }
84 defer term.Restore(fd, old)
85
86 w := os.Stdout
87 th := termHeight(fd)
88
89 // search state
90 searching := false
91 searchQuery := ""
92 filtered := items
93 filterIdx := make([]int, len(items))
94 for i := range items {
95 filterIdx[i] = i
96 }
97
98 sel := 0
99 scroll := 0
100 prevLines := 0 // lines printed in the previous frame; 0 = first frame
101
102 render := func() {
103 n := len(filtered)
104 vp := maxViewport(n, th, searching)
105 // adjust scroll to keep sel visible
106 if sel < scroll {
107 scroll = sel
108 }
109 if sel >= scroll+vp {
110 scroll = sel - vp + 1
111 }
112 if scroll < 0 {
113 scroll = 0
114 }
115
116 // scroll-up indicator (always 1 line)
117 if n > 0 && scroll > 0 {
118 fmt.Fprintf(w, "\r\033[K%s\n", dim(fmt.Sprintf(i18n.M.SelectMoreAboveFmt, scroll)))
119 } else {
120 fmt.Fprintf(w, "\r\033[K\r\n")
121 }
122
123 // menu rows
124 end := min(scroll+vp, n)
125 for i := scroll; i < end; i++ {
126 it := filtered[i]
127 name := fmt.Sprintf("%-10s", it.name)
128 if i == sel {
129 fmt.Fprintf(w, "\r\033[K%s\r\n", reverse(fmt.Sprintf(" ❯ %s %s ", name, it.desc)))
130 } else {
131 fmt.Fprintf(w, "\r\033[K %s %s\r\n", name, dim(it.desc))
132 }
133 }
134 // if fewer items than viewport, pad with blank lines so the frame
135 // height stays constant
136 for i := end - scroll; i < vp; i++ {
137 fmt.Fprintf(w, "\r\033[K\r\n")
138 }
139
140 // scroll-down indicator (always 1 line)
141 if n > 0 && end < n {
142 fmt.Fprintf(w, "\r\033[K%s\n", dim(fmt.Sprintf(i18n.M.SelectMoreBelowFmt, n-end)))
143 } else {
144 fmt.Fprintf(w, "\r\033[K\r\n")
145 }
146 }
147
148 drawHeader := func() {
149 if searching {
150 fmt.Fprintf(w, "\r\033[K%s %s %s\r\n\r\n", accent("▌"), bold(label), dim(i18n.M.SelectSearchHint))
151 renderSearchBar(w, searchQuery)
152 } else {
153 fmt.Fprintf(w, "\r\033[K%s %s %s\r\n\r\n", accent("▌"), bold(label), dim(i18n.M.SelectOneHint))
154 }
155 }
156
157 redraw := func() {
158 if prevLines > 0 {
159 fmt.Fprintf(w, "\033[%dA", prevLines)
160 }
161 drawHeader()
162 render()
163 // Clear everything below the current frame so stale rows from a taller
164 // previous frame don't linger.
165 fmt.Fprint(w, "\033[J")
166 prevLines = fixedLines(searching) + maxViewport(len(filtered), th, searching)
167 }
168
169 redraw() // initial draw
170
171 buf := make([]byte, 8)
172 for {
173 n, err := os.Stdin.Read(buf)
174 if err != nil {
175 return 0, err
176 }
177 k := buf[:n]
178
179 if searching {
180 switch {
181 case k[0] == 27: // Esc — exit search
182 searching = false
183 searchQuery = ""
184 filtered = items
185 filterIdx = make([]int, len(items))
186 for i := range items {
187 filterIdx[i] = i
188 }
189 sel = 0
190 scroll = 0
191 redraw()
192 case k[0] == '\r' || k[0] == '\n':
193 if len(filtered) > 0 {
194 fmt.Fprint(w, "\r\n")
195 return filterIdx[sel], nil
196 }
197 case k[0] == 127 || k[0] == 8: // backspace
198 if len(searchQuery) > 0 {
199 searchQuery = searchQuery[:len(searchQuery)-1]
200 filtered = filterMenuItems(items, searchQuery)
201 filterIdx = filterIndices(items, searchQuery)
202 sel = 0
203 scroll = 0
204 redraw()
205 }
206 case k[0] == 3: // Ctrl-C
207 fmt.Fprint(w, "\r\n")
208 return 0, errCancelled
209 case k[0] >= 32 && k[0] < 127: // printable
210 searchQuery += string(k[0])
211 filtered = filterMenuItems(items, searchQuery)
212 filterIdx = filterIndices(items, searchQuery)
213 sel = 0
214 scroll = 0
215 redraw()
216 default:
217 continue
218 }
219 continue
220 }
221
222 switch {
223 case k[0] == '\r' || k[0] == '\n':
224 fmt.Fprint(w, "\r\n")
225 return filterIdx[sel], nil
226 case k[0] == 3 || k[0] == 'q': // Ctrl-C or q
227 fmt.Fprint(w, "\r\n")
228 return 0, errCancelled
229 case k[0] == '/': // enter search mode
230 searching = true
231 searchQuery = ""
232 redraw()
233 case len(k) >= 3 && k[0] == 27 && k[1] == '[' && k[2] == 'A': // up
234 if sel > 0 {
235 sel--
236 }
237 case len(k) >= 3 && k[0] == 27 && k[1] == '[' && k[2] == 'B': // down
238 if sel < len(filtered)-1 {
239 sel++
240 }
241 case k[0] == 'k':
242 if sel > 0 {
243 sel--
244 }
245 case k[0] == 'j':
246 if sel < len(filtered)-1 {
247 sel++
248 }
249 default:
250 continue // ignore other keys, no redraw
251 }
252 redraw()
253 }
254 }
255
256 // selectMany renders an interactive multi-choice menu: arrow keys (or j/k) move,
257 // Space toggles, Enter confirms (at least one required), q/Ctrl-C aborts. It
258 // returns the checked indices in order and requires a TTY. When the item list
259 // exceeds the terminal height, only a viewport-sized window is shown. Pressing
260 // '/' enters search mode to filter items by keyword.
261 func selectMany(label string, items []menuItem) ([]int, error) {
262 fd := int(os.Stdin.Fd())
263 old, err := term.MakeRaw(fd)
264 if err != nil {
265 return nil, err
266 }
267 defer term.Restore(fd, old)
268
269 w := os.Stdout
270 th := termHeight(fd)
271
272 // search state
273 searching := false
274 searchQuery := ""
275 filtered := items
276 filterIdx := make([]int, len(items))
277 for i := range items {
278 filterIdx[i] = i
279 }
280
281 cur := 0
282 checked := make([]bool, len(items))
283 scroll := 0
284 prevLines := 0
285
286 render := func() {
287 n := len(filtered)
288 vp := maxViewport(n, th, searching)
289 if cur < scroll {
290 scroll = cur
291 }
292 if cur >= scroll+vp {
293 scroll = cur - vp + 1
294 }
295 if scroll < 0 {
296 scroll = 0
297 }
298
299 if n > 0 && scroll > 0 {
300 fmt.Fprintf(w, "\r\033[K%s\n", dim(fmt.Sprintf(i18n.M.SelectMoreAboveFmt, scroll)))
301 } else {
302 fmt.Fprintf(w, "\r\033[K\r\n")
303 }
304
305 end := min(scroll+vp, n)
306 for i := scroll; i < end; i++ {
307 it := filtered[i]
308 origIdx := filterIdx[i]
309 box := "[ ]"
310 if checked[origIdx] {
311 box = "[x]"
312 }
313 name := fmt.Sprintf("%-14s", it.name)
314 if i == cur {
315 fmt.Fprintf(w, "\r\033[K%s\r\n", reverse(fmt.Sprintf(" ❯ %s %s %s ", box, name, it.desc)))
316 } else {
317 fmt.Fprintf(w, "\r\033[K %s %s %s\r\n", box, name, dim(it.desc))
318 }
319 }
320 for i := end - scroll; i < vp; i++ {
321 fmt.Fprintf(w, "\r\033[K\r\n")
322 }
323
324 if n > 0 && end < n {
325 fmt.Fprintf(w, "\r\033[K%s\n", dim(fmt.Sprintf(i18n.M.SelectMoreBelowFmt, n-end)))
326 } else {
327 fmt.Fprintf(w, "\r\033[K\r\n")
328 }
329 }
330
331 drawHeader := func() {
332 if searching {
333 fmt.Fprintf(w, "\r\033[K%s %s %s\r\n\r\n", accent("▌"), bold(label), dim(i18n.M.SelectSearchHint))
334 renderSearchBar(w, searchQuery)
335 } else {
336 fmt.Fprintf(w, "\r\033[K%s %s %s\r\n\r\n", accent("▌"), bold(label), dim(i18n.M.SelectManyHint))
337 }
338 }
339
340 redraw := func() {
341 if prevLines > 0 {
342 fmt.Fprintf(w, "\033[%dA", prevLines)
343 }
344 drawHeader()
345 render()
346 fmt.Fprint(w, "\033[J")
347 prevLines = fixedLines(searching) + maxViewport(len(filtered), th, searching)
348 }
349
350 redraw()
351
352 buf := make([]byte, 8)
353 for {
354 n, err := os.Stdin.Read(buf)
355 if err != nil {
356 return nil, err
357 }
358 k := buf[:n]
359
360 if searching {
361 switch {
362 case k[0] == 27: // Esc — exit search
363 searching = false
364 searchQuery = ""
365 filtered = items
366 filterIdx = make([]int, len(items))
367 for i := range items {
368 filterIdx[i] = i
369 }
370 cur = 0
371 scroll = 0
372 redraw()
373 case k[0] == '\r' || k[0] == '\n':
374 var out []int
375 for i, c := range checked {
376 if c {
377 out = append(out, i)
378 }
379 }
380 if len(out) == 0 {
381 continue
382 }
383 fmt.Fprint(w, "\r\n")
384 return out, nil
385 case k[0] == ' ':
386 if len(filtered) > 0 {
387 origIdx := filterIdx[cur]
388 checked[origIdx] = !checked[origIdx]
389 }
390 case k[0] == 127 || k[0] == 8: // backspace
391 if len(searchQuery) > 0 {
392 searchQuery = searchQuery[:len(searchQuery)-1]
393 filtered = filterMenuItems(items, searchQuery)
394 filterIdx = filterIndices(items, searchQuery)
395 cur = 0
396 scroll = 0
397 redraw()
398 }
399 case k[0] == 3: // Ctrl-C
400 fmt.Fprint(w, "\r\n")
401 return nil, errCancelled
402 case k[0] >= 32 && k[0] < 127:
403 searchQuery += string(k[0])
404 filtered = filterMenuItems(items, searchQuery)
405 filterIdx = filterIndices(items, searchQuery)
406 cur = 0
407 scroll = 0
408 redraw()
409 case len(k) >= 3 && k[0] == 27 && k[1] == '[' && k[2] == 'A':
410 if cur > 0 {
411 cur--
412 }
413 case len(k) >= 3 && k[0] == 27 && k[1] == '[' && k[2] == 'B':
414 if cur < len(filtered)-1 {
415 cur++
416 }
417 case k[0] == 'k':
418 if cur > 0 {
419 cur--
420 }
421 case k[0] == 'j':
422 if cur < len(filtered)-1 {
423 cur++
424 }
425 default:
426 continue
427 }
428 redraw()
429 continue
430 }
431
432 switch {
433 case k[0] == '\r' || k[0] == '\n':
434 var out []int
435 for i, c := range checked {
436 if c {
437 out = append(out, i)
438 }
439 }
440 if len(out) == 0 {
441 continue // need at least one selection
442 }
443 fmt.Fprint(w, "\r\n")
444 return out, nil
445 case k[0] == 3 || k[0] == 'q':
446 fmt.Fprint(w, "\r\n")
447 return nil, errCancelled
448 case k[0] == '/': // enter search mode
449 searching = true
450 searchQuery = ""
451 redraw()
452 case k[0] == ' ':
453 if len(filtered) > 0 {
454 origIdx := filterIdx[cur]
455 checked[origIdx] = !checked[origIdx]
456 }
457 case len(k) >= 3 && k[0] == 27 && k[1] == '[' && k[2] == 'A':
458 if cur > 0 {
459 cur--
460 }
461 case len(k) >= 3 && k[0] == 27 && k[1] == '[' && k[2] == 'B':
462 if cur < len(filtered)-1 {
463 cur++
464 }
465 case k[0] == 'k':
466 if cur > 0 {
467 cur--
468 }
469 case k[0] == 'j':
470 if cur < len(filtered)-1 {
471 cur++
472 }
473 default:
474 continue
475 }
476 redraw()
477 }
478 }
479
480 // filterIndices returns the original indices of items matching query.
481 func filterIndices(items []menuItem, query string) []int {
482 if query == "" {
483 out := make([]int, len(items))
484 for i := range items {
485 out[i] = i
486 }
487 return out
488 }
489 lq := strings.ToLower(query)
490 var out []int
491 for i, it := range items {
492 if strings.Contains(strings.ToLower(it.name), lq) || strings.Contains(strings.ToLower(it.desc), lq) {
493 out = append(out, i)
494 }
495 }
496 return out
497 }
498
499 // FrameLines is exported for testing. It returns the total number of terminal
500 // lines that selectOne/selectMany will print for the given state.
501 func FrameLines(filteredLen, termRows int, searching bool) int {
502 return fixedLines(searching) + maxViewport(filteredLen, termRows, searching)
503 }
504
504 lines GO