返回 DeepSeek-Reasonix
chooser.go
根目录 / internal / cli / chooser.go
1 package cli
2
3 import (
4 "fmt"
5 "strings"
6
7 tea "charm.land/bubbletea/v2"
8 "charm.land/lipgloss/v2"
9
10 "reasonix/internal/event"
11 "reasonix/internal/i18n"
12 )
13
14 // chooser is the in-chat multiple-choice prompt the `ask` tool raises — the CLI's
15 // question card. It holds the questions, the per-question selections, and the
16 // cursor; chatTUI routes keystrokes to it while it's active
17 // (m.chooser != nil) and renders it pinned above the input. One AskRequest can
18 // carry several questions, shown as tabs (←/→) plus a final Submit tab.
19 type chooser struct {
20 id string
21 questions []event.AskQuestion
22 tab int // 0..len-1: a question; len: the Submit tab
23 cursor int // highlighted row within the current question
24 sel []map[int]bool // chosen option indices, per question
25 custom []string // free-typed answer, per question ("" = none)
26 typing bool // entering a free-text answer (keys go to the textarea)
27 }
28
29 func newChooser(a event.Ask) *chooser {
30 c := &chooser{
31 id: a.ID,
32 questions: a.Questions,
33 sel: make([]map[int]bool, len(a.Questions)),
34 custom: make([]string, len(a.Questions)),
35 }
36 for i := range c.sel {
37 c.sel[i] = map[int]bool{}
38 }
39 return c
40 }
41
42 func (c *chooser) onSubmitTab() bool { return c.tab >= len(c.questions) }
43
44 // rowCount is the rows of the current question: one per option, then a "Type
45 // something" row and a "Chat about this" row.
46 func (c *chooser) rowCount() int {
47 if c.onSubmitTab() {
48 return 0
49 }
50 return len(c.questions[c.tab].Options) + 2
51 }
52
53 func (c *chooser) answered(i int) bool { return len(c.sel[i]) > 0 || c.custom[i] != "" }
54
55 func (c *chooser) allAnswered() bool {
56 for i := range c.questions {
57 if !c.answered(i) {
58 return false
59 }
60 }
61 return true
62 }
63
64 // answers builds the AskAnswer list from the current selections (custom text wins
65 // when set).
66 func (c *chooser) answers() []event.AskAnswer {
67 out := make([]event.AskAnswer, len(c.questions))
68 for i, q := range c.questions {
69 var sel []string
70 if c.custom[i] != "" {
71 sel = []string{c.custom[i]}
72 } else {
73 for j := range q.Options {
74 if c.sel[i][j] {
75 sel = append(sel, q.Options[j].Label)
76 }
77 }
78 }
79 out[i] = event.AskAnswer{QuestionID: q.ID, Selected: sel}
80 }
81 return out
82 }
83
84 // chatTUI integration
85
86 // handleChooserKey routes a keystroke to the active chooser (when not in free-text
87 // mode — that's handled in Update by the textarea). Selecting an option in a
88 // single-select question advances; on the last/only question it submits.
89 func (m chatTUI) handleChooserKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
90 c := m.chooser
91 switch msg.String() {
92 case "ctrl+c":
93 m.ctrl.Cancel()
94 m.chooser = nil
95 return m, nil
96 case "esc":
97 return m.chooserAnswer(nil) // dismiss → empty answer
98 case "left", "h":
99 if c.tab > 0 {
100 c.tab--
101 c.cursor = 0
102 }
103 return m, nil
104 case "right", "l":
105 if c.tab < len(c.questions) {
106 c.tab++
107 c.cursor = 0
108 }
109 return m, nil
110 }
111
112 if c.onSubmitTab() {
113 switch msg.String() {
114 case "enter":
115 return m.chooserAnswer(c.answers())
116 case "up", "k", "down", "j":
117 c.tab = len(c.questions) - 1 // step back into the last question
118 c.cursor = 0
119 }
120 return m, nil
121 }
122
123 q := c.questions[c.tab]
124 switch msg.String() {
125 case "up", "k":
126 if c.cursor > 0 {
127 c.cursor--
128 }
129 case "down", "j":
130 if c.cursor < c.rowCount()-1 {
131 c.cursor++
132 }
133 case " ", "space":
134 if c.cursor < len(q.Options) && q.Multi {
135 c.sel[c.tab][c.cursor] = !c.sel[c.tab][c.cursor]
136 c.custom[c.tab] = ""
137 }
138 case "enter":
139 return m.chooserActivate(c.cursor)
140 default:
141 // number keys 1..9 jump to / pick a numbered row: a real option, the
142 // "Type something" free-text row, or the "Chat about this" dismissal row
143 // (chooserActivate already routes all three the same way Enter does).
144 if s := msg.String(); len(s) == 1 && s[0] >= '1' && s[0] <= '9' {
145 if idx := int(s[0] - '1'); idx < c.rowCount() {
146 return m.chooserActivate(idx)
147 }
148 }
149 }
150 return m, nil
151 }
152
153 // chooserActivate acts on the row: a normal option toggles (multi) or selects and
154 // advances (single); the "Type something" row opens free-text entry; the "Chat
155 // about this" row dismisses the prompt so the user can just talk.
156 func (m chatTUI) chooserActivate(row int) (tea.Model, tea.Cmd) {
157 c := m.chooser
158 q := c.questions[c.tab]
159 switch {
160 case row < len(q.Options):
161 if q.Multi {
162 // Space toggles; Enter confirms current selections and advances.
163 // (Toggling is handled in handleChooserKey; we only arrive here
164 // via Enter or number keys, both of which should commit.)
165 return m.chooserAdvance()
166 }
167 c.sel[c.tab] = map[int]bool{row: true}
168 c.custom[c.tab] = ""
169 return m.chooserAdvance()
170 case row == len(q.Options): // Type something
171 c.typing = true
172 c.cursor = row
173 m.resetComposerInput()
174 m.input.SetHeight(1)
175 m.refreshInputPlaceholder()
176 return m, nil
177 default: // Chat about this
178 return m.chooserAnswer(nil)
179 }
180 }
181
182 // chooserAdvance moves to the next question, or the Submit tab; a single-question
183 // prompt submits straight away.
184 func (m chatTUI) chooserAdvance() (tea.Model, tea.Cmd) {
185 c := m.chooser
186 if len(c.questions) == 1 {
187 return m.chooserAnswer(c.answers())
188 }
189 if c.tab < len(c.questions) {
190 c.tab++
191 c.cursor = 0
192 }
193 return m, nil
194 }
195
196 // chooserAnswer resolves the prompt with the given answers (nil = dismissed) and
197 // clears it; the blocked `ask` tool unblocks and the turn continues.
198 func (m chatTUI) chooserAnswer(answers []event.AskAnswer) (tea.Model, tea.Cmd) {
199 m.ctrl.AnswerQuestion(m.chooser.id, answers)
200 m.chooser = nil
201 m.refreshInputPlaceholder()
202 return m, nil
203 }
204
205 // renderChooser draws the pinned question card: a tab strip (when more than one
206 // question), the current question's prompt and options, and the Type-something /
207 // Chat-about-this rows. On the Submit tab it shows a review of the picks.
208 func (m chatTUI) renderChooser() string {
209 c := m.chooser
210 if c == nil {
211 return ""
212 }
213 w := max(m.width, 10)
214 var b strings.Builder
215
216 if len(c.questions) > 1 {
217 b.WriteString(m.chooserTabs() + "\n\n")
218 }
219
220 if c.onSubmitTab() {
221 b.WriteString(accent(i18n.M.AskSubmitTitle) + "\n")
222 for i, q := range c.questions {
223 label := headerOr(q, i)
224 ans := dim(i18n.M.AskUnanswered)
225 if a := c.answers()[i]; len(a.Selected) > 0 {
226 ans = strings.Join(a.Selected, ", ")
227 }
228 fmt.Fprintf(&b, " %s: %s\n", dim(label), ans)
229 }
230 b.WriteString(dim(i18n.M.AskSubmitHint))
231 return choicePanelStyle.Width(w).Render(b.String())
232 }
233
234 q := c.questions[c.tab]
235 b.WriteString(accent("? ") + q.Prompt + "\n")
236 for j, opt := range q.Options {
237 b.WriteString(m.chooserOptionRow(j, opt, q.Multi) + "\n")
238 }
239 // Type something
240 typeRow := len(q.Options)
241 typeLabel := i18n.M.AskTypeSomething
242 if c.custom[c.tab] != "" {
243 typeLabel = c.custom[c.tab]
244 } else if c.typing {
245 typeLabel = i18n.M.AskTypingHint
246 }
247 b.WriteString(rowLine(c.cursor == typeRow, typeRow+1, "", typeLabel, c.typing && c.custom[c.tab] == "") + "\n")
248 // Chat about this
249 b.WriteString(dim(strings.Repeat("─", min(w-2, 40))) + "\n")
250 chatRow := typeRow + 1
251 b.WriteString(rowLine(c.cursor == chatRow, chatRow+1, "", i18n.M.AskChatInstead, false))
252
253 return choicePanelStyle.Width(w).Render(b.String())
254 }
255
256 func (m chatTUI) chooserTabs() string {
257 c := m.chooser
258 parts := make([]string, 0, len(c.questions)+1)
259 for i, q := range c.questions {
260 mark := "☐"
261 if c.answered(i) {
262 mark = "✔"
263 }
264 label := mark + " " + headerOr(q, i)
265 if i == c.tab {
266 label = reverse(" " + label + " ")
267 } else {
268 label = dim(label)
269 }
270 parts = append(parts, label)
271 }
272 smark := "☐"
273 if c.allAnswered() {
274 smark = "✔"
275 }
276 submit := smark + " Submit"
277 if c.onSubmitTab() {
278 submit = reverse(" " + submit + " ")
279 } else {
280 submit = dim(submit)
281 }
282 return dim("← ") + strings.Join(parts, " ") + " " + submit + dim(" →")
283 }
284
285 // chooserOptionRow renders one option line (with its description underneath).
286 func (m chatTUI) chooserOptionRow(j int, opt event.AskOption, multi bool) string {
287 c := m.chooser
288 box := ""
289 if multi {
290 box = "☐ "
291 if c.sel[c.tab][j] {
292 box = "☑ "
293 }
294 }
295 line := rowLine(c.cursor == j, j+1, box, opt.Label, false)
296 if opt.Description != "" {
297 line += "\n" + dim(" "+opt.Description)
298 }
299 return line
300 }
301
302 // rowLine formats a selectable row: "❯ N. <box><label>", highlighted when current.
303 func rowLine(cur bool, num int, box, label string, active bool) string {
304 prefix := " "
305 if cur {
306 prefix = accent("❯ ")
307 }
308 body := fmt.Sprintf("%d. %s%s", num, box, label)
309 if cur {
310 body = bold(body)
311 } else if active {
312 body = yellow(body)
313 } else {
314 body = dim(body)
315 }
316 return prefix + body
317 }
318
319 func headerOr(q event.AskQuestion, i int) string {
320 if q.Header != "" {
321 return q.Header
322 }
323 return fmt.Sprintf("Q%d", i+1)
324 }
325
326 // choicePanelStyle frames the question card, matching the input box's top/bottom
327 // rule but in the accent colour.
328 var choicePanelStyle lipgloss.Style
329
329 lines GO