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