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"
"github.com/johannSo/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")
}