package ui import ( "bufio" "context" "fmt" "io" "strconv" "strings" "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 }