Vectrum
Sign In

ask: the agent stops and asks, and you answer in a box

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.
Jjonicommitted 1 parent 7ada7cfcommit9e0d91a

Showing 26 changed files with 2091 additions and 52 deletions.

+56−3README.mdView File
@@ -190,6 +190,57 @@ Commands: `/allowed`, `/clear`, `/compact`, `/effort`, `/exit`, `/help`,
190190 `/model`, `/mouse`, `/settings`, `/setup`, `/skills`, and one per discovered
191191 skill.
192192
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+
193244 ### The mouse
194245
195246 | Does | How |
@@ -671,6 +722,7 @@ and any key skips the one playing.
671722 | `edit_file` | prompts | Requires a prior read; refuses ambiguous matches; returns a diff |
672723 | `write_file` | prompts | Whole files only; separate from `edit_file` so the metrics stay honest |
673724 | `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) |
674726
675727 ## Skills
676728
@@ -786,6 +838,7 @@ internal/tools the tool registry and the tools
786838 internal/skills discovery and parsing of SKILL.md
787839 internal/config saved defaults: model, effort, approvals, theme, compaction
788840 internal/approval allow / deny / ask, with shell analysis
841+ internal/ask questions the agent puts to you mid-task, and the tool for it
789842 internal/metrics JSONL event log
790843 internal/ui line-based terminal rendering
791844 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.
800853 The TUI obeys the first rule through two one-way hatches. Events reach it via
801854 `agent.Observe`, forwarded into the Bubble Tea loop with `Program.Send`, which
802855 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.
+10−0cmd/codey/main.goView File
@@ -16,6 +16,7 @@ import (
1616
1717 "codey/internal/agent"
1818 "codey/internal/approval"
19+ "codey/internal/ask"
1920 "codey/internal/claudecode"
2021 "codey/internal/config"
2122 "codey/internal/llm"
@@ -244,6 +245,14 @@ func run() error {
244245 render := &ui.Renderer{W: os.Stdout, Verbose: *verbose}
245246 approver := &ui.Approver{In: in, Out: os.Stdout}
246247 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+
247256 policy.YOLO = acceptAll
248257 if acceptAll && !useTUI {
249258 // The TUI says this itself, in the banner.
@@ -317,6 +326,7 @@ func run() error {
317326 return tui.Run(context.Background(), tui.Options{
318327 Agent: a,
319328 Policy: policy,
329+ Asker: asker,
320330 Client: client,
321331 Root: root,
322332 SessionDir: sessionDir,
+3−0internal/approval/policy.goView File
@@ -47,6 +47,9 @@ var readOnly = map[string]bool{
4747 // skill reads one file from a directory found at startup, and cannot
4848 // reach anything read_file could not already reach.
4949 "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,
5053 }
5154
5255 // Policy decides whether a tool call proceeds, remembering what it is told.
+189−0internal/ask/ask.goaddedView File
@@ -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+ }
+201−0internal/ask/ask_test.goaddedView File
@@ -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+ }
+118−0internal/ask/tool.goaddedView File
@@ -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+ }
+5−5internal/subagent/tools.goView File
@@ -10,14 +10,14 @@ import (
1010
1111 // spawnArgs is what the model sends to start an agent.
1212 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."`
1717 }
1818
1919 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."`
2121 }
2222
2323 // Tools are the tools the parent agent gets. Subagents are built from the
+2−2internal/tools/bash.goView File
@@ -89,8 +89,8 @@ func RunCommand(ctx context.Context, dir string, argv []string, timeout time.Dur
8989
9090 // BashArgs is the bash input.
9191 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"`
9494 }
9595
9696 // NewBash builds the bash tool.
+4−4internal/tools/edit.goView File
@@ -9,10 +9,10 @@ import (
99
1010 // EditArgs is the edit_file input.
1111 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"`
1616 }
1717
1818 // NewEdit builds the edit_file tool. track must be the same tracker read_file
+2−2internal/tools/glob.goView File
@@ -31,8 +31,8 @@ var skipDirs = map[string]bool{
3131
3232 // GlobArgs is the glob input.
3333 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"`
3636 }
3737
3838 // NewGlob builds the glob tool. extra names additional directories that may be
+4−4internal/tools/grep.goView File
@@ -49,10 +49,10 @@ func sortMatches(ms []match) {
4949
5050 // GrepArgs is the grep input.
5151 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"`
5656 }
5757
5858 // NewGrep builds the grep tool. It shells out to ripgrep when available and
+3−3internal/tools/read.goView File
@@ -33,9 +33,9 @@ type FileTracker interface {
3333
3434 // ReadArgs is the read_file input.
3535 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"`
3939 }
4040
4141 // NewRead builds the read_file tool. root anchors relative paths; track may be
+1−1internal/tools/registry.goView File
@@ -63,7 +63,7 @@ var reflector = &jsonschema.Reflector{
6363 }
6464
6565 // 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:"..."`.
6767 func SchemaFor[T any]() json.RawMessage {
6868 var zero T
6969 b, err := json.Marshal(reflector.Reflect(zero))
+23−2internal/tools/registry_test.goView File
@@ -12,8 +12,8 @@ import (
1212 )
1313
1414 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"`
1717 }
1818
1919 func echoTool(fail bool) Tool {
@@ -60,6 +60,27 @@ func TestSchemaForReflectsStructTags(t *testing.T) {
6060 }
6161 }
6262
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+
6384 func TestSchemaForNoFields(t *testing.T) {
6485 type empty struct{}
6586 var got map[string]any
+2−2internal/tools/skill.goView File
@@ -15,8 +15,8 @@ import (
1515 // violation the model sees at once rather than a lookup failure it has to
1616 // reason about.
1717 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"`
2020 }
2121
2222 // NewSkill builds the skill tool over the skills discovered at startup. It
+2−2internal/tools/write.goView File
@@ -10,8 +10,8 @@ import (
1010
1111 // WriteArgs is the write_file input.
1212 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"`
1515 }
1616
1717 // NewWrite builds the write_file tool. It is kept separate from edit_file so
+474−0internal/tui/ask.goaddedView File
@@ -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+ }
+466−0internal/tui/ask_test.goaddedView File
@@ -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+ }
+31−0internal/tui/bridge.goView File
@@ -7,6 +7,7 @@ import (
77
88 "codey/internal/agent"
99 "codey/internal/approval"
10+ "codey/internal/ask"
1011 "codey/internal/llm"
1112 "codey/internal/subagent"
1213 )
@@ -57,6 +58,14 @@ type (
5758 reply chan<- approval.Decision
5859 }
5960
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+
6069 // turnDone reports the end of one prompt, with whatever the run returned.
6170 turnDone struct {
6271 err error
@@ -124,3 +133,25 @@ func (b *bridge) Prompt(ctx context.Context, req approval.Request) (approval.Dec
124133 return approval.Deny, ctx.Err()
125134 }
126135 }
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+ }
+69−0internal/tui/bridge_test.goView File
@@ -11,6 +11,7 @@ import (
1111
1212 "codey/internal/agent"
1313 "codey/internal/approval"
14+ "codey/internal/ask"
1415 )
1516
1617 // recorder stands in for tea.Program: it captures what the bridge sends
@@ -177,3 +178,71 @@ var (
177178 _ = func(b *bridge) { var _ func(agent.Event) = b.Observe }
178179 _ = func(b *bridge) { var _ approval.Prompt = b.Prompt }
179180 )
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+ }
+84−22internal/tui/model.goView File
@@ -16,6 +16,7 @@ import (
1616
1717 "codey/internal/agent"
1818 "codey/internal/approval"
19+ "codey/internal/ask"
1920 "codey/internal/config"
2021 "codey/internal/llm"
2122 "codey/internal/skills"
@@ -25,8 +26,11 @@ import (
2526 // Options wires the TUI to an already-built agent. The TUI owns no policy of
2627 // its own: it renders what the agent reports and asks what the policy asks.
2728 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
3034 Client *llm.Client
3135 Root string
3236 SessionDir string
@@ -77,6 +81,9 @@ const (
7781 // phaseSettings is the settings panel, where the saved defaults are
7882 // changed in place.
7983 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
8087 )
8188
8289 // inputMaxLines caps how far the prompt box grows before it scrolls. Past
@@ -173,10 +180,15 @@ type model struct {
173180 // nothing and means a second request can never be dropped — and with
174181 // subagents there can genuinely be several, from different agents.
175182 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
180192
181193 // ctx plumbing for the running turn.
182194 parent context.Context
@@ -289,6 +301,12 @@ func Run(ctx context.Context, opts Options) error {
289301 if opts.Policy != nil {
290302 opts.Policy.Prompt = m.bridge.Prompt
291303 }
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+ }
292310 if opts.Agents != nil {
293311 opts.Agents.Subscribe(m.bridge.Subagent, m.bridge.AgentsChanged)
294312 }
@@ -497,13 +515,27 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
497515 return m, m.finishCompact(msg)
498516
499517 case approvalMsg:
500- if m.phase != phaseApproving {
501- m.beforeApproval = m.phase
518+ if !m.inModal() {
519+ m.beforeModal = m.phase
502520 }
503521 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.
504524 m.phase = phaseApproving
505525 return m, m.sync()
506526
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+
507539 case evSub:
508540 return m, m.onSubEvent(msg.ev)
509541
@@ -635,9 +667,24 @@ func (m *model) finishTurn(msg turnDone) tea.Cmd {
635667 kept = append(kept, p)
636668 }
637669 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()
641688 }
642689
643690 var full *agent.ContextFullError
@@ -663,6 +710,9 @@ func (m *model) onKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
663710 if m.phase == phaseApproving {
664711 return m.onApprovalKey(msg)
665712 }
713+ if m.phase == phaseAsking {
714+ return m.onAskKey(msg)
715+ }
666716 if m.phase == phaseEffort {
667717 return m.onEffortKey(msg)
668718 }
@@ -979,7 +1029,7 @@ func (m *model) choices(req approval.Request) []choice {
9791029
9801030 func (m *model) onApprovalKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
9811031 if len(m.pending) == 0 {
982- m.phase = m.beforeApproval
1032+ m.phase = m.afterModal()
9831033 return m, nil
9841034 }
9851035 req := m.pending[0]
@@ -991,7 +1041,7 @@ func (m *model) onApprovalKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
9911041 if key == "ctrl+c" {
9921042 req.reply <- approval.Deny
9931043 m.pending = m.pending[1:]
994- m.phase = m.phaseAfterApproval()
1044+ m.phase = m.afterModal()
9951045 if m.phase == phaseRunning {
9961046 return m, tea.Batch(m.interrupt(), m.sync())
9971047 }
@@ -1015,17 +1065,25 @@ func (m *model) onApprovalKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
10151065 return m, nil
10161066 }
10171067
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:
10241076 return phaseApproving
1077+ case len(m.asking) > 0:
1078+ return phaseAsking
10251079 }
1026- return m.beforeApproval
1080+ return m.beforeModal
10271081 }
10281082
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+
10291087 // answerApproval sends a decision and moves on. It is shared by the keys and
10301088 // by a click on the choice, which must not be able to decide differently.
10311089 func (m *model) answerApproval(c choice) tea.Cmd {
@@ -1035,7 +1093,7 @@ func (m *model) answerApproval(c choice) tea.Cmd {
10351093 req := m.pending[0]
10361094 req.reply <- c.dec
10371095 m.pending = m.pending[1:]
1038- m.phase = m.phaseAfterApproval()
1096+ m.phase = m.afterModal()
10391097 // The decision belongs to the call it gated, not to whatever the
10401098 // transcript happens to end with once the result lands.
10411099 m.tr.annotateTool(req.req.Tool, c.note)
@@ -1149,6 +1207,8 @@ func (m *model) activity() string {
11491207 left = m.spin.View() + " " + styCoral.Render(verb+"…") + styDim.Render(" ("+detail+" · esc to interrupt)")
11501208 case phaseApproving:
11511209 left = styWarn.Render("● waiting for you")
1210+ case phaseAsking:
1211+ left = styWarn.Render("● the agent has a question")
11521212 }
11531213
11541214 // What was copied is reported here rather than in the transcript: five
@@ -1299,7 +1359,9 @@ func (m *model) contextWindow() int {
12991359 // modeLine reports what the approval policy will do next, because a mode that
13001360 // silently lets the agent write to disk has to be visible at all times.
13011361 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() {
13031365 return ""
13041366 }
13051367 agents := ""
+29−0internal/tui/mouse.goView File
@@ -34,6 +34,7 @@ const (
3434 secStatus
3535 secMode
3636 secApproval
37+ secAsk
3738 secEffort
3839 secModel
3940 secSettings
@@ -55,6 +56,8 @@ func (m *model) layout() []section {
5556 switch {
5657 case m.phase == phaseApproving && len(m.pending) > 0:
5758 secs = append(secs, section{secApproval, m.approvalView()})
59+ case m.phase == phaseAsking && len(m.asking) > 0:
60+ secs = append(secs, section{secAsk, m.askView()})
5861 case m.phase == phaseEffort:
5962 secs = append(secs, section{secEffort, m.effortView()})
6063 case m.phase == phaseModel:
@@ -282,6 +285,14 @@ func (m *model) onChromeWheel(kind sectionKind, msg tea.MouseMsg) tea.Cmd {
282285 case secEffort:
283286 m.effortIdx = clampIdx(m.effortIdx+step(up), len(llm.EffortLevels))
284287 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
285296 case secSettings:
286297 // The wheel moves between rows rather than changing a value: a roll
287298 // 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)
335346 }
336347 return m, nil
337348
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+
338367 case secApproval:
339368 if len(m.pending) == 0 {
340369 return m, nil
+27−0internal/tui/transcript.goView File
@@ -463,6 +463,9 @@ func summarizeInput(raw json.RawMessage) string {
463463 if len(raw) == 0 || json.Unmarshal(raw, &m) != nil {
464464 return string(raw)
465465 }
466+ if s := summarizeQuestions(m); s != "" {
467+ return s
468+ }
466469 keys := make([]string, 0, len(m))
467470 for k := range m {
468471 keys = append(keys, k)
@@ -476,3 +479,27 @@ func summarizeInput(raw json.RawMessage) string {
476479 }
477480 return strings.Join(parts, ", ")
478481 }
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+ }
+25−0internal/tui/transcript_test.goView File
@@ -338,3 +338,28 @@ func TestBlankLinesCarryNoGutter(t *testing.T) {
338338 }
339339 }
340340 }
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+ }
+118−0internal/ui/ask.goaddedView File
@@ -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+ }
+143−0internal/ui/ask_test.goaddedView File
@@ -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+ }