package ui
import (
"bufio"
"context"
"fmt"
"io"
"strconv"
"strings"
"github.com/johannSo/codey/internal/ask"
)
// Asker puts the agent's questions to a terminal, one line at a time. It is
// to the question box what Approver is to the approval box: the same decision
// without a full-screen program to draw it in.
//
// Stdin closing is not an answer. A piped or scripted run reaches EOF at the
// first question, and the tool tells the model to decide for itself — which
// is what should happen when nobody is there.
type Asker struct {
In *bufio.Scanner
Out io.Writer
}
// Prompt implements ask.Prompt.
func (a *Asker) Prompt(ctx context.Context, req ask.Request) (ask.Response, error) {
if err := ctx.Err(); err != nil {
return ask.Response{}, err
}
resp := ask.Response{}
for _, q := range req.Questions {
if err := ctx.Err(); err != nil {
return ask.Response{}, err
}
answer, ok := a.one(q)
if !ok {
// One question skipped is the whole request abandoned: a partial
// set of answers would be reported as if it were complete.
return ask.Response{Dismissed: true}, nil
}
resp.Answers = append(resp.Answers, answer)
}
return resp, nil
}
func (a *Asker) one(q ask.Question) (ask.Answer, bool) {
answer := ask.Answer{Header: strings.TrimSpace(q.Header), Question: strings.TrimSpace(q.Question)}
fmt.Fprintf(a.Out, "\n%s%s%s %s\n", color.bold, answer.Header, color.reset, answer.Question)
for i, o := range q.Options {
fmt.Fprintf(a.Out, " %s%d.%s %s\n", color.cyan, i+1, color.reset, o.Label)
if o.Description != "" {
fmt.Fprintf(a.Out, " %s%s%s\n", color.dim, o.Description, color.reset)
}
}
how := fmt.Sprintf("answer 1-%d", len(q.Options))
if q.MultiSelect {
how = fmt.Sprintf("answer 1-%d, several separated by commas", len(q.Options))
}
for {
fmt.Fprintf(a.Out, "%s%s, type something else, or press enter to skip%s\n> ", color.dim, how, color.reset)
if !a.In.Scan() {
fmt.Fprintln(a.Out)
return answer, false
}
text := strings.TrimSpace(a.In.Text())
if text == "" {
return answer, false
}
picked, numeric := parseChoices(text, len(q.Options))
switch {
case !numeric:
// Anything that is not a list of numbers is the user answering in
// their own words, which is always allowed.
answer.Other = text
return answer, true
case len(picked) == 0:
fmt.Fprintf(a.Out, "%sthere is no option %s%s\n", color.red, text, color.reset)
continue
case len(picked) > 1 && !q.MultiSelect:
fmt.Fprintf(a.Out, "%sthis question takes one answer%s\n", color.red, color.reset)
continue
}
for _, i := range picked {
answer.Chosen = append(answer.Chosen, q.Options[i].Label)
}
return answer, true
}
}
// parseChoices reads "2" or "1,3" as option indices. numeric is false as soon
// as any part is not a number, which is how a typed answer is told from a
// chosen one; picked is empty when every part was a number but one was out of
// range, which is a mistake worth saying out loud rather than filing as prose.
func parseChoices(text string, n int) (picked []int, numeric bool) {
parts := strings.FieldsFunc(text, func(r rune) bool { return r == ',' || r == ' ' })
if len(parts) == 0 {
return nil, false
}
seen := map[int]bool{}
for _, p := range parts {
v, err := strconv.Atoi(strings.TrimSpace(p))
if err != nil {
return nil, false
}
if v < 1 || v > n {
return nil, true
}
if !seen[v-1] {
seen[v-1] = true
picked = append(picked, v-1)
}
}
return picked, true
}