package cli import ( "fmt" "strings" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" "reasonix/internal/event" "reasonix/internal/i18n" ) // chooser is the in-chat multiple-choice prompt the `ask` tool raises — the CLI's // question card. It holds the questions, the per-question selections, and the // cursor; chatTUI routes keystrokes to it while it's active // (m.chooser != nil) and renders it pinned above the input. One AskRequest can // carry several questions, shown as tabs (←/→) plus a final Submit tab. type chooser struct { id string questions []event.AskQuestion tab int // 0..len-1: a question; len: the Submit tab cursor int // highlighted row within the current question sel []map[int]bool // chosen option indices, per question custom []string // free-typed answer, per question ("" = none) typing bool // entering a free-text answer (keys go to the textarea) } func newChooser(a event.Ask) *chooser { c := &chooser{ id: a.ID, questions: a.Questions, sel: make([]map[int]bool, len(a.Questions)), custom: make([]string, len(a.Questions)), } for i := range c.sel { c.sel[i] = map[int]bool{} } return c } func (c *chooser) onSubmitTab() bool { return c.tab >= len(c.questions) } // rowCount is the rows of the current question: one per option, then a "Type // something" row and a "Chat about this" row. func (c *chooser) rowCount() int { if c.onSubmitTab() { return 0 } return len(c.questions[c.tab].Options) + 2 } func (c *chooser) answered(i int) bool { return len(c.sel[i]) > 0 || c.custom[i] != "" } func (c *chooser) allAnswered() bool { for i := range c.questions { if !c.answered(i) { return false } } return true } // answers builds the AskAnswer list from the current selections (custom text wins // when set). func (c *chooser) answers() []event.AskAnswer { out := make([]event.AskAnswer, len(c.questions)) for i, q := range c.questions { var sel []string if c.custom[i] != "" { sel = []string{c.custom[i]} } else { for j := range q.Options { if c.sel[i][j] { sel = append(sel, q.Options[j].Label) } } } out[i] = event.AskAnswer{QuestionID: q.ID, Selected: sel} } return out } // --- chatTUI integration --- // handleChooserKey routes a keystroke to the active chooser (when not in free-text // mode — that's handled in Update by the textarea). Selecting an option in a // single-select question advances; on the last/only question it submits. func (m chatTUI) handleChooserKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { c := m.chooser switch msg.String() { case "ctrl+c": m.ctrl.Cancel() m.chooser = nil return m, nil case "esc": return m.chooserAnswer(nil) // dismiss → empty answer case "left", "h": if c.tab > 0 { c.tab-- c.cursor = 0 } return m, nil case "right", "l": if c.tab < len(c.questions) { c.tab++ c.cursor = 0 } return m, nil } if c.onSubmitTab() { switch msg.String() { case "enter": return m.chooserAnswer(c.answers()) case "up", "k", "down", "j": c.tab = len(c.questions) - 1 // step back into the last question c.cursor = 0 } return m, nil } q := c.questions[c.tab] switch msg.String() { case "up", "k": if c.cursor > 0 { c.cursor-- } case "down", "j": if c.cursor < c.rowCount()-1 { c.cursor++ } case " ", "space": if c.cursor < len(q.Options) && q.Multi { c.sel[c.tab][c.cursor] = !c.sel[c.tab][c.cursor] c.custom[c.tab] = "" } case "enter": return m.chooserActivate(c.cursor) default: // number keys 1..9 jump to / pick an option if s := msg.String(); len(s) == 1 && s[0] >= '1' && s[0] <= '9' { if idx := int(s[0] - '1'); idx < len(q.Options) { return m.chooserActivate(idx) } } } return m, nil } // chooserActivate acts on the row: a normal option toggles (multi) or selects and // advances (single); the "Type something" row opens free-text entry; the "Chat // about this" row dismisses the prompt so the user can just talk. func (m chatTUI) chooserActivate(row int) (tea.Model, tea.Cmd) { c := m.chooser q := c.questions[c.tab] switch { case row < len(q.Options): if q.Multi { // Space toggles; Enter confirms current selections and advances. // (Toggling is handled in handleChooserKey; we only arrive here // via Enter or number keys, both of which should commit.) return m.chooserAdvance() } c.sel[c.tab] = map[int]bool{row: true} c.custom[c.tab] = "" return m.chooserAdvance() case row == len(q.Options): // Type something c.typing = true c.cursor = row m.input.Reset() m.input.SetHeight(1) return m, nil default: // Chat about this return m.chooserAnswer(nil) } } // chooserAdvance moves to the next question, or the Submit tab; a single-question // prompt submits straight away. func (m chatTUI) chooserAdvance() (tea.Model, tea.Cmd) { c := m.chooser if len(c.questions) == 1 { return m.chooserAnswer(c.answers()) } if c.tab < len(c.questions) { c.tab++ c.cursor = 0 } return m, nil } // chooserAnswer resolves the prompt with the given answers (nil = dismissed) and // clears it; the blocked `ask` tool unblocks and the turn continues. func (m chatTUI) chooserAnswer(answers []event.AskAnswer) (tea.Model, tea.Cmd) { m.ctrl.AnswerQuestion(m.chooser.id, answers) m.chooser = nil return m, nil } // renderChooser draws the pinned question card: a tab strip (when more than one // question), the current question's prompt and options, and the Type-something / // Chat-about-this rows. On the Submit tab it shows a review of the picks. func (m chatTUI) renderChooser() string { c := m.chooser if c == nil { return "" } w := max(m.width, 10) var b strings.Builder if len(c.questions) > 1 { b.WriteString(m.chooserTabs() + "\n\n") } if c.onSubmitTab() { b.WriteString(accent(i18n.M.AskSubmitTitle) + "\n") for i, q := range c.questions { label := headerOr(q, i) ans := dim(i18n.M.AskUnanswered) if a := c.answers()[i]; len(a.Selected) > 0 { ans = strings.Join(a.Selected, ", ") } fmt.Fprintf(&b, " %s: %s\n", dim(label), ans) } b.WriteString(dim(i18n.M.AskSubmitHint)) return choicePanelStyle.Width(w).Render(b.String()) } q := c.questions[c.tab] b.WriteString(accent("? ") + q.Prompt + "\n") for j, opt := range q.Options { b.WriteString(m.chooserOptionRow(j, opt, q.Multi) + "\n") } // Type something typeRow := len(q.Options) typeLabel := i18n.M.AskTypeSomething if c.custom[c.tab] != "" { typeLabel = c.custom[c.tab] } else if c.typing { typeLabel = i18n.M.AskTypingHint } b.WriteString(rowLine(c.cursor == typeRow, typeRow+1, "", typeLabel, c.typing && c.custom[c.tab] == "") + "\n") // Chat about this b.WriteString(dim(strings.Repeat("─", min(w-2, 40))) + "\n") chatRow := typeRow + 1 b.WriteString(rowLine(c.cursor == chatRow, chatRow+1, "", i18n.M.AskChatInstead, false)) return choicePanelStyle.Width(w).Render(b.String()) } func (m chatTUI) chooserTabs() string { c := m.chooser parts := make([]string, 0, len(c.questions)+1) for i, q := range c.questions { mark := "☐" if c.answered(i) { mark = "✔" } label := mark + " " + headerOr(q, i) if i == c.tab { label = reverse(" " + label + " ") } else { label = dim(label) } parts = append(parts, label) } smark := "☐" if c.allAnswered() { smark = "✔" } submit := smark + " Submit" if c.onSubmitTab() { submit = reverse(" " + submit + " ") } else { submit = dim(submit) } return dim("← ") + strings.Join(parts, " ") + " " + submit + dim(" →") } // chooserOptionRow renders one option line (with its description underneath). func (m chatTUI) chooserOptionRow(j int, opt event.AskOption, multi bool) string { c := m.chooser box := "" if multi { box = "☐ " if c.sel[c.tab][j] { box = "☑ " } } line := rowLine(c.cursor == j, j+1, box, opt.Label, false) if opt.Description != "" { line += "\n" + dim(" "+opt.Description) } return line } // rowLine formats a selectable row: "❯ N.