A coding agent that cannot ask a question has two ways to handle an ambiguous request: guess, or stop and write a paragraph about it. Both cost a round trip, and the guess costs the work built on it. This is the third way, the one Claude Code calls AskUserQuestion: a tool the model calls mid-task that puts a multiple-choice question on screen and blocks until you answer. internal/ask owns the shape — one to four questions, two to four options each, a header naming the decision, a description per option saying what choosing it means — along with validation whose messages are written for the model to read and correct, and the result format, which repeats each question beside its answer because the model reads it many turns later. The picker is the approval box's twin and works the same way underneath: the agent blocks on a buffered channel, the update loop keeps running, and the wait honours the turn's context so an interrupt cannot strand either side. Arrows or the wheel move, digits answer outright, space ticks a box where several answers are allowed, left goes back to change an answer, and a click lands on the option it looks like it lands on — the hit map is what the last frame actually drew, so an option's description is part of the option. The last row is always free text: three options that are all wrong is a corner the user should never be in. A dismissal is not an answer, and is reported to the model as an error telling it to decide for itself and say what it chose. An approval outranks a question already on screen, since a tool call cannot start until someone says so. A question whose turn is interrupted dies with the turn; a subagent's does not, because it belongs to another one. Only the session's own agent gets the tool, registered on its own registry rather than in the shared factory: a subagent reports to its parent, and the parent is not a person to ask. Outside the TUI the same questions are put a line at a time, and a run with nothing on stdin gets no answer, which the tool reports as a dismissal. Along the way: jsonschema:"description=..." is split on commas by the reflector, so eleven field descriptions had been reaching the model as half a sentence — "It cannot ask you questions and it cannot see this conversation" stopped there. All twenty-five tags move to jsonschema_description, which takes the value whole, with a test that notices if one drifts back.
Showing 26 changed files with 2091 additions and 52 deletions.
| @@ -190,6 +190,57 @@ Commands: `/allowed`, `/clear`, `/compact`, `/effort`, `/exit`, `/help`, | ||
| 190 | 190 | `/model`, `/mouse`, `/settings`, `/setup`, `/skills`, and one per discovered |
| 191 | 191 | skill. |
| 192 | 192 | |
| 193 | + ### Questions | |
| 194 | + | |
| 195 | + The agent can stop in the middle of a task and ask you something. It calls | |
| 196 | + `ask_user`, the turn parks, and a box comes up with the options it thought of: | |
| 197 | + | |
| 198 | + ``` | |
| 199 | + ● the agent has a question | |
| 200 | + ╭──────────────────────────────────────────────────────────────────────────────╮ | |
| 201 | + │ Storage · question 1 of 2 │ | |
| 202 | + │ Sessions have to live somewhere once the process exits. Where should they go?│ | |
| 203 | + │ │ | |
| 204 | + │ ❯ 1. Postgres (recommended) │ | |
| 205 | + │ what the rest of the stack already runs; no new service to operate │ | |
| 206 | + │ 2. SQLite │ | |
| 207 | + │ a file on disk, and one fewer thing to have running │ | |
| 208 | + │ 3. Keep them in memory │ | |
| 209 | + │ simplest, and they are gone when you quit │ | |
| 210 | + │ 4. something else — type it yourself │ | |
| 211 | + │ │ | |
| 212 | + │ ↑↓ move · enter choose · esc skip the question │ | |
| 213 | + ╰──────────────────────────────────────────────────────────────────────────────╯ | |
| 214 | + ``` | |
| 215 | + | |
| 216 | + | Key | Does | | |
| 217 | + | --- | --- | | |
| 218 | + | `↑`/`↓` | move the cursor — the wheel does the same | | |
| 219 | + | `1`–`9` | answer with that option outright | | |
| 220 | + | `space` | tick a box, where the question allows several answers | | |
| 221 | + | `enter` | take the row under the cursor, or confirm what is ticked | | |
| 222 | + | `←` | back to the previous question, to change an answer | | |
| 223 | + | `esc` | skip: the agent is told to decide for itself and say what it chose | | |
| 224 | + | |
| 225 | + The last row is always there and always free text, so you are never cornered | |
| 226 | + by three options that are all wrong; typing an answer is the same key as | |
| 227 | + picking one. A click does what enter would — on the description under a label | |
| 228 | + as well as on the label, because that is what it looks like you are clicking. | |
| 229 | + | |
| 230 | + This is the approval box's twin, and it works the same way underneath: the | |
| 231 | + agent blocks on a channel, the UI keeps running, and an interrupt releases it. | |
| 232 | + Where the two collide the approval goes first — a tool call cannot start until | |
| 233 | + someone says so, and the question is still there behind it. A question whose | |
| 234 | + turn is interrupted goes with the turn, since the tool call waiting for the | |
| 235 | + answer no longer exists. | |
| 236 | + | |
| 237 | + Only the session's own agent gets the tool. A subagent's report goes to its | |
| 238 | + parent, and the parent is not a person to ask. | |
| 239 | + | |
| 240 | + Outside the TUI the same questions are asked a line at a time, and a run with | |
| 241 | + nothing on stdin — a `-p` one-shot in a script — gets no answer, which the tool | |
| 242 | + reports as a dismissal so the model decides for itself and says so. | |
| 243 | + | |
| 193 | 244 | ### The mouse |
| 194 | 245 | |
| 195 | 246 | | Does | How | |
| @@ -671,6 +722,7 @@ and any key skips the one playing. | ||
| 671 | 722 | | `edit_file` | prompts | Requires a prior read; refuses ambiguous matches; returns a diff | |
| 672 | 723 | | `write_file` | prompts | Whole files only; separate from `edit_file` so the metrics stay honest | |
| 673 | 724 | | `bash` | prompts | Own process group, killed whole on timeout; output capped head and tail | |
| 725 | + | `ask_user` | never | Puts a multiple-choice question to you and waits — see [questions](#questions) | | |
| 674 | 726 | |
| 675 | 727 | ## Skills |
| 676 | 728 | |
| @@ -786,6 +838,7 @@ internal/tools the tool registry and the tools | ||
| 786 | 838 | internal/skills discovery and parsing of SKILL.md |
| 787 | 839 | internal/config saved defaults: model, effort, approvals, theme, compaction |
| 788 | 840 | internal/approval allow / deny / ask, with shell analysis |
| 841 | + internal/ask questions the agent puts to you mid-task, and the tool for it | |
| 789 | 842 | internal/metrics JSONL event log |
| 790 | 843 | internal/ui line-based terminal rendering |
| 791 | 844 | internal/tui full-screen Bubble Tea front end, and the setup wizard |
| @@ -800,6 +853,6 @@ A failed edit is a normal event in the transcript. | ||
| 800 | 853 | The TUI obeys the first rule through two one-way hatches. Events reach it via |
| 801 | 854 | `agent.Observe`, forwarded into the Bubble Tea loop with `Program.Send`, which |
| 802 | 855 | is safe from the agent's goroutine. Approvals go out the same way and then |
| 803 | - block the agent on a reply channel until a key arrives. That channel is | |
| 804 | - buffered and the wait honours the turn's context, so neither side can strand | |
| 805 | - the other. | |
| 856 | + block the agent on a reply channel until a key arrives, and questions take the | |
| 857 | + same hatch with an answer on the way back. Those channels are buffered and the | |
| 858 | + wait honours the turn's context, so neither side can strand the other. | |
| @@ -16,6 +16,7 @@ import ( | ||
| 16 | 16 | |
| 17 | 17 | "codey/internal/agent" |
| 18 | 18 | "codey/internal/approval" |
| 19 | + "codey/internal/ask" | |
| 19 | 20 | "codey/internal/claudecode" |
| 20 | 21 | "codey/internal/config" |
| 21 | 22 | "codey/internal/llm" |
| @@ -244,6 +245,14 @@ func run() error { | ||
| 244 | 245 | render := &ui.Renderer{W: os.Stdout, Verbose: *verbose} |
| 245 | 246 | approver := &ui.Approver{In: in, Out: os.Stdout} |
| 246 | 247 | policy := approval.New(approver.Prompt) |
| 248 | + | |
| 249 | + // Questions go to the line front end until a TUI takes them over in | |
| 250 | + // tui.Run. The tool belongs to the session's own agent and is registered | |
| 251 | + // here rather than in newTools, so a subagent still cannot stop and ask: | |
| 252 | + // its report goes to the parent, and the parent is not a person. | |
| 253 | + asker := ask.NewAsker((&ui.Asker{In: in, Out: os.Stdout}).Prompt) | |
| 254 | + reg.Register(ask.Tool(asker.Ask)) | |
| 255 | + | |
| 247 | 256 | policy.YOLO = acceptAll |
| 248 | 257 | if acceptAll && !useTUI { |
| 249 | 258 | // The TUI says this itself, in the banner. |
| @@ -317,6 +326,7 @@ func run() error { | ||
| 317 | 326 | return tui.Run(context.Background(), tui.Options{ |
| 318 | 327 | Agent: a, |
| 319 | 328 | Policy: policy, |
| 329 | + Asker: asker, | |
| 320 | 330 | Client: client, |
| 321 | 331 | Root: root, |
| 322 | 332 | SessionDir: sessionDir, |
| @@ -47,6 +47,9 @@ var readOnly = map[string]bool{ | ||
| 47 | 47 | // skill reads one file from a directory found at startup, and cannot |
| 48 | 48 | // reach anything read_file could not already reach. |
| 49 | 49 | "skill": true, |
| 50 | + // ask_user changes nothing and is itself a prompt. Asking permission to | |
| 51 | + // ask a question would be two boxes for one decision. | |
| 52 | + "ask_user": true, | |
| 50 | 53 | } |
| 51 | 54 | |
| 52 | 55 | // Policy decides whether a tool call proceeds, remembering what it is told. |
| @@ -0,0 +1,189 @@ | ||
| 1 | + // Package ask is the question the agent puts to the user in the middle of a | |
| 2 | + // task, rather than at the end of one. | |
| 3 | + // | |
| 4 | + // It is the other half of approval. An approval asks "may I do this thing I | |
| 5 | + // have decided on"; a question asks "which of these should I decide on at | |
| 6 | + // all". Both suspend the agent on a channel until a human answers, and both | |
| 7 | + // reach the front end through a function the front end supplies — a TUI | |
| 8 | + // picker, a line of stdin, or a test — so nothing below this package knows a | |
| 9 | + // terminal exists. | |
| 10 | + // | |
| 11 | + // The shape is fixed rather than free: a handful of questions, a handful of | |
| 12 | + // labelled options each, and a way to type something that was not offered. | |
| 13 | + // A picker that can render anything renders nothing well, and an agent that | |
| 14 | + // can ask anything asks too much. | |
| 15 | + package ask | |
| 16 | + | |
| 17 | + import ( | |
| 18 | + "context" | |
| 19 | + "errors" | |
| 20 | + "fmt" | |
| 21 | + "strings" | |
| 22 | + "sync" | |
| 23 | + ) | |
| 24 | + | |
| 25 | + // The limits are the picker's, not the model's: four options fit on a screen | |
| 26 | + // beside their descriptions and can be reached with one digit, and a fifth | |
| 27 | + // question is a conversation the agent should be having in prose. | |
| 28 | + const ( | |
| 29 | + MaxQuestions = 4 | |
| 30 | + MinOptions = 2 | |
| 31 | + MaxOptions = 4 | |
| 32 | + ) | |
| 33 | + | |
| 34 | + // Option is one answer offered for a question. | |
| 35 | + type Option struct { | |
| 36 | + // Label is the answer itself, short enough to sit in a list. | |
| 37 | + Label string | |
| 38 | + // Description is the line under it: what choosing this one means. It may | |
| 39 | + // be empty, though a choice whose consequence is not stated is usually a | |
| 40 | + // choice the user cannot make. | |
| 41 | + Description string | |
| 42 | + } | |
| 43 | + | |
| 44 | + // Question is one multiple-choice question. | |
| 45 | + type Question struct { | |
| 46 | + // Header is a word or two naming the decision, used as the label on the | |
| 47 | + // box. Long headers are truncated when drawn rather than refused. | |
| 48 | + Header string | |
| 49 | + // Question is the question in full, as a sentence. | |
| 50 | + Question string | |
| 51 | + // Options are what is offered, in the order they are offered. | |
| 52 | + Options []Option | |
| 53 | + // MultiSelect allows any number of them to be picked rather than one. | |
| 54 | + MultiSelect bool | |
| 55 | + } | |
| 56 | + | |
| 57 | + // Request is one call of the ask tool: everything the agent wants to know | |
| 58 | + // before it carries on. | |
| 59 | + type Request struct { | |
| 60 | + // Agent is the subagent asking, empty for the session's own agent. The | |
| 61 | + // same field, for the same reason, as approval.Request.Agent: several | |
| 62 | + // agents share one box, and a question that does not say who is asking | |
| 63 | + // cannot be answered. | |
| 64 | + Agent string | |
| 65 | + Questions []Question | |
| 66 | + } | |
| 67 | + | |
| 68 | + // Answer is what came back for one question. Chosen holds option labels in | |
| 69 | + // the order they were offered; Other is text the user typed instead, which is | |
| 70 | + // how a question gets an answer nobody thought to offer. | |
| 71 | + type Answer struct { | |
| 72 | + Header string | |
| 73 | + Question string | |
| 74 | + Chosen []string | |
| 75 | + Other string | |
| 76 | + } | |
| 77 | + | |
| 78 | + // Response is the answer to a whole request. Dismissed says the user closed | |
| 79 | + // the box without answering, which is not an answer and must not be reported | |
| 80 | + // to the model as one. | |
| 81 | + type Response struct { | |
| 82 | + Answers []Answer | |
| 83 | + Dismissed bool | |
| 84 | + } | |
| 85 | + | |
| 86 | + // Prompt asks a human. Returning an error abandons the call: the tool reports | |
| 87 | + // the error to the model, which is what a cancelled turn looks like from the | |
| 88 | + // inside. | |
| 89 | + type Prompt func(context.Context, Request) (Response, error) | |
| 90 | + | |
| 91 | + // Asker is the indirection between the tool and whatever can reach a human. | |
| 92 | + // The tool is built when the registry is, which is before any front end | |
| 93 | + // exists, so the front end installs itself here afterwards — the same move | |
| 94 | + // approval.Policy makes with its Prompt field. | |
| 95 | + // | |
| 96 | + // The lock is not ceremony: Use runs on the goroutine that starts the program | |
| 97 | + // and Ask runs on the agent's. | |
| 98 | + type Asker struct { | |
| 99 | + mu sync.Mutex | |
| 100 | + prompt Prompt | |
| 101 | + } | |
| 102 | + | |
| 103 | + // NewAsker returns an asker with a starting prompt, which may be nil. | |
| 104 | + func NewAsker(p Prompt) *Asker { return &Asker{prompt: p} } | |
| 105 | + | |
| 106 | + // Use installs the prompt in force from now on. | |
| 107 | + func (a *Asker) Use(p Prompt) { | |
| 108 | + a.mu.Lock() | |
| 109 | + defer a.mu.Unlock() | |
| 110 | + a.prompt = p | |
| 111 | + } | |
| 112 | + | |
| 113 | + // Ask implements Prompt by forwarding to whatever is installed. | |
| 114 | + func (a *Asker) Ask(ctx context.Context, req Request) (Response, error) { | |
| 115 | + a.mu.Lock() | |
| 116 | + p := a.prompt | |
| 117 | + a.mu.Unlock() | |
| 118 | + if p == nil { | |
| 119 | + return Response{}, errors.New("nothing in this session can reach the user") | |
| 120 | + } | |
| 121 | + return p(ctx, req) | |
| 122 | + } | |
| 123 | + | |
| 124 | + // Validate checks a request the model built. Every message it returns is read | |
| 125 | + // by the model and has to say what to send instead, because a rejected call | |
| 126 | + // is one the model gets to make again. | |
| 127 | + func Validate(r Request) error { | |
| 128 | + switch { | |
| 129 | + case len(r.Questions) == 0: | |
| 130 | + return fmt.Errorf("no questions: send between 1 and %d", MaxQuestions) | |
| 131 | + case len(r.Questions) > MaxQuestions: | |
| 132 | + return fmt.Errorf("%d questions is too many: ask at most %d in one call, and ask the rest after these are answered", | |
| 133 | + len(r.Questions), MaxQuestions) | |
| 134 | + } | |
| 135 | + for i, q := range r.Questions { | |
| 136 | + where := fmt.Sprintf("question %d", i+1) | |
| 137 | + if strings.TrimSpace(q.Question) == "" { | |
| 138 | + return fmt.Errorf("%s has no text: say what is being decided", where) | |
| 139 | + } | |
| 140 | + if strings.TrimSpace(q.Header) == "" { | |
| 141 | + return fmt.Errorf("%s (%q) has no header: give it a word or two naming the decision", where, q.Question) | |
| 142 | + } | |
| 143 | + switch { | |
| 144 | + case len(q.Options) < MinOptions: | |
| 145 | + return fmt.Errorf("%s (%q) offers %d options: offer at least %d, or do not ask — a question with one answer is a statement", | |
| 146 | + where, q.Header, len(q.Options), MinOptions) | |
| 147 | + case len(q.Options) > MaxOptions: | |
| 148 | + return fmt.Errorf("%s (%q) offers %d options: offer at most %d, and fold the rest into the ones that matter — the user can always type something else", | |
| 149 | + where, q.Header, len(q.Options), MaxOptions) | |
| 150 | + } | |
| 151 | + seen := map[string]bool{} | |
| 152 | + for j, o := range q.Options { | |
| 153 | + label := strings.TrimSpace(o.Label) | |
| 154 | + if label == "" { | |
| 155 | + return fmt.Errorf("%s (%q) option %d has no label", where, q.Header, j+1) | |
| 156 | + } | |
| 157 | + if seen[strings.ToLower(label)] { | |
| 158 | + return fmt.Errorf("%s (%q) offers %q twice: an answer has to name one option", where, q.Header, label) | |
| 159 | + } | |
| 160 | + seen[strings.ToLower(label)] = true | |
| 161 | + } | |
| 162 | + } | |
| 163 | + return nil | |
| 164 | + } | |
| 165 | + | |
| 166 | + // Format renders a response as the tool result. It repeats each question | |
| 167 | + // beside its answer: the model reads this many turns later, by which time | |
| 168 | + // "Postgres" on its own has stopped meaning anything. | |
| 169 | + func (r Response) Format() string { | |
| 170 | + if len(r.Answers) == 0 { | |
| 171 | + return "the user answered nothing" | |
| 172 | + } | |
| 173 | + var b strings.Builder | |
| 174 | + b.WriteString("The user answered:\n") | |
| 175 | + for _, a := range r.Answers { | |
| 176 | + fmt.Fprintf(&b, "\n%s — %s\n", a.Header, a.Question) | |
| 177 | + switch { | |
| 178 | + case a.Other != "": | |
| 179 | + // Their own words are marked as such: the agent offered three | |
| 180 | + // options and none of them is what it is about to act on. | |
| 181 | + fmt.Fprintf(&b, " in their own words: %s\n", a.Other) | |
| 182 | + case len(a.Chosen) == 0: | |
| 183 | + b.WriteString(" (skipped)\n") | |
| 184 | + default: | |
| 185 | + fmt.Fprintf(&b, " %s\n", strings.Join(a.Chosen, ", ")) | |
| 186 | + } | |
| 187 | + } | |
| 188 | + return strings.TrimRight(b.String(), "\n") | |
| 189 | + } | |
| @@ -0,0 +1,201 @@ | ||
| 1 | + package ask | |
| 2 | + | |
| 3 | + import ( | |
| 4 | + "context" | |
| 5 | + "encoding/json" | |
| 6 | + "errors" | |
| 7 | + "strings" | |
| 8 | + "testing" | |
| 9 | + ) | |
| 10 | + | |
| 11 | + func twoOptions() Question { | |
| 12 | + return Question{ | |
| 13 | + Header: "Storage", | |
| 14 | + Question: "How should sessions be stored?", | |
| 15 | + Options: []Option{{Label: "Postgres"}, {Label: "SQLite"}}, | |
| 16 | + } | |
| 17 | + } | |
| 18 | + | |
| 19 | + // Every rejection is read by the model, which gets to send another call. A | |
| 20 | + // message that does not say what to send instead wastes that turn. | |
| 21 | + func TestValidateRejectsWhatCannotBeDrawn(t *testing.T) { | |
| 22 | + tooMany := Request{} | |
| 23 | + for i := 0; i < MaxQuestions+1; i++ { | |
| 24 | + tooMany.Questions = append(tooMany.Questions, twoOptions()) | |
| 25 | + } | |
| 26 | + oneOption := twoOptions() | |
| 27 | + oneOption.Options = oneOption.Options[:1] | |
| 28 | + fiveOptions := twoOptions() | |
| 29 | + for i := 0; i < 4; i++ { | |
| 30 | + fiveOptions.Options = append(fiveOptions.Options, Option{Label: string(rune('a' + i))}) | |
| 31 | + } | |
| 32 | + noHeader := twoOptions() | |
| 33 | + noHeader.Header = " " | |
| 34 | + noText := twoOptions() | |
| 35 | + noText.Question = "" | |
| 36 | + dup := twoOptions() | |
| 37 | + dup.Options[1].Label = "postgres" | |
| 38 | + blank := twoOptions() | |
| 39 | + blank.Options[1].Label = "" | |
| 40 | + | |
| 41 | + cases := map[string]struct { | |
| 42 | + req Request | |
| 43 | + want string | |
| 44 | + }{ | |
| 45 | + "nothing asked": {Request{}, "1 and 4"}, | |
| 46 | + "too many": {tooMany, "at most 4"}, | |
| 47 | + "one option": {Request{Questions: []Question{oneOption}}, "at least 2"}, | |
| 48 | + "five options": {Request{Questions: []Question{fiveOptions}}, "at most 4"}, | |
| 49 | + "no header": {Request{Questions: []Question{noHeader}}, "header"}, | |
| 50 | + "no question": {Request{Questions: []Question{noText}}, "no text"}, | |
| 51 | + "same label": {Request{Questions: []Question{dup}}, "twice"}, | |
| 52 | + "blank label": {Request{Questions: []Question{blank}}, "no label"}, | |
| 53 | + "a good question": {Request{Questions: []Question{twoOptions()}}, ""}, | |
| 54 | + } | |
| 55 | + for name, c := range cases { | |
| 56 | + err := Validate(c.req) | |
| 57 | + switch { | |
| 58 | + case c.want == "" && err != nil: | |
| 59 | + t.Errorf("%s: rejected a valid request: %v", name, err) | |
| 60 | + case c.want == "": | |
| 61 | + case err == nil: | |
| 62 | + t.Errorf("%s: accepted", name) | |
| 63 | + case !strings.Contains(err.Error(), c.want): | |
| 64 | + t.Errorf("%s: %q does not mention %q", name, err, c.want) | |
| 65 | + } | |
| 66 | + } | |
| 67 | + } | |
| 68 | + | |
| 69 | + // The model reads the result many turns later, by which time a bare label has | |
| 70 | + // stopped meaning anything. The question has to travel with the answer. | |
| 71 | + func TestFormatRepeatsTheQuestion(t *testing.T) { | |
| 72 | + got := Response{Answers: []Answer{ | |
| 73 | + {Header: "Storage", Question: "How should sessions be stored?", Chosen: []string{"Postgres"}}, | |
| 74 | + {Header: "Scope", Question: "What should it cover?", Chosen: []string{"the CLI", "the TUI"}}, | |
| 75 | + {Header: "Naming", Question: "What should it be called?", Other: "call it sessiondir"}, | |
| 76 | + }}.Format() | |
| 77 | + | |
| 78 | + for _, want := range []string{ | |
| 79 | + "How should sessions be stored?", "Postgres", | |
| 80 | + "the CLI, the TUI", | |
| 81 | + "in their own words: call it sessiondir", | |
| 82 | + } { | |
| 83 | + if !strings.Contains(got, want) { | |
| 84 | + t.Errorf("missing %q from:\n%s", want, got) | |
| 85 | + } | |
| 86 | + } | |
| 87 | + } | |
| 88 | + | |
| 89 | + func TestAskerForwardsAndCanBeReplaced(t *testing.T) { | |
| 90 | + a := NewAsker(nil) | |
| 91 | + if _, err := a.Ask(context.Background(), Request{}); err == nil { | |
| 92 | + t.Error("an asker with nothing installed should say so rather than hang") | |
| 93 | + } | |
| 94 | + | |
| 95 | + a.Use(func(context.Context, Request) (Response, error) { | |
| 96 | + return Response{Answers: []Answer{{Chosen: []string{"first"}}}}, nil | |
| 97 | + }) | |
| 98 | + got, err := a.Ask(context.Background(), Request{}) | |
| 99 | + if err != nil || got.Answers[0].Chosen[0] != "first" { | |
| 100 | + t.Fatalf("Ask = %+v, %v", got, err) | |
| 101 | + } | |
| 102 | + | |
| 103 | + // The TUI installs itself over the line front end at startup; the tool is | |
| 104 | + // already built by then and must follow the swap. | |
| 105 | + a.Use(func(context.Context, Request) (Response, error) { | |
| 106 | + return Response{Answers: []Answer{{Chosen: []string{"second"}}}}, nil | |
| 107 | + }) | |
| 108 | + if got, _ := a.Ask(context.Background(), Request{}); got.Answers[0].Chosen[0] != "second" { | |
| 109 | + t.Errorf("Ask = %+v, want the prompt installed last", got) | |
| 110 | + } | |
| 111 | + } | |
| 112 | + | |
| 113 | + func TestToolAnswersTheModel(t *testing.T) { | |
| 114 | + var seen Request | |
| 115 | + tool := Tool(func(_ context.Context, r Request) (Response, error) { | |
| 116 | + seen = r | |
| 117 | + return Response{Answers: []Answer{{Header: "Storage", Question: "Where?", Chosen: []string{"Postgres"}}}}, nil | |
| 118 | + }) | |
| 119 | + | |
| 120 | + out, err := tool.Execute(context.Background(), json.RawMessage(`{"questions":[ | |
| 121 | + {"header":"Storage","question":"Where?","options":[ | |
| 122 | + {"label":"Postgres","description":"already running"}, | |
| 123 | + {"label":"SQLite"}]}]}`)) | |
| 124 | + if err != nil { | |
| 125 | + t.Fatalf("Execute: %v", err) | |
| 126 | + } | |
| 127 | + if !strings.Contains(out, "Postgres") { | |
| 128 | + t.Errorf("result does not carry the answer: %q", out) | |
| 129 | + } | |
| 130 | + if len(seen.Questions) != 1 || seen.Questions[0].Options[0].Description != "already running" { | |
| 131 | + t.Errorf("the prompt saw %+v", seen) | |
| 132 | + } | |
| 133 | + } | |
| 134 | + | |
| 135 | + // A dismissal is not an answer, and reporting it as one would have the model | |
| 136 | + // act on a decision nobody made. | |
| 137 | + func TestToolReportsDismissalAsAnError(t *testing.T) { | |
| 138 | + tool := Tool(func(context.Context, Request) (Response, error) { | |
| 139 | + return Response{Dismissed: true}, nil | |
| 140 | + }) | |
| 141 | + out, err := tool.Execute(context.Background(), json.RawMessage(`{"questions":[ | |
| 142 | + {"header":"Storage","question":"Where?","options":[{"label":"a"},{"label":"b"}]}]}`)) | |
| 143 | + if err == nil { | |
| 144 | + t.Fatalf("a dismissed question returned %q and no error", out) | |
| 145 | + } | |
| 146 | + if !strings.Contains(err.Error(), "pick the option you think best") { | |
| 147 | + t.Errorf("the error should say what to do instead, got %q", err) | |
| 148 | + } | |
| 149 | + } | |
| 150 | + | |
| 151 | + // A call that cannot be drawn never reaches the user: the model is told to fix | |
| 152 | + // it while the screen is still quiet. | |
| 153 | + func TestToolValidatesBeforeAsking(t *testing.T) { | |
| 154 | + called := false | |
| 155 | + tool := Tool(func(context.Context, Request) (Response, error) { | |
| 156 | + called = true | |
| 157 | + return Response{}, nil | |
| 158 | + }) | |
| 159 | + if _, err := tool.Execute(context.Background(), json.RawMessage(`{"questions":[ | |
| 160 | + {"header":"Storage","question":"Where?","options":[{"label":"only one"}]}]}`)); err == nil { | |
| 161 | + t.Fatal("a one-option question was accepted") | |
| 162 | + } | |
| 163 | + if called { | |
| 164 | + t.Error("the user was asked a question that could not be drawn") | |
| 165 | + } | |
| 166 | + } | |
| 167 | + | |
| 168 | + func TestToolWithoutAPromptSaysSoRatherThanPanicking(t *testing.T) { | |
| 169 | + _, err := Tool(nil).Execute(context.Background(), json.RawMessage(`{"questions":[ | |
| 170 | + {"header":"Storage","question":"Where?","options":[{"label":"a"},{"label":"b"}]}]}`)) | |
| 171 | + if err == nil { | |
| 172 | + t.Fatal("expected an error") | |
| 173 | + } | |
| 174 | + } | |
| 175 | + | |
| 176 | + func TestToolPassesTheErrorFromACancelledTurn(t *testing.T) { | |
| 177 | + want := errors.New("turn cancelled") | |
| 178 | + _, err := Tool(func(context.Context, Request) (Response, error) { | |
| 179 | + return Response{}, want | |
| 180 | + }).Execute(context.Background(), json.RawMessage(`{"questions":[ | |
| 181 | + {"header":"Storage","question":"Where?","options":[{"label":"a"},{"label":"b"}]}]}`)) | |
| 182 | + if !errors.Is(err, want) { | |
| 183 | + t.Fatalf("err = %v, want the prompt's own error", err) | |
| 184 | + } | |
| 185 | + } | |
| 186 | + | |
| 187 | + // The schema is the only thing the model sees before it calls. A field | |
| 188 | + // description that stops at the first comma is worse than none: it reads as a | |
| 189 | + // complete instruction and is half of one. | |
| 190 | + func TestSchemaKeepsWholeDescriptions(t *testing.T) { | |
| 191 | + schema := string(Tool(nil).InputSchema()) | |
| 192 | + for _, want := range []string{ | |
| 193 | + "1 to 4 of them", | |
| 194 | + "2 to 4 of them", | |
| 195 | + "the consequence, not the label again", | |
| 196 | + } { | |
| 197 | + if !strings.Contains(schema, want) { | |
| 198 | + t.Errorf("schema lost %q:\n%s", want, schema) | |
| 199 | + } | |
| 200 | + } | |
| 201 | + } | |
| @@ -0,0 +1,118 @@ | ||
| 1 | + package ask | |
| 2 | + | |
| 3 | + import ( | |
| 4 | + "context" | |
| 5 | + "errors" | |
| 6 | + "fmt" | |
| 7 | + | |
| 8 | + "codey/internal/tools" | |
| 9 | + ) | |
| 10 | + | |
| 11 | + // Name is the tool the model calls. It is snake_case like every other tool | |
| 12 | + // here rather than Claude Code's AskUserQuestion, because a model picks a tool | |
| 13 | + // by its description and the rest of this registry is named this way. | |
| 14 | + const Name = "ask_user" | |
| 15 | + | |
| 16 | + // Description is deliberately more prohibition than invitation. A model that | |
| 17 | + // enjoys asking questions turns a five-minute task into a quiz, and the whole | |
| 18 | + // value of the tool is that a question means something when it arrives. | |
| 19 | + const Description = `Ask the user a multiple-choice question and wait for the answer. | |
| 20 | + | |
| 21 | + Use this only when you are blocked on a decision that is genuinely theirs to | |
| 22 | + make: one you cannot settle from the request, from the code in front of you, | |
| 23 | + or from an obvious default. Ambiguity you can resolve by reading another file | |
| 24 | + is not a question — go and read the file. A preference about scope, risk, | |
| 25 | + naming, or which of several defensible designs they want is. | |
| 26 | + | |
| 27 | + Ask at the point the decision is needed, not after you have built one of the | |
| 28 | + answers. Ask everything you need in one call rather than stopping the user | |
| 29 | + three times, and then carry on: do not ask and then ask again about the same | |
| 30 | + thing in different words. | |
| 31 | + | |
| 32 | + Each question needs a short header naming the decision, the question itself, | |
| 33 | + and two to four options. Give every option a description saying what choosing | |
| 34 | + it means — the consequence, not a restatement of the label. Make the options | |
| 35 | + mutually exclusive, and put the one you would pick first with "(recommended)" | |
| 36 | + at the end of its label when you genuinely have a view. Set multi_select when | |
| 37 | + several answers can be true at once. | |
| 38 | + | |
| 39 | + The user can always type an answer you did not offer, so do not add an | |
| 40 | + "Other" option yourself. They can also dismiss the question, which comes back | |
| 41 | + as an error: when it does, pick the most sensible option, say which you | |
| 42 | + picked, and keep going.` | |
| 43 | + | |
| 44 | + // Tool builds the ask tool. prompt is how it reaches a human; a nil prompt | |
| 45 | + // means nothing can answer, and the caller should not register the tool at | |
| 46 | + // all rather than offering the model a tool that always fails. | |
| 47 | + func Tool(prompt Prompt) tools.Tool { | |
| 48 | + return tools.New(Name, Description, func(ctx context.Context, in args) (string, error) { | |
| 49 | + if prompt == nil { | |
| 50 | + return "", errors.New("there is no way to reach the user from this session. Decide it yourself and say what you assumed") | |
| 51 | + } | |
| 52 | + req := in.request() | |
| 53 | + if err := Validate(req); err != nil { | |
| 54 | + return "", err | |
| 55 | + } | |
| 56 | + resp, err := prompt(ctx, req) | |
| 57 | + if err != nil { | |
| 58 | + return "", err | |
| 59 | + } | |
| 60 | + if resp.Dismissed { | |
| 61 | + return "", errors.New("the user dismissed the question without answering. Do not ask it again: pick the option you think best, say which you picked and why, and carry on") | |
| 62 | + } | |
| 63 | + if len(resp.Answers) == 0 { | |
| 64 | + return "", errors.New("the user answered nothing. Pick the option you think best, say so, and carry on") | |
| 65 | + } | |
| 66 | + return resp.Format(), nil | |
| 67 | + }) | |
| 68 | + } | |
| 69 | + | |
| 70 | + // args is the wire shape. The schema is derived from these tags, so the | |
| 71 | + // description a model reads for each field lives here and nowhere else. | |
| 72 | + type args struct { | |
| 73 | + Questions []question `json:"questions" jsonschema_description:"The questions to put to the user, 1 to 4 of them. They are answered one after another in this order."` | |
| 74 | + } | |
| 75 | + | |
| 76 | + type question struct { | |
| 77 | + Header string `json:"header" jsonschema_description:"One or two words naming the decision — 'Storage', 'Error handling'. It labels the box, so keep it under about twelve characters."` | |
| 78 | + Question string `json:"question" jsonschema_description:"The question itself, as a full sentence the user can answer without having read your reasoning."` | |
| 79 | + Options []option `json:"options" jsonschema_description:"The answers offered, 2 to 4 of them. Put the one you recommend first."` | |
| 80 | + MultiSelect bool `json:"multi_select,omitempty" jsonschema_description:"Allow several answers at once. Leave it out for a question with one right answer."` | |
| 81 | + } | |
| 82 | + | |
| 83 | + type option struct { | |
| 84 | + Label string `json:"label" jsonschema_description:"The answer, short enough to read in a list. Add ' (recommended)' to the one you would pick, if you have a view."` | |
| 85 | + Description string `json:"description,omitempty" jsonschema_description:"What choosing this one means: the consequence, not the label again."` | |
| 86 | + } | |
| 87 | + | |
| 88 | + // request converts the wire shape into the domain type. The two are separate | |
| 89 | + // so the JSON field names are free to change without the picker knowing. | |
| 90 | + func (a args) request() Request { | |
| 91 | + req := Request{Questions: make([]Question, 0, len(a.Questions))} | |
| 92 | + for _, q := range a.Questions { | |
| 93 | + out := Question{ | |
| 94 | + Header: q.Header, | |
| 95 | + Question: q.Question, | |
| 96 | + MultiSelect: q.MultiSelect, | |
| 97 | + Options: make([]Option, 0, len(q.Options)), | |
| 98 | + } | |
| 99 | + for _, o := range q.Options { | |
| 100 | + out.Options = append(out.Options, Option{Label: o.Label, Description: o.Description}) | |
| 101 | + } | |
| 102 | + req.Questions = append(req.Questions, out) | |
| 103 | + } | |
| 104 | + return req | |
| 105 | + } | |
| 106 | + | |
| 107 | + // Summarize is the one-line description of a call, for a transcript that has | |
| 108 | + // to say what the agent asked before the answer exists. | |
| 109 | + func Summarize(r Request) string { | |
| 110 | + switch len(r.Questions) { | |
| 111 | + case 0: | |
| 112 | + return "ask the user" | |
| 113 | + case 1: | |
| 114 | + return r.Questions[0].Question | |
| 115 | + default: | |
| 116 | + return fmt.Sprintf("%d questions, starting with %s", len(r.Questions), r.Questions[0].Question) | |
| 117 | + } | |
| 118 | + } | |
| @@ -10,14 +10,14 @@ import ( | ||
| 10 | 10 | |
| 11 | 11 | // spawnArgs is what the model sends to start an agent. |
| 12 | 12 | type spawnArgs struct { |
| 13 | - Preset string `json:"preset" jsonschema:"description=Which kind of agent to start. See the list in this tool's description."` | |
| 14 | - Task string `json:"task" jsonschema:"description=The complete brief for the agent: what to do and what to report back. It cannot ask you questions and it cannot see this conversation, so everything it needs has to be in here — the paths you already know, what you have tried, what the answer should look like."` | |
| 15 | - Effort string `json:"effort,omitempty" jsonschema:"description=How hard it should think: low, medium, high, xhigh or max. Low is right for mechanical work — searching, listing, reading something back; reach higher when the answer has to be right. Leave it out to use this session's level. Some models take no effort level at all, and then this is ignored and the answer says so."` | |
| 16 | - Model string `json:"model,omitempty" jsonschema:"description=Which model to run it on. Pick one that suits the job — a small fast model for mechanical search, a strong one for design or code that has to be right. Leave it out to use this session's model, which is the safe answer when you do not know what this endpoint serves."` | |
| 13 | + Preset string `json:"preset" jsonschema_description:"Which kind of agent to start. See the list in this tool's description."` | |
| 14 | + Task string `json:"task" jsonschema_description:"The complete brief for the agent: what to do and what to report back. It cannot ask you questions and it cannot see this conversation, so everything it needs has to be in here — the paths you already know, what you have tried, what the answer should look like."` | |
| 15 | + Effort string `json:"effort,omitempty" jsonschema_description:"How hard it should think: low, medium, high, xhigh or max. Low is right for mechanical work — searching, listing, reading something back; reach higher when the answer has to be right. Leave it out to use this session's level. Some models take no effort level at all, and then this is ignored and the answer says so."` | |
| 16 | + Model string `json:"model,omitempty" jsonschema_description:"Which model to run it on. Pick one that suits the job — a small fast model for mechanical search, a strong one for design or code that has to be right. Leave it out to use this session's model, which is the safe answer when you do not know what this endpoint serves."` | |
| 17 | 17 | } |
| 18 | 18 | |
| 19 | 19 | type waitArgs struct { |
| 20 | - ID string `json:"id" jsonschema:"description=The agent id returned by spawn_agent, such as a1."` | |
| 20 | + ID string `json:"id" jsonschema_description:"The agent id returned by spawn_agent, such as a1."` | |
| 21 | 21 | } |
| 22 | 22 | |
| 23 | 23 | // Tools are the tools the parent agent gets. Subagents are built from the |
| @@ -89,8 +89,8 @@ func RunCommand(ctx context.Context, dir string, argv []string, timeout time.Dur | ||
| 89 | 89 | |
| 90 | 90 | // BashArgs is the bash input. |
| 91 | 91 | type BashArgs struct { |
| 92 | - Command string `json:"command" jsonschema:"description=Shell command to run, from the working root"` | |
| 93 | - TimeoutMS int `json:"timeout_ms,omitempty" jsonschema:"description=Timeout in milliseconds; defaults to 60000, maximum 600000"` | |
| 92 | + Command string `json:"command" jsonschema_description:"Shell command to run, from the working root"` | |
| 93 | + TimeoutMS int `json:"timeout_ms,omitempty" jsonschema_description:"Timeout in milliseconds; defaults to 60000, maximum 600000"` | |
| 94 | 94 | } |
| 95 | 95 | |
| 96 | 96 | // NewBash builds the bash tool. |
| @@ -9,10 +9,10 @@ import ( | ||
| 9 | 9 | |
| 10 | 10 | // EditArgs is the edit_file input. |
| 11 | 11 | type EditArgs struct { |
| 12 | - Path string `json:"path" jsonschema:"description=Path to the file, absolute or relative to the working root"` | |
| 13 | - OldStr string `json:"old_str" jsonschema:"description=Exact text to replace, including indentation. Must appear exactly once unless replace_all is set"` | |
| 14 | - NewStr string `json:"new_str" jsonschema:"description=Text to replace it with. Use an empty string to delete"` | |
| 15 | - ReplaceAll bool `json:"replace_all,omitempty" jsonschema:"description=Replace every occurrence instead of requiring a unique match"` | |
| 12 | + Path string `json:"path" jsonschema_description:"Path to the file, absolute or relative to the working root"` | |
| 13 | + OldStr string `json:"old_str" jsonschema_description:"Exact text to replace, including indentation. Must appear exactly once unless replace_all is set"` | |
| 14 | + NewStr string `json:"new_str" jsonschema_description:"Text to replace it with. Use an empty string to delete"` | |
| 15 | + ReplaceAll bool `json:"replace_all,omitempty" jsonschema_description:"Replace every occurrence instead of requiring a unique match"` | |
| 16 | 16 | } |
| 17 | 17 | |
| 18 | 18 | // NewEdit builds the edit_file tool. track must be the same tracker read_file |
| @@ -31,8 +31,8 @@ var skipDirs = map[string]bool{ | ||
| 31 | 31 | |
| 32 | 32 | // GlobArgs is the glob input. |
| 33 | 33 | type GlobArgs struct { |
| 34 | - Pattern string `json:"pattern" jsonschema:"description=Glob pattern. ** matches across directories; a pattern with no slash is matched against the file name at any depth"` | |
| 35 | - Path string `json:"path,omitempty" jsonschema:"description=Directory to search under; defaults to the working root"` | |
| 34 | + Pattern string `json:"pattern" jsonschema_description:"Glob pattern. ** matches across directories; a pattern with no slash is matched against the file name at any depth"` | |
| 35 | + Path string `json:"path,omitempty" jsonschema_description:"Directory to search under; defaults to the working root"` | |
| 36 | 36 | } |
| 37 | 37 | |
| 38 | 38 | // NewGlob builds the glob tool. extra names additional directories that may be |
| @@ -49,10 +49,10 @@ func sortMatches(ms []match) { | ||
| 49 | 49 | |
| 50 | 50 | // GrepArgs is the grep input. |
| 51 | 51 | type GrepArgs struct { |
| 52 | - Pattern string `json:"pattern" jsonschema:"description=Regular expression to search for"` | |
| 53 | - Path string `json:"path,omitempty" jsonschema:"description=File or directory to search; defaults to the working root"` | |
| 54 | - Glob string `json:"glob,omitempty" jsonschema:"description=Only search files matching this glob, e.g. *.go"` | |
| 55 | - IgnoreCase bool `json:"ignore_case,omitempty" jsonschema:"description=Match case-insensitively"` | |
| 52 | + Pattern string `json:"pattern" jsonschema_description:"Regular expression to search for"` | |
| 53 | + Path string `json:"path,omitempty" jsonschema_description:"File or directory to search; defaults to the working root"` | |
| 54 | + Glob string `json:"glob,omitempty" jsonschema_description:"Only search files matching this glob, e.g. *.go"` | |
| 55 | + IgnoreCase bool `json:"ignore_case,omitempty" jsonschema_description:"Match case-insensitively"` | |
| 56 | 56 | } |
| 57 | 57 | |
| 58 | 58 | // NewGrep builds the grep tool. It shells out to ripgrep when available and |
| @@ -33,9 +33,9 @@ type FileTracker interface { | ||
| 33 | 33 | |
| 34 | 34 | // ReadArgs is the read_file input. |
| 35 | 35 | type ReadArgs struct { |
| 36 | - Path string `json:"path" jsonschema:"description=Path to the file, absolute or relative to the working root"` | |
| 37 | - Offset int `json:"offset,omitempty" jsonschema:"description=1-indexed line to start at; omit to start at the beginning"` | |
| 38 | - Limit int `json:"limit,omitempty" jsonschema:"description=Maximum number of lines to return; defaults to 2000"` | |
| 36 | + Path string `json:"path" jsonschema_description:"Path to the file, absolute or relative to the working root"` | |
| 37 | + Offset int `json:"offset,omitempty" jsonschema_description:"1-indexed line to start at; omit to start at the beginning"` | |
| 38 | + Limit int `json:"limit,omitempty" jsonschema_description:"Maximum number of lines to return; defaults to 2000"` | |
| 39 | 39 | } |
| 40 | 40 | |
| 41 | 41 | // NewRead builds the read_file tool. root anchors relative paths; track may be |
| @@ -63,7 +63,7 @@ var reflector = &jsonschema.Reflector{ | ||
| 63 | 63 | } |
| 64 | 64 | |
| 65 | 65 | // SchemaFor derives a JSON Schema from T. Fields tagged omitempty are optional; |
| 66 | - // everything else is required. Descriptions come from `jsonschema:"description=..."`. | |
| 66 | + // everything else is required. Descriptions come from `jsonschema_description:"..."`. | |
| 67 | 67 | func SchemaFor[T any]() json.RawMessage { |
| 68 | 68 | var zero T |
| 69 | 69 | b, err := json.Marshal(reflector.Reflect(zero)) |
| @@ -12,8 +12,8 @@ import ( | ||
| 12 | 12 | ) |
| 13 | 13 | |
| 14 | 14 | type echoArgs struct { |
| 15 | - Text string `json:"text" jsonschema:"description=Text to echo back"` | |
| 16 | - Times int `json:"times,omitempty" jsonschema:"description=How many times to repeat it"` | |
| 15 | + Text string `json:"text" jsonschema_description:"Text to echo back"` | |
| 16 | + Times int `json:"times,omitempty" jsonschema_description:"How many times to repeat it"` | |
| 17 | 17 | } |
| 18 | 18 | |
| 19 | 19 | func echoTool(fail bool) Tool { |
| @@ -60,6 +60,27 @@ func TestSchemaForReflectsStructTags(t *testing.T) { | ||
| 60 | 60 | } |
| 61 | 61 | } |
| 62 | 62 | |
| 63 | + // jsonschema:"description=..." is split on commas, so half of any sentence | |
| 64 | + // with one in it never reached the model. jsonschema_description takes the tag | |
| 65 | + // whole, and every tool here uses it for that reason; this is the test that | |
| 66 | + // notices if one drifts back. | |
| 67 | + func TestSchemaKeepsDescriptionsPastAComma(t *testing.T) { | |
| 68 | + type args struct { | |
| 69 | + Path string `json:"path" jsonschema_description:"Path to the file, absolute or relative to the working root"` | |
| 70 | + } | |
| 71 | + var got struct { | |
| 72 | + Properties map[string]struct { | |
| 73 | + Description string `json:"description"` | |
| 74 | + } `json:"properties"` | |
| 75 | + } | |
| 76 | + if err := json.Unmarshal(SchemaFor[args](), &got); err != nil { | |
| 77 | + t.Fatalf("schema is not valid JSON: %v", err) | |
| 78 | + } | |
| 79 | + if want := "Path to the file, absolute or relative to the working root"; got.Properties["path"].Description != want { | |
| 80 | + t.Errorf("description = %q, want the whole of %q", got.Properties["path"].Description, want) | |
| 81 | + } | |
| 82 | + } | |
| 83 | + | |
| 63 | 84 | func TestSchemaForNoFields(t *testing.T) { |
| 64 | 85 | type empty struct{} |
| 65 | 86 | var got map[string]any |
| @@ -15,8 +15,8 @@ import ( | ||
| 15 | 15 | // violation the model sees at once rather than a lookup failure it has to |
| 16 | 16 | // reason about. |
| 17 | 17 | type SkillArgs struct { |
| 18 | - Name string `json:"name" jsonschema:"description=Which skill to load"` | |
| 19 | - Args string `json:"args,omitempty" jsonschema:"description=Arguments for the skill; whatever followed the skill name"` | |
| 18 | + Name string `json:"name" jsonschema_description:"Which skill to load"` | |
| 19 | + Args string `json:"args,omitempty" jsonschema_description:"Arguments for the skill; whatever followed the skill name"` | |
| 20 | 20 | } |
| 21 | 21 | |
| 22 | 22 | // NewSkill builds the skill tool over the skills discovered at startup. It |
| @@ -10,8 +10,8 @@ import ( | ||
| 10 | 10 | |
| 11 | 11 | // WriteArgs is the write_file input. |
| 12 | 12 | type WriteArgs struct { |
| 13 | - Path string `json:"path" jsonschema:"description=Path to write, absolute or relative to the working root"` | |
| 14 | - Content string `json:"content" jsonschema:"description=Full contents of the file"` | |
| 13 | + Path string `json:"path" jsonschema_description:"Path to write, absolute or relative to the working root"` | |
| 14 | + Content string `json:"content" jsonschema_description:"Full contents of the file"` | |
| 15 | 15 | } |
| 16 | 16 | |
| 17 | 17 | // NewWrite builds the write_file tool. It is kept separate from edit_file so |
| @@ -0,0 +1,474 @@ | ||
| 1 | + package tui | |
| 2 | + | |
| 3 | + // The question box is the approval box's twin: the agent is blocked on a | |
| 4 | + // channel, the update loop keeps running, and a keypress is what lets it go. | |
| 5 | + // What differs is the shape of the answer. An approval has four fixed choices | |
| 6 | + // and the same four every time; a question has whatever options the model | |
| 7 | + // thought of, sometimes several questions in a row, and always the option of | |
| 8 | + // typing something nobody offered. | |
| 9 | + // | |
| 10 | + // The working state lives in askState rather than in a handful of fields on | |
| 11 | + // the model, because there can be a queue of these and each one carries its | |
| 12 | + // own half-finished answers. | |
| 13 | + | |
| 14 | + import ( | |
| 15 | + "fmt" | |
| 16 | + "strings" | |
| 17 | + | |
| 18 | + "github.com/charmbracelet/bubbles/textarea" | |
| 19 | + "github.com/charmbracelet/bubbles/textinput" | |
| 20 | + tea "github.com/charmbracelet/bubbletea" | |
| 21 | + | |
| 22 | + "codey/internal/ask" | |
| 23 | + ) | |
| 24 | + | |
| 25 | + // askState is one question box: what was asked, where the cursor is, what has | |
| 26 | + // been ticked so far, and the channel the agent is waiting on. | |
| 27 | + type askState struct { | |
| 28 | + req ask.Request | |
| 29 | + reply chan<- ask.Response | |
| 30 | + | |
| 31 | + // q is which question of the request is on screen, idx the row the cursor | |
| 32 | + // is on. picked and other are per question and survive going back: an | |
| 33 | + // answer you changed your mind about twice should still be there. | |
| 34 | + q int | |
| 35 | + idx int | |
| 36 | + picked []map[int]bool | |
| 37 | + other []string | |
| 38 | + | |
| 39 | + // typing is the free-text field, open over the list. | |
| 40 | + typing bool | |
| 41 | + input textinput.Model | |
| 42 | + | |
| 43 | + // hit maps a row inside the box to the option drawn on it. A click is | |
| 44 | + // resolved against what was drawn rather than against an assumed row | |
| 45 | + // height, because an option with a description takes two rows and one | |
| 46 | + // without takes one. | |
| 47 | + hit map[int]int | |
| 48 | + } | |
| 49 | + | |
| 50 | + func newAskState(req ask.Request, reply chan<- ask.Response) *askState { | |
| 51 | + in := textinput.New() | |
| 52 | + in.Prompt = "› " | |
| 53 | + in.Placeholder = "an answer of your own" | |
| 54 | + in.CharLimit = 0 | |
| 55 | + s := &askState{ | |
| 56 | + req: req, | |
| 57 | + reply: reply, | |
| 58 | + picked: make([]map[int]bool, len(req.Questions)), | |
| 59 | + other: make([]string, len(req.Questions)), | |
| 60 | + input: in, | |
| 61 | + hit: map[int]int{}, | |
| 62 | + } | |
| 63 | + for i := range s.picked { | |
| 64 | + s.picked[i] = map[int]bool{} | |
| 65 | + } | |
| 66 | + return s | |
| 67 | + } | |
| 68 | + | |
| 69 | + func (s *askState) question() ask.Question { return s.req.Questions[s.q] } | |
| 70 | + | |
| 71 | + // otherRow is the last row of the list: the way to answer something nobody | |
| 72 | + // offered. It is always there, which is why the tool tells the model not to | |
| 73 | + // write an "Other" option of its own. | |
| 74 | + func (s *askState) otherRow() int { return len(s.question().Options) } | |
| 75 | + | |
| 76 | + func (s *askState) rows() int { return s.otherRow() + 1 } | |
| 77 | + | |
| 78 | + // move walks the list, wrapping. A list this short is a ring: pressing down | |
| 79 | + // on the last row to get back to the first is quicker than finding ↑. | |
| 80 | + func (s *askState) move(step int) { | |
| 81 | + n := s.rows() | |
| 82 | + s.idx = (s.idx + step + n) % n | |
| 83 | + } | |
| 84 | + | |
| 85 | + // tick marks an option. In a single-answer question it is the answer, so | |
| 86 | + // anything else ticked stops being it. | |
| 87 | + func (s *askState) tick(i int) { | |
| 88 | + if !s.question().MultiSelect { | |
| 89 | + s.picked[s.q] = map[int]bool{} | |
| 90 | + } | |
| 91 | + s.picked[s.q][i] = true | |
| 92 | + } | |
| 93 | + | |
| 94 | + func (s *askState) toggle(i int) { | |
| 95 | + if s.picked[s.q][i] { | |
| 96 | + delete(s.picked[s.q], i) | |
| 97 | + return | |
| 98 | + } | |
| 99 | + s.tick(i) | |
| 100 | + } | |
| 101 | + | |
| 102 | + // cursorHome is where the cursor sits when a question comes up: on the answer | |
| 103 | + // already given, when there is one, so going back to change your mind starts | |
| 104 | + // at the thing you are changing rather than at the top of the list. | |
| 105 | + func (s *askState) cursorHome() int { | |
| 106 | + if strings.TrimSpace(s.other[s.q]) != "" { | |
| 107 | + return s.otherRow() | |
| 108 | + } | |
| 109 | + for i := range s.question().Options { | |
| 110 | + if s.picked[s.q][i] { | |
| 111 | + return i | |
| 112 | + } | |
| 113 | + } | |
| 114 | + return 0 | |
| 115 | + } | |
| 116 | + | |
| 117 | + func (s *askState) anyPicked() bool { | |
| 118 | + return len(s.picked[s.q]) > 0 || strings.TrimSpace(s.other[s.q]) != "" | |
| 119 | + } | |
| 120 | + | |
| 121 | + // answers is the response, in the order the questions were asked. | |
| 122 | + func (s *askState) answers() []ask.Answer { | |
| 123 | + out := make([]ask.Answer, 0, len(s.req.Questions)) | |
| 124 | + for i, q := range s.req.Questions { | |
| 125 | + a := ask.Answer{Header: q.Header, Question: q.Question, Other: strings.TrimSpace(s.other[i])} | |
| 126 | + for j, o := range q.Options { | |
| 127 | + if s.picked[i][j] { | |
| 128 | + a.Chosen = append(a.Chosen, o.Label) | |
| 129 | + } | |
| 130 | + } | |
| 131 | + out = append(out, a) | |
| 132 | + } | |
| 133 | + return out | |
| 134 | + } | |
| 135 | + | |
| 136 | + // summary is what the transcript records against the call: the answers, short | |
| 137 | + // enough for one line, so scrolling back says what was decided without | |
| 138 | + // reopening the tool result. | |
| 139 | + func (s *askState) summary() string { | |
| 140 | + parts := make([]string, 0, len(s.req.Questions)) | |
| 141 | + for _, a := range s.answers() { | |
| 142 | + switch { | |
| 143 | + case a.Other != "": | |
| 144 | + parts = append(parts, truncate(a.Other, 40)) | |
| 145 | + case len(a.Chosen) > 0: | |
| 146 | + parts = append(parts, strings.Join(a.Chosen, " + ")) | |
| 147 | + } | |
| 148 | + } | |
| 149 | + if len(parts) == 0 { | |
| 150 | + return "answered" | |
| 151 | + } | |
| 152 | + return "answered: " + strings.Join(parts, " · ") | |
| 153 | + } | |
| 154 | + | |
| 155 | + // ---- keys --------------------------------------------------------------- | |
| 156 | + | |
| 157 | + func (m *model) onAskKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { | |
| 158 | + if len(m.asking) == 0 { | |
| 159 | + m.phase = m.afterModal() | |
| 160 | + return m, nil | |
| 161 | + } | |
| 162 | + s := m.asking[0] | |
| 163 | + | |
| 164 | + // ctrl-c abandons the question and the turn it belongs to, the way it | |
| 165 | + // does at an approval. A question nobody answered is not an answer, so | |
| 166 | + // the tool is told it was dismissed rather than handed an empty one. | |
| 167 | + if msg.String() == "ctrl+c" { | |
| 168 | + cmd := m.dismissAsk() | |
| 169 | + if m.phase == phaseRunning { | |
| 170 | + return m, tea.Batch(m.interrupt(), cmd) | |
| 171 | + } | |
| 172 | + return m, cmd | |
| 173 | + } | |
| 174 | + | |
| 175 | + if s.typing { | |
| 176 | + return m, m.askTypingKey(s, msg) | |
| 177 | + } | |
| 178 | + | |
| 179 | + switch key := msg.String(); key { | |
| 180 | + case "up", "k", "shift+tab": | |
| 181 | + s.move(-1) | |
| 182 | + | |
| 183 | + case "down", "j", "tab": | |
| 184 | + s.move(1) | |
| 185 | + | |
| 186 | + case "home": | |
| 187 | + s.idx = 0 | |
| 188 | + | |
| 189 | + case "end": | |
| 190 | + s.idx = s.rows() - 1 | |
| 191 | + | |
| 192 | + case " ": | |
| 193 | + // Space ticks a box. In a one-answer question there is no difference | |
| 194 | + // between ticking and answering, so it answers. | |
| 195 | + if s.idx == s.otherRow() { | |
| 196 | + return m, m.openAskInput(s) | |
| 197 | + } | |
| 198 | + if !s.question().MultiSelect { | |
| 199 | + return m, m.chooseAsk(s, s.idx) | |
| 200 | + } | |
| 201 | + s.toggle(s.idx) | |
| 202 | + | |
| 203 | + case "enter": | |
| 204 | + return m, m.activateAsk(s) | |
| 205 | + | |
| 206 | + case "left", "backspace": | |
| 207 | + // Back to the question before this one, to change an answer already | |
| 208 | + // given. At the first question there is nothing behind it. | |
| 209 | + if s.q > 0 { | |
| 210 | + s.q-- | |
| 211 | + s.idx = s.cursorHome() | |
| 212 | + } | |
| 213 | + | |
| 214 | + case "right": | |
| 215 | + // Forward only where an answer exists: skipping ahead would leave a | |
| 216 | + // question behind with nothing in it. | |
| 217 | + if s.q < len(s.req.Questions)-1 && s.anyPicked() { | |
| 218 | + s.q++ | |
| 219 | + s.idx = s.cursorHome() | |
| 220 | + } | |
| 221 | + | |
| 222 | + case "esc": | |
| 223 | + return m, m.dismissAsk() | |
| 224 | + | |
| 225 | + default: | |
| 226 | + // A digit picks that row outright, including the last one, which is | |
| 227 | + // the way to type an answer without reaching for the arrows. | |
| 228 | + if len(key) == 1 && key[0] >= '1' && key[0] <= '9' { | |
| 229 | + if i := int(key[0] - '1'); i < s.rows() { | |
| 230 | + s.idx = i | |
| 231 | + return m, m.activateAsk(s) | |
| 232 | + } | |
| 233 | + } | |
| 234 | + } | |
| 235 | + return m, m.sync() | |
| 236 | + } | |
| 237 | + | |
| 238 | + // activateAsk is enter: take the row under the cursor, or — in a question | |
| 239 | + // that allows several answers and already has some — take what is ticked. | |
| 240 | + func (m *model) activateAsk(s *askState) tea.Cmd { | |
| 241 | + if s.idx == s.otherRow() { | |
| 242 | + return m.openAskInput(s) | |
| 243 | + } | |
| 244 | + if s.question().MultiSelect { | |
| 245 | + // Enter on a question with nothing ticked means the row you are on: | |
| 246 | + // pressing space first and then enter is a ceremony nobody wants for | |
| 247 | + // a single answer to a question that merely allowed several. | |
| 248 | + if len(s.picked[s.q]) == 0 { | |
| 249 | + s.tick(s.idx) | |
| 250 | + } | |
| 251 | + return m.advanceAsk(s) | |
| 252 | + } | |
| 253 | + return m.chooseAsk(s, s.idx) | |
| 254 | + } | |
| 255 | + | |
| 256 | + // chooseAsk answers a one-answer question and moves on. | |
| 257 | + func (m *model) chooseAsk(s *askState, i int) tea.Cmd { | |
| 258 | + s.other[s.q] = "" | |
| 259 | + s.tick(i) | |
| 260 | + return m.advanceAsk(s) | |
| 261 | + } | |
| 262 | + | |
| 263 | + // openAskInput opens the free-text field, holding whatever was typed there | |
| 264 | + // before so that going back and forth does not lose it. | |
| 265 | + func (m *model) openAskInput(s *askState) tea.Cmd { | |
| 266 | + s.typing = true | |
| 267 | + s.input.SetValue(s.other[s.q]) | |
| 268 | + s.input.CursorEnd() | |
| 269 | + return tea.Batch(s.input.Focus(), m.sync()) | |
| 270 | + } | |
| 271 | + | |
| 272 | + func (m *model) askTypingKey(s *askState, msg tea.KeyMsg) tea.Cmd { | |
| 273 | + switch msg.String() { | |
| 274 | + case "enter": | |
| 275 | + text := strings.TrimSpace(s.input.Value()) | |
| 276 | + if text == "" { | |
| 277 | + // An empty answer is not an answer; the options are still there. | |
| 278 | + s.typing = false | |
| 279 | + s.input.Blur() | |
| 280 | + return m.sync() | |
| 281 | + } | |
| 282 | + s.other[s.q] = text | |
| 283 | + s.typing = false | |
| 284 | + s.input.Blur() | |
| 285 | + if !s.question().MultiSelect { | |
| 286 | + // Typed instead of chosen: a one-answer question cannot be both. | |
| 287 | + s.picked[s.q] = map[int]bool{} | |
| 288 | + } | |
| 289 | + return m.advanceAsk(s) | |
| 290 | + | |
| 291 | + case "esc": | |
| 292 | + s.typing = false | |
| 293 | + s.input.Blur() | |
| 294 | + return m.sync() | |
| 295 | + } | |
| 296 | + var cmd tea.Cmd | |
| 297 | + s.input, cmd = s.input.Update(msg) | |
| 298 | + return tea.Batch(cmd, m.sync()) | |
| 299 | + } | |
| 300 | + | |
| 301 | + // advanceAsk moves to the next question, or answers the request when that was | |
| 302 | + // the last one. | |
| 303 | + func (m *model) advanceAsk(s *askState) tea.Cmd { | |
| 304 | + if s.q < len(s.req.Questions)-1 { | |
| 305 | + s.q++ | |
| 306 | + s.idx = s.cursorHome() | |
| 307 | + return m.sync() | |
| 308 | + } | |
| 309 | + return m.submitAsk(s) | |
| 310 | + } | |
| 311 | + | |
| 312 | + func (m *model) submitAsk(s *askState) tea.Cmd { | |
| 313 | + s.reply <- ask.Response{Answers: s.answers()} | |
| 314 | + note := s.summary() | |
| 315 | + m.popAsk() | |
| 316 | + m.tr.annotateTool(ask.Name, note) | |
| 317 | + return tea.Batch(m.sync(), m.spin.Tick, textarea.Blink) | |
| 318 | + } | |
| 319 | + | |
| 320 | + // dismissAsk closes the box without an answer. The tool turns that into an | |
| 321 | + // error telling the model to decide for itself and say what it decided, which | |
| 322 | + // is the only honest thing to do with a question nobody answered. | |
| 323 | + func (m *model) dismissAsk() tea.Cmd { | |
| 324 | + if len(m.asking) == 0 { | |
| 325 | + return nil | |
| 326 | + } | |
| 327 | + m.asking[0].reply <- ask.Response{Dismissed: true} | |
| 328 | + m.popAsk() | |
| 329 | + m.tr.annotateTool(ask.Name, "dismissed") | |
| 330 | + return tea.Batch(m.sync(), m.spin.Tick, textarea.Blink) | |
| 331 | + } | |
| 332 | + | |
| 333 | + // popAsk drops the answered question and puts the session back wherever it | |
| 334 | + // should be: at the next thing waiting, or at what it was doing before. | |
| 335 | + func (m *model) popAsk() { | |
| 336 | + m.asking = m.asking[1:] | |
| 337 | + m.phase = m.afterModal() | |
| 338 | + } | |
| 339 | + | |
| 340 | + // ---- view --------------------------------------------------------------- | |
| 341 | + | |
| 342 | + func (m *model) askView() string { | |
| 343 | + s := m.asking[0] | |
| 344 | + q := s.question() | |
| 345 | + width := maxInt(20, m.mainWidth()-2) | |
| 346 | + inner := maxInt(10, width-4) | |
| 347 | + | |
| 348 | + var b strings.Builder | |
| 349 | + row := 0 | |
| 350 | + put := func(text string) { | |
| 351 | + b.WriteString(text + "\n") | |
| 352 | + row += strings.Count(text, "\n") + 1 | |
| 353 | + } | |
| 354 | + | |
| 355 | + put(m.askHead(s)) | |
| 356 | + put(styText.Render(wrapText(q.Question, inner))) | |
| 357 | + put("") | |
| 358 | + | |
| 359 | + s.hit = map[int]int{} | |
| 360 | + if s.typing { | |
| 361 | + // Sized here rather than when it opened, so a terminal resized | |
| 362 | + // mid-answer does not leave the field the width it used to be. | |
| 363 | + s.input.Width = maxInt(8, inner-4) | |
| 364 | + put(s.input.View()) | |
| 365 | + put("") | |
| 366 | + put(styFaint.Render("enter to answer · esc for the options")) | |
| 367 | + return styModal.Width(width - 2).Render(strings.TrimRight(b.String(), "\n")) | |
| 368 | + } | |
| 369 | + | |
| 370 | + for i, o := range q.Options { | |
| 371 | + s.hit[row] = i | |
| 372 | + put(m.askOption(s, i, o, inner)) | |
| 373 | + if o.Description != "" { | |
| 374 | + s.hit[row] = i | |
| 375 | + put(styFaint.Render(truncate(" "+o.Description, inner))) | |
| 376 | + } | |
| 377 | + } | |
| 378 | + s.hit[row] = s.otherRow() | |
| 379 | + put(m.askOther(s, inner)) | |
| 380 | + | |
| 381 | + put("") | |
| 382 | + put(styFaint.Render(askKeys(s))) | |
| 383 | + return styModal.Width(width - 2).Render(strings.TrimRight(b.String(), "\n")) | |
| 384 | + } | |
| 385 | + | |
| 386 | + // askHead names the decision and says where in the request it is. When a | |
| 387 | + // subagent is asking it says which one, for the same reason the approval box | |
| 388 | + // does: several agents share this box. | |
| 389 | + func (m *model) askHead(s *askState) string { | |
| 390 | + head := styHeading.Render(strings.TrimSpace(s.question().Header)) | |
| 391 | + if s.req.Agent != "" { | |
| 392 | + who := s.req.Agent | |
| 393 | + if info, ok := m.agentInfo(s.req.Agent); ok { | |
| 394 | + who += " (" + info.Preset + ")" | |
| 395 | + } | |
| 396 | + head = styCoral.Render(who) + styDim.Render(" · ") + head | |
| 397 | + } | |
| 398 | + if n := len(s.req.Questions); n > 1 { | |
| 399 | + head += styDim.Render(fmt.Sprintf(" · question %d of %d", s.q+1, n)) | |
| 400 | + } | |
| 401 | + return head | |
| 402 | + } | |
| 403 | + | |
| 404 | + // askOption is one row of the list: the cursor, the number to press, a box | |
| 405 | + // when several answers are allowed, and the label. | |
| 406 | + func (m *model) askOption(s *askState, i int, o ask.Option, inner int) string { | |
| 407 | + marker := " " | |
| 408 | + label := styText.Render(o.Label) | |
| 409 | + if i == s.idx { | |
| 410 | + marker = styKey.Render("❯ ") | |
| 411 | + label = styKey.Render(o.Label) | |
| 412 | + } | |
| 413 | + box := "" | |
| 414 | + if s.question().MultiSelect { | |
| 415 | + box = "◻ " | |
| 416 | + if s.picked[s.q][i] { | |
| 417 | + box = styOK.Render("◼ ") | |
| 418 | + } | |
| 419 | + } else if s.picked[s.q][i] { | |
| 420 | + // A single-answer question only shows a tick when you have been here | |
| 421 | + // before: the answer you gave last time is the one you are changing. | |
| 422 | + box = styOK.Render("✓ ") | |
| 423 | + } | |
| 424 | + return truncateStyled(marker+styDim.Render(fmt.Sprint(i+1)+". ")+box+label, inner) | |
| 425 | + } | |
| 426 | + | |
| 427 | + func (m *model) askOther(s *askState, inner int) string { | |
| 428 | + marker := " " | |
| 429 | + text := styDim.Render("something else — type it yourself") | |
| 430 | + if s.idx == s.otherRow() { | |
| 431 | + marker = styKey.Render("❯ ") | |
| 432 | + text = styKey.Render("something else — type it yourself") | |
| 433 | + } | |
| 434 | + if got := strings.TrimSpace(s.other[s.q]); got != "" { | |
| 435 | + text = styOK.Render("✓ ") + styText.Render(truncate(got, maxInt(10, inner-12))) | |
| 436 | + } | |
| 437 | + return truncateStyled(marker+styDim.Render(fmt.Sprint(s.otherRow()+1)+". ")+text, inner) | |
| 438 | + } | |
| 439 | + | |
| 440 | + func askKeys(s *askState) string { | |
| 441 | + keys := "↑↓ move · enter choose" | |
| 442 | + if s.question().MultiSelect { | |
| 443 | + keys = "↑↓ move · space tick · enter confirm" | |
| 444 | + } | |
| 445 | + if s.q > 0 { | |
| 446 | + keys += " · ← back" | |
| 447 | + } | |
| 448 | + return keys + " · esc skip the question" | |
| 449 | + } | |
| 450 | + | |
| 451 | + // wrapText folds a sentence to a width. The question is the one part of the | |
| 452 | + // box that is prose and the one part that must not be cut off: an option can | |
| 453 | + // be truncated and still be recognisable, a half a question cannot be | |
| 454 | + // answered. | |
| 455 | + func wrapText(text string, width int) string { | |
| 456 | + width = maxInt(10, width) | |
| 457 | + var out []string | |
| 458 | + for _, para := range strings.Split(strings.TrimSpace(text), "\n") { | |
| 459 | + line := "" | |
| 460 | + for _, word := range strings.Fields(para) { | |
| 461 | + switch { | |
| 462 | + case line == "": | |
| 463 | + line = word | |
| 464 | + case len(line)+1+len(word) <= width: | |
| 465 | + line += " " + word | |
| 466 | + default: | |
| 467 | + out = append(out, line) | |
| 468 | + line = word | |
| 469 | + } | |
| 470 | + } | |
| 471 | + out = append(out, line) | |
| 472 | + } | |
| 473 | + return strings.Join(out, "\n") | |
| 474 | + } | |
| @@ -0,0 +1,466 @@ | ||
| 1 | + package tui | |
| 2 | + | |
| 3 | + import ( | |
| 4 | + "strings" | |
| 5 | + "testing" | |
| 6 | + | |
| 7 | + tea "github.com/charmbracelet/bubbletea" | |
| 8 | + "github.com/charmbracelet/lipgloss" | |
| 9 | + | |
| 10 | + "codey/internal/approval" | |
| 11 | + "codey/internal/ask" | |
| 12 | + ) | |
| 13 | + | |
| 14 | + // question queues a question the way the bridge would and returns the channel | |
| 15 | + // the model is expected to answer on. | |
| 16 | + func question(m *model, req ask.Request) chan ask.Response { | |
| 17 | + reply := make(chan ask.Response, 1) | |
| 18 | + m.Update(askMsg{req: req, reply: reply}) | |
| 19 | + return reply | |
| 20 | + } | |
| 21 | + | |
| 22 | + func oneQuestion() ask.Request { | |
| 23 | + return ask.Request{Questions: []ask.Question{{ | |
| 24 | + Header: "Storage", | |
| 25 | + Question: "How should sessions be stored?", | |
| 26 | + Options: []ask.Option{ | |
| 27 | + {Label: "Postgres", Description: "what the rest of the stack runs"}, | |
| 28 | + {Label: "SQLite", Description: "no new service to operate"}, | |
| 29 | + }, | |
| 30 | + }}} | |
| 31 | + } | |
| 32 | + | |
| 33 | + // answered reads the reply the model should have sent. A question that leaves | |
| 34 | + // nothing on the channel has stranded the agent, which is the failure this | |
| 35 | + // whole mechanism exists to avoid. | |
| 36 | + func answered(t *testing.T, reply chan ask.Response) ask.Response { | |
| 37 | + t.Helper() | |
| 38 | + select { | |
| 39 | + case r := <-reply: | |
| 40 | + return r | |
| 41 | + default: | |
| 42 | + t.Fatal("no answer was sent: the agent is still blocked on the channel") | |
| 43 | + return ask.Response{} | |
| 44 | + } | |
| 45 | + } | |
| 46 | + | |
| 47 | + func TestAskPutsTheQuestionOnScreen(t *testing.T) { | |
| 48 | + m := newTestModel(t) | |
| 49 | + question(m, oneQuestion()) | |
| 50 | + | |
| 51 | + if m.phase != phaseAsking { | |
| 52 | + t.Fatalf("phase = %v, want phaseAsking", m.phase) | |
| 53 | + } | |
| 54 | + out := plain(m.askView()) | |
| 55 | + for _, want := range []string{"Storage", "How should sessions be stored?", "Postgres", "SQLite", "something else"} { | |
| 56 | + if !strings.Contains(out, want) { | |
| 57 | + t.Errorf("the box does not show %q:\n%s", want, out) | |
| 58 | + } | |
| 59 | + } | |
| 60 | + if !strings.Contains(plain(m.activity()), "question") { | |
| 61 | + t.Errorf("the activity line should say a question is waiting, got %q", plain(m.activity())) | |
| 62 | + } | |
| 63 | + } | |
| 64 | + | |
| 65 | + func TestAskEnterChoosesTheRowUnderTheCursor(t *testing.T) { | |
| 66 | + m := newTestModel(t) | |
| 67 | + reply := question(m, oneQuestion()) | |
| 68 | + | |
| 69 | + m.Update(key("down")) | |
| 70 | + m.Update(key("enter")) | |
| 71 | + | |
| 72 | + got := answered(t, reply) | |
| 73 | + if got.Dismissed { | |
| 74 | + t.Fatal("choosing an option is not a dismissal") | |
| 75 | + } | |
| 76 | + if len(got.Answers) != 1 || len(got.Answers[0].Chosen) != 1 || got.Answers[0].Chosen[0] != "SQLite" { | |
| 77 | + t.Fatalf("answers = %+v, want the second option", got.Answers) | |
| 78 | + } | |
| 79 | + if got.Answers[0].Question != "How should sessions be stored?" { | |
| 80 | + t.Error("the answer should carry the question, so the model reads it in context") | |
| 81 | + } | |
| 82 | + if m.phase == phaseAsking { | |
| 83 | + t.Error("the box should be gone once it is answered") | |
| 84 | + } | |
| 85 | + } | |
| 86 | + | |
| 87 | + // A digit is the quick way to answer, and it must mean the same thing as | |
| 88 | + // walking to that row and pressing enter. | |
| 89 | + func TestAskDigitAnswersOutright(t *testing.T) { | |
| 90 | + m := newTestModel(t) | |
| 91 | + reply := question(m, oneQuestion()) | |
| 92 | + | |
| 93 | + m.Update(key("1")) | |
| 94 | + | |
| 95 | + got := answered(t, reply) | |
| 96 | + if len(got.Answers) != 1 || len(got.Answers[0].Chosen) != 1 || got.Answers[0].Chosen[0] != "Postgres" { | |
| 97 | + t.Fatalf("answers = %+v, want the first option", got.Answers) | |
| 98 | + } | |
| 99 | + } | |
| 100 | + | |
| 101 | + // A digit past the end of the list is not a row. It must not answer anything, | |
| 102 | + // because the option it would land on does not exist. | |
| 103 | + func TestAskIgnoresADigitPastTheList(t *testing.T) { | |
| 104 | + m := newTestModel(t) | |
| 105 | + reply := question(m, oneQuestion()) | |
| 106 | + | |
| 107 | + m.Update(key("9")) | |
| 108 | + | |
| 109 | + select { | |
| 110 | + case r := <-reply: | |
| 111 | + t.Fatalf("9 answered the question with %+v", r) | |
| 112 | + default: | |
| 113 | + } | |
| 114 | + } | |
| 115 | + | |
| 116 | + func TestAskEscDismisses(t *testing.T) { | |
| 117 | + m := newTestModel(t) | |
| 118 | + reply := question(m, oneQuestion()) | |
| 119 | + | |
| 120 | + m.Update(key("esc")) | |
| 121 | + | |
| 122 | + got := answered(t, reply) | |
| 123 | + if !got.Dismissed { | |
| 124 | + t.Fatal("esc should report a dismissal, not an empty answer") | |
| 125 | + } | |
| 126 | + if m.phase == phaseAsking { | |
| 127 | + t.Error("the box should be gone") | |
| 128 | + } | |
| 129 | + if !strings.Contains(plain(m.tr.render()), "dismissed") { | |
| 130 | + t.Error("the transcript should record that the question went unanswered") | |
| 131 | + } | |
| 132 | + } | |
| 133 | + | |
| 134 | + // The last row is always there and is how an answer nobody offered gets in. | |
| 135 | + func TestAskTypesAnAnswerOfYourOwn(t *testing.T) { | |
| 136 | + m := newTestModel(t) | |
| 137 | + reply := question(m, oneQuestion()) | |
| 138 | + | |
| 139 | + m.Update(key("3")) // the "something else" row | |
| 140 | + if !m.asking[0].typing { | |
| 141 | + t.Fatal("the free-text field should be open") | |
| 142 | + } | |
| 143 | + if !strings.Contains(plain(m.askView()), "esc for the options") { | |
| 144 | + t.Error("the box should say how to get back to the list") | |
| 145 | + } | |
| 146 | + typeIn(m, "neither: keep it in memory") | |
| 147 | + m.Update(key("enter")) | |
| 148 | + | |
| 149 | + got := answered(t, reply) | |
| 150 | + if got.Answers[0].Other != "neither: keep it in memory" { | |
| 151 | + t.Fatalf("Other = %q, want what was typed", got.Answers[0].Other) | |
| 152 | + } | |
| 153 | + if len(got.Answers[0].Chosen) != 0 { | |
| 154 | + t.Error("a typed answer to a one-answer question replaces the options rather than joining them") | |
| 155 | + } | |
| 156 | + } | |
| 157 | + | |
| 158 | + // Enter on an empty free-text field is not an answer: it puts the options | |
| 159 | + // back rather than sending nothing. | |
| 160 | + func TestAskEmptyFreeTextReturnsToTheOptions(t *testing.T) { | |
| 161 | + m := newTestModel(t) | |
| 162 | + reply := question(m, oneQuestion()) | |
| 163 | + | |
| 164 | + m.Update(key("3")) | |
| 165 | + m.Update(key("enter")) | |
| 166 | + | |
| 167 | + if m.asking[0].typing { | |
| 168 | + t.Error("the field should have closed") | |
| 169 | + } | |
| 170 | + select { | |
| 171 | + case r := <-reply: | |
| 172 | + t.Fatalf("an empty field answered the question with %+v", r) | |
| 173 | + default: | |
| 174 | + } | |
| 175 | + } | |
| 176 | + | |
| 177 | + func TestAskMultiSelectTicksSeveral(t *testing.T) { | |
| 178 | + m := newTestModel(t) | |
| 179 | + reply := question(m, ask.Request{Questions: []ask.Question{{ | |
| 180 | + Header: "Scope", | |
| 181 | + Question: "What should the change cover?", | |
| 182 | + MultiSelect: true, | |
| 183 | + Options: []ask.Option{{Label: "the CLI"}, {Label: "the TUI"}, {Label: "the docs"}}, | |
| 184 | + }}}) | |
| 185 | + | |
| 186 | + m.Update(key(" ")) // tick the CLI | |
| 187 | + m.Update(key("down")) | |
| 188 | + m.Update(key(" ")) // tick the TUI | |
| 189 | + m.Update(key("enter")) | |
| 190 | + | |
| 191 | + got := answered(t, reply) | |
| 192 | + if want := []string{"the CLI", "the TUI"}; strings.Join(got.Answers[0].Chosen, ",") != strings.Join(want, ",") { | |
| 193 | + t.Fatalf("chosen = %v, want %v", got.Answers[0].Chosen, want) | |
| 194 | + } | |
| 195 | + } | |
| 196 | + | |
| 197 | + // Enter with nothing ticked takes the row you are on. Requiring space first | |
| 198 | + // would be a ceremony for the common case of one answer. | |
| 199 | + func TestAskMultiSelectEnterTakesTheCursorRow(t *testing.T) { | |
| 200 | + m := newTestModel(t) | |
| 201 | + reply := question(m, ask.Request{Questions: []ask.Question{{ | |
| 202 | + Header: "Scope", | |
| 203 | + Question: "What should the change cover?", | |
| 204 | + MultiSelect: true, | |
| 205 | + Options: []ask.Option{{Label: "the CLI"}, {Label: "the TUI"}}, | |
| 206 | + }}}) | |
| 207 | + | |
| 208 | + m.Update(key("down")) | |
| 209 | + m.Update(key("enter")) | |
| 210 | + | |
| 211 | + got := answered(t, reply) | |
| 212 | + if len(got.Answers[0].Chosen) != 1 || got.Answers[0].Chosen[0] != "the TUI" { | |
| 213 | + t.Fatalf("chosen = %v, want the row the cursor was on", got.Answers[0].Chosen) | |
| 214 | + } | |
| 215 | + } | |
| 216 | + | |
| 217 | + func TestAskWalksSeveralQuestionsAndGoesBack(t *testing.T) { | |
| 218 | + m := newTestModel(t) | |
| 219 | + reply := question(m, ask.Request{Questions: []ask.Question{ | |
| 220 | + {Header: "Storage", Question: "Where?", Options: []ask.Option{{Label: "Postgres"}, {Label: "SQLite"}}}, | |
| 221 | + {Header: "Tests", Question: "How far?", Options: []ask.Option{{Label: "unit"}, {Label: "end to end"}}}, | |
| 222 | + }}) | |
| 223 | + | |
| 224 | + if !strings.Contains(plain(m.askView()), "question 1 of 2") { | |
| 225 | + t.Error("a request with several questions should say where in it you are") | |
| 226 | + } | |
| 227 | + m.Update(key("1")) // Postgres, on to the second question | |
| 228 | + | |
| 229 | + select { | |
| 230 | + case <-reply: | |
| 231 | + t.Fatal("the request was answered before the second question was") | |
| 232 | + default: | |
| 233 | + } | |
| 234 | + if m.asking[0].q != 1 { | |
| 235 | + t.Fatalf("q = %d, want the second question", m.asking[0].q) | |
| 236 | + } | |
| 237 | + | |
| 238 | + // Back to the first, change the answer, forward again. | |
| 239 | + m.Update(key("left")) | |
| 240 | + if m.asking[0].q != 0 { | |
| 241 | + t.Fatal("left should go back to the previous question") | |
| 242 | + } | |
| 243 | + m.Update(key("2")) // SQLite this time | |
| 244 | + m.Update(key("1")) // unit tests | |
| 245 | + | |
| 246 | + got := answered(t, reply) | |
| 247 | + if len(got.Answers) != 2 { | |
| 248 | + t.Fatalf("got %d answers, want one per question", len(got.Answers)) | |
| 249 | + } | |
| 250 | + if got.Answers[0].Chosen[0] != "SQLite" { | |
| 251 | + t.Errorf("answer 1 = %v, want the changed answer", got.Answers[0].Chosen) | |
| 252 | + } | |
| 253 | + if got.Answers[1].Chosen[0] != "unit" { | |
| 254 | + t.Errorf("answer 2 = %v", got.Answers[1].Chosen) | |
| 255 | + } | |
| 256 | + } | |
| 257 | + | |
| 258 | + // An approval and a question can be waiting at once — a subagent raises one | |
| 259 | + // while the session's agent has asked the other. The approval goes first, | |
| 260 | + // and answering it must reveal the question rather than the prompt box. | |
| 261 | + func TestAskWaitsBehindAnApproval(t *testing.T) { | |
| 262 | + m := newTestModel(t) | |
| 263 | + reply := question(m, oneQuestion()) | |
| 264 | + pend(m, approval.Request{Tool: "bash", Summary: "run a shell command", Detail: "go test ./...", Rememberable: true}) | |
| 265 | + | |
| 266 | + if m.phase != phaseApproving { | |
| 267 | + t.Fatalf("phase = %v, want the approval to be on top", m.phase) | |
| 268 | + } | |
| 269 | + m.Update(key("y")) | |
| 270 | + | |
| 271 | + if m.phase != phaseAsking { | |
| 272 | + t.Fatalf("phase = %v, want the question back on screen", m.phase) | |
| 273 | + } | |
| 274 | + m.Update(key("1")) | |
| 275 | + answered(t, reply) | |
| 276 | + if m.phase != phaseIdle { | |
| 277 | + t.Errorf("phase = %v, want the session back where it started", m.phase) | |
| 278 | + } | |
| 279 | + } | |
| 280 | + | |
| 281 | + // The tool call that asked is part of the turn. When the turn ends without | |
| 282 | + // it, nobody is left to read the answer, so the question goes with it. | |
| 283 | + func TestAskIsDroppedWhenTheTurnEnds(t *testing.T) { | |
| 284 | + m := newTestModel(t) | |
| 285 | + reply := question(m, oneQuestion()) | |
| 286 | + | |
| 287 | + m.Update(turnDone{}) | |
| 288 | + | |
| 289 | + got := answered(t, reply) | |
| 290 | + if !got.Dismissed { | |
| 291 | + t.Fatal("a question whose turn ended is dismissed, not answered") | |
| 292 | + } | |
| 293 | + if m.phase == phaseAsking { | |
| 294 | + t.Errorf("phase = %v, want the box gone with the turn", m.phase) | |
| 295 | + } | |
| 296 | + } | |
| 297 | + | |
| 298 | + // A subagent's question belongs to its own turn, which the session's turn | |
| 299 | + // ending says nothing about. | |
| 300 | + func TestAskFromASubagentSurvivesTheTurn(t *testing.T) { | |
| 301 | + m := newTestModel(t) | |
| 302 | + req := oneQuestion() | |
| 303 | + req.Agent = "a1" | |
| 304 | + reply := question(m, req) | |
| 305 | + | |
| 306 | + m.Update(turnDone{}) | |
| 307 | + | |
| 308 | + select { | |
| 309 | + case r := <-reply: | |
| 310 | + t.Fatalf("the subagent's question was dropped with the session's turn: %+v", r) | |
| 311 | + default: | |
| 312 | + } | |
| 313 | + if m.phase != phaseAsking { | |
| 314 | + t.Errorf("phase = %v, want the question still up", m.phase) | |
| 315 | + } | |
| 316 | + if !strings.Contains(plain(m.askView()), "a1") { | |
| 317 | + t.Error("the box should say which agent is asking") | |
| 318 | + } | |
| 319 | + } | |
| 320 | + | |
| 321 | + // A click on an option is a choice, the way it is everywhere else in the | |
| 322 | + // chrome — including a click on the description under the label. | |
| 323 | + func TestAskClickChooses(t *testing.T) { | |
| 324 | + m := newTestModel(t) | |
| 325 | + reply := question(m, oneQuestion()) | |
| 326 | + m.mouseOn = true | |
| 327 | + | |
| 328 | + // The row is found in the frame rather than counted from a constant: the | |
| 329 | + // point of the test is that the hit map agrees with what was drawn, and | |
| 330 | + // arithmetic repeated here could be wrong in exactly the same way. | |
| 331 | + secs := m.layout() | |
| 332 | + row := m.height - rowsOf(secs) | |
| 333 | + for _, s := range secs { | |
| 334 | + if s.kind == secAsk { | |
| 335 | + break | |
| 336 | + } | |
| 337 | + row += lipgloss.Height(s.text) | |
| 338 | + } | |
| 339 | + for i, line := range strings.Split(plain(m.askView()), "\n") { | |
| 340 | + if strings.Contains(line, "what the rest of the stack runs") { | |
| 341 | + row += i | |
| 342 | + break | |
| 343 | + } | |
| 344 | + } | |
| 345 | + m.Update(tea.MouseMsg{Action: tea.MouseActionPress, Button: tea.MouseButtonLeft, X: 4, Y: row}) | |
| 346 | + | |
| 347 | + got := answered(t, reply) | |
| 348 | + if len(got.Answers[0].Chosen) != 1 || got.Answers[0].Chosen[0] != "Postgres" { | |
| 349 | + t.Fatalf("chosen = %v, want the option whose description was clicked", got.Answers[0].Chosen) | |
| 350 | + } | |
| 351 | + } | |
| 352 | + | |
| 353 | + func TestAskWheelMovesTheCursorWithoutAnswering(t *testing.T) { | |
| 354 | + m := newTestModel(t) | |
| 355 | + reply := question(m, oneQuestion()) | |
| 356 | + m.mouseOn = true | |
| 357 | + | |
| 358 | + m.Update(tea.MouseMsg{Action: tea.MouseActionPress, Button: tea.MouseButtonWheelDown, X: 4, Y: m.height - 3}) | |
| 359 | + | |
| 360 | + select { | |
| 361 | + case r := <-reply: | |
| 362 | + t.Fatalf("the wheel answered the question with %+v", r) | |
| 363 | + default: | |
| 364 | + } | |
| 365 | + if m.asking[0].idx == 0 { | |
| 366 | + t.Error("the wheel should have moved the cursor") | |
| 367 | + } | |
| 368 | + } | |
| 369 | + | |
| 370 | + func TestAskWrapsTheQuestionRatherThanCuttingIt(t *testing.T) { | |
| 371 | + long := "Should the retry live in the client where every call gets it or in the caller where only the ones that need it pay for it?" | |
| 372 | + got := wrapText(long, 40) | |
| 373 | + for _, line := range strings.Split(got, "\n") { | |
| 374 | + if len(line) > 40 { | |
| 375 | + t.Fatalf("line of %d columns: %q", len(line), line) | |
| 376 | + } | |
| 377 | + } | |
| 378 | + if strings.Join(strings.Fields(got), " ") != long { | |
| 379 | + t.Error("wrapping must not lose or add words") | |
| 380 | + } | |
| 381 | + } | |
| 382 | + | |
| 383 | + // The box is chrome, and chrome that draws past the edge of the terminal | |
| 384 | + // smears the frame. The model writes the labels, so their length is not | |
| 385 | + // something this side gets to assume anything about. | |
| 386 | + func TestAskBoxFitsTheTerminal(t *testing.T) { | |
| 387 | + long := ask.Request{Questions: []ask.Question{{ | |
| 388 | + Header: "A header nobody should have written this long", | |
| 389 | + Question: "Sessions have to live somewhere once the process exits, and the answer decides what has to be running before Codey will start at all. Where should they go?", | |
| 390 | + Options: []ask.Option{ | |
| 391 | + {Label: "Postgres (recommended)", Description: "what the rest of the stack already runs, so there is no new service to operate or back up"}, | |
| 392 | + {Label: "SQLite", Description: "a file on disk"}, | |
| 393 | + }, | |
| 394 | + }}} | |
| 395 | + for _, width := range []int{24, 40, 60, 100} { | |
| 396 | + m := newTestModel(t) | |
| 397 | + m.Update(tea.WindowSizeMsg{Width: width, Height: 24}) | |
| 398 | + question(m, long) | |
| 399 | + for _, state := range []string{"list", "typing"} { | |
| 400 | + if state == "typing" { | |
| 401 | + m.Update(key("3")) | |
| 402 | + } | |
| 403 | + for _, line := range strings.Split(m.askView(), "\n") { | |
| 404 | + if w := lipgloss.Width(line); w > width { | |
| 405 | + t.Errorf("at width %d the %s box drew %d cells: %q", width, state, w, plain(line)) | |
| 406 | + } | |
| 407 | + } | |
| 408 | + } | |
| 409 | + } | |
| 410 | + } | |
| 411 | + | |
| 412 | + // ctrl-c at a question abandons the question and the turn it belongs to: the | |
| 413 | + // answer would go to a tool call that is about to stop existing. | |
| 414 | + func TestAskCtrlCDismissesAndInterrupts(t *testing.T) { | |
| 415 | + m := newTestModel(t) | |
| 416 | + cancelled := false | |
| 417 | + m.cancel = func() { cancelled = true } | |
| 418 | + m.beforeModal = phaseRunning | |
| 419 | + m.phase = phaseRunning | |
| 420 | + reply := question(m, oneQuestion()) | |
| 421 | + | |
| 422 | + m.Update(key("ctrl+c")) | |
| 423 | + | |
| 424 | + got := answered(t, reply) | |
| 425 | + if !got.Dismissed { | |
| 426 | + t.Fatal("ctrl-c is not an answer") | |
| 427 | + } | |
| 428 | + if !cancelled { | |
| 429 | + t.Error("the turn the question belonged to should have been interrupted") | |
| 430 | + } | |
| 431 | + } | |
| 432 | + | |
| 433 | + // With nothing running there is no turn to cancel, and ctrl-c is only the | |
| 434 | + // dismissal. Cancelling here would tear down the next turn's context. | |
| 435 | + func TestAskCtrlCWhenIdleOnlyDismisses(t *testing.T) { | |
| 436 | + m := newTestModel(t) | |
| 437 | + cancelled := false | |
| 438 | + m.cancel = func() { cancelled = true } | |
| 439 | + reply := question(m, oneQuestion()) | |
| 440 | + | |
| 441 | + m.Update(key("ctrl+c")) | |
| 442 | + | |
| 443 | + if got := answered(t, reply); !got.Dismissed { | |
| 444 | + t.Fatal("ctrl-c should dismiss") | |
| 445 | + } | |
| 446 | + if cancelled { | |
| 447 | + t.Error("nothing was running, so nothing should have been cancelled") | |
| 448 | + } | |
| 449 | + } | |
| 450 | + | |
| 451 | + // Going back to a question puts the cursor on the answer you gave, because | |
| 452 | + // that is the thing you came back to change. | |
| 453 | + func TestAskGoingBackStartsOnTheAnswerGiven(t *testing.T) { | |
| 454 | + m := newTestModel(t) | |
| 455 | + question(m, ask.Request{Questions: []ask.Question{ | |
| 456 | + {Header: "Storage", Question: "Where?", Options: []ask.Option{{Label: "Postgres"}, {Label: "SQLite"}}}, | |
| 457 | + {Header: "Tests", Question: "How far?", Options: []ask.Option{{Label: "unit"}, {Label: "end to end"}}}, | |
| 458 | + }}) | |
| 459 | + | |
| 460 | + m.Update(key("2")) // SQLite, on to the second question | |
| 461 | + m.Update(key("left")) | |
| 462 | + | |
| 463 | + if got := m.asking[0].idx; got != 1 { | |
| 464 | + t.Errorf("cursor = %d, want the row holding the answer already given", got) | |
| 465 | + } | |
| 466 | + } | |
| @@ -7,6 +7,7 @@ import ( | ||
| 7 | 7 | |
| 8 | 8 | "codey/internal/agent" |
| 9 | 9 | "codey/internal/approval" |
| 10 | + "codey/internal/ask" | |
| 10 | 11 | "codey/internal/llm" |
| 11 | 12 | "codey/internal/subagent" |
| 12 | 13 | ) |
| @@ -57,6 +58,14 @@ type ( | ||
| 57 | 58 | reply chan<- approval.Decision |
| 58 | 59 | } |
| 59 | 60 | |
| 61 | + // askMsg is a question the agent stopped to ask. Same shape as an | |
| 62 | + // approval and the same buffered reply, because it is the same bargain: | |
| 63 | + // the agent waits on a channel and the UI stays alive. | |
| 64 | + askMsg struct { | |
| 65 | + req ask.Request | |
| 66 | + reply chan<- ask.Response | |
| 67 | + } | |
| 68 | + | |
| 60 | 69 | // turnDone reports the end of one prompt, with whatever the run returned. |
| 61 | 70 | turnDone struct { |
| 62 | 71 | err error |
| @@ -124,3 +133,25 @@ func (b *bridge) Prompt(ctx context.Context, req approval.Request) (approval.Dec | ||
| 124 | 133 | return approval.Deny, ctx.Err() |
| 125 | 134 | } |
| 126 | 135 | } |
| 136 | + | |
| 137 | + // Ask implements ask.Prompt: it puts a question on screen and waits for an | |
| 138 | + // answer, honouring the context so an interrupted turn does not leave the | |
| 139 | + // agent parked on a channel nobody will read. | |
| 140 | + // | |
| 141 | + // A cancelled question is not a dismissed one. Dismissal is a thing the user | |
| 142 | + // did and the model is told about it; cancellation is the turn going away, | |
| 143 | + // and the error says so. | |
| 144 | + func (b *bridge) Ask(ctx context.Context, req ask.Request) (ask.Response, error) { | |
| 145 | + if err := ctx.Err(); err != nil { | |
| 146 | + return ask.Response{}, err | |
| 147 | + } | |
| 148 | + reply := make(chan ask.Response, 1) | |
| 149 | + b.p.Send(askMsg{req: req, reply: reply}) | |
| 150 | + | |
| 151 | + select { | |
| 152 | + case r := <-reply: | |
| 153 | + return r, nil | |
| 154 | + case <-ctx.Done(): | |
| 155 | + return ask.Response{}, ctx.Err() | |
| 156 | + } | |
| 157 | + } | |
| @@ -11,6 +11,7 @@ import ( | ||
| 11 | 11 | |
| 12 | 12 | "codey/internal/agent" |
| 13 | 13 | "codey/internal/approval" |
| 14 | + "codey/internal/ask" | |
| 14 | 15 | ) |
| 15 | 16 | |
| 16 | 17 | // recorder stands in for tea.Program: it captures what the bridge sends |
| @@ -177,3 +178,71 @@ var ( | ||
| 177 | 178 | _ = func(b *bridge) { var _ func(agent.Event) = b.Observe } |
| 178 | 179 | _ = func(b *bridge) { var _ approval.Prompt = b.Prompt } |
| 179 | 180 | ) |
| 181 | + | |
| 182 | + // A question blocks the agent the same way an approval does, and comes back | |
| 183 | + // with exactly what the user picked. | |
| 184 | + func TestAskBlocksUntilAnswered(t *testing.T) { | |
| 185 | + r := &recorder{} | |
| 186 | + r.onSend = func(m tea.Msg) { | |
| 187 | + if am, ok := m.(askMsg); ok { | |
| 188 | + go func() { | |
| 189 | + time.Sleep(10 * time.Millisecond) | |
| 190 | + am.reply <- ask.Response{Answers: []ask.Answer{{Chosen: []string{"Postgres"}}}} | |
| 191 | + }() | |
| 192 | + } | |
| 193 | + } | |
| 194 | + b := &bridge{p: r} | |
| 195 | + | |
| 196 | + start := time.Now() | |
| 197 | + got, err := b.Ask(context.Background(), ask.Request{Questions: []ask.Question{{Header: "Storage"}}}) | |
| 198 | + if err != nil { | |
| 199 | + t.Fatalf("Ask: %v", err) | |
| 200 | + } | |
| 201 | + if len(got.Answers) != 1 || got.Answers[0].Chosen[0] != "Postgres" { | |
| 202 | + t.Errorf("response = %+v", got) | |
| 203 | + } | |
| 204 | + if time.Since(start) < 10*time.Millisecond { | |
| 205 | + t.Error("Ask returned before the UI answered") | |
| 206 | + } | |
| 207 | + } | |
| 208 | + | |
| 209 | + // The same deadlock the approval path has to avoid: an interrupted turn must | |
| 210 | + // not leave the agent parked on a channel nobody will read. | |
| 211 | + func TestAskUnblocksOnCancel(t *testing.T) { | |
| 212 | + b := &bridge{p: &recorder{}} // never answers | |
| 213 | + ctx, cancel := context.WithCancel(context.Background()) | |
| 214 | + go func() { | |
| 215 | + time.Sleep(10 * time.Millisecond) | |
| 216 | + cancel() | |
| 217 | + }() | |
| 218 | + | |
| 219 | + done := make(chan struct{}) | |
| 220 | + var err error | |
| 221 | + go func() { | |
| 222 | + _, err = b.Ask(ctx, ask.Request{}) | |
| 223 | + close(done) | |
| 224 | + }() | |
| 225 | + | |
| 226 | + select { | |
| 227 | + case <-done: | |
| 228 | + case <-time.After(2 * time.Second): | |
| 229 | + t.Fatal("Ask did not return after its context was cancelled") | |
| 230 | + } | |
| 231 | + if err == nil { | |
| 232 | + t.Error("a cancelled question is an error, not an empty answer") | |
| 233 | + } | |
| 234 | + } | |
| 235 | + | |
| 236 | + func TestAskRefusesAnAlreadyCancelledContext(t *testing.T) { | |
| 237 | + r := &recorder{} | |
| 238 | + b := &bridge{p: r} | |
| 239 | + ctx, cancel := context.WithCancel(context.Background()) | |
| 240 | + cancel() | |
| 241 | + | |
| 242 | + if _, err := b.Ask(ctx, ask.Request{}); err == nil { | |
| 243 | + t.Error("want an error for a context that was cancelled before the question") | |
| 244 | + } | |
| 245 | + if len(r.all()) != 0 { | |
| 246 | + t.Error("a cancelled question still bothered the UI") | |
| 247 | + } | |
| 248 | + } | |
| @@ -16,6 +16,7 @@ import ( | ||
| 16 | 16 | |
| 17 | 17 | "codey/internal/agent" |
| 18 | 18 | "codey/internal/approval" |
| 19 | + "codey/internal/ask" | |
| 19 | 20 | "codey/internal/config" |
| 20 | 21 | "codey/internal/llm" |
| 21 | 22 | "codey/internal/skills" |
| @@ -25,8 +26,11 @@ import ( | ||
| 25 | 26 | // Options wires the TUI to an already-built agent. The TUI owns no policy of |
| 26 | 27 | // its own: it renders what the agent reports and asks what the policy asks. |
| 27 | 28 | type Options struct { |
| 28 | - Agent *agent.Agent | |
| 29 | - Policy *approval.Policy | |
| 29 | + Agent *agent.Agent | |
| 30 | + Policy *approval.Policy | |
| 31 | + // Asker is what the ask_user tool reaches the user through. Nil means the | |
| 32 | + // session has no such tool, and nothing here will try to draw a question. | |
| 33 | + Asker *ask.Asker | |
| 30 | 34 | Client *llm.Client |
| 31 | 35 | Root string |
| 32 | 36 | SessionDir string |
| @@ -77,6 +81,9 @@ const ( | ||
| 77 | 81 | // phaseSettings is the settings panel, where the saved defaults are |
| 78 | 82 | // changed in place. |
| 79 | 83 | phaseSettings |
| 84 | + // phaseAsking is a question the agent stopped to ask. Like an approval it | |
| 85 | + // is the agent waiting on a human rather than the other way round. | |
| 86 | + phaseAsking | |
| 80 | 87 | ) |
| 81 | 88 | |
| 82 | 89 | // inputMaxLines caps how far the prompt box grows before it scrolls. Past |
| @@ -173,10 +180,15 @@ type model struct { | ||
| 173 | 180 | // nothing and means a second request can never be dropped — and with |
| 174 | 181 | // subagents there can genuinely be several, from different agents. |
| 175 | 182 | pending []approvalMsg |
| 176 | - // beforeApproval is what the session was doing when the box went up. An | |
| 177 | - // approval no longer implies a turn in flight: a subagent can ask while | |
| 178 | - // you are idle, and answering has to put you back where you were. | |
| 179 | - beforeApproval phase | |
| 183 | + // asking is the queue of questions the agent has stopped to ask, the same | |
| 184 | + // shape and for the same reasons as pending. Each carries its own | |
| 185 | + // half-finished answers, so it is state rather than a bare message. | |
| 186 | + asking []*askState | |
| 187 | + // beforeModal is what the session was doing when the first box went up — | |
| 188 | + // an approval or a question. Neither implies a turn in flight: a subagent | |
| 189 | + // can ask while you are idle, and answering has to put you back where you | |
| 190 | + // were. | |
| 191 | + beforeModal phase | |
| 180 | 192 | |
| 181 | 193 | // ctx plumbing for the running turn. |
| 182 | 194 | parent context.Context |
| @@ -289,6 +301,12 @@ func Run(ctx context.Context, opts Options) error { | ||
| 289 | 301 | if opts.Policy != nil { |
| 290 | 302 | opts.Policy.Prompt = m.bridge.Prompt |
| 291 | 303 | } |
| 304 | + if opts.Asker != nil { | |
| 305 | + // Until this line the tool would have reached whatever the line | |
| 306 | + // front end installed, which is reading from a stdin the alt screen | |
| 307 | + // has just taken over. | |
| 308 | + opts.Asker.Use(m.bridge.Ask) | |
| 309 | + } | |
| 292 | 310 | if opts.Agents != nil { |
| 293 | 311 | opts.Agents.Subscribe(m.bridge.Subagent, m.bridge.AgentsChanged) |
| 294 | 312 | } |
| @@ -497,13 +515,27 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { | ||
| 497 | 515 | return m, m.finishCompact(msg) |
| 498 | 516 | |
| 499 | 517 | case approvalMsg: |
| 500 | - if m.phase != phaseApproving { | |
| 501 | - m.beforeApproval = m.phase | |
| 518 | + if !m.inModal() { | |
| 519 | + m.beforeModal = m.phase | |
| 502 | 520 | } |
| 503 | 521 | m.pending = append(m.pending, msg) |
| 522 | + // An approval outranks a question already on screen: the question can | |
| 523 | + // wait, and a tool call cannot start until someone says so. | |
| 504 | 524 | m.phase = phaseApproving |
| 505 | 525 | return m, m.sync() |
| 506 | 526 | |
| 527 | + case askMsg: | |
| 528 | + if !m.inModal() { | |
| 529 | + m.beforeModal = m.phase | |
| 530 | + } | |
| 531 | + m.asking = append(m.asking, newAskState(msg.req, msg.reply)) | |
| 532 | + // An approval already on screen keeps it, the way it does when a | |
| 533 | + // second approval arrives: the question is behind it, not lost. | |
| 534 | + if m.phase != phaseApproving { | |
| 535 | + m.phase = phaseAsking | |
| 536 | + } | |
| 537 | + return m, m.sync() | |
| 538 | + | |
| 507 | 539 | case evSub: |
| 508 | 540 | return m, m.onSubEvent(msg.ev) |
| 509 | 541 | |
| @@ -635,9 +667,24 @@ func (m *model) finishTurn(msg turnDone) tea.Cmd { | ||
| 635 | 667 | kept = append(kept, p) |
| 636 | 668 | } |
| 637 | 669 | m.pending = kept |
| 638 | - if len(m.pending) > 0 { | |
| 639 | - m.phase = phaseApproving | |
| 640 | - m.beforeApproval = phaseIdle | |
| 670 | + | |
| 671 | + // A question outlives its turn no better than an approval does: the tool | |
| 672 | + // call that asked it is gone, and nobody is left to read the answer. The | |
| 673 | + // half-typed reply goes with it, and a subagent's question stays, for the | |
| 674 | + // same reasons. | |
| 675 | + var asking []*askState | |
| 676 | + for _, s := range m.asking { | |
| 677 | + if s.req.Agent == "" { | |
| 678 | + s.reply <- ask.Response{Dismissed: true} | |
| 679 | + continue | |
| 680 | + } | |
| 681 | + asking = append(asking, s) | |
| 682 | + } | |
| 683 | + m.asking = asking | |
| 684 | + | |
| 685 | + if len(m.pending) > 0 || len(m.asking) > 0 { | |
| 686 | + m.beforeModal = phaseIdle | |
| 687 | + m.phase = m.afterModal() | |
| 641 | 688 | } |
| 642 | 689 | |
| 643 | 690 | var full *agent.ContextFullError |
| @@ -663,6 +710,9 @@ func (m *model) onKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { | ||
| 663 | 710 | if m.phase == phaseApproving { |
| 664 | 711 | return m.onApprovalKey(msg) |
| 665 | 712 | } |
| 713 | + if m.phase == phaseAsking { | |
| 714 | + return m.onAskKey(msg) | |
| 715 | + } | |
| 666 | 716 | if m.phase == phaseEffort { |
| 667 | 717 | return m.onEffortKey(msg) |
| 668 | 718 | } |
| @@ -979,7 +1029,7 @@ func (m *model) choices(req approval.Request) []choice { | ||
| 979 | 1029 | |
| 980 | 1030 | func (m *model) onApprovalKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { |
| 981 | 1031 | if len(m.pending) == 0 { |
| 982 | - m.phase = m.beforeApproval | |
| 1032 | + m.phase = m.afterModal() | |
| 983 | 1033 | return m, nil |
| 984 | 1034 | } |
| 985 | 1035 | req := m.pending[0] |
| @@ -991,7 +1041,7 @@ func (m *model) onApprovalKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { | ||
| 991 | 1041 | if key == "ctrl+c" { |
| 992 | 1042 | req.reply <- approval.Deny |
| 993 | 1043 | m.pending = m.pending[1:] |
| 994 | - m.phase = m.phaseAfterApproval() | |
| 1044 | + m.phase = m.afterModal() | |
| 995 | 1045 | if m.phase == phaseRunning { |
| 996 | 1046 | return m, tea.Batch(m.interrupt(), m.sync()) |
| 997 | 1047 | } |
| @@ -1015,17 +1065,25 @@ func (m *model) onApprovalKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { | ||
| 1015 | 1065 | return m, nil |
| 1016 | 1066 | } |
| 1017 | 1067 | |
| 1018 | - // phaseAfterApproval is where the session goes once the queue empties: back to | |
| 1019 | - // whatever it was doing, or straight on to the next request if one is waiting. | |
| 1020 | - // An approval no longer implies a turn — a subagent can ask while you are idle | |
| 1021 | - // — so answering one puts the session back where it found it. | |
| 1022 | - func (m *model) phaseAfterApproval() phase { | |
| 1023 | - if len(m.pending) > 0 { | |
| 1068 | + // afterModal is where the session goes once a box is answered: on to the next | |
| 1069 | + // thing waiting — another approval, or a question that had to stand aside for | |
| 1070 | + // one — and otherwise back to whatever it was doing. Neither kind of box | |
| 1071 | + // implies a turn, since a subagent can raise one while you are idle, so | |
| 1072 | + // answering puts the session back where it found it. | |
| 1073 | + func (m *model) afterModal() phase { | |
| 1074 | + switch { | |
| 1075 | + case len(m.pending) > 0: | |
| 1024 | 1076 | return phaseApproving |
| 1077 | + case len(m.asking) > 0: | |
| 1078 | + return phaseAsking | |
| 1025 | 1079 | } |
| 1026 | - return m.beforeApproval | |
| 1080 | + return m.beforeModal | |
| 1027 | 1081 | } |
| 1028 | 1082 | |
| 1083 | + // inModal says a box is on screen, which is how the session knows whether | |
| 1084 | + // m.beforeModal already holds something worth going back to. | |
| 1085 | + func (m *model) inModal() bool { return m.phase == phaseApproving || m.phase == phaseAsking } | |
| 1086 | + | |
| 1029 | 1087 | // answerApproval sends a decision and moves on. It is shared by the keys and |
| 1030 | 1088 | // by a click on the choice, which must not be able to decide differently. |
| 1031 | 1089 | func (m *model) answerApproval(c choice) tea.Cmd { |
| @@ -1035,7 +1093,7 @@ func (m *model) answerApproval(c choice) tea.Cmd { | ||
| 1035 | 1093 | req := m.pending[0] |
| 1036 | 1094 | req.reply <- c.dec |
| 1037 | 1095 | m.pending = m.pending[1:] |
| 1038 | - m.phase = m.phaseAfterApproval() | |
| 1096 | + m.phase = m.afterModal() | |
| 1039 | 1097 | // The decision belongs to the call it gated, not to whatever the |
| 1040 | 1098 | // transcript happens to end with once the result lands. |
| 1041 | 1099 | m.tr.annotateTool(req.req.Tool, c.note) |
| @@ -1149,6 +1207,8 @@ func (m *model) activity() string { | ||
| 1149 | 1207 | left = m.spin.View() + " " + styCoral.Render(verb+"…") + styDim.Render(" ("+detail+" · esc to interrupt)") |
| 1150 | 1208 | case phaseApproving: |
| 1151 | 1209 | left = styWarn.Render("● waiting for you") |
| 1210 | + case phaseAsking: | |
| 1211 | + left = styWarn.Render("● the agent has a question") | |
| 1152 | 1212 | } |
| 1153 | 1213 | |
| 1154 | 1214 | // What was copied is reported here rather than in the transcript: five |
| @@ -1299,7 +1359,9 @@ func (m *model) contextWindow() int { | ||
| 1299 | 1359 | // modeLine reports what the approval policy will do next, because a mode that |
| 1300 | 1360 | // silently lets the agent write to disk has to be visible at all times. |
| 1301 | 1361 | func (m *model) modeLine() string { |
| 1302 | - if m.phase == phaseApproving { | |
| 1362 | + // While a box is up, shift+tab belongs to it and the line would be | |
| 1363 | + // advertising a key that does something else. | |
| 1364 | + if m.inModal() { | |
| 1303 | 1365 | return "" |
| 1304 | 1366 | } |
| 1305 | 1367 | agents := "" |
| @@ -34,6 +34,7 @@ const ( | ||
| 34 | 34 | secStatus |
| 35 | 35 | secMode |
| 36 | 36 | secApproval |
| 37 | + secAsk | |
| 37 | 38 | secEffort |
| 38 | 39 | secModel |
| 39 | 40 | secSettings |
| @@ -55,6 +56,8 @@ func (m *model) layout() []section { | ||
| 55 | 56 | switch { |
| 56 | 57 | case m.phase == phaseApproving && len(m.pending) > 0: |
| 57 | 58 | secs = append(secs, section{secApproval, m.approvalView()}) |
| 59 | + case m.phase == phaseAsking && len(m.asking) > 0: | |
| 60 | + secs = append(secs, section{secAsk, m.askView()}) | |
| 58 | 61 | case m.phase == phaseEffort: |
| 59 | 62 | secs = append(secs, section{secEffort, m.effortView()}) |
| 60 | 63 | case m.phase == phaseModel: |
| @@ -282,6 +285,14 @@ func (m *model) onChromeWheel(kind sectionKind, msg tea.MouseMsg) tea.Cmd { | ||
| 282 | 285 | case secEffort: |
| 283 | 286 | m.effortIdx = clampIdx(m.effortIdx+step(up), len(llm.EffortLevels)) |
| 284 | 287 | return nil |
| 288 | + case secAsk: | |
| 289 | + // The wheel moves the cursor and nothing else, for the reason the | |
| 290 | + // settings panel gives: a roll that answered the question on its own | |
| 291 | + // would be a decision nobody made. | |
| 292 | + if len(m.asking) > 0 && !m.asking[0].typing { | |
| 293 | + m.asking[0].move(step(up)) | |
| 294 | + } | |
| 295 | + return nil | |
| 285 | 296 | case secSettings: |
| 286 | 297 | // The wheel moves between rows rather than changing a value: a roll |
| 287 | 298 | // that silently turned approvals off would be the worst possible |
| @@ -335,6 +346,24 @@ func (m *model) onChromeClick(kind sectionKind, row, x int) (tea.Model, tea.Cmd) | ||
| 335 | 346 | } |
| 336 | 347 | return m, nil |
| 337 | 348 | |
| 349 | + case secAsk: | |
| 350 | + if len(m.asking) == 0 { | |
| 351 | + return m, nil | |
| 352 | + } | |
| 353 | + // The hit map is what the last frame actually drew, so a click on the | |
| 354 | + // description under a label lands on that option rather than between | |
| 355 | + // two of them. Row 0 of the section is the box's top border, which is | |
| 356 | + // what the map does not count. | |
| 357 | + s := m.asking[0] | |
| 358 | + if s.typing { | |
| 359 | + return m, nil | |
| 360 | + } | |
| 361 | + if i, ok := s.hit[row-1]; ok { | |
| 362 | + s.idx = i | |
| 363 | + return m, m.activateAsk(s) | |
| 364 | + } | |
| 365 | + return m, nil | |
| 366 | + | |
| 338 | 367 | case secApproval: |
| 339 | 368 | if len(m.pending) == 0 { |
| 340 | 369 | return m, nil |
| @@ -463,6 +463,9 @@ func summarizeInput(raw json.RawMessage) string { | ||
| 463 | 463 | if len(raw) == 0 || json.Unmarshal(raw, &m) != nil { |
| 464 | 464 | return string(raw) |
| 465 | 465 | } |
| 466 | + if s := summarizeQuestions(m); s != "" { | |
| 467 | + return s | |
| 468 | + } | |
| 466 | 469 | keys := make([]string, 0, len(m)) |
| 467 | 470 | for k := range m { |
| 468 | 471 | keys = append(keys, k) |
| @@ -476,3 +479,27 @@ func summarizeInput(raw json.RawMessage) string { | ||
| 476 | 479 | } |
| 477 | 480 | return strings.Join(parts, ", ") |
| 478 | 481 | } |
| 482 | + | |
| 483 | + // summarizeQuestions is ask_user's arguments, which are a list of maps and | |
| 484 | + // render as unreadable rubbish through the k=v path. What the line wants is | |
| 485 | + // the question. It keys off the shape rather than the tool name because that | |
| 486 | + // is all summarizeInput is given, and nothing else here sends a "questions" | |
| 487 | + // list of objects with a "question" in them. | |
| 488 | + func summarizeQuestions(m map[string]any) string { | |
| 489 | + qs, ok := m["questions"].([]any) | |
| 490 | + if !ok || len(qs) == 0 { | |
| 491 | + return "" | |
| 492 | + } | |
| 493 | + first, ok := qs[0].(map[string]any) | |
| 494 | + if !ok { | |
| 495 | + return "" | |
| 496 | + } | |
| 497 | + text, ok := first["question"].(string) | |
| 498 | + if !ok || text == "" { | |
| 499 | + return "" | |
| 500 | + } | |
| 501 | + if n := len(qs); n > 1 { | |
| 502 | + return fmt.Sprintf("%s (+%d more)", truncate(text, 60), n-1) | |
| 503 | + } | |
| 504 | + return truncate(text, 70) | |
| 505 | + } | |
| @@ -338,3 +338,28 @@ func TestBlankLinesCarryNoGutter(t *testing.T) { | ||
| 338 | 338 | } |
| 339 | 339 | } |
| 340 | 340 | } |
| 341 | + | |
| 342 | + // ask_user's arguments are a list of objects, which through the k=v path | |
| 343 | + // renders as map[...] noise. The transcript line should be the question. | |
| 344 | + func TestSummarizeInputShowsTheQuestion(t *testing.T) { | |
| 345 | + raw := json.RawMessage(`{"questions":[ | |
| 346 | + {"header":"Storage","question":"How should sessions be stored?","options":[{"label":"Postgres"}]}, | |
| 347 | + {"header":"Tests","question":"How far?","options":[{"label":"unit"}]}]}`) | |
| 348 | + | |
| 349 | + got := summarizeInput(raw) | |
| 350 | + if !strings.Contains(got, "How should sessions be stored?") { | |
| 351 | + t.Errorf("summary = %q, want the first question", got) | |
| 352 | + } | |
| 353 | + if !strings.Contains(got, "+1 more") { | |
| 354 | + t.Errorf("summary = %q, should say the other questions are there", got) | |
| 355 | + } | |
| 356 | + } | |
| 357 | + | |
| 358 | + // Nothing else sends a "questions" list, but the shape test must not swallow | |
| 359 | + // a tool call that merely has a field of that name. | |
| 360 | + func TestSummarizeInputLeavesOtherToolsAlone(t *testing.T) { | |
| 361 | + got := summarizeInput(json.RawMessage(`{"questions":"how many"}`)) | |
| 362 | + if got != "questions=how many" { | |
| 363 | + t.Errorf("summary = %q, want the ordinary k=v rendering", got) | |
| 364 | + } | |
| 365 | + } | |
| @@ -0,0 +1,118 @@ | ||
| 1 | + package ui | |
| 2 | + | |
| 3 | + import ( | |
| 4 | + "bufio" | |
| 5 | + "context" | |
| 6 | + "fmt" | |
| 7 | + "io" | |
| 8 | + "strconv" | |
| 9 | + "strings" | |
| 10 | + | |
| 11 | + "codey/internal/ask" | |
| 12 | + ) | |
| 13 | + | |
| 14 | + // Asker puts the agent's questions to a terminal, one line at a time. It is | |
| 15 | + // to the question box what Approver is to the approval box: the same decision | |
| 16 | + // without a full-screen program to draw it in. | |
| 17 | + // | |
| 18 | + // Stdin closing is not an answer. A piped or scripted run reaches EOF at the | |
| 19 | + // first question, and the tool tells the model to decide for itself — which | |
| 20 | + // is what should happen when nobody is there. | |
| 21 | + type Asker struct { | |
| 22 | + In *bufio.Scanner | |
| 23 | + Out io.Writer | |
| 24 | + } | |
| 25 | + | |
| 26 | + // Prompt implements ask.Prompt. | |
| 27 | + func (a *Asker) Prompt(ctx context.Context, req ask.Request) (ask.Response, error) { | |
| 28 | + if err := ctx.Err(); err != nil { | |
| 29 | + return ask.Response{}, err | |
| 30 | + } | |
| 31 | + resp := ask.Response{} | |
| 32 | + for _, q := range req.Questions { | |
| 33 | + if err := ctx.Err(); err != nil { | |
| 34 | + return ask.Response{}, err | |
| 35 | + } | |
| 36 | + answer, ok := a.one(q) | |
| 37 | + if !ok { | |
| 38 | + // One question skipped is the whole request abandoned: a partial | |
| 39 | + // set of answers would be reported as if it were complete. | |
| 40 | + return ask.Response{Dismissed: true}, nil | |
| 41 | + } | |
| 42 | + resp.Answers = append(resp.Answers, answer) | |
| 43 | + } | |
| 44 | + return resp, nil | |
| 45 | + } | |
| 46 | + | |
| 47 | + func (a *Asker) one(q ask.Question) (ask.Answer, bool) { | |
| 48 | + answer := ask.Answer{Header: strings.TrimSpace(q.Header), Question: strings.TrimSpace(q.Question)} | |
| 49 | + | |
| 50 | + fmt.Fprintf(a.Out, "\n%s%s%s %s\n", color.bold, answer.Header, color.reset, answer.Question) | |
| 51 | + for i, o := range q.Options { | |
| 52 | + fmt.Fprintf(a.Out, " %s%d.%s %s\n", color.cyan, i+1, color.reset, o.Label) | |
| 53 | + if o.Description != "" { | |
| 54 | + fmt.Fprintf(a.Out, " %s%s%s\n", color.dim, o.Description, color.reset) | |
| 55 | + } | |
| 56 | + } | |
| 57 | + | |
| 58 | + how := fmt.Sprintf("answer 1-%d", len(q.Options)) | |
| 59 | + if q.MultiSelect { | |
| 60 | + how = fmt.Sprintf("answer 1-%d, several separated by commas", len(q.Options)) | |
| 61 | + } | |
| 62 | + for { | |
| 63 | + fmt.Fprintf(a.Out, "%s%s, type something else, or press enter to skip%s\n> ", color.dim, how, color.reset) | |
| 64 | + if !a.In.Scan() { | |
| 65 | + fmt.Fprintln(a.Out) | |
| 66 | + return answer, false | |
| 67 | + } | |
| 68 | + text := strings.TrimSpace(a.In.Text()) | |
| 69 | + if text == "" { | |
| 70 | + return answer, false | |
| 71 | + } | |
| 72 | + | |
| 73 | + picked, numeric := parseChoices(text, len(q.Options)) | |
| 74 | + switch { | |
| 75 | + case !numeric: | |
| 76 | + // Anything that is not a list of numbers is the user answering in | |
| 77 | + // their own words, which is always allowed. | |
| 78 | + answer.Other = text | |
| 79 | + return answer, true | |
| 80 | + case len(picked) == 0: | |
| 81 | + fmt.Fprintf(a.Out, "%sthere is no option %s%s\n", color.red, text, color.reset) | |
| 82 | + continue | |
| 83 | + case len(picked) > 1 && !q.MultiSelect: | |
| 84 | + fmt.Fprintf(a.Out, "%sthis question takes one answer%s\n", color.red, color.reset) | |
| 85 | + continue | |
| 86 | + } | |
| 87 | + for _, i := range picked { | |
| 88 | + answer.Chosen = append(answer.Chosen, q.Options[i].Label) | |
| 89 | + } | |
| 90 | + return answer, true | |
| 91 | + } | |
| 92 | + } | |
| 93 | + | |
| 94 | + // parseChoices reads "2" or "1,3" as option indices. numeric is false as soon | |
| 95 | + // as any part is not a number, which is how a typed answer is told from a | |
| 96 | + // chosen one; picked is empty when every part was a number but one was out of | |
| 97 | + // range, which is a mistake worth saying out loud rather than filing as prose. | |
| 98 | + func parseChoices(text string, n int) (picked []int, numeric bool) { | |
| 99 | + parts := strings.FieldsFunc(text, func(r rune) bool { return r == ',' || r == ' ' }) | |
| 100 | + if len(parts) == 0 { | |
| 101 | + return nil, false | |
| 102 | + } | |
| 103 | + seen := map[int]bool{} | |
| 104 | + for _, p := range parts { | |
| 105 | + v, err := strconv.Atoi(strings.TrimSpace(p)) | |
| 106 | + if err != nil { | |
| 107 | + return nil, false | |
| 108 | + } | |
| 109 | + if v < 1 || v > n { | |
| 110 | + return nil, true | |
| 111 | + } | |
| 112 | + if !seen[v-1] { | |
| 113 | + seen[v-1] = true | |
| 114 | + picked = append(picked, v-1) | |
| 115 | + } | |
| 116 | + } | |
| 117 | + return picked, true | |
| 118 | + } | |
| @@ -0,0 +1,143 @@ | ||
| 1 | + package ui | |
| 2 | + | |
| 3 | + import ( | |
| 4 | + "bufio" | |
| 5 | + "bytes" | |
| 6 | + "context" | |
| 7 | + "strings" | |
| 8 | + "testing" | |
| 9 | + | |
| 10 | + "codey/internal/ask" | |
| 11 | + ) | |
| 12 | + | |
| 13 | + func askOn(t *testing.T, input string, req ask.Request) (ask.Response, string) { | |
| 14 | + t.Helper() | |
| 15 | + var out bytes.Buffer | |
| 16 | + a := &Asker{In: bufio.NewScanner(strings.NewReader(input)), Out: &out} | |
| 17 | + resp, err := a.Prompt(context.Background(), req) | |
| 18 | + if err != nil { | |
| 19 | + t.Fatalf("Prompt: %v", err) | |
| 20 | + } | |
| 21 | + return resp, out.String() | |
| 22 | + } | |
| 23 | + | |
| 24 | + func storage() ask.Request { | |
| 25 | + return ask.Request{Questions: []ask.Question{{ | |
| 26 | + Header: "Storage", | |
| 27 | + Question: "How should sessions be stored?", | |
| 28 | + Options: []ask.Option{ | |
| 29 | + {Label: "Postgres", Description: "already running"}, | |
| 30 | + {Label: "SQLite"}, | |
| 31 | + }, | |
| 32 | + }}} | |
| 33 | + } | |
| 34 | + | |
| 35 | + func TestLineAskerTakesANumberedAnswer(t *testing.T) { | |
| 36 | + got, printed := askOn(t, "2\n", storage()) | |
| 37 | + | |
| 38 | + if got.Dismissed || len(got.Answers) != 1 || got.Answers[0].Chosen[0] != "SQLite" { | |
| 39 | + t.Fatalf("response = %+v", got) | |
| 40 | + } | |
| 41 | + for _, want := range []string{"Storage", "How should sessions be stored?", "1.", "Postgres", "already running"} { | |
| 42 | + if !strings.Contains(printed, want) { | |
| 43 | + t.Errorf("the prompt never showed %q:\n%s", want, printed) | |
| 44 | + } | |
| 45 | + } | |
| 46 | + } | |
| 47 | + | |
| 48 | + // Anything that is not a number is the user answering in their own words, | |
| 49 | + // which every question allows. | |
| 50 | + func TestLineAskerTakesWordsAsTheAnswer(t *testing.T) { | |
| 51 | + got, _ := askOn(t, "neither, keep them in memory\n", storage()) | |
| 52 | + | |
| 53 | + if got.Answers[0].Other != "neither, keep them in memory" { | |
| 54 | + t.Fatalf("Other = %q", got.Answers[0].Other) | |
| 55 | + } | |
| 56 | + if len(got.Answers[0].Chosen) != 0 { | |
| 57 | + t.Error("typed words are not a chosen option") | |
| 58 | + } | |
| 59 | + } | |
| 60 | + | |
| 61 | + func TestLineAskerRefusesANumberThatIsNotAnOption(t *testing.T) { | |
| 62 | + got, printed := askOn(t, "7\n1\n", storage()) | |
| 63 | + | |
| 64 | + if !strings.Contains(printed, "no option 7") { | |
| 65 | + t.Errorf("expected the mistake to be named:\n%s", printed) | |
| 66 | + } | |
| 67 | + if got.Answers[0].Chosen[0] != "Postgres" { | |
| 68 | + t.Fatalf("response = %+v, want the answer given second", got) | |
| 69 | + } | |
| 70 | + } | |
| 71 | + | |
| 72 | + func TestLineAskerTakesSeveralWhenTheQuestionAllowsIt(t *testing.T) { | |
| 73 | + req := ask.Request{Questions: []ask.Question{{ | |
| 74 | + Header: "Scope", Question: "What should it cover?", MultiSelect: true, | |
| 75 | + Options: []ask.Option{{Label: "the CLI"}, {Label: "the TUI"}, {Label: "the docs"}}, | |
| 76 | + }}} | |
| 77 | + | |
| 78 | + got, _ := askOn(t, "1,3\n", req) | |
| 79 | + | |
| 80 | + if want := "the CLI,the docs"; strings.Join(got.Answers[0].Chosen, ",") != want { | |
| 81 | + t.Fatalf("chosen = %v, want %s", got.Answers[0].Chosen, want) | |
| 82 | + } | |
| 83 | + } | |
| 84 | + | |
| 85 | + // One answer means one. Two numbers is a misunderstanding worth correcting | |
| 86 | + // rather than a free-text answer to file. | |
| 87 | + func TestLineAskerRefusesSeveralAnswersToOneQuestion(t *testing.T) { | |
| 88 | + got, printed := askOn(t, "1,2\n2\n", storage()) | |
| 89 | + | |
| 90 | + if !strings.Contains(printed, "takes one answer") { | |
| 91 | + t.Errorf("expected a correction:\n%s", printed) | |
| 92 | + } | |
| 93 | + if got.Answers[0].Chosen[0] != "SQLite" { | |
| 94 | + t.Fatalf("response = %+v", got) | |
| 95 | + } | |
| 96 | + } | |
| 97 | + | |
| 98 | + // An empty line is a skip, and a skipped question abandons the request: half | |
| 99 | + // the answers reported as if they were all of them is worse than none. | |
| 100 | + func TestLineAskerSkipOnEmptyLine(t *testing.T) { | |
| 101 | + req := storage() | |
| 102 | + req.Questions = append(req.Questions, ask.Question{ | |
| 103 | + Header: "Tests", Question: "How far?", Options: []ask.Option{{Label: "unit"}, {Label: "e2e"}}, | |
| 104 | + }) | |
| 105 | + | |
| 106 | + got, _ := askOn(t, "1\n\n", req) | |
| 107 | + | |
| 108 | + if !got.Dismissed { | |
| 109 | + t.Fatalf("response = %+v, want a dismissal", got) | |
| 110 | + } | |
| 111 | + if len(got.Answers) != 0 { | |
| 112 | + t.Error("a dismissed request carries no answers") | |
| 113 | + } | |
| 114 | + } | |
| 115 | + | |
| 116 | + // Nobody is there: a piped run reaches EOF, and the tool turns that into an | |
| 117 | + // instruction to decide alone rather than waiting forever. | |
| 118 | + func TestLineAskerClosedStdinIsNotAnAnswer(t *testing.T) { | |
| 119 | + got, _ := askOn(t, "", storage()) | |
| 120 | + | |
| 121 | + if !got.Dismissed { | |
| 122 | + t.Fatalf("response = %+v, want a dismissal", got) | |
| 123 | + } | |
| 124 | + } | |
| 125 | + | |
| 126 | + func TestLineAskerAsksEveryQuestion(t *testing.T) { | |
| 127 | + req := storage() | |
| 128 | + req.Questions = append(req.Questions, ask.Question{ | |
| 129 | + Header: "Tests", Question: "How far should the tests go?", Options: []ask.Option{{Label: "unit"}, {Label: "e2e"}}, | |
| 130 | + }) | |
| 131 | + | |
| 132 | + got, printed := askOn(t, "1\n2\n", req) | |
| 133 | + | |
| 134 | + if len(got.Answers) != 2 { | |
| 135 | + t.Fatalf("got %d answers, want one per question", len(got.Answers)) | |
| 136 | + } | |
| 137 | + if got.Answers[1].Chosen[0] != "e2e" { | |
| 138 | + t.Errorf("second answer = %v", got.Answers[1].Chosen) | |
| 139 | + } | |
| 140 | + if !strings.Contains(printed, "How far should the tests go?") { | |
| 141 | + t.Error("the second question was never put") | |
| 142 | + } | |
| 143 | + } | |