| 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 an option |
| 142 | if s := msg.String(); len(s) == 1 && s[0] >= '1' && s[0] <= '9' { |
| 143 | if idx := int(s[0] - '1'); idx < len(q.Options) { |
| 144 | return m.chooserActivate(idx) |
| 145 | } |
| 146 | } |
| 147 | } |
| 148 | return m, nil |
| 149 | } |
| 150 | |
| 151 | // chooserActivate acts on the row: a normal option toggles (multi) or selects and |
| 152 | // advances (single); the "Type something" row opens free-text entry; the "Chat |
| 153 | // about this" row dismisses the prompt so the user can just talk. |
| 154 | func (m chatTUI) chooserActivate(row int) (tea.Model, tea.Cmd) { |
| 155 | c := m.chooser |
| 156 | q := c.questions[c.tab] |
| 157 | switch { |
| 158 | case row < len(q.Options): |
| 159 | if q.Multi { |
| 160 | // Space toggles; Enter confirms current selections and advances. |
| 161 | // (Toggling is handled in handleChooserKey; we only arrive here |
| 162 | // via Enter or number keys, both of which should commit.) |
| 163 | return m.chooserAdvance() |
| 164 | } |
| 165 | c.sel[c.tab] = map[int]bool{row: true} |
| 166 | c.custom[c.tab] = "" |
| 167 | return m.chooserAdvance() |
| 168 | case row == len(q.Options): // Type something |
| 169 | c.typing = true |
| 170 | c.cursor = row |
| 171 | m.input.Reset() |
| 172 | m.input.SetHeight(1) |
| 173 | m.refreshInputPlaceholder() |
| 174 | return m, nil |
| 175 | default: // Chat about this |
| 176 | return m.chooserAnswer(nil) |
| 177 | } |
| 178 | } |
| 179 | |
| 180 | // chooserAdvance moves to the next question, or the Submit tab; a single-question |
| 181 | // prompt submits straight away. |
| 182 | func (m chatTUI) chooserAdvance() (tea.Model, tea.Cmd) { |
| 183 | c := m.chooser |
| 184 | if len(c.questions) == 1 { |
| 185 | return m.chooserAnswer(c.answers()) |
| 186 | } |
| 187 | if c.tab < len(c.questions) { |
| 188 | c.tab++ |
| 189 | c.cursor = 0 |
| 190 | } |
| 191 | return m, nil |
| 192 | } |
| 193 | |
| 194 | // chooserAnswer resolves the prompt with the given answers (nil = dismissed) and |
| 195 | // clears it; the blocked `ask` tool unblocks and the turn continues. |
| 196 | func (m chatTUI) chooserAnswer(answers []event.AskAnswer) (tea.Model, tea.Cmd) { |
| 197 | m.ctrl.AnswerQuestion(m.chooser.id, answers) |
| 198 | m.chooser = nil |
| 199 | m.refreshInputPlaceholder() |
| 200 | return m, nil |
| 201 | } |
| 202 | |
| 203 | // renderChooser draws the pinned question card: a tab strip (when more than one |
| 204 | // question), the current question's prompt and options, and the Type-something / |
| 205 | // Chat-about-this rows. On the Submit tab it shows a review of the picks. |
| 206 | func (m chatTUI) renderChooser() string { |
| 207 | c := m.chooser |
| 208 | if c == nil { |
| 209 | return "" |
| 210 | } |
| 211 | w := max(m.width, 10) |
| 212 | var b strings.Builder |
| 213 | |
| 214 | if len(c.questions) > 1 { |
| 215 | b.WriteString(m.chooserTabs() + "\n\n") |
| 216 | } |
| 217 | |
| 218 | if c.onSubmitTab() { |
| 219 | b.WriteString(accent(i18n.M.AskSubmitTitle) + "\n") |
| 220 | for i, q := range c.questions { |
| 221 | label := headerOr(q, i) |
| 222 | ans := dim(i18n.M.AskUnanswered) |
| 223 | if a := c.answers()[i]; len(a.Selected) > 0 { |
| 224 | ans = strings.Join(a.Selected, ", ") |
| 225 | } |
| 226 | fmt.Fprintf(&b, " %s: %s\n", dim(label), ans) |
| 227 | } |
| 228 | b.WriteString(dim(i18n.M.AskSubmitHint)) |
| 229 | return choicePanelStyle.Width(w).Render(b.String()) |
| 230 | } |
| 231 | |
| 232 | q := c.questions[c.tab] |
| 233 | b.WriteString(accent("? ") + q.Prompt + "\n") |
| 234 | for j, opt := range q.Options { |
| 235 | b.WriteString(m.chooserOptionRow(j, opt, q.Multi) + "\n") |
| 236 | } |
| 237 | // Type something |
| 238 | typeRow := len(q.Options) |
| 239 | typeLabel := i18n.M.AskTypeSomething |
| 240 | if c.custom[c.tab] != "" { |
| 241 | typeLabel = c.custom[c.tab] |
| 242 | } else if c.typing { |
| 243 | typeLabel = i18n.M.AskTypingHint |
| 244 | } |
| 245 | b.WriteString(rowLine(c.cursor == typeRow, typeRow+1, "", typeLabel, c.typing && c.custom[c.tab] == "") + "\n") |
| 246 | // Chat about this |
| 247 | b.WriteString(dim(strings.Repeat("─", min(w-2, 40))) + "\n") |
| 248 | chatRow := typeRow + 1 |
| 249 | b.WriteString(rowLine(c.cursor == chatRow, chatRow+1, "", i18n.M.AskChatInstead, false)) |
| 250 | |
| 251 | return choicePanelStyle.Width(w).Render(b.String()) |
| 252 | } |
| 253 | |
| 254 | func (m chatTUI) chooserTabs() string { |
| 255 | c := m.chooser |
| 256 | parts := make([]string, 0, len(c.questions)+1) |
| 257 | for i, q := range c.questions { |
| 258 | mark := "☐" |
| 259 | if c.answered(i) { |
| 260 | mark = "✔" |
| 261 | } |
| 262 | label := mark + " " + headerOr(q, i) |
| 263 | if i == c.tab { |
| 264 | label = reverse(" " + label + " ") |
| 265 | } else { |
| 266 | label = dim(label) |
| 267 | } |
| 268 | parts = append(parts, label) |
| 269 | } |
| 270 | smark := "☐" |
| 271 | if c.allAnswered() { |
| 272 | smark = "✔" |
| 273 | } |
| 274 | submit := smark + " Submit" |
| 275 | if c.onSubmitTab() { |
| 276 | submit = reverse(" " + submit + " ") |
| 277 | } else { |
| 278 | submit = dim(submit) |
| 279 | } |
| 280 | return dim("← ") + strings.Join(parts, " ") + " " + submit + dim(" →") |
| 281 | } |
| 282 | |
| 283 | // chooserOptionRow renders one option line (with its description underneath). |
| 284 | func (m chatTUI) chooserOptionRow(j int, opt event.AskOption, multi bool) string { |
| 285 | c := m.chooser |
| 286 | box := "" |
| 287 | if multi { |
| 288 | box = "☐ " |
| 289 | if c.sel[c.tab][j] { |
| 290 | box = "☑ " |
| 291 | } |
| 292 | } |
| 293 | line := rowLine(c.cursor == j, j+1, box, opt.Label, false) |
| 294 | if opt.Description != "" { |
| 295 | line += "\n" + dim(" "+opt.Description) |
| 296 | } |
| 297 | return line |
| 298 | } |
| 299 | |
| 300 | // rowLine formats a selectable row: "❯ N. <box><label>", highlighted when current. |
| 301 | func rowLine(cur bool, num int, box, label string, active bool) string { |
| 302 | prefix := " " |
| 303 | if cur { |
| 304 | prefix = accent("❯ ") |
| 305 | } |
| 306 | body := fmt.Sprintf("%d. %s%s", num, box, label) |
| 307 | if cur { |
| 308 | body = bold(body) |
| 309 | } else if active { |
| 310 | body = yellow(body) |
| 311 | } else { |
| 312 | body = dim(body) |
| 313 | } |
| 314 | return prefix + body |
| 315 | } |
| 316 | |
| 317 | func headerOr(q event.AskQuestion, i int) string { |
| 318 | if q.Header != "" { |
| 319 | return q.Header |
| 320 | } |
| 321 | return fmt.Sprintf("Q%d", i+1) |
| 322 | } |
| 323 | |
| 324 | // choicePanelStyle frames the question card, matching the input box's top/bottom |
| 325 | // rule but in the accent colour. |
| 326 | var choicePanelStyle lipgloss.Style |
| 327 |