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") }