d247b19608
Move the Go TUI from github.com/charmbracelet/{bubbletea,lipgloss} to the v2
ecosystem at charm.land/{bubbletea,lipgloss}/v2 (Go 1.25). v2's kitty keyboard
disambiguation is on by default, so Shift+Enter now reliably inserts a newline
in the composer (Ctrl+J / Alt+Enter kept as fallbacks for non-kitty terminals).
Approach: rather than rewrite ~190 key-match sites to v2's (Code, Mod) idiom, a
small shim (internal/app/key.go) converts a v2 KeyPressMsg into the v1-shaped
keyMsg the handlers already expect, at the single Update boundary. The rest is
mechanical:
- key constants tea.Key* → shim consts; tea.KeyMsg → keyMsg.
- lipgloss.Color is now a func returning color.Color, not a type → fields/params
retyped to image/color.Color (theme/diff/overlays/view).
- v2 View() returns tea.View: render() builds the string, View() wraps it and
carries AltScreen (alt-screen is a per-frame View field now, not a program opt).
- WithWhitespaceBackground/Foreground → WithWhitespaceStyle(Style).
- preview: drop lipgloss.SetColorProfile (v2 renders truecolor by default).
Build, vet, gofmt, and the full test suite are green; preview renders truecolor
across kinds.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
337 lines
8.6 KiB
Go
337 lines
8.6 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
|
|
}
|
|
head := marker
|
|
if q.Header != "" {
|
|
head += lipgloss.NewStyle().Foreground(t.P.Accent2).Background(t.P.BgPanel).Render("[" + q.Header + "] ")
|
|
}
|
|
head += lipgloss.NewStyle().Foreground(promptFg).Background(t.P.BgPanel).Render(q.Prompt)
|
|
if q.MultiSelect {
|
|
head += mbg(t, " (multi)", t.P.Faint)
|
|
}
|
|
b.WriteString(clip(head, w) + "\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()
|
|
}
|
|
|
|
// 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 + " ")
|
|
}
|