package app import ( "strings" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" "github.com/correx/tui-go/internal/protocol" ) // clarcard.go renders and drives the clarification view: a stage's open questions // presented as an interactive form — a header chip, the prompt, a row of selectable // option chips, and a custom free-text slot, like the AskUserQuestion widget. // Submitting sends the answers back; the parked stage re-runs with them in context. // --- per-question widget state --- func (m *Model) clarInitState(n int) { m.clarFocus = 0 m.clarCursor = 0 m.clarTyping = false m.clarDismissed = false m.clarChosen = make([]map[int]bool, n) for i := range m.clarChosen { m.clarChosen[i] = map[int]bool{} } m.clarText = make([]string, n) } func (m *Model) clarResetState() { m.clarChosen = nil m.clarText = nil m.clarFocus = 0 m.clarCursor = 0 m.clarTyping = false m.clarDismissed = false } // clarEnsureState (re)initializes the per-question buffers when they don't match the // selected session's question set — e.g. a clarification that arrived for a session // before it was selected, so onClarificationRequired didn't size the buffers. func (m *Model) clarEnsureState() { s := m.session(m.selectedID) if s == nil || s.Clar == nil { return } if len(m.clarChosen) != len(s.Clar.Questions) || len(m.clarText) != len(s.Clar.Questions) { m.clarInitState(len(s.Clar.Questions)) } } func (m Model) clarChosenAt(i, oi int) bool { if i < 0 || i >= len(m.clarChosen) || m.clarChosen[i] == nil { return false } return m.clarChosen[i][oi] } func (m Model) clarTextAt(i int) string { if i < 0 || i >= len(m.clarText) { return "" } return m.clarText[i] } func (m Model) clarAnswered(i int, q ClarQuestion) bool { if strings.TrimSpace(m.clarTextAt(i)) != "" { return true } for oi := range q.Options { if m.clarChosenAt(i, oi) { return true } } return false } // clarAnswerValue is the submitted value for question i: the custom text if present, // else the chosen option(s) joined in option order. func (m Model) clarAnswerValue(i int, q ClarQuestion) string { if t := strings.TrimSpace(m.clarTextAt(i)); t != "" { return t } var sel []string for oi, opt := range q.Options { if m.clarChosenAt(i, oi) { sel = append(sel, opt) } } return strings.Join(sel, ", ") } // --- key handling --- func (m Model) handleClarificationKey(k keyMsg) (tea.Model, tea.Cmd) { s := m.session(m.selectedID) if s == nil || s.Clar == nil { return m, nil } qs := s.Clar.Questions if m.clarTyping { return m.handleClarTyping(k), nil } switch k.Type { case keyEsc: m.clarDismissed = true case keyUp: m.clarFocusMove(-1, qs) case keyDown: m.clarFocusMove(1, qs) case keyLeft: m.clarCursorMove(-1, qs) case keyRight: m.clarCursorMove(1, qs) case keySpace: m.clarSelect(qs) case keyEnter: return m.clarSubmit() case keyRunes: switch string(k.Runes) { case "k": m.clarFocusMove(-1, qs) case "j": m.clarFocusMove(1, qs) case "h": m.clarCursorMove(-1, qs) case "l": m.clarCursorMove(1, qs) case "e": m.clarCursor = len(qs[m.clarFocus].Options) // jump to the custom slot m.clarTyping = true case "s": return m.clarSubmit() } } return m, nil } func (m Model) handleClarTyping(k keyMsg) tea.Model { if m.clarFocus >= len(m.clarText) { return m } switch k.Type { case keyEsc: m.clarTyping = false case keyEnter: m.clarTyping = false // committing custom text supersedes any chosen option for this question if m.clarFocus < len(m.clarChosen) { m.clarChosen[m.clarFocus] = map[int]bool{} } case keyBackspace: if t := m.clarText[m.clarFocus]; len(t) > 0 { m.clarText[m.clarFocus] = t[:len(t)-1] } case keyRunes, keySpace: m.clarText[m.clarFocus] += string(k.Runes) } return m } func (m *Model) clarFocusMove(d int, qs []ClarQuestion) { if len(qs) == 0 { return } m.clarFocus = clampInt(m.clarFocus+d, 0, len(qs)-1) m.clarCursor = clampInt(m.clarCursor, 0, len(qs[m.clarFocus].Options)) } func (m *Model) clarCursorMove(d int, qs []ClarQuestion) { if m.clarFocus >= len(qs) { return } m.clarCursor = clampInt(m.clarCursor+d, 0, len(qs[m.clarFocus].Options)) } func (m *Model) clarSelect(qs []ClarQuestion) { if m.clarFocus >= len(qs) || m.clarFocus >= len(m.clarChosen) { return } q := qs[m.clarFocus] if m.clarCursor >= len(q.Options) { // custom slot m.clarTyping = true return } if q.MultiSelect { if m.clarChosen[m.clarFocus] == nil { m.clarChosen[m.clarFocus] = map[int]bool{} } m.clarChosen[m.clarFocus][m.clarCursor] = !m.clarChosen[m.clarFocus][m.clarCursor] } else { m.clarChosen[m.clarFocus] = map[int]bool{m.clarCursor: true} } if m.clarFocus < len(m.clarText) { m.clarText[m.clarFocus] = "" // choosing an option supersedes a typed answer } } func (m Model) clarSubmit() (tea.Model, tea.Cmd) { s := m.session(m.selectedID) if s == nil || s.Clar == nil { return m, nil } qs := s.Clar.Questions for i, q := range qs { if !m.clarAnswered(i, q) { m.clarFocus = i // nudge to the first unanswered rather than submitting partial m.clarCursor = 0 return m, nil } } answers := make([]protocol.ClarificationAnswerDto, 0, len(qs)) for i, q := range qs { answers = append(answers, protocol.ClarificationAnswerDto{ QuestionID: q.ID, Value: m.clarAnswerValue(i, q), }) } m.client.Send(protocol.ClarificationResponse(s.Clar.SessionID, s.Clar.StageID, s.Clar.RequestID, answers)) s.Clar = nil m.clarResetState() return m, nil } func clampInt(v, lo, hi int) int { if v < lo { return lo } if v > hi { return hi } return v } // --- rendering --- func (m Model) clarificationModal() string { t := m.theme w := m.modalWidth() s := m.session(m.selectedID) if s == nil || s.Clar == nil { return "" } qs := s.Clar.Questions var b strings.Builder b.WriteString(m.titleLine("clarifying questions")) b.WriteString(mbg(t, " — "+s.Clar.StageID, t.P.Dim)) answered := 0 for i, q := range qs { if m.clarAnswered(i, q) { answered++ } } b.WriteString(mbg(t, " ("+itoa(answered)+"/"+itoa(len(qs))+" answered)", t.P.Faint) + "\n\n") contentW := w - 6 for i, q := range qs { b.WriteString(m.clarQuestionLines(i, q, i == m.clarFocus, contentW)) if i < len(qs)-1 { b.WriteString("\n") } } b.WriteString("\n" + modalHints(t, [][2]string{ {"←→", "option"}, {"↑↓", "question"}, {"space", "select"}, {"e", "custom"}, {"enter", "submit"}, {"esc", "later"}, })) return t.Overlay.Width(w).Render(b.String()) } // clarQuestionLines renders one question: a focus marker + header chip + prompt, then // a wrapped row of option chips (cursor highlighted, chosen marked) plus a custom slot. func (m Model) clarQuestionLines(i int, q ClarQuestion, focused bool, w int) string { t := m.theme var b strings.Builder marker := mbg(t, " ", t.P.BgPanel) promptFg := t.P.Fg if focused { marker = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("▸ ") promptFg = t.P.FgStrong } promptStyle := lipgloss.NewStyle().Foreground(promptFg).Background(t.P.BgPanel) headChip, headW := "", 2 // marker is 2 cols if q.Header != "" { headChip = lipgloss.NewStyle().Foreground(t.P.Accent2).Background(t.P.BgPanel).Render("[" + q.Header + "] ") headW += len(q.Header) + 3 // "[" + header + "] " } prompt := q.Prompt if q.MultiSelect { prompt += " (multi)" } // Soft-wrap the prompt so long questions (e.g. the [API]/[Architecture] ones) // stay fully readable instead of being clipped at the modal edge. The first line // flows after the marker + header chip; continuation lines indent under the prompt. contIndent := mbg(t, " ", t.P.BgPanel) for li, line := range wrapPrompt(prompt, max(w-headW, 8), max(w-4, 8)) { if li == 0 { b.WriteString(marker + headChip + promptStyle.Render(line) + "\n") } else { b.WriteString(contIndent + promptStyle.Render(line) + "\n") } } chips := make([]string, 0, len(q.Options)+1) for oi, opt := range q.Options { chips = append(chips, m.clarChip(opt, m.clarChosenAt(i, oi), focused && m.clarCursor == oi)) } chips = append(chips, m.clarCustomChip(i, q, focused)) gap := mbg(t, " ", t.P.BgPanel) for _, line := range wrapStyled(chips, gap, 2, w-4) { b.WriteString(mbg(t, " ", t.P.BgPanel) + line + "\n") } return b.String() } // wrapPrompt greedily word-wraps s, giving the first line firstW columns (it shares // its row with the marker + header chip) and every continuation line contW. A word // longer than the limit occupies its own line rather than being truncated. func wrapPrompt(s string, firstW, contW int) []string { words := strings.Fields(s) if len(words) == 0 { return []string{""} } var lines []string cur, limit := "", firstW for _, wd := range words { switch { case cur == "": cur = wd case len(cur)+1+len(wd) <= limit: cur += " " + wd default: lines = append(lines, cur) cur, limit = wd, contW } } return append(lines, cur) } // clarCustomChip renders the free-text slot: "+ custom" when empty, the typed text // (with a caret while editing) otherwise. func (m Model) clarCustomChip(i int, q ClarQuestion, focused bool) string { text := m.clarTextAt(i) chosen := strings.TrimSpace(text) != "" cursor := focused && m.clarCursor >= len(q.Options) label := "+ custom" switch { case cursor && m.clarTyping: label = "✎ " + text + "▏" case chosen: label = "✎ " + text } return m.clarChip(label, chosen, cursor) } // clarChip renders one option as a bracketed badge: inverse-highlighted when the // cursor is on it, marked (●) when chosen. func (m Model) clarChip(label string, chosen, cursor bool) string { t := m.theme mark, fg := "○ ", t.P.Dim if chosen { mark, fg = "● ", t.P.Accent2 } style := lipgloss.NewStyle().Foreground(fg).Background(t.P.BgPanel) if cursor { style = lipgloss.NewStyle().Foreground(t.P.BgPanel).Background(t.P.Accent).Bold(true) } return style.Render(" " + mark + label + " ") }