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