Vectrum
Sign In
118 lines · 3.4 KB · Go
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
}