Vectrum
Sign In
474 lines · 13.3 KB · Go
package tui

// The question box is the approval box's twin: the agent is blocked on a
// channel, the update loop keeps running, and a keypress is what lets it go.
// What differs is the shape of the answer. An approval has four fixed choices
// and the same four every time; a question has whatever options the model
// thought of, sometimes several questions in a row, and always the option of
// typing something nobody offered.
//
// The working state lives in askState rather than in a handful of fields on
// the model, because there can be a queue of these and each one carries its
// own half-finished answers.

import (
	"fmt"
	"strings"

	"github.com/charmbracelet/bubbles/textarea"
	"github.com/charmbracelet/bubbles/textinput"
	tea "github.com/charmbracelet/bubbletea"

	"codey/internal/ask"
)

// askState is one question box: what was asked, where the cursor is, what has
// been ticked so far, and the channel the agent is waiting on.
type askState struct {
	req   ask.Request
	reply chan<- ask.Response

	// q is which question of the request is on screen, idx the row the cursor
	// is on. picked and other are per question and survive going back: an
	// answer you changed your mind about twice should still be there.
	q      int
	idx    int
	picked []map[int]bool
	other  []string

	// typing is the free-text field, open over the list.
	typing bool
	input  textinput.Model

	// hit maps a row inside the box to the option drawn on it. A click is
	// resolved against what was drawn rather than against an assumed row
	// height, because an option with a description takes two rows and one
	// without takes one.
	hit map[int]int
}

func newAskState(req ask.Request, reply chan<- ask.Response) *askState {
	in := textinput.New()
	in.Prompt = "› "
	in.Placeholder = "an answer of your own"
	in.CharLimit = 0
	s := &askState{
		req:    req,
		reply:  reply,
		picked: make([]map[int]bool, len(req.Questions)),
		other:  make([]string, len(req.Questions)),
		input:  in,
		hit:    map[int]int{},
	}
	for i := range s.picked {
		s.picked[i] = map[int]bool{}
	}
	return s
}

func (s *askState) question() ask.Question { return s.req.Questions[s.q] }

// otherRow is the last row of the list: the way to answer something nobody
// offered. It is always there, which is why the tool tells the model not to
// write an "Other" option of its own.
func (s *askState) otherRow() int { return len(s.question().Options) }

func (s *askState) rows() int { return s.otherRow() + 1 }

// move walks the list, wrapping. A list this short is a ring: pressing down
// on the last row to get back to the first is quicker than finding ↑.
func (s *askState) move(step int) {
	n := s.rows()
	s.idx = (s.idx + step + n) % n
}

// tick marks an option. In a single-answer question it is the answer, so
// anything else ticked stops being it.
func (s *askState) tick(i int) {
	if !s.question().MultiSelect {
		s.picked[s.q] = map[int]bool{}
	}
	s.picked[s.q][i] = true
}

func (s *askState) toggle(i int) {
	if s.picked[s.q][i] {
		delete(s.picked[s.q], i)
		return
	}
	s.tick(i)
}

// cursorHome is where the cursor sits when a question comes up: on the answer
// already given, when there is one, so going back to change your mind starts
// at the thing you are changing rather than at the top of the list.
func (s *askState) cursorHome() int {
	if strings.TrimSpace(s.other[s.q]) != "" {
		return s.otherRow()
	}
	for i := range s.question().Options {
		if s.picked[s.q][i] {
			return i
		}
	}
	return 0
}

func (s *askState) anyPicked() bool {
	return len(s.picked[s.q]) > 0 || strings.TrimSpace(s.other[s.q]) != ""
}

// answers is the response, in the order the questions were asked.
func (s *askState) answers() []ask.Answer {
	out := make([]ask.Answer, 0, len(s.req.Questions))
	for i, q := range s.req.Questions {
		a := ask.Answer{Header: q.Header, Question: q.Question, Other: strings.TrimSpace(s.other[i])}
		for j, o := range q.Options {
			if s.picked[i][j] {
				a.Chosen = append(a.Chosen, o.Label)
			}
		}
		out = append(out, a)
	}
	return out
}

// summary is what the transcript records against the call: the answers, short
// enough for one line, so scrolling back says what was decided without
// reopening the tool result.
func (s *askState) summary() string {
	parts := make([]string, 0, len(s.req.Questions))
	for _, a := range s.answers() {
		switch {
		case a.Other != "":
			parts = append(parts, truncate(a.Other, 40))
		case len(a.Chosen) > 0:
			parts = append(parts, strings.Join(a.Chosen, " + "))
		}
	}
	if len(parts) == 0 {
		return "answered"
	}
	return "answered: " + strings.Join(parts, " · ")
}

// ---- keys ---------------------------------------------------------------

func (m *model) onAskKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
	if len(m.asking) == 0 {
		m.phase = m.afterModal()
		return m, nil
	}
	s := m.asking[0]

	// ctrl-c abandons the question and the turn it belongs to, the way it
	// does at an approval. A question nobody answered is not an answer, so
	// the tool is told it was dismissed rather than handed an empty one.
	if msg.String() == "ctrl+c" {
		cmd := m.dismissAsk()
		if m.phase == phaseRunning {
			return m, tea.Batch(m.interrupt(), cmd)
		}
		return m, cmd
	}

	if s.typing {
		return m, m.askTypingKey(s, msg)
	}

	switch key := msg.String(); key {
	case "up", "k", "shift+tab":
		s.move(-1)

	case "down", "j", "tab":
		s.move(1)

	case "home":
		s.idx = 0

	case "end":
		s.idx = s.rows() - 1

	case " ":
		// Space ticks a box. In a one-answer question there is no difference
		// between ticking and answering, so it answers.
		if s.idx == s.otherRow() {
			return m, m.openAskInput(s)
		}
		if !s.question().MultiSelect {
			return m, m.chooseAsk(s, s.idx)
		}
		s.toggle(s.idx)

	case "enter":
		return m, m.activateAsk(s)

	case "left", "backspace":
		// Back to the question before this one, to change an answer already
		// given. At the first question there is nothing behind it.
		if s.q > 0 {
			s.q--
			s.idx = s.cursorHome()
		}

	case "right":
		// Forward only where an answer exists: skipping ahead would leave a
		// question behind with nothing in it.
		if s.q < len(s.req.Questions)-1 && s.anyPicked() {
			s.q++
			s.idx = s.cursorHome()
		}

	case "esc":
		return m, m.dismissAsk()

	default:
		// A digit picks that row outright, including the last one, which is
		// the way to type an answer without reaching for the arrows.
		if len(key) == 1 && key[0] >= '1' && key[0] <= '9' {
			if i := int(key[0] - '1'); i < s.rows() {
				s.idx = i
				return m, m.activateAsk(s)
			}
		}
	}
	return m, m.sync()
}

// activateAsk is enter: take the row under the cursor, or — in a question
// that allows several answers and already has some — take what is ticked.
func (m *model) activateAsk(s *askState) tea.Cmd {
	if s.idx == s.otherRow() {
		return m.openAskInput(s)
	}
	if s.question().MultiSelect {
		// Enter on a question with nothing ticked means the row you are on:
		// pressing space first and then enter is a ceremony nobody wants for
		// a single answer to a question that merely allowed several.
		if len(s.picked[s.q]) == 0 {
			s.tick(s.idx)
		}
		return m.advanceAsk(s)
	}
	return m.chooseAsk(s, s.idx)
}

// chooseAsk answers a one-answer question and moves on.
func (m *model) chooseAsk(s *askState, i int) tea.Cmd {
	s.other[s.q] = ""
	s.tick(i)
	return m.advanceAsk(s)
}

// openAskInput opens the free-text field, holding whatever was typed there
// before so that going back and forth does not lose it.
func (m *model) openAskInput(s *askState) tea.Cmd {
	s.typing = true
	s.input.SetValue(s.other[s.q])
	s.input.CursorEnd()
	return tea.Batch(s.input.Focus(), m.sync())
}

func (m *model) askTypingKey(s *askState, msg tea.KeyMsg) tea.Cmd {
	switch msg.String() {
	case "enter":
		text := strings.TrimSpace(s.input.Value())
		if text == "" {
			// An empty answer is not an answer; the options are still there.
			s.typing = false
			s.input.Blur()
			return m.sync()
		}
		s.other[s.q] = text
		s.typing = false
		s.input.Blur()
		if !s.question().MultiSelect {
			// Typed instead of chosen: a one-answer question cannot be both.
			s.picked[s.q] = map[int]bool{}
		}
		return m.advanceAsk(s)

	case "esc":
		s.typing = false
		s.input.Blur()
		return m.sync()
	}
	var cmd tea.Cmd
	s.input, cmd = s.input.Update(msg)
	return tea.Batch(cmd, m.sync())
}

// advanceAsk moves to the next question, or answers the request when that was
// the last one.
func (m *model) advanceAsk(s *askState) tea.Cmd {
	if s.q < len(s.req.Questions)-1 {
		s.q++
		s.idx = s.cursorHome()
		return m.sync()
	}
	return m.submitAsk(s)
}

func (m *model) submitAsk(s *askState) tea.Cmd {
	s.reply <- ask.Response{Answers: s.answers()}
	note := s.summary()
	m.popAsk()
	m.tr.annotateTool(ask.Name, note)
	return tea.Batch(m.sync(), m.spin.Tick, textarea.Blink)
}

// dismissAsk closes the box without an answer. The tool turns that into an
// error telling the model to decide for itself and say what it decided, which
// is the only honest thing to do with a question nobody answered.
func (m *model) dismissAsk() tea.Cmd {
	if len(m.asking) == 0 {
		return nil
	}
	m.asking[0].reply <- ask.Response{Dismissed: true}
	m.popAsk()
	m.tr.annotateTool(ask.Name, "dismissed")
	return tea.Batch(m.sync(), m.spin.Tick, textarea.Blink)
}

// popAsk drops the answered question and puts the session back wherever it
// should be: at the next thing waiting, or at what it was doing before.
func (m *model) popAsk() {
	m.asking = m.asking[1:]
	m.phase = m.afterModal()
}

// ---- view ---------------------------------------------------------------

func (m *model) askView() string {
	s := m.asking[0]
	q := s.question()
	width := maxInt(20, m.mainWidth()-2)
	inner := maxInt(10, width-4)

	var b strings.Builder
	row := 0
	put := func(text string) {
		b.WriteString(text + "\n")
		row += strings.Count(text, "\n") + 1
	}

	put(m.askHead(s))
	put(styText.Render(wrapText(q.Question, inner)))
	put("")

	s.hit = map[int]int{}
	if s.typing {
		// Sized here rather than when it opened, so a terminal resized
		// mid-answer does not leave the field the width it used to be.
		s.input.Width = maxInt(8, inner-4)
		put(s.input.View())
		put("")
		put(styFaint.Render("enter to answer · esc for the options"))
		return styModal.Width(width - 2).Render(strings.TrimRight(b.String(), "\n"))
	}

	for i, o := range q.Options {
		s.hit[row] = i
		put(m.askOption(s, i, o, inner))
		if o.Description != "" {
			s.hit[row] = i
			put(styFaint.Render(truncate("      "+o.Description, inner)))
		}
	}
	s.hit[row] = s.otherRow()
	put(m.askOther(s, inner))

	put("")
	put(styFaint.Render(askKeys(s)))
	return styModal.Width(width - 2).Render(strings.TrimRight(b.String(), "\n"))
}

// askHead names the decision and says where in the request it is. When a
// subagent is asking it says which one, for the same reason the approval box
// does: several agents share this box.
func (m *model) askHead(s *askState) string {
	head := styHeading.Render(strings.TrimSpace(s.question().Header))
	if s.req.Agent != "" {
		who := s.req.Agent
		if info, ok := m.agentInfo(s.req.Agent); ok {
			who += " (" + info.Preset + ")"
		}
		head = styCoral.Render(who) + styDim.Render(" · ") + head
	}
	if n := len(s.req.Questions); n > 1 {
		head += styDim.Render(fmt.Sprintf(" · question %d of %d", s.q+1, n))
	}
	return head
}

// askOption is one row of the list: the cursor, the number to press, a box
// when several answers are allowed, and the label.
func (m *model) askOption(s *askState, i int, o ask.Option, inner int) string {
	marker := "  "
	label := styText.Render(o.Label)
	if i == s.idx {
		marker = styKey.Render("❯ ")
		label = styKey.Render(o.Label)
	}
	box := ""
	if s.question().MultiSelect {
		box = "◻ "
		if s.picked[s.q][i] {
			box = styOK.Render("◼ ")
		}
	} else if s.picked[s.q][i] {
		// A single-answer question only shows a tick when you have been here
		// before: the answer you gave last time is the one you are changing.
		box = styOK.Render("✓ ")
	}
	return truncateStyled(marker+styDim.Render(fmt.Sprint(i+1)+". ")+box+label, inner)
}

func (m *model) askOther(s *askState, inner int) string {
	marker := "  "
	text := styDim.Render("something else — type it yourself")
	if s.idx == s.otherRow() {
		marker = styKey.Render("❯ ")
		text = styKey.Render("something else — type it yourself")
	}
	if got := strings.TrimSpace(s.other[s.q]); got != "" {
		text = styOK.Render("✓ ") + styText.Render(truncate(got, maxInt(10, inner-12)))
	}
	return truncateStyled(marker+styDim.Render(fmt.Sprint(s.otherRow()+1)+". ")+text, inner)
}

func askKeys(s *askState) string {
	keys := "↑↓ move · enter choose"
	if s.question().MultiSelect {
		keys = "↑↓ move · space tick · enter confirm"
	}
	if s.q > 0 {
		keys += " · ← back"
	}
	return keys + " · esc skip the question"
}

// wrapText folds a sentence to a width. The question is the one part of the
// box that is prose and the one part that must not be cut off: an option can
// be truncated and still be recognisable, a half a question cannot be
// answered.
func wrapText(text string, width int) string {
	width = maxInt(10, width)
	var out []string
	for _, para := range strings.Split(strings.TrimSpace(text), "\n") {
		line := ""
		for _, word := range strings.Fields(para) {
			switch {
			case line == "":
				line = word
			case len(line)+1+len(word) <= width:
				line += " " + word
			default:
				out = append(out, line)
				line = word
			}
		}
		out = append(out, line)
	}
	return strings.Join(out, "\n")
}