8cfc590c7e
The clarification modal hard-truncated each question's head line with clip(), so long prompts (e.g. an API question and an Architecture question) were cut at the modal edge and unreadable. Wrap the prompt across rows instead: wrapPrompt() greedily word-wraps with a narrower first line (marker + header chip eat into it) and full-width continuation lines, indented to align under the prompt.
374 lines
9.8 KiB
Go
374 lines
9.8 KiB
Go
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 + " ")
|
|
}
|