diff --git a/docs/changes/unreleased/946-question-tabs.md b/docs/changes/unreleased/946-question-tabs.md
new file mode 100644
index 0000000000..d400c9bcd0
--- /dev/null
+++ b/docs/changes/unreleased/946-question-tabs.md
@@ -0,0 +1,36 @@
+---
+kind: changed
+title: questions from one step are one panel with tabs, and permissions from one batch one frame
+pr: 946
+surface: [chat, docs]
+invalidates:
+ - "Two or more questions from one step were a SHEET (internal/tui3/questionsheet.go): a
+ list grouped by shape, answered row by row, sent with `s`, with `g` spreading one answer
+ over every row alike. The sheet, `s`, `g` and formsSheet are deleted. Those questions are
+ now ONE panel with a tab per question in its top edge and a review tab last
+ (internal/tui3/questionset.go): `←→` move between questions, `↑↓` still choose, `enter`
+ or a digit HOLDS the answer and moves on, and the review sends every held answer through
+ answerQuestions in tab order, in one command. A question nobody answered stays open."
+ - "What counted as 'the same step' was the SURFACE's guess: questionStep, a three-second
+ gather (questionGatherMsg) and questionReach.pending held a quiet question back until a
+ step boundary. All of it is deleted. The one key is the engine's step token,
+ session.Question.Batch (#910); nothing is held back, and a question is pinned the moment
+ it arrives."
+ - "Several permissions from one tool batch each got a frame of their own, one after
+ another. Two or more with the same Batch, each with a plain grant and a safe answer, are
+ now ONE frame: what each call wants, then `allow all N · one by one · deny all`, with the
+ pointer on `deny all` where each of them asked alone would open on `deny`. `one by one`
+ opens them as tabs. The frame carries no `how long` row, for the reason a single
+ permission carries none (#953). An irreversible or
+ confirmation question never joins a set or a frame."
+ - "docs/design/questions/DESIGN.md said questions were BATCHED AT THE BOUNDARY. It now says
+ ONE STEP, ONE PANEL, and the gallery's 'Several at once — the sheet' screens are replaced
+ by the tabs screens, with a 'Several approvals at once — one frame' section beside them."
+---
+
+The step was always the engine's to know: a model that calls three tools at once raises
+three questions in one step, and only the engine saw the step begin and end. The surface's
+own copy of it guessed with a clock, and while it held a question back the turn waiting on
+that question could not move. Keying the set on `Question.Batch` removes the guess and the
+clock together, and the review sending through the one door keeps F's law that one gesture
+is one command, in order.
diff --git a/docs/design/icons/DESIGN.md b/docs/design/icons/DESIGN.md
index e5649eba43..4e84c7aab6 100644
--- a/docs/design/icons/DESIGN.md
+++ b/docs/design/icons/DESIGN.md
@@ -194,6 +194,14 @@ than pictographs, so they are geometry and neither tier swaps the byte.
| it no longer needs answering | `GWithdrawn` | `⊘` | nf-fa-ban | `-` |
| the answer your enter takes | `GPointer` | `▸` | *(geometry — same byte)* | *(the question draws `>`)* |
| the asker's pick | `GRecommended` | `◆` | nf-fa-star_o | `*` |
+| the question of several the panel is showing | `GTabHere` | `●` | nf-fa-circle | `@` |
+| a question of several still waiting for its answer | `GTabOpen` | `○` | nf-fa-circle_o | `o` |
+
+The two tab slots are for several questions one step raised, drawn as one
+panel with a tab each (`internal/tui3/questionset.go`): `●` is the tab the panel
+is showing, `○` one still waiting, and a question already answered in the set
+wears `GSettled`. They share their plain bytes with the plan's step dots, and the
+test that allows that says why (`TestOneGlyphOneMeaning`).
And the ONE frame a question hangs in (`internal/tui3/frame.go`, owner ruling
2026-09-11): `GFrameTopLeft` `╭`, `GFrameTopRight` `╮`, `GFrameBottomLeft` `╰`,
diff --git a/docs/design/questions/DESIGN.md b/docs/design/questions/DESIGN.md
index a8785d7064..6ed54071d5 100644
--- a/docs/design/questions/DESIGN.md
+++ b/docs/design/questions/DESIGN.md
@@ -30,7 +30,7 @@ Before the engine puts a question to the person, the asker has climbed:
The engine's question gate refuses an `ask` with no reason, no stakes, or fewer
than two answers where the kind needs them, and tells the model to decide or
state the assumption. At most `QuestionCap` open questions per task; past it the
-model must consolidate into one sheet.
+model must consolidate them into fewer questions.
## The object — `session.Question`
@@ -183,7 +183,7 @@ never resolves a task by itself.
- **pointer** — every question with answers has the person's pointer (`▸` on a card, the band on a line): `↑↓`/`←→`/`tab` walk it, `enter` takes it, a digit takes at once. It starts on the asker's pick, which says `suggested` in its consequence column; on a confirmation it starts on the answer that loses nothing. The task record page (enter on a needs-you row) draws the node's landing question above its foot and takes its keys through the block's door. Landed 2026-09-10.
- **room** — a page over the conversation (the task-room idiom), and the owner's page pick A of 2026-09-11: **two panes with a seam between them** where the body is `railSlimFloor` cells or wider — the answers on the left with the question's own `Attach` under them as ONE dim-titled section (`what it showed you`), and the evidence of the answer the pointer is on beside them — and **one column** narrower than that, the same evidence unfolded under that answer's own row. Nothing folds: the answer the pointer is on IS the open one. `x` compare on the asker's dimensions (fallback: `+`/`−` lines), `c` comment on the focused part, `?` ask back (one exchange per option, answered in place, closes with the question), the foot composes the answer (pick · with · notes · scope), `d` you decide shows the pick and reason first, `D` sets the dial for the kind. **The pointer walks it**: `↑↓` choose (the foot says so) and the answer it is on is always in view, `enter` takes it, a digit walks to it rather than answering, `→ detail` hands the arrows to the evidence pane and `← back to the answers` hands them back; a click on an answer's ROW moves onto it, and a second click on that row is `enter` — one press to read, one to decide. **The page keeps its own key road** — the two tiers written into its foot's rule, in place of the legend.
- **panel split** — the same reading on the block above the box (preview picks A and B, 2026-09-11): where the ANSWERS carry blocks, a panel `railSlimFloor` cells or wider draws the list on the left and the pointer's own evidence on the right, and a narrower one unfolds that evidence under the pointer's row — the row giving up its consequence to it rather than saying it twice. A panel never takes more than half the frame, and what will not fit is cut on a dim row saying how much is left and `o open full`. **A sentence about the whole QUESTION crosses the seam**, at the frame's own width, inside the frame — the clock's `any key stops the clock · you can still change the answer afterwards` (#954) is the worked example: in a column half the panel wide it is cut mid-word, in the pane beside the list it reads as the pointer's own answer, and under the frame it would sit in the second key tier. On the page the same sentence stands with the reason, above the panes. One account of the case and one block renderer serve both, and the page (`internal/tui3/questionevidence.go`).
-- **sheet** — many questions from one step or many hands: grouped by shape, `✓`/`?` per row, `enter` opens one, `s` sends what is answered and delegates the rest, "same answer for all like this" on a group; dependent questions withdraw with a reason and the sheet re-flows.
+- **tabs** (owner pick `multi — A`, 2026-09-11; `internal/tui3/questionset.go`) — two or more open questions sharing `Question.Batch` (the engine's step) are ONE panel: a tab per question in the top edge (`●` on screen, `○` waiting, `✓` answered) and a review tab last. `←→` move between questions, every other key is the question's own; `enter`/a digit HOLDS the answer and moves on; the review sends every held answer in tab order through the one door in one command, and a question nobody answered stays open. `esc` folds the whole set. Where every member is a permission with a plain grant and a safe answer the set is ONE permission frame — what each call wants, then `allow all N · one by one · deny all` — and `one by one` opens it as tabs. It carries NO lifetime row: a consent's answer scope never reaches the gate, so the frame offers what it can honour and nothing else (#953 grades the calls and brings the row back). Irreversible and confirmation questions never join. It replaced the **sheet** (a list gathered at a surface-side "step boundary", `s` send / `g` same answer), deleted with its gather.
**An answer's evidence reads in three tiers**, wherever it is drawn: the label bold in ordinary ink (as on a card — the hue is on the marks alone since the 2026-09-11 colour ruling below), the body ink, and every aside dim behind its own word — `then ·` the consequence, `why this one ·` the pick's reason, `would switch if` (the asker's sentence joined onto the clause, never `if If`), `confidence ·` how sure it is — then the blocks, a blank row above each.
@@ -198,7 +198,7 @@ conversation with the same answers row.
- **THE SETTLE GUARD.** A question accepts no key pressed before it had been on screen for `questionSettle` (250ms); a key that arrived earlier is dropped, never applied. Destructive answers never share the routine key; the confirmation kind keeps stop.go's and tabclose.go's laws verbatim.
- **THE BOX IS NEVER MOVED UNDER A HAND.** A question arriving while the box holds words queues behind the chip until send or a pause of `questionQuiet` (3s).
- **PRESENCE-AWARE DELIVERY (D2 = A).** On the page: pinned now. On home or another page: in the row, plus the chip. Away (`awayAfter`, 10m without a key): dial resolves what it may and records `DecidedBy: dial`; the rest go to home and the phone; the terminal bell rings once for a blocking question only (D6 = A).
-- **BATCHED AT THE BOUNDARY.** Non-blocking questions raised inside one step arrive together as a sheet at the step's end; a blocking one arrives at once with the count of what is behind it.
+- **ONE STEP, ONE PANEL.** Questions raised by one step of a turn (`Question.Batch`) are drawn and answered together as tabs, and nothing is held back to make that happen: every question is pinned the moment it arrives. (This was BATCHED AT THE BOUNDARY — non-blocking questions held until the model spoke again — which the engine's step replaced; the hold once deadlocked two `ask` calls behind a boundary that needed their answers.)
- **WITHDRAWN, WITH A REASON.** A question whose subject is gone, whose plan changed, or that another answer made moot is withdrawn by the asker; the surface says `⊘
— no longer needed · ` once, dim, and the chip count drops.
- **THE ANSWER IS THE RECORD.** A dim line stays where the question was: `decided → · with: … · you · 14:02 · reversible · c change`; `c change` shows the unwind cost before it reopens; irreversible records say `cannot change`. A record is read by the model before it asks anything.
- **RULES ARE OFFERED, VISIBLE, FORGETTABLE (D4 = A).** The third same-shaped yes offers `r make it a rule for `; a row answered by a rule says `· your rule from · change`. Never a hidden rule.
diff --git a/docs/design/questions/GALLERY.md b/docs/design/questions/GALLERY.md
index 614f151ef2..e6556a56a9 100644
--- a/docs/design/questions/GALLERY.md
+++ b/docs/design/questions/GALLERY.md
@@ -221,22 +221,44 @@ for it) and a sentence about assumptions — the row borrows the task proposal's

*`2`.*
-## Several at once — the sheet
+## Several at once — tabs
-
-*Two `ask` calls in one step, grouped by shape, each row carrying that question's own
-keys. `▸` is the cursor, `?` is a row still waiting.*
+
+*Two `ask` calls in one step are one panel. The top edge is a tab per question — each
+called by the words that tell it apart, the words both heads share taken off — and the
+review last; the bottom edge adds `←→ question`.*
-
-*`1`. The row wears `✓` and the answer beside it, the cursor walks on, and `[g] same
-answer for all like this` is offered now that there is an answer to spread.*
+
+*`→ →`. The review names each question with `not answered — ← to go back`, and with
+nothing held it offers no send at all — `enter` is off the edge.*
-
-*`g`. Both rows answered, and `[s] send what is answered (2)`.*
+
+*`← ← 1 1`. `enter` (or a digit) on a tab holds the answer and moves on; nothing has
+gone to the engine yet. The review lists what is held and offers `send all 2`.*
-
-*`s`. One record per row — which this wave added; before it, a batch you sent vanished
-without a line.*
+
+*`enter`. Both answers go through the one door in tab order, in one command, and each
+leaves its own receipt.*
+
+## Several approvals at once — one frame
+
+
+*Four `read` calls the model asked for in one message are ONE frame: a row per thing
+wanted, then `allow all 4 · one by one · deny all`. The pointer opens on `deny all` and
+says `safe answer` — every permission opens on the answer that loses nothing until the
+calls are graded (#953). There is no lifetime row: a consent's answer scope never reaches
+the gate, so the frame offers only what it can honour.*
+
+
+*`2`. The same four open as tabs, each with its own keys, so a person who wants to read
+the files before granting can.*
+
+
+*`1 1 1 3`. Three allowed and one denied, held rather than sent; the review offers
+`send all 4`.*
+
+
+*`enter`. The deny goes through the same door as the allows, in one command.*
## Two windows
diff --git a/docs/design/questions/screens/four-reads-in-one-batch-are-one-permission-frame-one-by-one.ansi b/docs/design/questions/screens/four-reads-in-one-batch-are-one-permission-frame-one-by-one.ansi
new file mode 100644
index 0000000000..69d05a26a3
--- /dev/null
+++ b/docs/design/questions/screens/four-reads-in-one-batch-are-one-permission-frame-one-by-one.ansi
@@ -0,0 +1,36 @@
+ [1m[38;5;110maforge[0m [38;5;243mfri 10:33pm[39m
+ [38;5;243m Home [1m[38;5;237m[48;5;252m ? new conversation × [0m[38;5;243m + [39m
+[38;5;243m────────────────────────────────────────────────────────────────────────────────────────────────────
+
+
+[39m [38;5;243m· still yours to set · [38;5;146m[48;5;237m/budget[38;5;243m[49m sets what aforge may spend · [38;5;146m[48;5;237m/model[38;5;243m[49m and [38;5;146m[48;5;237m/crew[38;5;243m[49m pick the models[39m
+ [38;5;243m· [38;5;116mesc[38;5;243m interrupts · [38;5;116mctrl+c[38;5;243m twice quits · [38;5;116m?[38;5;243m for help[39m
+
+ [38;5;146m› [38;5;110mRead [4m]8;;file:///tmp/TestQuestionsE2EFourReadsInOneBatchAreOnePermissionFrame272754868/002/q-group/e.txt\/tmp/TestQuestionsE2EFourReadsInOneBatchAreOnePermissionFrame272754868/002/q-[0m]8;;\
+ [4m[38;5;110mgroup/e.txt[0m[38;5;110m, [4m]8;;file:///tmp/TestQuestionsE2EFourReadsInOneBatchAreOnePermissionFrame272754868/002/q-group/f.txt\/tmp/TestQuestionsE2EFourReadsInOneBatchAreOnePermissionFrame272754868/002/q-[0m]8;;\
+ [4m[38;5;110mgroup/f.txt[0m[38;5;110m, [4m]8;;file:///tmp/TestQuestionsE2EFourReadsInOneBatchAreOnePermissionFrame272754868/002/q-group/g.txt\/tmp/TestQuestionsE2EFourReadsInOneBatchAreOnePermissionFrame272754868/002/q-[0m]8;;\
+ [4m[38;5;110mgroup/g.txt[0m[38;5;110m, [4m]8;;file:///tmp/TestQuestionsE2EFourReadsInOneBatchAreOnePermissionFrame272754868/002/q-group/h.txt\/tmp/TestQuestionsE2EFourReadsInOneBatchAreOnePermissionFrame272754868/002/q-[0m]8;;\
+ [4m[38;5;110mgroup/h.txt[0m[38;5;110m with the read tool — all four read calls in the same message, at once, not one[39m [38;5;252m❮
+[39m [38;5;110mafter another — and run nothing else. Then tell me each file's first line.[39m
+
+ [38;5;243m▾ [38;5;103mreading 4 files in[39m [38;5;243m2s · ↓ 99[39m
+ [38;5;243m [38;5;103m/tmp/TestQuestionsE2EFourReadsInOneBatchAreOnePermissionFrame272754868/002/q-group[39m
+ [1m[38;5;186m├─▶ [0m[38;5;252mread[39m [38;5;252m/tmp/TestQuestionsE2EFourReadsInOneBatchA…ermissionFrame272754868/002/q-group/f.txt[39m [1m[38;5;186m?[0m
+ [1m[38;5;186m├─▶ [0m[38;5;252mread[39m [38;5;252m/tmp/TestQuestionsE2EFourReadsInOneBatchA…ermissionFrame272754868/002/q-group/g.txt[39m [1m[38;5;186m?[0m
+ [1m[38;5;186m╰─▶ [0m[38;5;252mread[39m [38;5;252m/tmp/TestQuestionsE2EFourReadsInOneBatchA…ermissionFrame272754868/002/q-group/h.txt[39m [1m[38;5;186m?[0m
+ [38;5;103m▪ [39m [38;5;235m↑[38;5;243m 14.2k[39m [38;5;235m↓[38;5;243m 240[39m
+
+[38;5;243m─ [38;5;186mq-group · deepseek/deepseek-v4-flash · ⠿ auto · via wafer · main[38;5;243m ───────────────── [38;5;116mesc[38;5;243m interrupt ─
+╭─[39m [1m[38;5;186m●[0m [38;5;252me.txt[39m [38;5;243m○ f.txt[39m [38;5;243m○ g.txt[39m [38;5;243m○ h.txt[39m [38;5;243m○ review[39m [38;5;243m──────────────────────────────────────[39m [38;5;243m1 of 4[39m [38;5;243m─╮
+│[39m [38;5;243m│
+│[39m [1m[38;5;186m?[0m [38;5;252mneeds your ok to run read[39m [38;5;243m│
+│[39m [38;5;116m/tmp/TestQuestionsE2EFourReadsInOneBatchAreOnePermissionFrame272754868/002/q-group/e.txt[38;5;243m · tool…[39m [38;5;243m│
+│[39m [38;5;243m│
+│[39m [38;5;116m1[39m [38;5;252mallow once[39m [38;5;243m│
+│[39m [38;5;116m2[39m [38;5;252malways [39m [38;5;243m│
+│[39m[48;5;237m [1m[38;5;186m▸[0m[48;5;237m [38;5;116m3[39m [38;5;252mdeny [39m [38;5;243msafe answer[39m [38;5;243m[49m│
+│[39m [38;5;243m│
+╰─[39m [38;5;116m←→[38;5;243m question · [38;5;116m↑↓[38;5;243m choose · [38;5;116menter[38;5;243m take it · [38;5;116mesc[38;5;243m later[39m [38;5;243m────────────────────────────────────────────╯
+[39m [38;5;116mc[38;5;243m change · [38;5;116m?[38;5;243m ask back · [38;5;116m1–3[38;5;243m jump[39m
+ [38;5;243m› [39m
+ [38;5;243m$0.0027 14.2k/1M · 1% [38;5;173mYOLO[39m [38;5;243mrunning read · 2s · [38;5;186m? 4 questions · alt+a[38;5;243m · [1m[38;5;186mwaiting · your call
diff --git a/docs/design/questions/screens/four-reads-in-one-batch-are-one-permission-frame-one-by-one.png b/docs/design/questions/screens/four-reads-in-one-batch-are-one-permission-frame-one-by-one.png
new file mode 100644
index 0000000000..4158acd8d4
Binary files /dev/null and b/docs/design/questions/screens/four-reads-in-one-batch-are-one-permission-frame-one-by-one.png differ
diff --git a/docs/design/questions/screens/four-reads-in-one-batch-are-one-permission-frame-raised.ansi b/docs/design/questions/screens/four-reads-in-one-batch-are-one-permission-frame-raised.ansi
new file mode 100644
index 0000000000..05afa5eaed
--- /dev/null
+++ b/docs/design/questions/screens/four-reads-in-one-batch-are-one-permission-frame-raised.ansi
@@ -0,0 +1,36 @@
+ [1m[38;5;110maforge[0m [38;5;243mfri 10:33pm[39m
+ [38;5;243m Home [1m[38;5;237m[48;5;252m ? new conversation × [0m[38;5;243m + [39m
+[38;5;243m────────────────────────────────────────────────────────────────────────────────────────────────────
+
+
+[39m [38;5;243m· still yours to set · [38;5;146m[48;5;237m/budget[38;5;243m[49m sets what aforge may spend · [38;5;146m[48;5;237m/model[38;5;243m[49m and [38;5;146m[48;5;237m/crew[38;5;243m[49m pick the models[39m
+ [38;5;243m· [38;5;116mesc[38;5;243m interrupts · [38;5;116mctrl+c[38;5;243m twice quits · [38;5;116m?[38;5;243m for help[39m
+
+ [38;5;146m› [38;5;110mRead [4m]8;;file:///tmp/TestQuestionsE2EFourReadsInOneBatchAreOnePermissionFrame272754868/002/q-group/e.txt\/tmp/TestQuestionsE2EFourReadsInOneBatchAreOnePermissionFrame272754868/002/q-[0m]8;;\
+ [4m[38;5;110mgroup/e.txt[0m[38;5;110m, [4m]8;;file:///tmp/TestQuestionsE2EFourReadsInOneBatchAreOnePermissionFrame272754868/002/q-group/f.txt\/tmp/TestQuestionsE2EFourReadsInOneBatchAreOnePermissionFrame272754868/002/q-[0m]8;;\
+ [4m[38;5;110mgroup/f.txt[0m[38;5;110m, [4m]8;;file:///tmp/TestQuestionsE2EFourReadsInOneBatchAreOnePermissionFrame272754868/002/q-group/g.txt\/tmp/TestQuestionsE2EFourReadsInOneBatchAreOnePermissionFrame272754868/002/q-[0m]8;;\
+ [4m[38;5;110mgroup/g.txt[0m[38;5;110m, [4m]8;;file:///tmp/TestQuestionsE2EFourReadsInOneBatchAreOnePermissionFrame272754868/002/q-group/h.txt\/tmp/TestQuestionsE2EFourReadsInOneBatchAreOnePermissionFrame272754868/002/q-[0m]8;;\
+ [4m[38;5;110mgroup/h.txt[0m[38;5;110m with the read tool — all four read calls in the same message, at once, not one[39m [38;5;252m❮
+[39m [38;5;110mafter another — and run nothing else. Then tell me each file's first line.[39m
+
+ [38;5;243m▾ [38;5;103mreading 4 files in[39m [38;5;243m↓ 99[39m
+ [38;5;243m [38;5;103m/tmp/TestQuestionsE2EFourReadsInOneBatchAreOnePermissionFrame272754868/002/q-group[39m
+ [1m[38;5;186m├─▶ [0m[38;5;252mread[39m [38;5;252m/tmp/TestQuestionsE2EFourReadsInOneBatchA…ermissionFrame272754868/002/q-group/f.txt[39m [1m[38;5;186m?[0m
+ [1m[38;5;186m├─▶ [0m[38;5;252mread[39m [38;5;252m/tmp/TestQuestionsE2EFourReadsInOneBatchA…ermissionFrame272754868/002/q-group/g.txt[39m [1m[38;5;186m?[0m
+ [1m[38;5;186m╰─▶ [0m[38;5;252mread[39m [38;5;252m/tmp/TestQuestionsE2EFourReadsInOneBatchA…ermissionFrame272754868/002/q-group/h.txt[39m [1m[38;5;186m?[0m
+ [38;5;103m▪ [39m [38;5;238m+132[39m [38;5;238m↑[38;5;103m 4,933[39m [38;5;238m↓[38;5;252m 102[39m
+
+[38;5;243m─ [38;5;186mq-group · deepseek/deepseek-v4-flash · ⠿ auto · via wafer · main[38;5;243m ───────────────── [38;5;116mesc[38;5;243m interrupt ─
+╭─[39m [1m[38;5;186m?[0m [38;5;252mallow these 4?[39m [38;5;243m────────────────────────────────────────────────────────────────────────[39m [38;5;243mread[39m [38;5;243m─╮
+│[39m [38;5;243mread[39m [38;5;116m/tmp/TestQuestionsE2EFourReadsInOneBatchAreOnePermissionFrame272754868/002/q-group/e.txt[39m [38;5;243m│
+│[39m [38;5;243mread[39m [38;5;116m/tmp/TestQuestionsE2EFourReadsInOneBatchAreOnePermissionFrame272754868/002/q-group/f.txt[39m [38;5;243m│
+│[39m [38;5;243mread[39m [38;5;116m/tmp/TestQuestionsE2EFourReadsInOneBatchAreOnePermissionFrame272754868/002/q-group/g.txt[39m [38;5;243m│
+│[39m [38;5;243mread[39m [38;5;116m/tmp/TestQuestionsE2EFourReadsInOneBatchAreOnePermissionFrame272754868/002/q-group/h.txt[39m [38;5;243m│
+│[39m [38;5;243m│
+│[39m [38;5;116m1[39m [38;5;252mallow all 4[39m [38;5;243m│
+│[39m [38;5;116m2[39m [38;5;252mone by one[39m [38;5;243m│
+│[39m[48;5;237m [1m[38;5;186m▸[0m[48;5;237m [38;5;116m3[39m [38;5;252mdeny all[39m [38;5;243msafe answer[39m [38;5;243m[49m│
+│[39m [38;5;243m│
+╰─[39m [38;5;116m↑↓[38;5;243m choose · [38;5;116menter[38;5;243m take it · [38;5;116mesc[38;5;243m later[39m [38;5;243m──────────────────────────────────────────────────────────╯
+[39m [38;5;243m› [39m
+ [38;5;243m$0.0013 4.9k/1M [38;5;173mYOLO[39m [38;5;243mrunning read · [38;5;186m? 4 questions · alt+a[38;5;243m · [1m[38;5;186mwaiting · your call
diff --git a/docs/design/questions/screens/four-reads-in-one-batch-are-one-permission-frame-raised.png b/docs/design/questions/screens/four-reads-in-one-batch-are-one-permission-frame-raised.png
new file mode 100644
index 0000000000..6dcab778fc
Binary files /dev/null and b/docs/design/questions/screens/four-reads-in-one-batch-are-one-permission-frame-raised.png differ
diff --git a/docs/design/questions/screens/four-reads-in-one-batch-are-one-permission-frame-review.ansi b/docs/design/questions/screens/four-reads-in-one-batch-are-one-permission-frame-review.ansi
new file mode 100644
index 0000000000..53e0448135
--- /dev/null
+++ b/docs/design/questions/screens/four-reads-in-one-batch-are-one-permission-frame-review.ansi
@@ -0,0 +1,36 @@
+ [1m[38;5;110maforge[0m [38;5;186m1 want you[39m · [38;5;115m$0.0027 / $500[39m · [38;5;243mfri 10:33pm[39m
+ [38;5;243m Home [1m[38;5;237m[48;5;252m ? new conversation × [0m[38;5;243m + [39m
+[38;5;243m────────────────────────────────────────────────────────────────────────────────────────────────────
+
+
+[39m [38;5;243m· still yours to set · [38;5;146m[48;5;237m/budget[38;5;243m[49m sets what aforge may spend · [38;5;146m[48;5;237m/model[38;5;243m[49m and [38;5;146m[48;5;237m/crew[38;5;243m[49m pick the models[39m
+ [38;5;243m· [38;5;116mesc[38;5;243m interrupts · [38;5;116mctrl+c[38;5;243m twice quits · [38;5;116m?[38;5;243m for help[39m
+
+ [38;5;146m› [38;5;110mRead [4m]8;;file:///tmp/TestQuestionsE2EFourReadsInOneBatchAreOnePermissionFrame272754868/002/q-group/e.txt\/tmp/TestQuestionsE2EFourReadsInOneBatchAreOnePermissionFrame272754868/002/q-[0m]8;;\
+ [4m[38;5;110mgroup/e.txt[0m[38;5;110m, [4m]8;;file:///tmp/TestQuestionsE2EFourReadsInOneBatchAreOnePermissionFrame272754868/002/q-group/f.txt\/tmp/TestQuestionsE2EFourReadsInOneBatchAreOnePermissionFrame272754868/002/q-[0m]8;;\
+ [4m[38;5;110mgroup/f.txt[0m[38;5;110m, [4m]8;;file:///tmp/TestQuestionsE2EFourReadsInOneBatchAreOnePermissionFrame272754868/002/q-group/g.txt\/tmp/TestQuestionsE2EFourReadsInOneBatchAreOnePermissionFrame272754868/002/q-[0m]8;;\
+ [4m[38;5;110mgroup/g.txt[0m[38;5;110m, [4m]8;;file:///tmp/TestQuestionsE2EFourReadsInOneBatchAreOnePermissionFrame272754868/002/q-group/h.txt\/tmp/TestQuestionsE2EFourReadsInOneBatchAreOnePermissionFrame272754868/002/q-[0m]8;;\
+ [4m[38;5;110mgroup/h.txt[0m[38;5;110m with the read tool — all four read calls in the same message, at once, not one[39m
+ [38;5;110mafter another — and run nothing else. Then tell me each file's first line.[39m [38;5;252m❮
+
+[39m [38;5;243m▾ [38;5;103mreading 4 files in[39m [38;5;243m10s · ↓ 99[39m
+ [38;5;243m [38;5;103m/tmp/TestQuestionsE2EFourReadsInOneBatchAreOnePermissionFrame272754868/002/q-[39m
+ [38;5;243m [38;5;103mgroup[39m
+ [1m[38;5;186m├─▶ [0m[38;5;252mread[39m [38;5;252m/tmp/TestQuestionsE2EFourReadsInOneBatchA…ermissionFrame272754868/002/q-group/f.txt[39m [1m[38;5;186m?[0m
+ [1m[38;5;186m├─▶ [0m[38;5;252mread[39m [38;5;252m/tmp/TestQuestionsE2EFourReadsInOneBatchA…ermissionFrame272754868/002/q-group/g.txt[39m [1m[38;5;186m?[0m
+ [1m[38;5;186m╰─▶ [0m[38;5;252mread[39m [38;5;252m/tmp/TestQuestionsE2EFourReadsInOneBatchA…ermissionFrame272754868/002/q-group/h.txt[39m [1m[38;5;186m?[0m
+ [38;5;103m▪ [38;5;243m 10s[39m [38;5;235m↑[38;5;243m 14.2k[39m [38;5;235m↓[38;5;243m 240[39m
+
+[38;5;243m─ [38;5;186mq-group · deepseek/deepseek-v4-flash · ⠿ auto · via wafer · main[38;5;243m ───────────────── [38;5;116mesc[38;5;243m interrupt ─
+╭─[39m [38;5;243m✓ e.txt[39m [38;5;243m✓ f.txt[39m [38;5;243m✓ g.txt[39m [38;5;243m✓ h.txt[39m [1m[38;5;186m●[0m [38;5;252mreview[39m [38;5;243m──────────────────────────────────────[39m [38;5;243mreview[39m [38;5;243m─╮
+│[39m [38;5;243m│
+│[39m [38;5;243m✓[39m [38;5;252me.txt[39m [38;5;252mallow once[39m [38;5;243m│
+│[39m [38;5;243m✓[39m [38;5;252mf.txt[39m [38;5;252mallow once[39m [38;5;243m│
+│[39m [38;5;243m✓[39m [38;5;252mg.txt[39m [38;5;252mallow once[39m [38;5;243m│
+│[39m [38;5;243m✓[39m [38;5;252mh.txt[39m [38;5;252mdeny[39m [38;5;243m│
+│[39m [38;5;243m│
+│[39m[48;5;237m [1m[38;5;186m▸[0m[48;5;237m [38;5;252msend all 4[39m [38;5;243mthe turn resumes with every answer[39m [38;5;243m[49m│
+│[39m [38;5;243m│
+╰─[39m [38;5;116menter[38;5;243m send · [38;5;116m←[38;5;243m back · [38;5;116mesc[38;5;243m later[39m [38;5;243m────────────────────────────────────────────────────────────────╯
+[39m [38;5;243m› [39m
+ [38;5;243m$0.0027 14.2k/1M · 1% [38;5;173mYOLO[39m [38;5;243mrunning read · 10s · [38;5;186m? 4 questions · alt+a[38;5;243m · [1m[38;5;186mwaiting · your call
diff --git a/docs/design/questions/screens/four-reads-in-one-batch-are-one-permission-frame-review.png b/docs/design/questions/screens/four-reads-in-one-batch-are-one-permission-frame-review.png
new file mode 100644
index 0000000000..6d5d2d9ba4
Binary files /dev/null and b/docs/design/questions/screens/four-reads-in-one-batch-are-one-permission-frame-review.png differ
diff --git a/docs/design/questions/screens/four-reads-in-one-batch-are-one-permission-frame-sent.ansi b/docs/design/questions/screens/four-reads-in-one-batch-are-one-permission-frame-sent.ansi
new file mode 100644
index 0000000000..cebd8b8f6f
--- /dev/null
+++ b/docs/design/questions/screens/four-reads-in-one-batch-are-one-permission-frame-sent.ansi
@@ -0,0 +1,36 @@
+ [1m[38;5;110maforge[0m [38;5;186m1 want you[39m · [38;5;115m$0.0027 / $500[39m · [38;5;243mfri 10:33pm[39m
+ [38;5;243m Home [1m[38;5;237m[48;5;252m ◐ new conversation × [0m[38;5;243m + [39m
+[38;5;243m────────────────────────────────────────────────────────────────────────────────────────────────────
+
+
+[39m [38;5;243m· still yours to set · [38;5;146m[48;5;237m/budget[38;5;243m[49m sets what aforge may spend · [38;5;146m[48;5;237m/model[38;5;243m[49m and [38;5;146m[48;5;237m/crew[38;5;243m[49m pick the models[39m
+ [38;5;243m· [38;5;116mesc[38;5;243m interrupts · [38;5;116mctrl+c[38;5;243m twice quits · [38;5;116m?[38;5;243m for help[39m
+
+ [38;5;146m› [38;5;110mRead [4m]8;;file:///tmp/TestQuestionsE2EFourReadsInOneBatchAreOnePermissionFrame272754868/002/q-group/e.txt\/tmp/TestQuestionsE2EFourReadsInOneBatchAreOnePermissionFrame272754868/002/q-[0m]8;;\
+ [4m[38;5;110mgroup/e.txt[0m[38;5;110m, [4m]8;;file:///tmp/TestQuestionsE2EFourReadsInOneBatchAreOnePermissionFrame272754868/002/q-group/f.txt\/tmp/TestQuestionsE2EFourReadsInOneBatchAreOnePermissionFrame272754868/002/q-[0m]8;;\
+ [4m[38;5;110mgroup/f.txt[0m[38;5;110m, [4m]8;;file:///tmp/TestQuestionsE2EFourReadsInOneBatchAreOnePermissionFrame272754868/002/q-group/g.txt\/tmp/TestQuestionsE2EFourReadsInOneBatchAreOnePermissionFrame272754868/002/q-[0m]8;;\
+ [4m[38;5;110mgroup/g.txt[0m[38;5;110m, [4m]8;;file:///tmp/TestQuestionsE2EFourReadsInOneBatchAreOnePermissionFrame272754868/002/q-group/h.txt\/tmp/TestQuestionsE2EFourReadsInOneBatchAreOnePermissionFrame272754868/002/q-[0m]8;;\
+ [4m[38;5;110mgroup/h.txt[0m[38;5;110m with the read tool — all four read calls in the same message, at once, not one[39m
+ [38;5;110mafter another — and run nothing else. Then tell me each file's first line.[39m
+
+ [38;5;243m▾ [38;5;103mread 4 files in[39m [38;5;243m4 calls[39m
+ [38;5;243m [38;5;103m/tmp/TestQuestionsE2EFourReadsInOneBatchAreOnePermissionFrame272754868/002/q-group[39m
+ [38;5;243m├─▶ [38;5;110mread[39m [4m[38;5;252m]8;;file:///tmp/TestQuestionsE2EFourReadsInOneBatchAreOnePermissionFrame272754868/002/q-group/f.txt\/tmp/TestQuestionsE2EFourReadsInOn…nFrame272754868/002/q-group/f.txt[0m]8;;\ [38;5;243m1 line[39m · [38;5;243mallowed[39m [38;5;252m❮
+[39m [38;5;243m├─▶ [38;5;110mread[39m [4m[38;5;252m]8;;file:///tmp/TestQuestionsE2EFourReadsInOneBatchAreOnePermissionFrame272754868/002/q-group/g.txt\/tmp/TestQuestionsE2EFourReadsInOn…nFrame272754868/002/q-group/g.txt[0m]8;;\ [38;5;243m1 line[39m · [38;5;243mallowed[39m
+ [38;5;243m╰─▶ [38;5;110mread[39m [4m[38;5;252m]8;;file:///tmp/TestQuestionsE2EFourReadsInOneBatchAreOnePermissionFrame272754868/002/q-group/h.txt\/tmp/TestQuestionsE2EFourReadsInO…nFrame272754868/002/q-group/h.txt[0m]8;;\ [38;5;173m✕[39m [38;5;243m1 line[39m · [38;5;243mdenied[39m
+ [38;5;243m│ [38;5;102mdenied by the person: tool "read"[39m
+ [38;5;103m▪ [38;5;243m 12s[39m [38;5;235m↑[38;5;243m 14.2k[39m [38;5;235m↓[38;5;243m 240[39m
+
+
+
+
+
+
+
+
+
+[38;5;243m─ q-group · deepseek/deepseek-v4-flash · ⠿ auto · via wafer · main ───────────────── [38;5;116mesc[38;5;243m interrupt ─
+ ✓ needs your ok to run read → allow once · you · 22:33 · c change · ⠧ working[39m
+[38;5;243m ✓ needs your ok to run read → deny · you · 22:33 · c change · ⠧ working[39m
+ [38;5;243m› [39m
+ [38;5;243m$0.0027 14.2k/1M · 1% [38;5;173mYOLO[39m [38;5;146m⠧[39m [38;5;146mworking[38;5;243m · [38;5;146m14s
diff --git a/docs/design/questions/screens/four-reads-in-one-batch-are-one-permission-frame-sent.png b/docs/design/questions/screens/four-reads-in-one-batch-are-one-permission-frame-sent.png
new file mode 100644
index 0000000000..87ee21f6e1
Binary files /dev/null and b/docs/design/questions/screens/four-reads-in-one-batch-are-one-permission-frame-sent.png differ
diff --git a/docs/design/questions/screens/two-questions-in-one-step-are-one-panel-with-tabs-raised.ansi b/docs/design/questions/screens/two-questions-in-one-step-are-one-panel-with-tabs-raised.ansi
new file mode 100644
index 0000000000..4a5d10ce29
--- /dev/null
+++ b/docs/design/questions/screens/two-questions-in-one-step-are-one-panel-with-tabs-raised.ansi
@@ -0,0 +1,36 @@
+ [1m[38;5;110maforge[0m [38;5;243mfri 10:33pm[39m
+ [38;5;243m Home [1m[38;5;237m[48;5;252m ◐ new conversation × [0m[38;5;243m + [39m
+[38;5;243m────────────────────────────────────────────────────────────────────────────────────────────────────
+
+
+[39m [38;5;243m· still yours to set · [38;5;146m[48;5;237m/budget[38;5;243m[49m sets what aforge may spend · [38;5;146m[48;5;237m/model[38;5;243m[49m and [38;5;146m[48;5;237m/crew[38;5;243m[49m pick the models[39m
+ [38;5;243m· [38;5;116mesc[38;5;243m interrupts · [38;5;116mctrl+c[38;5;243m twice quits · [38;5;116m?[38;5;243m for help[39m
+
+ [38;5;146m› [38;5;110mCall the ask tool TWICE IN THE SAME MESSAGE — two tool calls at once, not one after the other[39m
+ [38;5;110m— and run nothing else. The first: head "read package.json?", kind permission, form line,[39m
+ [38;5;110mreason "the dependency list decides the rest", stakes reversible, options[39m
+ [38;5;110m[{"key":"1","label":"allow once"},{"key":"2","label":"skip it"}]. The second: head "read[39m
+ [38;5;110mgo.mod?", kind permission, form line, reason "the module path decides the import", stakes[39m [38;5;252m❮
+[39m [38;5;110mreversible, options [{"key":"1","label":"allow once"},{"key":"2","label":"skip it"}].[39m
+
+ [38;5;235m$ load_capability 1 call[39m
+ [38;5;238m» ask 2 calls[39m
+ [38;5;103m▪ [39m [38;5;238m+9.2k[39m [38;5;238m↑[38;5;103m 14k[39m [38;5;238m↓[38;5;103m 46[39m
+
+
+
+
+[38;5;243m─ q-tabs · deepseek/deepseek-v4-flash · ⠿ auto · via wafer · main ────────────────── [38;5;116mesc[38;5;243m interrupt ─
+╭─[39m [1m[38;5;186m●[0m [38;5;252mgo.mod[39m [38;5;243m○ package.json[39m [38;5;243m○ review[39m [38;5;243m──────────────────────────────────────────────────[39m [38;5;243m1 of 2[39m [38;5;243m─╮
+│[39m [38;5;243m│
+│[39m [1m[38;5;186m?[0m [38;5;252mread go.mod?[39m [38;5;243m│
+│[39m [38;5;243mthe module path decides the import[39m [38;5;243m│
+│[39m [38;5;243m│
+│[39m[48;5;237m [1m[38;5;186m▸[0m[48;5;237m [38;5;116m1[39m [38;5;252mallow once[39m [38;5;243m[49m│
+│[39m [38;5;116m2[39m [38;5;252mskip it [39m [38;5;243m│
+│[39m [38;5;116m3[39m [38;5;252msomething else…[39m [38;5;243m│
+│[39m [38;5;243m│
+╰─[39m [38;5;116m←→[38;5;243m question · [38;5;116m↑↓[38;5;243m choose · [38;5;116menter[38;5;243m take it · [38;5;116mesc[38;5;243m later[39m [38;5;243m────────────────────────────────────────────╯
+[39m [38;5;116mc[38;5;243m change · [38;5;116m?[38;5;243m ask back · [38;5;116md[38;5;243m you decide · [38;5;116mD[38;5;243m decide these from now on · [38;5;116m1–2[38;5;243m jump[39m
+ [38;5;243m› [39m
+ [38;5;252m$0.0014[38;5;243m [38;5;252m14k/1M · 1%[38;5;243m [38;5;173mYOLO[39m [38;5;243mrunning ask · [38;5;186m? 2 questions · alt+a[38;5;243m · [38;5;146m⠇[39m [38;5;146mworking[38;5;243m · [38;5;146m2s
diff --git a/docs/design/questions/screens/two-questions-in-one-step-are-one-panel-with-tabs-raised.png b/docs/design/questions/screens/two-questions-in-one-step-are-one-panel-with-tabs-raised.png
new file mode 100644
index 0000000000..cdb667c3c5
Binary files /dev/null and b/docs/design/questions/screens/two-questions-in-one-step-are-one-panel-with-tabs-raised.png differ
diff --git a/docs/design/questions/screens/two-questions-in-one-step-are-one-panel-with-tabs-review-empty.ansi b/docs/design/questions/screens/two-questions-in-one-step-are-one-panel-with-tabs-review-empty.ansi
new file mode 100644
index 0000000000..6605b48d7a
--- /dev/null
+++ b/docs/design/questions/screens/two-questions-in-one-step-are-one-panel-with-tabs-review-empty.ansi
@@ -0,0 +1,36 @@
+ [1m[38;5;110maforge[0m [38;5;243mfri 10:33pm[39m
+ [38;5;243m Home [1m[38;5;237m[48;5;252m ◐ new conversation × [0m[38;5;243m + [39m
+[38;5;243m────────────────────────────────────────────────────────────────────────────────────────────────────
+
+
+[39m [38;5;243m· still yours to set · [38;5;146m[48;5;237m/budget[38;5;243m[49m sets what aforge may spend · [38;5;146m[48;5;237m/model[38;5;243m[49m and [38;5;146m[48;5;237m/crew[38;5;243m[49m pick the models[39m
+ [38;5;243m· [38;5;116mesc[38;5;243m interrupts · [38;5;116mctrl+c[38;5;243m twice quits · [38;5;116m?[38;5;243m for help[39m
+
+ [38;5;146m› [38;5;110mCall the ask tool TWICE IN THE SAME MESSAGE — two tool calls at once, not one after the other[39m
+ [38;5;110m— and run nothing else. The first: head "read package.json?", kind permission, form line,[39m
+ [38;5;110mreason "the dependency list decides the rest", stakes reversible, options[39m
+ [38;5;110m[{"key":"1","label":"allow once"},{"key":"2","label":"skip it"}]. The second: head "read[39m
+ [38;5;110mgo.mod?", kind permission, form line, reason "the module path decides the import", stakes[39m
+ [38;5;110mreversible, options [{"key":"1","label":"allow once"},{"key":"2","label":"skip it"}].[39m
+
+ [38;5;235m$ load_capability 1 call[39m [38;5;252m❮
+[39m [38;5;238m» ask 2 calls[39m
+ [38;5;103m▪ [39m [38;5;235m↑[38;5;243m 15.6k[39m [38;5;235m↓[38;5;243m 352[39m
+
+
+
+
+
+
+
+
+
+[38;5;243m─ q-tabs · deepseek/deepseek-v4-flash · ⠿ auto · via wafer · main ────────────────── [38;5;116mesc[38;5;243m interrupt ─
+╭─[39m [38;5;243m○ go.mod[39m [38;5;243m○ package.json[39m [1m[38;5;186m●[0m [38;5;252mreview[39m [38;5;243m──────────────────────────────────────────────────[39m [38;5;243mreview[39m [38;5;243m─╮
+│[39m [38;5;243m│
+│[39m [38;5;243m○[39m [38;5;252mgo.mod[39m [38;5;243mnot answered — ← to go back[39m [38;5;243m│
+│[39m [38;5;243m○[39m [38;5;252mpackage.json[39m [38;5;243mnot answered — ← to go back[39m [38;5;243m│
+│[39m [38;5;243m│
+╰─[39m [38;5;116m←[38;5;243m back · [38;5;116mesc[38;5;243m later[39m [38;5;243m─────────────────────────────────────────────────────────────────────────────╯
+[39m [38;5;243m› [39m
+ [38;5;252m$0.0026[38;5;243m · ⟲ 28% cached [38;5;252m15.6k/1M · 1%[38;5;243m [38;5;173mYOLO[39m [38;5;186m? 2 questions · alt+a[38;5;243m · [38;5;146m⠏[39m [38;5;146mworking[38;5;243m · [38;5;146m6s
diff --git a/docs/design/questions/screens/two-questions-in-one-step-are-one-panel-with-tabs-review-empty.png b/docs/design/questions/screens/two-questions-in-one-step-are-one-panel-with-tabs-review-empty.png
new file mode 100644
index 0000000000..2edfbfec2a
Binary files /dev/null and b/docs/design/questions/screens/two-questions-in-one-step-are-one-panel-with-tabs-review-empty.png differ
diff --git a/docs/design/questions/screens/two-questions-in-one-step-are-one-panel-with-tabs-review-held.ansi b/docs/design/questions/screens/two-questions-in-one-step-are-one-panel-with-tabs-review-held.ansi
new file mode 100644
index 0000000000..1f4fefb346
--- /dev/null
+++ b/docs/design/questions/screens/two-questions-in-one-step-are-one-panel-with-tabs-review-held.ansi
@@ -0,0 +1,36 @@
+ [1m[38;5;110maforge[0m [38;5;186m1 want you[39m · [38;5;115m$0.0072 / $500[39m · [38;5;243mfri 10:33pm[39m
+ [38;5;243m Home [1m[38;5;237m[48;5;252m ◐ parallel permission × [0m[38;5;243m + [39m
+[38;5;243m────────────────────────────────────────────────────────────────────────────────────────────────────
+
+
+[39m [38;5;243m· still yours to set · [38;5;146m[48;5;237m/budget[38;5;243m[49m sets what aforge may spend · [38;5;146m[48;5;237m/model[38;5;243m[49m and [38;5;146m[48;5;237m/crew[38;5;243m[49m pick the models[39m
+ [38;5;243m· [38;5;116mesc[38;5;243m interrupts · [38;5;116mctrl+c[38;5;243m twice quits · [38;5;116m?[38;5;243m for help[39m
+
+ [38;5;146m› [38;5;110mCall the ask tool TWICE IN THE SAME MESSAGE — two tool calls at once, not one after the other[39m
+ [38;5;110m— and run nothing else. The first: head "read package.json?", kind permission, form line,[39m
+ [38;5;110mreason "the dependency list decides the rest", stakes reversible, options[39m
+ [38;5;110m[{"key":"1","label":"allow once"},{"key":"2","label":"skip it"}]. The second: head "read[39m
+ [38;5;110mgo.mod?", kind permission, form line, reason "the module path decides the import", stakes[39m
+ [38;5;110mreversible, options [{"key":"1","label":"allow once"},{"key":"2","label":"skip it"}].[39m
+ [38;5;252m❮
+[39m [38;5;235m$ load_capability 1 call[39m
+ [38;5;238m» ask 2 calls[39m
+ [38;5;103m▪ [38;5;243m 10s[39m [38;5;235m↑[38;5;243m 15.6k[39m [38;5;235m↓[38;5;243m 997[39m
+
+
+
+
+
+
+
+[38;5;243m─ parallel permission request… · deepseek/deepseek-v4-flash · ⠿ auto · via wafer ─── [38;5;116mesc[38;5;243m interrupt ─
+╭─[39m [38;5;243m✓ go.mod[39m [38;5;243m✓ package.json[39m [1m[38;5;186m●[0m [38;5;252mreview[39m [38;5;243m──────────────────────────────────────────────────[39m [38;5;243mreview[39m [38;5;243m─╮
+│[39m [38;5;243m│
+│[39m [38;5;243m✓[39m [38;5;252mgo.mod[39m [38;5;252mallow once[39m [38;5;243m│
+│[39m [38;5;243m✓[39m [38;5;252mpackage.json[39m [38;5;252mallow once[39m [38;5;243m│
+│[39m [38;5;243m│
+│[39m[48;5;237m [1m[38;5;186m▸[0m[48;5;237m [38;5;252msend all 2[39m [38;5;243mthe turn resumes with every answer[39m [38;5;243m[49m│
+│[39m [38;5;243m│
+╰─[39m [38;5;116menter[38;5;243m send · [38;5;116m←[38;5;243m back · [38;5;116mesc[38;5;243m later[39m [38;5;243m────────────────────────────────────────────────────────────────╯
+[39m [38;5;243m› [39m
+ [38;5;110m$0.0072[38;5;243m · [38;5;110m⟲ 26% cached[38;5;243m 15.6k/1M · 1% [38;5;173mYOLO[39m [38;5;186m? 2 questions · alt+a[38;5;243m · [38;5;146m⠙[39m [38;5;146mworking[38;5;243m · [38;5;146m13s
diff --git a/docs/design/questions/screens/two-questions-in-one-step-are-one-panel-with-tabs-review-held.png b/docs/design/questions/screens/two-questions-in-one-step-are-one-panel-with-tabs-review-held.png
new file mode 100644
index 0000000000..1b55aea64a
Binary files /dev/null and b/docs/design/questions/screens/two-questions-in-one-step-are-one-panel-with-tabs-review-held.png differ
diff --git a/docs/design/questions/screens/two-questions-in-one-step-arrive-as-a-sheet-sent.ansi b/docs/design/questions/screens/two-questions-in-one-step-are-one-panel-with-tabs-sent.ansi
similarity index 54%
rename from docs/design/questions/screens/two-questions-in-one-step-arrive-as-a-sheet-sent.ansi
rename to docs/design/questions/screens/two-questions-in-one-step-are-one-panel-with-tabs-sent.ansi
index b17f3733f7..97be4472d1 100644
--- a/docs/design/questions/screens/two-questions-in-one-step-arrive-as-a-sheet-sent.ansi
+++ b/docs/design/questions/screens/two-questions-in-one-step-are-one-panel-with-tabs-sent.ansi
@@ -1,8 +1,8 @@
-
- [38;5;243m Home [1m[38;5;237m[48;5;252m ◐ footing two × [0m[38;5;243m + [39m
-
+ [1m[38;5;110maforge[0m [38;5;186m1 want you[39m · [38;5;115m$0.0072 / $500[39m · [38;5;243mfri 10:33pm[39m
+ [38;5;243m Home [1m[38;5;237m[48;5;252m parallel permission × [0m[38;5;243m + [39m
[38;5;243m────────────────────────────────────────────────────────────────────────────────────────────────────
+
[39m [38;5;243m· still yours to set · [38;5;146m[48;5;237m/budget[38;5;243m[49m sets what aforge may spend · [38;5;146m[48;5;237m/model[38;5;243m[49m and [38;5;146m[48;5;237m/crew[38;5;243m[49m pick the models[39m
[38;5;243m· [38;5;116mesc[38;5;243m interrupts · [38;5;116mctrl+c[38;5;243m twice quits · [38;5;116m?[38;5;243m for help[39m
@@ -13,12 +13,12 @@
[38;5;110mgo.mod?", kind permission, form line, reason "the module path decides the import", stakes[39m
[38;5;110mreversible, options [{"key":"1","label":"allow once"},{"key":"2","label":"skip it"}].[39m
- [38;5;238m$ load_capability 1 call[39m
- [38;5;241m» ask 2 calls[39m [38;5;235m↑[38;5;243m 32.7k[39m [38;5;235m↓[38;5;243m 625[39m [38;5;252m❮
+ [38;5;243m▸ worked 16s · 2 steps · 3 tool calls · ctrl+e[39m
-[39m [38;5;243m· 1 more question answered the same way[39m
- [38;5;103mWorking[38;5;243m first word · 0.7s[39m
+ [38;5;252mBoth allowed once.[39m [38;5;252m❮
+[39m [38;5;243m· ⟲ 23.3k cached · saved $0.0012[39m
+ [38;5;110m· 22:33 · 16s · 3 tool calls · $0.0080 ·[39m
@@ -28,9 +28,9 @@
-[38;5;243m─ main ───────────────────────────────────────────────────────────────────────────── [38;5;116mesc[38;5;243m interrupt ─
- decided read package.json? → allow once · you · 21:25 · c change[39m
-[38;5;243m decided read go.mod? → allow once · you · 21:25 · c change[39m
+[38;5;243m─ parallel permission… · deepseek/deepseek-v4-flash · ⠿ auto · via wafer ─── [38;5;116m/[38;5;243m shows every command ─
+ ✓ read go.mod? → allow once · you · 22:33 · c change[39m
+[38;5;243m ✓ read package.json? → allow once · you · 22:33 · c change[39m
[38;5;243m› [39m
-[38;5;243mfooting two tool calls · deepseek-v4-flash · via deepinfra[39m [38;5;243m5.5k/1M · 1% · [38;5;173mYOLO[38;5;243m · [38;5;146m⠹[39m [38;5;146mworking[38;5;243m · [38;5;146m20s
+ [38;5;252m$0.0080[38;5;243m · [38;5;252m⟲ saved $0.0012 · 50% cached[38;5;243m [38;5;252m15.8k/1M · 2%[38;5;243m [38;5;173mYOLO[39m [38;5;243midle
diff --git a/docs/design/questions/screens/two-questions-in-one-step-are-one-panel-with-tabs-sent.png b/docs/design/questions/screens/two-questions-in-one-step-are-one-panel-with-tabs-sent.png
new file mode 100644
index 0000000000..26055d6466
Binary files /dev/null and b/docs/design/questions/screens/two-questions-in-one-step-are-one-panel-with-tabs-sent.png differ
diff --git a/docs/design/questions/screens/two-questions-in-one-step-arrive-as-a-sheet-answered.ansi b/docs/design/questions/screens/two-questions-in-one-step-arrive-as-a-sheet-answered.ansi
deleted file mode 100644
index 48654b7539..0000000000
--- a/docs/design/questions/screens/two-questions-in-one-step-arrive-as-a-sheet-answered.ansi
+++ /dev/null
@@ -1,36 +0,0 @@
-
- [38;5;243m Home [1m[38;5;237m[48;5;252m ◐ footing two × [0m[38;5;243m + [39m
-
-[38;5;243m────────────────────────────────────────────────────────────────────────────────────────────────────
-
-[39m [38;5;243m· still yours to set · [38;5;146m[48;5;237m/budget[38;5;243m[49m sets what aforge may spend · [38;5;146m[48;5;237m/model[38;5;243m[49m and [38;5;146m[48;5;237m/crew[38;5;243m[49m pick the models[39m
- [38;5;243m· [38;5;116mesc[38;5;243m interrupts · [38;5;116mctrl+c[38;5;243m twice quits · [38;5;116m?[38;5;243m for help[39m
-
- [38;5;146m› [38;5;110mCall the ask tool TWICE IN THE SAME MESSAGE — two tool calls at once, not one after the other[39m
- [38;5;110m— and run nothing else. The first: head "read package.json?", kind permission, form line,[39m
- [38;5;110mreason "the dependency list decides the rest", stakes reversible, options[39m
- [38;5;110m[{"key":"1","label":"allow once"},{"key":"2","label":"skip it"}]. The second: head "read[39m
- [38;5;110mgo.mod?", kind permission, form line, reason "the module path decides the import", stakes[39m
- [38;5;110mreversible, options [{"key":"1","label":"allow once"},{"key":"2","label":"skip it"}].[39m
-
- [38;5;238m$ load_capability 1 call[39m [38;5;252m❮
-[39m [38;5;103m» ask 2 calls[39m [38;5;235m↑[38;5;243m 32.7k[39m [38;5;235m↓[38;5;243m 625[39m
-
-
-
-
-
-
-
-
-
-
-[38;5;243m─ main ───────────────────────────────────────────────────────────────────────────── [38;5;116mesc[38;5;243m interrupt ─
-[1m[38;5;186m?[0m [38;5;140m2 questions raised together[39m
-[38;5;243m asking permission[39m
-[38;5;140m [38;5;243m✓[38;5;140m read package.json?[39m [38;5;243mallow once[39m
-[38;5;140m▸ [1m[38;5;186m?[0m[38;5;140m read go.mod?[39m [38;5;140m1 allow once · 2 skip it[39m
-[38;5;140m [enter][1m open it · [0m[38;5;140m[s][1m send what is answered (1) · [0m[38;5;140m[g][1m same answer for all like this · [0m[38;5;140m[esc][1m later[0m
-
- [38;5;243m› [39m
-[38;5;243mfooting two tool calls · deepseek-v4-flash[39m [38;5;173mYOLO[38;5;243m · [38;5;186m? 2 questions · alt+a[38;5;243m · [38;5;146m⠼[39m [38;5;146mworking[38;5;243m · [38;5;146m15s
diff --git a/docs/design/questions/screens/two-questions-in-one-step-arrive-as-a-sheet-answered.png b/docs/design/questions/screens/two-questions-in-one-step-arrive-as-a-sheet-answered.png
deleted file mode 100644
index ce88ea3ef8..0000000000
Binary files a/docs/design/questions/screens/two-questions-in-one-step-arrive-as-a-sheet-answered.png and /dev/null differ
diff --git a/docs/design/questions/screens/two-questions-in-one-step-arrive-as-a-sheet-raised.ansi b/docs/design/questions/screens/two-questions-in-one-step-arrive-as-a-sheet-raised.ansi
deleted file mode 100644
index 8d6d701e74..0000000000
--- a/docs/design/questions/screens/two-questions-in-one-step-arrive-as-a-sheet-raised.ansi
+++ /dev/null
@@ -1,36 +0,0 @@
-
- [38;5;243m Home [1m[38;5;237m[48;5;252m ◐ footing two × [0m[38;5;243m + [39m
-
-[38;5;243m────────────────────────────────────────────────────────────────────────────────────────────────────
-
-[39m [38;5;243m· still yours to set · [38;5;146m[48;5;237m/budget[38;5;243m[49m sets what aforge may spend · [38;5;146m[48;5;237m/model[38;5;243m[49m and [38;5;146m[48;5;237m/crew[38;5;243m[49m pick the models[39m
- [38;5;243m· [38;5;116mesc[38;5;243m interrupts · [38;5;116mctrl+c[38;5;243m twice quits · [38;5;116m?[38;5;243m for help[39m
-
- [38;5;146m› [38;5;110mCall the ask tool TWICE IN THE SAME MESSAGE — two tool calls at once, not one after the other[39m
- [38;5;110m— and run nothing else. The first: head "read package.json?", kind permission, form line,[39m
- [38;5;110mreason "the dependency list decides the rest", stakes reversible, options[39m
- [38;5;110m[{"key":"1","label":"allow once"},{"key":"2","label":"skip it"}]. The second: head "read[39m
- [38;5;110mgo.mod?", kind permission, form line, reason "the module path decides the import", stakes[39m
- [38;5;110mreversible, options [{"key":"1","label":"allow once"},{"key":"2","label":"skip it"}].[39m
-
- [38;5;238m$ load_capability 1 call[39m [38;5;252m❮
-[39m [38;5;103m» ask 2 calls[39m [38;5;235m↑[38;5;243m 32.7k[39m [38;5;235m↓[38;5;243m 625[39m
-
-
-
-
-
-
-
-
-
-
-[38;5;243m─ main ───────────────────────────────────────────────────────────────────────────── [38;5;116mesc[38;5;243m interrupt ─
-[1m[38;5;186m?[0m [38;5;140m2 questions raised together[39m
-[38;5;243m asking permission[39m
-[38;5;140m▸ [1m[38;5;186m?[0m[38;5;140m read package.json?[39m [38;5;140m1 allow once · 2 skip it[39m
-[38;5;140m [1m[38;5;186m?[0m[38;5;140m read go.mod?[39m [38;5;140m1 allow once · 2 skip it[39m
-[38;5;140m [enter][1m open it · [0m[38;5;140m[s][1m send what is answered · [0m[38;5;140m[esc][1m later[0m
-
- [38;5;243m› [39m
-[38;5;243mfooting two tool calls · deepseek-v4-flash[39m [38;5;173mYOLO[38;5;243m · [38;5;186m? 2 questions · alt+a[38;5;243m · [38;5;146m⠴[39m [38;5;146mworking[38;5;243m · [38;5;146m12s
diff --git a/docs/design/questions/screens/two-questions-in-one-step-arrive-as-a-sheet-raised.png b/docs/design/questions/screens/two-questions-in-one-step-arrive-as-a-sheet-raised.png
deleted file mode 100644
index 57fa644f80..0000000000
Binary files a/docs/design/questions/screens/two-questions-in-one-step-arrive-as-a-sheet-raised.png and /dev/null differ
diff --git a/docs/design/questions/screens/two-questions-in-one-step-arrive-as-a-sheet-sent.png b/docs/design/questions/screens/two-questions-in-one-step-arrive-as-a-sheet-sent.png
deleted file mode 100644
index 42fed41311..0000000000
Binary files a/docs/design/questions/screens/two-questions-in-one-step-arrive-as-a-sheet-sent.png and /dev/null differ
diff --git a/docs/design/questions/screens/two-questions-in-one-step-arrive-as-a-sheet-spread.ansi b/docs/design/questions/screens/two-questions-in-one-step-arrive-as-a-sheet-spread.ansi
deleted file mode 100644
index 43641150b4..0000000000
--- a/docs/design/questions/screens/two-questions-in-one-step-arrive-as-a-sheet-spread.ansi
+++ /dev/null
@@ -1,36 +0,0 @@
-
- [38;5;243m Home [1m[38;5;237m[48;5;252m ◐ footing two × [0m[38;5;243m + [39m
-
-[38;5;243m────────────────────────────────────────────────────────────────────────────────────────────────────
-
-[39m [38;5;243m· still yours to set · [38;5;146m[48;5;237m/budget[38;5;243m[49m sets what aforge may spend · [38;5;146m[48;5;237m/model[38;5;243m[49m and [38;5;146m[48;5;237m/crew[38;5;243m[49m pick the models[39m
- [38;5;243m· [38;5;116mesc[38;5;243m interrupts · [38;5;116mctrl+c[38;5;243m twice quits · [38;5;116m?[38;5;243m for help[39m
-
- [38;5;146m› [38;5;110mCall the ask tool TWICE IN THE SAME MESSAGE — two tool calls at once, not one after the other[39m
- [38;5;110m— and run nothing else. The first: head "read package.json?", kind permission, form line,[39m
- [38;5;110mreason "the dependency list decides the rest", stakes reversible, options[39m
- [38;5;110m[{"key":"1","label":"allow once"},{"key":"2","label":"skip it"}]. The second: head "read[39m
- [38;5;110mgo.mod?", kind permission, form line, reason "the module path decides the import", stakes[39m
- [38;5;110mreversible, options [{"key":"1","label":"allow once"},{"key":"2","label":"skip it"}].[39m
-
- [38;5;238m$ load_capability 1 call[39m [38;5;252m❮
-[39m [38;5;103m» ask 2 calls[39m [38;5;235m↑[38;5;243m 32.7k[39m [38;5;235m↓[38;5;243m 625[39m
-
- [38;5;243m· 1 more question answered the same way[39m
-
-
-
-
-
-
-
-
-[38;5;243m─ main ───────────────────────────────────────────────────────────────────────────── [38;5;116mesc[38;5;243m interrupt ─
-[1m[38;5;186m?[0m [38;5;140m2 questions raised together[39m
-[38;5;243m asking permission[39m
-[38;5;140m [38;5;243m✓[38;5;140m read package.json?[39m [38;5;243mallow once[39m
-[38;5;140m▸ [38;5;243m✓[38;5;140m read go.mod?[39m [38;5;243mallow once[39m
-[38;5;140m [enter][1m open it · [0m[38;5;140m[s][1m send what is answered (2) · [0m[38;5;140m[esc][1m later[0m
-
- [38;5;243m› [39m
-[38;5;243mfooting two tool calls · deepseek-v4-flash[39m [38;5;173mYOLO[38;5;243m · [38;5;186m? 2 questions · alt+a[38;5;243m · [38;5;146m⠸[39m [38;5;146mworking[38;5;243m · [38;5;146m17s
diff --git a/docs/design/questions/screens/two-questions-in-one-step-arrive-as-a-sheet-spread.png b/docs/design/questions/screens/two-questions-in-one-step-arrive-as-a-sheet-spread.png
deleted file mode 100644
index 07f1609a90..0000000000
Binary files a/docs/design/questions/screens/two-questions-in-one-step-arrive-as-a-sheet-spread.png and /dev/null differ
diff --git a/internal/e2e/questions_e2e_test.go b/internal/e2e/questions_e2e_test.go
index 94ad307eed..78b1ed9f66 100644
--- a/internal/e2e/questions_e2e_test.go
+++ b/internal/e2e/questions_e2e_test.go
@@ -98,7 +98,8 @@ func TestQuestionsE2E(t *testing.T) {
t.Run("ADialIsMovedWithTheArrows", questionsDial)
t.Run("ARatifiedActSaysWhatItDidAndHowToUndoIt", questionsRatify)
t.Run("AssumptionsStandUntilOneIsStruck", questionsAssumption)
- t.Run("TwoQuestionsInOneStepArriveAsASheet", questionsSheet)
+ t.Run("TwoQuestionsInOneStepAreOnePanelWithTabs", questionsTabs)
+ t.Run("FourReadsInOneBatchAreOnePermissionFrame", questionsGroup)
t.Run("AnotherWindowAnswersAndTheFirstSaysWho", questionsReach)
t.Run("AQuestionWhoseSubjectWentAwayIsWithdrawn", questionsWithdrawn)
t.Run("AProjectRuleDecidesAChoiceAndTheRowWearsIt", questionsAutonomy)
@@ -819,11 +820,12 @@ func questionsAssumption(t *testing.T) {
// ── several at once ─────────────────────────────────────────────────────────
-// questionsSheet is BATCHED AT THE BOUNDARY: two questions raised inside one
-// step arrive together, `g` gives the answer just given to every row like it,
-// and `s` sends what is answered.
-func questionsSheet(t *testing.T) {
- r := questionRig(t, "q-sheet", nil)
+// questionsTabs is SEVERAL QUESTIONS FROM ONE STEP ARE ONE PANEL: two questions
+// the model asked in one message arrive as one panel with a tab each, `←→` move
+// between them, `enter` holds an answer rather than sending it, and the review
+// sends every held answer through the one door in one command.
+func questionsTabs(t *testing.T) {
+ r := questionRig(t, "q-tabs", nil)
steer(t, r, `Call the ask tool TWICE IN THE SAME MESSAGE — two tool calls at once, `+
`not one after the other — and run nothing else. `+
`The first: head "read package.json?", kind permission, form line, `+
@@ -833,29 +835,91 @@ func questionsSheet(t *testing.T) {
`reason "the module path decides the import", stakes reversible, `+
`options [{"key":"1","label":"allow once"},{"key":"2","label":"skip it"}].`)
- screen := awaitQuestion(t, r, say(t, "questionSheetTogetherWord"))
- screenSays(t, screen, "read package.json?", "the first row")
- screenSays(t, screen, "read go.mod?", "the second row")
- screenSays(t, screen, say(t, "questionSheetSendWord"), "the key that sends the batch")
+ // ONE PANEL, A TAB EACH: the first question in the body, the second one's
+ // words in the top edge, and the key between them on the bottom edge.
+ screen := awaitQuestion(t, r, say(t, "questionTabKeyWord"))
+ screenSays(t, screen, "read package.json?", "the tab on screen")
+ screenSays(t, screen, "go.mod", "the second question's tab")
shot(t, r, "raised")
- press(t, r, "1")
- answered := r.capture()
- screenSays(t, answered, say(t, "questionSheetSameWord"),
- "an answered row is what makes `same answer for all like this` mean anything")
- shot(t, r, "answered")
+ press(t, r, "Right")
+ press(t, r, "Right")
+ review := r.capture()
+ screenSays(t, review, say(t, "questionNotAnsweredWord"),
+ "the review names a question with nothing held for it")
+ shot(t, r, "review-empty")
- press(t, r, "g")
- spread := r.capture()
- screenSays(t, spread, say(t, "questionSheetSpreadWord"), "how many rows took the same answer")
- shot(t, r, "spread")
+ press(t, r, "Left")
+ press(t, r, "Left")
+ press(t, r, "1")
+ press(t, r, "1")
+ held := r.capture()
+ screenSays(t, held, say(t, "questionSetSendWord"), "both answers held, and the review offers to send them")
+ shot(t, r, "review-held")
- press(t, r, "s")
+ press(t, r, "Enter")
sent := r.waitFor(30*time.Second, say(t, "questionReceiptWord"))
screenSays(t, sent, "allow once", "the receipt of what was sent")
shot(t, r, "sent")
}
+// questionsGroup is PERMISSIONS FROM ONE STEP ARE ONE FRAME: four reads the
+// model asked for in one message are one frame listing what each wants, with
+// `allow all 4 · one by one · deny all`; `one by one` opens the same four as
+// tabs, and the review sends what was held — an allow and a deny mixed — in one
+// command.
+//
+// ONLY `read` ASKS, so nothing else in the turn puts a question of its own in
+// front of the four. The files are named by their whole path because the
+// model's working folder is not always the project it was opened on, and a read
+// that failed for want of the file would still have asked.
+func questionsGroup(t *testing.T) {
+ r := questionRig(t, "q-group", map[string]any{"tools.approvalMode": "allow", "tools.approval": "read:prompt"})
+ paths := make([]string, 0, 4)
+ for _, name := range []string{"e", "f", "g", "h"} {
+ path := filepath.Join(r.ws, name+".txt")
+ if err := os.WriteFile(path, []byte("the "+name+" line\n"), 0o644); err != nil {
+ t.Fatalf("seed %s: %v", name, err)
+ }
+ paths = append(paths, path)
+ }
+ steer(t, r, `Read `+strings.Join(paths, ", ")+` with the read tool — all four read calls `+
+ `in the same message, at once, not one after another — and run nothing else. `+
+ `Then tell me each file's first line.`)
+
+ screen := awaitQuestion(t, r, say(t, "questionGroupAllowWord"))
+ for _, name := range []string{"e.txt", "f.txt", "g.txt", "h.txt"} {
+ screenSays(t, screen, name, "the frame lists what each call wants")
+ }
+ screenSays(t, screen, say(t, "questionGroupApartWord"), "the way to answer them one at a time")
+ // AND THE POINTER IS ON THE ANSWER THAT LOSES NOTHING, which is the whole of
+ // the deny-first ruling as a person meets it. Nothing here read the pointer
+ // until this line, so four screens captured before the ruling stayed in the
+ // repository showing `▸ allow all 4` and no test could tell.
+ screenSays(t, screen, say(t, "questionGroupSafeWord"), "the pointer opens on deny all, and says why")
+ shot(t, r, "raised")
+
+ press(t, r, "2")
+ tabs := r.capture()
+ screenSays(t, tabs, say(t, "questionTabKeyWord"), "one by one opens the same four as tabs")
+ shot(t, r, "one-by-one")
+
+ // Allow the first three and deny the last: the review holds a mix, and the
+ // deny goes through the same door as the allows.
+ press(t, r, "1")
+ press(t, r, "1")
+ press(t, r, "1")
+ press(t, r, "3")
+ review := r.capture()
+ screenSays(t, review, "send all 4", "all four held, and the review offers to send them")
+ shot(t, r, "review")
+
+ press(t, r, "Enter")
+ sent := r.waitFor(60*time.Second, say(t, "questionReceiptWord"))
+ screenSays(t, sent, "deny", "the receipt of the one that was denied")
+ shot(t, r, "sent")
+}
+
// ── two windows ─────────────────────────────────────────────────────────────
// questionsReach is FIRST ANSWER WINS across two terminals on one machine: the
diff --git a/internal/e2e/tuiwords_test.go b/internal/e2e/tuiwords_test.go
index 9ca8980d33..ef5ec9ec4e 100644
--- a/internal/e2e/tuiwords_test.go
+++ b/internal/e2e/tuiwords_test.go
@@ -910,23 +910,38 @@ var tuiWords = map[string]tuiWord{
screen: "only what differs is here",
why: "the compare table, built on the asker's own dimensions rather than on anything invented",
},
- "questionSheetTogetherWord": {
- screen: " raised together",
- why: "BATCHED AT THE BOUNDARY: the one thing about a sheet that is not visible from its rows " +
- "is that they arrived at the same moment, from one piece of work",
- },
- "questionSheetSendWord": {
- screen: "send what is answered",
- why: "a batch answered row by row and then not sent is a batch nobody answered",
- },
- "questionSheetSameWord": {
- screen: "same answer for all like this",
- why: "`g` spreads the answer just given across the rows in front of you and writes nothing down " +
- "— which is a different act from the room's `=`, that writes a rule about the future",
- },
- "questionSheetSpreadWord": {
- screen: " answered the same way",
- why: "and it says how many rows took it, because a spread nobody can count is a spread nobody can check",
+ "questionTabKeyWord": {
+ screen: "←→ question",
+ source: "question",
+ why: "SEVERAL QUESTIONS FROM ONE STEP ARE ONE PANEL: the key that moves between them is the one " +
+ "thing a set's bottom edge says that a panel of one does not (questionset.go)",
+ },
+ "questionNotAnsweredWord": {
+ screen: "not answered — ← to go back",
+ why: "the review names a question with nothing held for it and where it is answered, rather than " +
+ "sending a pick nobody pressed",
+ },
+ "questionSetSendWord": {
+ screen: "send all 2",
+ source: "send all ",
+ why: "the review's one answer: every held answer, in tab order, through the one door in one command",
+ },
+ "questionGroupAllowWord": {
+ screen: "allow all 4",
+ source: "allow all ",
+ why: "PERMISSIONS FROM ONE STEP ARE ONE FRAME: approving all of them is one answer on it, with " +
+ "the count of what it approves (questionset.go)",
+ },
+ "questionGroupApartWord": {
+ screen: "one by one",
+ why: "the frame's way back to answering each permission on its own, which opens them as tabs",
+ },
+ "questionGroupSafeWord": {
+ screen: "safe answer",
+ why: "DENY-FIRST UNTIL THE CALLS ARE GRADED: the pointer on a grouped permission opens on the " +
+ "answer that loses nothing, and the row says why it is there. Without this on the screen " +
+ "the suite never checked the pointer at all, and four captured screens went on showing " +
+ "`allow all` under it long after the ruling moved (questionpanel.go's questionSafeWord)",
},
"consentOldOfferWord": {
screen: "enter take it",
diff --git a/internal/manual/chat/permissions.md b/internal/manual/chat/permissions.md
index 69905e6688..1be8f8273a 100644
--- a/internal/manual/chat/permissions.md
+++ b/internal/manual/chat/permissions.md
@@ -85,9 +85,41 @@ this question says so on **home**, with the same three answers on the row —
not looking at can be answered from the dashboard (home's own page states the
limits, and what `2 always` banks when it is pressed there).
-When more than one question is queued a `N more` line appears. Questions are
-answered oldest first, and each gets its own countdown when it reaches the
-front.
+When questions from different steps are waiting, a `N more` line appears under
+the one on screen; they are answered oldest first, and each gets its own
+countdown when it reaches the front. **Approvals the same step asked for are not
+a queue** — they are one frame (the next section).
+
+## Several approvals at once — approve all of these, allow all, deny all, one by one
+
+When one batch of calls needs your ok more than once — four reads outside the
+project, say — they arrive as **one frame**: what each call wants, then three
+answers.
+
+```
+╭─ ? allow these 4? ───────────────────────────────────────── read ─╮
+│ read ~/notes/plan.md │
+│ read ~/notes/todo.md │
+│ read ~/notes/ideas.md │
+│ read ~/notes/log.md │
+│ │
+│ 1 allow all 4 │
+│ 2 one by one │
+│ ▸ 3 deny all safe answer │
+╰─ ↑↓ choose · enter take it · esc later ───────────────────────────╯
+```
+
+- **`1 allow all 4`** gives every one of them its own `allow once`, in one go.
+- **`3 deny all`** gives every one its own `deny`, and the pointer starts here,
+ marked `safe answer`, so `enter` on a frame you have not moved denies them all.
+- **`2 one by one`** answers nothing: it opens the same approvals as tabs, where
+ `←` `→` move between them and the last tab sends them all (questions.md,
+ *Several questions at once*).
+- **`esc`** puts all of them off; the work stays waiting.
+
+Each leaves its own receipt. There is no `how long` here, for the reason a
+single approval has none (*How long an answer lasts*). **An irreversible call
+never joins the frame**, and neither does an approval from another step.
## I pressed ctrl+t while it was asking — where did the question go, and did my typing answer it
diff --git a/internal/manual/chat/questions.md b/internal/manual/chat/questions.md
index 187157e0e4..709223544d 100644
--- a/internal/manual/chat/questions.md
+++ b/internal/manual/chat/questions.md
@@ -365,41 +365,62 @@ receipt reads `aforge, on your settings` — and what the model is told says in
so many words that the answer is provisional and you may still change it. That is
the next section.
-## Several questions at once — the sheet, and the same answer for all of them
+## Several questions at once — tabs, go back to the previous question, answer them all at once
-Quiet questions raised while one step is running do not land on top of each
-other. They wait for that step to end — the moment the model speaks again, or the
-turn finishes — and arrive together, grouped by the kind of decision each one is:
+When one step of the work raises more than one question — the model asks two or
+three things in the same breath, or a batch of calls each needs your ok — they
+are **one panel with a tab each**, not a pile. The questions that belong
+together are the ones the same step raised; a question from another step waits
+behind the panel as `N more`.
```
-? 4 questions raised together
- asking permission
- ▸ ✓ 1 read vendor/modernc.org? allow once
- ? 2 read vendor/golang.org/x?
- ? 3 write to .github/workflows?
- choosing
- ? 4 which index should this use?
- enter open it · s send what is answered · g same answer for all like this · esc later
+╭─ ● storage ○ naming ○ tests ✓ review ────────────── 1 of 3 ─╮
+│ │
+│ ? Which storage for the session index? │
+│ │
+│ ▸ 1 SQLite one file beside the conversation ◆ recommended │
+│ 2 JSONL append-only, no new dependency │
+│ 3 something else… │
+│ │
+╰─ ←→ question · ↑↓ choose · enter take it · esc later ─────────────╯
```
-`▸` is where you are, `✓` is a row you have answered with the answer you gave
-beside it, `?` is one still waiting.
-
-- `1`–`9` move to that row
-- `enter` opens that one on its own, with everything a question normally draws
-- `g` gives the row you are on the same answer as every other row of that kind
- **that offers that same answer** — and says how many it reached. Two questions
- whose second answer is `deny` on one and `always` on the other are not the same
- answer, and `g` will not treat them as one
-- `s` sends everything you answered and lets each remaining question take its
- own recommended answer. Anything nobody recommended an answer for stays open,
- and one line says `still needs you`. Every answer it sends leaves the same dim
- `decided …` line one answered on its own would
-- `esc` puts the whole sheet off; nothing is answered, the count does not drop,
- and the chip's key brings it back
-
-A question something is blocked on never waits for a boundary. It arrives at
-once, on its own.
+`●` is the question on screen, `○` one still waiting, `✓` one you have
+answered. Everything a single question does works on its tab.
+
+- **`←` and `→` go to the previous or next question.** They stop at the ends.
+- **`enter` or a digit holds that answer** — nothing is sent yet — and moves to
+ the next question still waiting. To change one, go back to its tab and pick
+ again.
+- **The last tab is the review**, and it sends everything at once:
+
+```
+╭─ ✓ storage ✓ naming ○ tests ● review ────────────── review ─╮
+│ │
+│ ✓ storage SQLite │
+│ ✓ naming session-index.db │
+│ ○ tests not answered — ← to go back │
+│ │
+│ ▸ send the 2 answered · 1 stays open │
+│ │
+╰─ enter send · ← back · esc later ─────────────────────────────────╯
+```
+
+`enter` on the review sends every held answer in one go, in tab order — `send
+all 3` when everything is answered. **A question you did not answer stays open**;
+nothing is picked for you, and with nothing answered the review offers no send.
+
+**`esc` puts the whole set off**: it folds to one rule, `? 3 questions · storage,
+naming, tests`, what you had answered stays held, and `space` or the chip brings
+every tab back. **Only a key on the panel holds an answer** — answer one from
+home, another window or its own page (`o`) and it goes at once, and its tab
+disappears.
+
+**What never joins a set:** anything irreversible, anything asking you to
+confirm, and a question whose `←` `→` move something (a dial, a choice inside a
+sentence). There is no "same answer for all of these" key on tabs; for
+permissions there is `allow all`, on permissions.md's grouped frame. Under sixty
+columns the questions come one at a time.
## The question disappeared — it vanished without me answering, withdrawal
@@ -411,11 +432,9 @@ dim line stays where it was:
⊘ allow this? — no longer needed · the turn moved on without it
```
-The count in the chip drops, and an open sheet loses that row and re-flows —
+The count in the chip drops, and an open set of tabs loses that tab —
**without moving anybody else's answer**, because an answer belongs to its
-question and not to the position it was drawn in. A question still gathering
-inside a step is dropped from that batch too, so a boundary never delivers a
-decision that stopped needing to be made.
+question and not to the position it was drawn in.
It is never called cancelled: nothing failed, and nobody decided anything. The
decision simply stopped needing to be made.
@@ -603,13 +622,11 @@ the row.
| `D` | decide questions like this from now on |
| `r` | make it a rule |
| `u` | undo, while what was done is still real |
-| `←` `→` | walk the two answers of a confirmation, or change what is in the hole where the question has one |
-| `s` | on a sheet: send what you answered, and let the rest take their own picks |
-| `g` | on a sheet: same answer for all like this |
+| `←` `→` | walk the two answers of a confirmation, or change what is in the hole where the question has one. On **tabs** — several questions from one step — they go to the previous or next question |
+| `enter` | on the **review** tab: send every answer you gave, at once |
-On a **sheet** — several questions that arrived together — `1`–`9` move to a row
-instead of answering, because the numbers on screen are the rows; `enter` opens
-the row you are on, and `s` and `g` are the two keys about the whole batch.
+On **tabs**, `enter` and the digits answer the question on screen and hold the
+answer until the review sends them all; `esc` puts the whole set off.
**Typing is answering.** The box under the block stays live. While there are
words in it every ordinary key belongs to the box, and pressing `enter` sends
@@ -1264,7 +1281,7 @@ picks one before anything is written.
While a beat is up, the digits belong to it — `3` is the third shape and not the
third answer — and `esc` backs out of the beat rather than putting the question
off.
-## Questions over the session host and on another machine — do questions work over --host, and on the engine behind an ordinary aforge
+## Questions over the session host and on another machine — do questions work over --host, and on the engine behind an ordinary aforge when I run it normally
**A question reaches you wherever the conversation is, and you answer it where
you are standing.** That is true on all three roads and there is nothing to turn
@@ -1322,7 +1339,8 @@ you; a conversation with no project to keep it in says so.
it** — see *The row on home shows the question but my key does nothing over it*.
The count of open questions in the status line IS built: that is the chip
-described above. So is the sheet, and so is the per-project setting that answers
+described above. So are the tabs for several questions from one step, and so is
+the per-project setting that answers
a whole kind of question while you are away (`/autonomy`, and `D` on a row).
**Every question the block draws is drawn only by the block now.** The last of the
diff --git a/internal/manual/chat_test.go b/internal/manual/chat_test.go
index abe1f8ed56..9c576e730b 100644
--- a/internal/manual/chat_test.go
+++ b/internal/manual/chat_test.go
@@ -111,8 +111,7 @@ func TestTheChatManualAnswersTheQuestionsPeopleAsk(t *testing.T) {
{"stop asking me this kind of thing", "questions"},
{"none of the answers it offered are right", "questions"},
// Reach (docs/design/questions/DESIGN.md's PRESENCE-AWARE DELIVERY,
- // BATCHED AT THE BOUNDARY, WITHDRAWN WITH A REASON and FIRST ANSWER
- // WINS), asked the way somebody meets each of them: answering somewhere
+ // ONE STEP ONE PANEL, WITHDRAWN WITH A REASON and FIRST ANSWER WINS), asked the way somebody meets each of them: answering somewhere
// else, coming back to a window nobody was at, a pile of them arriving
// together, one vanishing, two windows racing, and the setting that
// governs the whole thing.
@@ -128,6 +127,16 @@ func TestTheChatManualAnswersTheQuestionsPeopleAsk(t *testing.T) {
{"why did several questions arrive at once", "questions"},
{"answer a whole batch of questions at once", "questions"},
{"same answer for all of these", "questions"},
+ // Several questions from one step are tabs with a review (lane T of the
+ // question-views wave), asked the ways somebody meets them: moving
+ // between them, sending them together, and a batch of approvals.
+ {"go back to the previous question", "questions"},
+ {"answer them all at once", "questions"},
+ {"it asked me three questions as tabs", "questions"},
+ {"how do I send all my answers", "questions"},
+ {"approve all of these", "permissions"},
+ {"allow all the reads at once", "permissions"},
+ {"deny all of them", "permissions"},
{"the question disappeared", "questions"},
{"a question vanished without me answering", "questions"},
{"I typed a sentence instead of pressing a key and the question disappeared", "questions"},
diff --git a/internal/tui3/app.go b/internal/tui3/app.go
index bc6a204644..6329bfc0ba 100644
--- a/internal/tui3/app.go
+++ b/internal/tui3/app.go
@@ -2462,26 +2462,22 @@ type app struct {
// or had just clicked back into, was "away" — and away used to mean a
// question nobody could see (questiondelivery.go).
lastQuestionKey time.Time
- // questionReach is PRESENCE-AWARE DELIVERY and BATCHED AT THE BOUNDARY in
- // the ONE place both are decided (questiondelivery.go). It holds the quiet
- // questions a step is still gathering and remembers which blocking question
- // has already rung, so neither fact can be re-derived differently by home,
- // by the phone or by a second page.
+ // questionReach is PRESENCE-AWARE DELIVERY in the ONE place it is decided
+ // (questiondelivery.go). It remembers which blocking question has already
+ // rung, so that fact cannot be re-derived differently by home, by the phone
+ // or by a second page.
questionReach questionDeliveryRule
- // questionBatch is the sheet a step's boundary released, or nil
- // (questionsheet.go). It is APART FROM [app.questions] rather than a flag on
- // them, because a question in a sheet is not a question on the block: it has
- // no settle stamp, no cursor and no rule offer, and the moment `enter` takes
- // one out it gains all three.
- questionBatch *questionSheet
- // questionBatchFolded is `esc` on the sheet. It is THE SAME `later` the
- // block's fold is — the questions stay open, the chip keeps counting them —
- // and [app.raiseFolded] brings it back.
- questionBatchFolded bool
- // questionStepAt names the step quiet questions are being gathered under.
- // It moves when the model speaks again or the turn ends, which is the
- // boundary this surface can honestly see (questiondelivery.go).
- questionStepAt int
+ // questionSetAt is where the panel stands on the set of questions one step
+ // raised — which tab is showing, the permission frame's pointer, and
+ // whether the person asked to go one by one (questionset.go). The set itself
+ // is never kept: it is read off [app.questions] on every frame.
+ questionSetAt questionSetState
+ // questionStaging is the step whose answers are being HELD rather than sent,
+ // and it is set for exactly the length of one keystroke or one press routed
+ // into a tab of that step's set ([app.questionSetKey]). An answer reaching
+ // the door at any other moment — from home, from another window, from the
+ // project's rule while nobody is here — goes straight through.
+ questionStaging string
// autonomyRules is this project's stored question rules as this surface last
// read them, or nil for "not read yet" (autonomysheet.go). Nil rather than
// an empty map is the difference between a project with no rules and a file
@@ -3183,11 +3179,6 @@ func (a *app) route(msg tea.Msg) (tea.Model, tea.Cmd) {
// one command waiting on it (doorbell.go).
return a, a.news.waitRing()
- case questionGatherMsg:
- // The step's own clock, going off (questionsheet.go). It releases the
- // batch it was armed for and never a later one.
- return a, a.questionBoundaryFor(msg.step)
-
case sigQuitMsg:
// A REAL SIGNAL, forwarded by this package's own handler (tui3.go's
// [forwardSignals]) because Bubble Tea answers an interrupt by returning
@@ -5116,13 +5107,6 @@ func (a *app) applyEvent(ev session.Event, lump bool) tea.Cmd {
switch ev.Kind {
case session.EventTextDelta, session.EventThinking, session.EventReasoning:
a.lastDelta = time.Now()
- // THE STEP'S BOUNDARY IS THE MODEL SPEAKING AGAIN. Quiet questions
- // raised while the tools were running arrive here, together, as the
- // sheet (questionsheet.go's [app.questionBoundary]). It is a no-op on
- // the overwhelmingly common delta, which is one with nothing gathered.
- if cmd := a.questionBoundary(); cmd != nil {
- after = tea.Batch(after, cmd)
- }
case session.EventQuestion, session.EventQuestionWithdrawn, session.EventQuestionAnswered:
// THE OBJECT ALSO RIDES THE TURN'S OWN STREAM, and this is where a
@@ -5336,12 +5320,6 @@ func (a *app) applyEvent(ev session.Event, lump bool) tea.Cmd {
a.retrying = true
case session.EventTurnDone:
- // AND A TURN ENDING IS THE LAST BOUNDARY THERE IS. A question the model
- // raised in its final step has no next sentence to wait for, and one
- // held past the end of the turn would be held forever.
- if cmd := a.questionBoundary(); cmd != nil {
- after = tea.Batch(after, cmd)
- }
// Both notes go in BEFORE the turn settles, so they land under the reply
// they are about rather than above whatever is said next. What was
// CHANGED comes first and what it COST second: the files are the work,
diff --git a/internal/tui3/autonomysheet.go b/internal/tui3/autonomysheet.go
index 1e24865dd8..dfee06395e 100644
--- a/internal/tui3/autonomysheet.go
+++ b/internal/tui3/autonomysheet.go
@@ -320,3 +320,56 @@ func (a *app) readAutonomy() tea.Cmd {
// autonomyChanged reads the rows again, so the next question raised wears what
// was just written rather than what was there before it.
func (a *app) autonomyChanged() tea.Cmd { return a.readAutonomy() }
+
+// questionShapeOrder is which kind of question goes above which, on this sheet's
+// rows and in anything else that lists the kinds.
+//
+// IT IS THE KIND TABLE'S OWN ORDER (docs/design/questions/DESIGN.md's "Kinds and
+// their defaults") AND NOT THE ALPHABET. The table is ordered by how much of the
+// person a question wants — a permission is a key, a judgement is a reading, a
+// clarification is a sentence only they can write — so a list in that order is
+// a list whose cheap rows are at the top. Sorting on the value's own spelling
+// would put `assumptions it made` above `asking permission` because of how the
+// two words happen to start.
+func questionShapeOrder(kind session.AskKind) int {
+ for i, one := range []session.AskKind{
+ session.AskPermission, session.AskChoice, session.AskJudgement,
+ session.AskClarification, session.AskConfirmation, session.AskLanding,
+ session.AskAssumption, session.AskRatify,
+ } {
+ if one == kind {
+ return i
+ }
+ }
+ return 99
+}
+
+// questionShapeWord is one kind of question in a person's words: this sheet's
+// row names and the note `D` leaves.
+//
+// NO MACHINERY VOCABULARY. [session.AskKind]'s own spellings are the engine's
+// nouns — `permission`, `judgement`, `ratify` — and three of them are words
+// about a taxonomy rather than about what is being asked. What goes on a
+// heading is what the rows under it have in common, said the way somebody would
+// say it out loud.
+func questionShapeWord(kind session.AskKind) string {
+ switch kind {
+ case session.AskPermission:
+ return "asking permission"
+ case session.AskChoice:
+ return "choosing"
+ case session.AskJudgement:
+ return "your judgement"
+ case session.AskClarification:
+ return "what you meant"
+ case session.AskConfirmation:
+ return "confirming"
+ case session.AskLanding:
+ return "your call"
+ case session.AskAssumption:
+ return "assumptions it made"
+ case session.AskRatify:
+ return "already done"
+ }
+ return ""
+}
diff --git a/internal/tui3/question.go b/internal/tui3/question.go
index f8307bb2f3..fe1bf650e2 100644
--- a/internal/tui3/question.go
+++ b/internal/tui3/question.go
@@ -1,6 +1,7 @@
package tui3
import (
+ "cmp"
"sort"
"strings"
"time"
@@ -240,6 +241,16 @@ type questionShown struct {
spans []choiceSpan
// row is which row of the block those spans are on.
row int
+ // staged is the answer held for this question while it is one tab of a SET
+ // (questionset.go), and nil otherwise. It is sent with its neighbours from
+ // the review, and until then it is only this window's: nothing has been
+ // told to the engine, and a question answered anywhere else simply leaves
+ // with its held answer.
+ //
+ // IT LIVES ON THE QUESTION for [questionShown.other]'s reason: a held answer
+ // belongs to one question, and a set that re-flows when a neighbour is
+ // withdrawn must never move it onto another.
+ staged *session.Answer
// shapes answers what this question's widening answer could be banked as,
// and it is the second beat's whole door.
//
@@ -357,7 +368,7 @@ func (q questionShown) token() string {
// questionTokenOf names one question the way the whole product names it — the
// lane it belongs to and that lane's own token ([session.Question.Token]) —
// spelled once here because every place that holds a LIST of questions has to
-// agree about when two of them are the same one: the block, the sheet, and the
+// agree about when two of them are the same one: the block, a set of tabs, and the
// page reading another conversation's work (taskowner.go).
func questionTokenOf(q session.Question) string {
return string(q.Kind) + ":" + q.Token()
@@ -452,11 +463,18 @@ func questionRaisedHere(q session.Question) bool {
}
// questionHead is the question the block is drawing, and whether there is one.
+//
+// WHERE SEVERAL QUESTIONS FROM ONE STEP ARE UP, IT IS THE TAB ON SCREEN
+// (questionset.go), so every key, press and hint that reads the head reads the
+// question the person is actually looking at.
func (a *app) questionHead() (questionShown, bool) {
open := a.questionOpen()
if len(open) == 0 {
return questionShown{}, false
}
+ if set := a.questionSetOf(open); set != nil {
+ return a.questionSetHead(set), true
+ }
return open[0], true
}
@@ -485,7 +503,7 @@ func (a *app) questioning() bool {
// across every page, not whether the row exists: a ratify is still shown, still
// answerable, and still carries its own keys where it is drawn.
func (a *app) questionCount() int {
- n := a.sheetOpen()
+ n := 0
for _, q := range a.questions {
if q.question.Ask.Waits() {
n++
@@ -538,6 +556,7 @@ func (a *app) raiseQuestion(q questionShown) {
if a.questions[i].token() != q.token() {
continue
}
+ batch := q.question.Batch
// The SHOWN stamp survives the replay. A question re-sent by a
// reattaching watcher has not just arrived, and restamping it would
// hand it a fresh settle guard every few seconds — which is a question
@@ -600,6 +619,15 @@ func (a *app) raiseQuestion(q questionShown) {
q.question = a.questions[i].question
q.holes = a.questions[i].holes
}
+ if q.staged == nil {
+ q.staged = a.questions[i].staged
+ }
+ // AND WHICHEVER COPY ARRIVED SECOND, THE STEP STAYS. Only the engine's
+ // copy can know which step raised a question — a lane that dressed one
+ // from its own event has no batch to put on it — and the two copies race
+ // each other onto the block, so a set may not lose a member because the
+ // copy without a batch landed last (questionset.go).
+ q.question.Batch = cmp.Or(batch, a.questions[i].question.Batch)
q.clockHeld = q.clockHeld || a.questions[i].clockHeld
a.questions[i] = q
a.touch()
@@ -773,13 +801,8 @@ func questionSafeAt(q session.Question) int {
// where it was.
func (a *app) withdrawQuestion(q session.Question, reason string) {
token := questionToken(q)
- // A QUESTION THAT NEVER REACHED A ROW IS STILL TAKEN BACK. It may be inside
- // a step the rule is still gathering, in which case the boundary must not
- // release a decision that stopped needing to be made
- // (questiondelivery.go's [questionDeliveryRule.forget]).
- a.questionReach.forget(q)
kept := a.questions[:0]
- found := a.questionBatch != nil && a.questionBatch.withdraw(q)
+ found := false
for _, open := range a.questions {
if open.token() == token {
found = true
@@ -959,7 +982,7 @@ func questionRuleWord(q session.Question) string {
// consent.go's reason: a block whose height and whose rows disagree puts the
// caret a row off the box.
func (a *app) questionHeight() int {
- if len(a.questions) == 0 && len(a.questionRecords) == 0 && a.sheetOpen() == 0 {
+ if len(a.questions) == 0 && len(a.questionRecords) == 0 {
return 0
}
width, _ := a.size()
@@ -983,7 +1006,7 @@ func (a *app) questionRows(width int) []string {
a.questionBands, a.questionSpans = nil, nil
return nil
}
- if len(a.questions) == 0 && len(a.questionRecords) == 0 && a.sheetOpen() == 0 {
+ if len(a.questions) == 0 && len(a.questionRecords) == 0 {
// The empty block, on the empty path: no spans to clear because none
// were written, and nothing allocated (see [app.questionOpen]).
return nil
@@ -1016,13 +1039,13 @@ func (a *app) questionRows(width int) []string {
// the same question drawn twice, with `space open` offering what is
// already open.
if folded, has := a.questionPutOff(); has && a.questionQuieted() && !a.questionPageShows(folded) {
- out = append(out, a.questionFoldedRow(folded, width))
- }
- // THE SHEET STANDS DOWN FOR A QUESTION BEING READ, and this is the
- // other half of that: with nothing on the block, the batch one step
- // gathered is what the rows are spent on (questionsheet.go).
- if a.questionQuieted() && a.sheetShowing() {
- out = append(out, a.questionSheetRows(a.questionBatch, width)...)
+ // A SET PUT OFF FOLDS TO ONE RULE, which says how many and what
+ // each is called (questionset.go).
+ if set := a.questionSetFolded(folded); set != nil {
+ out = append(out, a.questionSetFoldedRow(set, width))
+ } else {
+ out = append(out, a.questionFoldedRow(folded, width))
+ }
}
return out
}
@@ -1077,6 +1100,16 @@ func (a *app) questionRows(width int) []string {
out = append(out, a.questionNarrowRows(head, width)...)
a.shiftQuestionMarks(base)
return out
+ case viewTabs:
+ // SEVERAL QUESTIONS FROM ONE STEP ARE ONE PANEL, and the count under it
+ // is of what is waiting BEHIND the set rather than inside it.
+ set := a.questionSet()
+ out = append(out, a.questionSetRows(set, width)...)
+ a.shiftQuestionMarks(base)
+ if more := a.questionWaitingCount() - len(set); more > 0 {
+ out = append(out, a.pal.dim(fit(" "+itoa(more)+" more", width)))
+ }
+ return out
case viewRow:
if head.question.Ask == session.AskRatify {
out = append(out, a.questionRatifyRows(head, width)...)
@@ -1666,7 +1699,7 @@ func (a *app) questionBeatRow(q questionShown, row, width int) string {
// printing it twice on two lines is the two-renderings defect this block exists
// to avoid.
//
-// IT IS NOT [questionShapeWord], which is the sheet's group heading and takes an
+// IT IS NOT [questionShapeWord], which is the autonomy sheet's row name and takes an
// [session.AskKind]. The two words are about different things — a rule's shape
// and a decision's shape — and sharing one name once cost a build.
func questionBeatShapeWord(shapes []string, at int) string {
@@ -2426,7 +2459,18 @@ type questionAnswer struct {
// receipt. Swallowing that sentence was the other half of the measured defect:
// the engine applied the key, this window was told the connection was gone,
// and nothing on screen said so.
+//
+// AND WHILE A KEY ON A SET IS BEING ROUTED, WHAT IT WOULD SEND IS HELD INSTEAD
+// ([app.stageAnswers]) — the one place a set's review differs from a panel of
+// one, said at the one door every key's answer reaches.
func (a *app) answerQuestions(all []questionAnswer) tea.Cmd {
+ return a.sendAnswers(a.stageAnswers(all))
+}
+
+// sendAnswers is [app.answerQuestions] with nothing held back: the list goes
+// through the door now, in order, in one command. It is what a set's review
+// and its permission frame send with.
+func (a *app) sendAnswers(all []questionAnswer) tea.Cmd {
if len(all) == 0 {
return nil
}
@@ -2808,8 +2852,7 @@ func (a *app) questionUnfoldKey(msg tea.KeyPressMsg) (tea.Cmd, bool) {
if a.questionOffFrame() || a.startingChat() {
return nil, false
}
- _, folded := a.questionPutOff()
- if !folded && !(a.sheetOpen() > 0 && a.questionBatchFolded) {
+ if _, folded := a.questionPutOff(); !folded {
return nil, false
}
a.raiseFolded()
@@ -2825,14 +2868,6 @@ func (a *app) questionUnfoldKey(msg tea.KeyPressMsg) (tea.Cmd, bool) {
// thing that changed it. Answering the head of the queue instead would be the
// surface deciding they meant something else.
func (a *app) raiseFolded() {
- // THE SHEET COMES BACK FIRST. It is the newest thing anybody put off — a
- // batch arrives at a step's end, after every question already on the block
- // — and it is what the chip's count is mostly made of when there is one.
- if a.sheetOpen() > 0 && a.questionBatchFolded {
- a.questionBatchFolded = false
- a.touch()
- return
- }
if len(a.questions) == 0 {
return
}
@@ -2846,6 +2881,11 @@ func (a *app) raiseFolded() {
}
}
if at >= 0 {
+ // A SET COMES BACK WHOLE. `esc` folded every tab of it at once, so
+ // opening it again opens every tab, with every answer held still held.
+ for _, q := range a.questionSetFolded(a.questions[at]) {
+ delete(a.questionFolded, q.token())
+ }
delete(a.questionFolded, a.questions[at].token())
}
a.touch()
@@ -2883,21 +2923,14 @@ func (a *app) questionKey(msg tea.KeyPressMsg) (tea.Cmd, bool) {
if cmd, taken := a.questionReceiptKey(msg); taken {
return cmd, true
}
- // WHAT IS DRAWN IS WHAT TAKES THE KEY. With nothing on the block the
- // sheet has the rows, so the sheet has the keyboard (questionsheet.go)
- // — and the same off-frame guard stands in front of it, because a sheet
- // behind a place or a page is as invisible as a block behind one. It
- // had none of its own: ↑↓, tab and the digits were being taken from
- // behind whatever the person was actually looking at.
+ // WHAT IS DRAWN IS WHAT TAKES THE KEY. With nothing on the block, what
+ // is drawn is the fold rule, which prints the key that opens it
+ // ([questionOpenFoldWord]) — and the same off-frame guard stands in
+ // front of it, because a rule behind a place or a page is as invisible
+ // as a block behind one.
if a.questionOffFrame() {
return nil, false
}
- if cmd, taken := a.questionSheetKey(msg); taken {
- return cmd, taken
- }
- // AND WITH NEITHER, WHAT IS DRAWN IS THE FOLD RULE, which prints the key
- // that opens it ([questionOpenFoldWord]) and until now was the only thing
- // in this program that knew about it.
return a.questionUnfoldKey(msg)
}
// THE START PAGE TAKES EVERY KEY BEFORE THE CONVERSATION BEHIND IT. Unlike
@@ -2930,6 +2963,11 @@ func (a *app) questionKey(msg tea.KeyPressMsg) (tea.Cmd, bool) {
// would be modal in the one place nobody could tell.
return nil, false
}
+ // WHAT IS DRAWN IS WHAT TAKES THE KEY, and several questions from one step
+ // are drawn as one panel with keys of its own (questionset.go).
+ if set := a.questionSet(); set != nil {
+ return a.questionSetKey(set, head, msg)
+ }
return a.questionKeyOn(head, msg)
}
@@ -3899,6 +3937,15 @@ func (a *app) questionPress(x, y int) (tea.Cmd, bool) {
if !ok || a.copy.on {
return nil, false
}
+ set := a.questionSet()
+ if set != nil {
+ if cmd, took, mine := a.questionSetPress(set, head, x, y); mine {
+ return cmd, took
+ }
+ // A PRESS ON A TAB'S ANSWER IS HELD like a key on it is.
+ a.questionStaging = head.question.Batch
+ defer func() { a.questionStaging = "" }()
+ }
if cmd, took, sheeted := a.questionBandPress(head, x, y); sheeted {
return cmd, took
}
@@ -3951,7 +3998,7 @@ func (a *app) questionRowMark(i int) chromeRow {
//
// THE EMPTINESS LAW. Nothing open is nothing drawn — never `0 questions`.
func (a *app) questionSegment() string {
- count := a.questionWaitingCount() + a.sheetOpen()
+ count := a.questionWaitingCount()
if count == 0 {
return ""
}
diff --git a/internal/tui3/questionchooser.go b/internal/tui3/questionchooser.go
index 57d646185a..c56d875650 100644
--- a/internal/tui3/questionchooser.go
+++ b/internal/tui3/questionchooser.go
@@ -12,7 +12,7 @@ import (
//
// It used to be four decisions in four places, each keyed on a different thing:
// [app.questionForm] read the asker's `Form` word and the count of its options,
-// [app.questionNarrowed] read the width, the batch drew itself whenever it had
+// [app.questionNarrowed] read the width, the sheet drew itself whenever it had
// rows, and home and the errand pane each called a renderer by name. A person
// could not predict which shape a question would take, and neither could a lane.
//
@@ -32,13 +32,10 @@ const (
// viewPhone is the bottom sheet a phone-width frame gets
// (questionnarrow.go).
viewPhone
- // AND THE RUNG FOR TABS IS NOT HERE YET. Two or more questions from one step
- // are one decision taken in parts and the owner ruled they become one panel
- // with a tab each (2026-09-11, several-questions pick A) — but that drawing
- // is a lane of its own and nothing raises it today. A rung whose `when` can
- // only answer false is a ladder that lies about what it can produce, so the
- // view and its rung arrive together with the panel that draws them.
- //
+ // viewTabs is two or more questions from one step, drawn as one panel with
+ // a tab per question and a review tab, or as one permission frame where
+ // every one of them is a permission (questionset.go).
+ viewTabs
// viewPanel is the framed panel a question hangs in above the box
// (questionpanel.go). It is the ordinary answer.
viewPanel
@@ -80,6 +77,15 @@ var questionLadder = []questionRung{
return a.questionNarrowed(q, width)
},
},
+ {
+ view: viewTabs,
+ why: "two or more questions from one step are one decision taken in parts, so they are one " +
+ "panel with a tab each — and where every one of them is a permission, one frame that " +
+ "answers all of them at once",
+ when: func(a *app, q questionShown, width int) bool {
+ return a.questionTabbed(q)
+ },
+ },
{
view: viewRow,
why: "a ratify line is a statement about something already done and nothing waits on it, " +
diff --git a/internal/tui3/questiondelivery.go b/internal/tui3/questiondelivery.go
index 9918f867eb..68ac378916 100644
--- a/internal/tui3/questiondelivery.go
+++ b/internal/tui3/questiondelivery.go
@@ -34,20 +34,22 @@ package tui3
//
// decided allow rm -rf build? → allow once · the rule · 14:02
//
-// AND AT A STEP'S BOUNDARY, where several quiet questions were raised inside one
-// piece of work, they arrive together as the sheet (questionsheet.go) instead of
-// one at a time on top of each other.
+// AND NOTHING IS HELD BACK. Several questions raised by one step used to be
+// gathered here until "the model spoke again" and released as a sheet; the
+// engine now names the step on every question it raises inside one
+// ([session.Question.Batch]), so they go onto the block the moment they arrive
+// and are drawn there as ONE panel (questionset.go). Holding them bought
+// nothing but a delay — and once a deadlock: two `ask` calls waiting on
+// answers to questions held for a boundary that could not come until they
+// were answered.
//
-// ── THE TWO LAWS THIS FILE IS THE ENFORCEMENT OF ────────────────────────────
+// ── THE LAW THIS FILE IS THE ENFORCEMENT OF ─────────────────────────────────
//
// - PRESENCE-AWARE DELIVERY. docs/design/questions/DESIGN.md: "On the page:
// pinned now. On home or another page: in the row, plus the chip. Away
// ([awayAfter], 10m without a key): dial resolves what it may and records
// DecidedBy: dial; the rest go to home and the phone; the terminal bell
// rings once for a blocking question only."
-// - BATCHED AT THE BOUNDARY. "Non-blocking questions raised inside one step
-// arrive together as a sheet at the step's end; a blocking one arrives at
-// once with the count of what is behind it."
import (
"strings"
@@ -101,19 +103,10 @@ type questionDelivery struct {
// Bell is the terminal bell, and it is true at most once per question and
// only for one something is blocked on.
Bell bool
- // Gather is a question held for its step's boundary. The caller arms the
- // boundary's own clock on it ([app.gatherQuestions]).
- Gather bool
}
-// questionDeliveryRule owns presence, the boundary batch and the bell's memory.
-//
-// pending is keyed by a STEP TOKEN because only questions born inside the same
-// step batch together: two steps' questions arriving as one sheet would be a
-// sheet about two different pieces of work, which is the thing the sheet exists
-// to stop being.
+// questionDeliveryRule owns presence and the bell's memory.
type questionDeliveryRule struct {
- pending map[string][]session.Question
// rung is every question that has already had its bell. A question is
// re-emitted whenever a surface attaches ([session.Agent.WatchQuestions]
// replays), and a terminal that rang on every reattach would be a terminal
@@ -122,11 +115,11 @@ type questionDeliveryRule struct {
}
func newQuestionDeliveryRule() questionDeliveryRule {
- return questionDeliveryRule{pending: map[string][]session.Question{}, rung: map[string]bool{}}
+ return questionDeliveryRule{rung: map[string]bool{}}
}
// questionToken is the one string a question is known by across this file and
-// the sheet: the lane and the lane's own id, because two lanes may both be
+// home: the lane and the lane's own id, because two lanes may both be
// waiting on id 7 ([questionShown.token] is the same string from the block's
// side).
func questionToken(q session.Question) string { return string(q.Kind) + ":" + q.Token() }
@@ -148,7 +141,7 @@ func (a *app) questionPresenceNow() questionPresence {
}
// deliver places one question, and is the whole of the rule.
-func (d *questionDeliveryRule) deliver(q session.Question, step string, presence questionPresence, now time.Time) questionDelivery {
+func (d *questionDeliveryRule) deliver(q session.Question, presence questionPresence, now time.Time) questionDelivery {
if q.Withdrawn != nil {
// A WITHDRAWN QUESTION IS NEVER DELIVERED. Its one dim line is the
// block's ([app.withdrawQuestion]); arriving here it is simply not a
@@ -177,63 +170,12 @@ func (d *questionDeliveryRule) deliver(q session.Question, step string, presence
}
return out
}
- if step != "" && !q.Blocking.Blocks() {
- // BATCHED AT THE BOUNDARY. A quiet question raised inside a step is
- // held for [questionDeliveryRule.boundary]; nothing is drawn now,
- // because a question landing on top of the last one is the thing the
- // sheet replaces.
- d.pending[step] = appendQuestionOnce(d.pending[step], q)
- return questionDelivery{Gather: true}
- }
if presence == questionOtherPage {
return questionDelivery{Pin: &q, Note: questionWaitingLine(q)}
}
return questionDelivery{Pin: &q}
}
-// boundary releases every quiet question one step gathered, oldest first. The
-// caller turns two or more of them into the sheet and one of them into the
-// ordinary block ([app.questionBoundary]).
-func (d *questionDeliveryRule) boundary(step string) []session.Question {
- held := d.pending[step]
- delete(d.pending, step)
- return held
-}
-
-// forget drops a withdrawn question from whatever step is still gathering, so a
-// boundary cannot release a decision that stopped needing to be made.
-func (d *questionDeliveryRule) forget(q session.Question) {
- token := questionToken(q)
- for step, held := range d.pending {
- kept := held[:0]
- for _, one := range held {
- if questionToken(one) == token {
- continue
- }
- kept = append(kept, one)
- }
- if len(kept) == 0 {
- delete(d.pending, step)
- continue
- }
- d.pending[step] = kept
- }
-}
-
-// appendQuestionOnce replaces by token rather than appending, for
-// [app.raiseQuestion]'s reason: the engine re-emits an open question whenever a
-// surface attaches, and a batch that grew a row on every reattach would arrive
-// as a sheet with one decision on it four times.
-func appendQuestionOnce(questions []session.Question, q session.Question) []session.Question {
- for i := range questions {
- if questionToken(questions[i]) == questionToken(q) {
- questions[i] = q
- return questions
- }
- }
- return append(questions, q)
-}
-
// dialAnswer is what the project's rule may take while nobody is here, and it
// is deliberately the NARROWEST reading of that permission.
//
@@ -295,7 +237,7 @@ func questionWaitingLine(q session.Question) string {
// deliverQuestion is the ONE place the rule's facts become acts.
func (a *app) deliverQuestion(q session.Question) tea.Cmd {
- out := a.questionReach.deliver(q, a.questionStep(), a.questionPresenceNow(), a.now())
+ out := a.questionReach.deliver(q, a.questionPresenceNow(), a.now())
var cmds []tea.Cmd
if out.Answer != nil {
// THE RULE'S ANSWER GOES THROUGH THE ONE DOOR, exactly as a person's
@@ -320,24 +262,6 @@ func (a *app) deliverQuestion(q session.Question) tea.Cmd {
if out.Note != "" {
a.sayWhereQuestionWent(out.Note)
}
- if out.Gather {
- // AND THE BOUNDARY GETS A CLOCK, WHICH IS NOT BELT AND BRACES BUT THE
- // ONLY THING THAT MAKES HOLDING SAFE AT ALL.
- //
- // A question can be holding open the very tool call whose return would
- // have been the boundary — the `ask` tool blocks its turn until it is
- // answered (session's tools_ask.go), so a question held for "the model
- // speaking again" would be held until the model speaks, which cannot
- // happen until somebody answers the question nobody can see. That
- // deadlock was observed on a real run: two `ask` calls waiting, the
- // status line honestly saying `2 open`, and nothing above the box.
- //
- // So the hold is bounded by [questionGatherFor] and the boundary fires
- // on whichever comes first. Batching is a claim that several questions
- // arrived AT ONCE, and a few seconds is exactly the window in which
- // that is true.
- cmds = append(cmds, a.gatherQuestions())
- }
if out.Phone {
cmds = append(cmds, a.notifyAsk())
}
@@ -357,28 +281,3 @@ func (a *app) sayWhereQuestionWent(note string) {
}
a.pageMsg = note
}
-
-// questionStep is the token quiet questions gather under, and "" when there is
-// no step to gather inside.
-//
-// NOTHING BATCHES OUTSIDE A LIVE TURN, and that is the boundary rule read
-// literally: a question raised while nothing is running has no boundary coming,
-// so holding it would be holding it forever.
-//
-// AND THE STEP THIS SURFACE CAN HONESTLY SEE IS "UNTIL THE MODEL SPEAKS AGAIN".
-// The engine has no step event, and what a person experiences as one step is a
-// batch of tool calls between two things the model said — so that is what is
-// counted ([app.questionStepEnded] is called from the reducer on the first text
-// of the next reply and on the turn ending). It is coarser than the engine's own
-// idea of a step and it is named for what it actually measures rather than for
-// what would have been convenient.
-func (a *app) questionStep() string {
- if a.stream == nil {
- return ""
- }
- return "step:" + itoa(a.questionStepAt)
-}
-
-// questionStepEnded moves the token on, so the next quiet question gathers into
-// a new batch rather than into the one that has just been handed over.
-func (a *app) questionStepEnded() { a.questionStepAt++ }
diff --git a/internal/tui3/questiondelivery_test.go b/internal/tui3/questiondelivery_test.go
index 778a9a1723..6660a51eaa 100644
--- a/internal/tui3/questiondelivery_test.go
+++ b/internal/tui3/questiondelivery_test.go
@@ -21,27 +21,28 @@ func deliveryQuestion(id uint64, ask session.AskKind, blocking bool) session.Que
}
}
-func TestAQuietQuestionWaitsForItsStepAndABlockingOneDoesNot(t *testing.T) {
+// TestAQuietQuestionIsPinnedTheMomentItArrives is the gather's deletion, pinned.
+// Quiet questions raised inside a step used to be held until "the model spoke
+// again", and a question holding its own turn open could be held until it was
+// answered — which nobody could do, because it was not on the screen. The step
+// is on the question now ([session.Question.Batch]) and several from one step
+// are drawn together as one panel (questionset.go), so nothing is held at all.
+func TestAQuietQuestionIsPinnedTheMomentItArrives(t *testing.T) {
d := newQuestionDeliveryRule()
now := time.Date(2026, 9, 9, 12, 1, 0, 0, time.UTC)
- if got := d.deliver(deliveryQuestion(1, session.AskChoice, false), "step:7", questionOnPage, now); got.Pin != nil {
- t.Fatalf("a quiet question escaped its step: %#v", got)
+ quiet := deliveryQuestion(1, session.AskChoice, false)
+ quiet.Batch = "step:7"
+ if got := d.deliver(quiet, questionOnPage, now); got.Pin == nil {
+ t.Fatalf("a quiet question raised inside a step was held back: %#v", got)
}
- if got := d.deliver(deliveryQuestion(2, session.AskPermission, true), "step:7", questionOnPage, now); got.Pin == nil {
- t.Fatal("a blocking question was held for the boundary")
- }
- held := d.boundary("step:7")
- if len(held) != 1 || held[0].ID != 1 {
- t.Fatalf("boundary = %#v, want only the quiet question", held)
- }
- if again := d.boundary("step:7"); len(again) != 0 {
- t.Fatalf("the boundary handed its batch over twice: %#v", again)
+ if got := d.deliver(deliveryQuestion(2, session.AskPermission, true), questionOnPage, now); got.Pin == nil {
+ t.Fatal("a blocking question was held back")
}
}
func TestAQuestionOnAnotherPageIsPinnedAndSaysWhereItWent(t *testing.T) {
d := newQuestionDeliveryRule()
- got := d.deliver(deliveryQuestion(1, session.AskChoice, true), "", questionOtherPage, time.Now())
+ got := d.deliver(deliveryQuestion(1, session.AskChoice, true), questionOtherPage, time.Now())
if got.Pin == nil {
t.Fatal("a question on another page never reached the block")
}
@@ -56,17 +57,17 @@ func TestAwayTakesOnlyAMatureSafePickAndRingsOnce(t *testing.T) {
q := deliveryQuestion(1, session.AskChoice, true)
q.Policy = session.Policy{Kind: session.PolicyRecommendThenAuto, After: time.Minute}
d := newQuestionDeliveryRule()
- got := d.deliver(q, "", questionAway, now)
+ got := d.deliver(q, questionAway, now)
if got.Answer == nil || got.Answer.DecidedBy != session.DecidedByDial || got.Answer.Key != "1" {
t.Fatalf("the rule did not take a mature reversible pick: %#v", got)
}
// A clarification is never taken by a clock, however mature the rule is.
q.ID, q.Ask = 2, session.AskClarification
- got = d.deliver(q, "", questionAway, now)
+ got = d.deliver(q, questionAway, now)
if got.Answer != nil || !got.Phone || !got.Bell {
t.Fatalf("clarification away = %#v, want the phone and one bell", got)
}
- if got = d.deliver(q, "", questionAway, now.Add(time.Second)); got.Bell {
+ if got = d.deliver(q, questionAway, now.Add(time.Second)); got.Bell {
t.Fatal("the same blocking question rang twice")
}
}
@@ -76,22 +77,11 @@ func TestAnIrreversibleQuestionIsNeverTakenWhileNobodyIsThere(t *testing.T) {
q.Stakes = session.StakesIrreversible
q.Policy = session.Policy{Kind: session.PolicyDecide}
d := newQuestionDeliveryRule()
- if got := d.deliver(q, "", questionAway, time.Now()); got.Answer != nil {
+ if got := d.deliver(q, questionAway, time.Now()); got.Answer != nil {
t.Fatalf("irreversible stakes were decided by the rule: %#v", got.Answer)
}
}
-func TestAWithdrawnQuestionLeavesTheStepItWasGatheringIn(t *testing.T) {
- d := newQuestionDeliveryRule()
- q := deliveryQuestion(1, session.AskChoice, false)
- _ = d.deliver(q, "step:1", questionOnPage, time.Now())
- q.Withdrawn = &session.Withdrawal{Reason: "the plan changed"}
- d.forget(q)
- if held := d.boundary("step:1"); len(held) != 0 {
- t.Fatalf("the boundary released a withdrawn question: %#v", held)
- }
-}
-
func TestTheWithdrawnLineSaysWhatStoppedNeedingAnAnswer(t *testing.T) {
a := newTestApp(&fakeAgent{})
q := deliveryQuestion(1, session.AskChoice, false)
@@ -193,26 +183,6 @@ func TestAnAnswerThisWindowAlreadyGaveIsNotDrawnTwice(t *testing.T) {
}
}
-// TestAQuestionHoldingItsOwnStepOpenIsStillReleased is the deadlock the gather
-// clock exists to prevent: the `ask` tool blocks its turn until it is answered,
-// so a question held for "the model speaking again" would be held until the
-// model speaks, which cannot happen until the question is answered.
-func TestAQuestionHoldingItsOwnStepOpenIsStillReleased(t *testing.T) {
- d := newQuestionDeliveryRule()
- got := d.deliver(deliveryQuestion(1, session.AskPermission, false), "step:1", questionOnPage, time.Now())
- if !got.Gather {
- t.Fatal("a held question did not ask for the boundary's clock")
- }
- if got.Pin != nil {
- t.Fatal("a held question was drawn anyway")
- }
- // The clock's own release names the step it was armed for, and hands the
- // batch over exactly once.
- if held := d.boundary("step:1"); len(held) != 1 {
- t.Fatalf("the clock released %d questions, want the one that was held", len(held))
- }
-}
-
// TestHomeAnswersALaneItHasNoOlderPathFor is the reach law from home's side:
// the whole question rides in the presence file, so a lane home never learnt
// about is still answerable there.
diff --git a/internal/tui3/questiondemo.go b/internal/tui3/questiondemo.go
index d1ff668099..c2f5743919 100644
--- a/internal/tui3/questiondemo.go
+++ b/internal/tui3/questiondemo.go
@@ -53,6 +53,20 @@ func (a *app) openDemoQuestion(env func(string) string) {
}
return
}
+ if stage, known := setDemos[name]; known {
+ // AND SEVERAL QUESTIONS FROM ONE STEP, on the same terms: a set is
+ // raised by a model calling `ask` more than once in one batch, or by a
+ // batch of calls that each needs an approval — both of which cost a
+ // real turn to see.
+ for _, q := range stage(a) {
+ a.raiseQuestion(questionShown{question: q})
+ }
+ _ = a.questionRows(a.width)
+ for i := range a.questions {
+ a.questions[i].shown = a.questions[i].shown.Add(-questionSettle)
+ }
+ return
+ }
if build, known := blockDemos[name]; known {
// AND THE BLOCK'S OWN FIXTURES, on the same terms and for the same
// reason. The five lanes that moved onto it in the questions wave — the
@@ -387,6 +401,58 @@ func (a *app) demoConsent(tool, command string, stakes session.Stakes, scope []s
}
}
+// setDemos are the fixtures for several questions one step raised
+// (questionset.go): the tabs and their review, and the one permission frame.
+var setDemos = map[string]func(*app) []session.Question{
+ "several": demoSeveral,
+ "permissions": demoPermissions,
+}
+
+// demoSeveral is three questions a model asked in one batch — one drawing
+// they all share, a tab each and the review after them.
+func demoSeveral(a *app) []session.Question {
+ storage := demoWeighed(a)
+ storage.ID, storage.Batch = 21, "demo-step"
+ naming := session.Question{
+ ID: 22, Kind: session.QuestionAsk, Ask: session.AskChoice, Batch: "demo-step",
+ Asker: session.Asker{Kind: session.AskerModel},
+ Head: "what should the index file be called?",
+ Stakes: session.StakesReversible,
+ Options: []session.AnswerOption{
+ {Key: "1", Label: "session-index.db", Consequence: "says what it is"},
+ {Key: "2", Label: "index.db", Consequence: "short, and there is only one"},
+ },
+ Pick: &session.Pick{Key: "1"},
+ }
+ tests := session.Question{
+ ID: 23, Kind: session.QuestionAsk, Ask: session.AskChoice, Batch: "demo-step",
+ Asker: session.Asker{Kind: session.AskerModel},
+ Head: "which tests should cover it?",
+ Stakes: session.StakesReversible,
+ Options: []session.AnswerOption{
+ {Key: "1", Label: "a crash test", Consequence: "kills the write half-way and reopens"},
+ {Key: "2", Label: "round trip only", Consequence: "write, read back, compare"},
+ },
+ }
+ return []session.Question{storage, naming, tests}
+}
+
+// demoPermissions is four reads one batch asked approval for — the grouped
+// frame, with each call's row already in the transcript as a real gate's is.
+func demoPermissions(a *app) []session.Question {
+ out := make([]session.Question, 0, 4)
+ for i, target := range []string{"~/notes/plan.md", "~/notes/todo.md", "~/notes/ideas.md", "~/notes/log.md"} {
+ q := a.demoConsent("read", target, session.StakesCostly,
+ []session.AnswerScope{session.ScopeOnce, session.ScopeAlways})
+ call := "demo-call-read-" + itoa(i)
+ a.entries[len(a.entries)-1].callID = call
+ q.ID, q.Batch, q.Subject.CallID = uint64(31+i), "demo-step", call
+ q.Head = "needs your ok to run read"
+ out = append(out, q)
+ }
+ return out
+}
+
// demoWeighed is the ordinary panel: a choice whose answers carry what each one
// costs, with a pick, its reason, its confidence and what would change its mind.
// It is the drawing most questions in this program get, and it had no fixture.
diff --git a/internal/tui3/questionhand_test.go b/internal/tui3/questionhand_test.go
index ede13bcc3d..1cc76af132 100644
--- a/internal/tui3/questionhand_test.go
+++ b/internal/tui3/questionhand_test.go
@@ -39,7 +39,7 @@ func TestAQuestionRaisedWhileNobodyWasThereIsStillOnTheBlock(t *testing.T) {
// AND THE PHONE AND THE BELL ARE WHAT AWAY ADDS ON TOP, never what it
// swaps the block for.
rule := newQuestionDeliveryRule()
- out := rule.deliver(consentAsk(), "", questionAway, lab.at)
+ out := rule.deliver(consentAsk(), questionAway, lab.at)
if out.Pin == nil || !out.Phone {
t.Fatalf("away delivered %+v, want the block AND the phone", out)
}
@@ -65,31 +65,30 @@ func TestThePointerAndTheWindowComingForwardCountAsSomebodyBeingThere(t *testing
}
}
-// THE SHEET TAKES NO KEYS FROM BEHIND A PAGE. It had no off-frame guard of its
-// own, so ↑↓, tab and the digits were taken from behind whatever the person was
-// actually looking at.
-func TestTheSheetTakesNoKeysFromBehindAPage(t *testing.T) {
- a := newTestApp(&fakeAgent{})
- a.questionBatch = newQuestionSheet([]session.Question{
- sheetQuestion(1, session.AskPermission, "read vendor/?",
- session.AnswerOption{Key: "1", Label: "allow once"}, session.AnswerOption{Key: "2", Label: "not now"}),
- sheetQuestion(2, session.AskPermission, "write .github/?",
- session.AnswerOption{Key: "1", Label: "allow once"}, session.AnswerOption{Key: "2", Label: "not now"}),
- })
- a.showPage(pageTasks)
- if _, took := a.questionKey(key("down")); took {
- t.Fatal("the sheet walked its cursor from behind a place")
- }
- if _, took := a.questionKey(key("1")); took {
- t.Fatal("the sheet answered a question from behind a place")
+// A SET TAKES NO KEYS FROM BEHIND A PAGE. The sheet it replaced had no
+// off-frame guard of its own, so ↑↓, tab and the digits were taken from behind
+// whatever the person was actually looking at; the set is routed by the block's
+// own guards, in front of everything it does.
+func TestASetTakesNoKeysFromBehindAPage(t *testing.T) {
+ lab := newQuestionLab(t)
+ lab.raise(setQuestion(1, "step:1", "read vendor/?"))
+ lab.raise(setQuestion(2, "step:1", "write .github/?"))
+ lab.tick(questionSettle * 2)
+ lab.a.showPage(pageTasks)
+ for _, k := range []string{"down", "right", "1", "enter"} {
+ if lab.press(k) {
+ t.Fatalf("the set took %q from behind a place", k)
+ }
}
- if a.questionBatch.answeredCount() != 0 {
- t.Fatalf("%d rows were answered from behind a page", a.questionBatch.answeredCount())
+ for _, q := range lab.a.questions {
+ if q.staged != nil {
+ t.Fatalf("question %d was answered from behind a page", q.question.ID)
+ }
}
// AND NOTHING OF IT IS DRAWN THERE EITHER, which is the same rule's other
// half: what is not on the frame is not on the keyboard.
- if rows := a.questionRows(a.width); len(rows) != 0 {
- t.Fatalf("the sheet drew %d rows from behind a place", len(rows))
+ if rows := lab.rows(); len(rows) != 0 {
+ t.Fatalf("the set drew %d rows from behind a place", len(rows))
}
}
diff --git a/internal/tui3/questionkeys.go b/internal/tui3/questionkeys.go
index de3800fb7a..5c4a821014 100644
--- a/internal/tui3/questionkeys.go
+++ b/internal/tui3/questionkeys.go
@@ -101,10 +101,14 @@ const (
formsCard
formsRatify
formsRoom
- // formsSheet is the batch form (questionsheet.go). Its two own keys are in
- // this table for the reason every other key is: the manual's page and the
- // row a person reads have to be spelt from one place.
- formsSheet
+ // formsTabs, formsReview and formsGroup are the three drawings of a SET —
+ // several questions one step raised (questionset.go): a tab, the review that
+ // sends them, and the one permission frame. Their own keys are in this table
+ // for the reason every other key is: the manual's page and the row a person
+ // reads have to be spelt from one place.
+ formsTabs
+ formsReview
+ formsGroup
)
// formsBlock is the three forms THIS lane draws — the pinned block above the
@@ -218,9 +222,8 @@ const (
questionRuleKey = "r"
questionUndoKey = "u"
// questionScopeKey cycles HOW LONG the answer lasts (questionscope.go). It
- // is `t` and not `s` because `s` is the sheet's send, and a key that sent a
- // batch on one form and changed a lifetime on another is two keys as far as
- // anybody learning it is concerned.
+ // is `t`, the initial of what it changes, and it is a letter a sentence can
+ // start with, so it waits for the block to be aimed at like every other.
questionScopeKey = "t"
questionBlankKey = "tab"
// questionToggleKey IS `space` AND NOT `" "`, which the table's own contract
@@ -230,18 +233,11 @@ const (
// the room became the first form to route this key, and a comparison against
// the byte matched nothing at all.
questionToggleKey = "space"
- // questionSendKey and questionAlikeKey are the sheet's, and they are LETTERS
- // where a question's answers are digits because a sheet's digits are already
- // its answers: `1`-`9` answer the row the cursor is on, so the two acts that
- // are about the WHOLE batch cannot also be digits.
- //
- // AND IT IS `g` RATHER THAN THE ROOM'S `=` ([questionSameKey]) because the
- // two are not the same act. The room's `=` says "this pair keeps coming up,
- // answer it the same way from now on" — a rule about the future. The sheet's
- // `g` says "these rows in front of me take the answer I just gave" — one
- // batch, now, nothing written down.
- questionSendKey = "s"
- questionAlikeKey = "g"
+ // questionTabBackKey is the left arrow ALONE, on the review of a set: the
+ // review is the last tab, so there is nowhere to its right and `←` is not
+ // half of a pair there. The routing reads `left` ([app.questionReviewKey]);
+ // this is the SPELLING.
+ questionTabBackKey = "←"
// questionBackKey is the up arrow ALONE, which is the one place on this
// surface a single arrow is a key in its own right: from the box at the foot
// of a panel there is nowhere below to go, so `↑` is not half of a pair. The
@@ -309,21 +305,24 @@ var questionKeys = []questionVerb{
// open a section and fold it (questionroom.go). A row that names the keys
// that do nothing is worse than a row that names none: the owner pressed
// them, 2026-09-10, and reported "no arrow or click".
- {key: questionWalkDownKey, word: "choose", forms: formsCard | formsRoom, needs: needWalk, tier: keyPrimary, giveUp: 2},
+ {key: questionWalkDownKey, word: "choose", forms: formsCard | formsRoom | formsGroup, needs: needWalk, tier: keyPrimary, giveUp: 2},
+ // ON A SET `←→` MOVE BETWEEN QUESTIONS, and its edge says so first
+ // ([app.questionTabRows] puts it there), because it is the one thing a set
+ // does that a panel of one does not. It is never given up: a set whose edge
+ // lost it would be tabs nobody knew how to reach.
+ {key: questionWalkKey, word: questionTabWord, forms: formsTabs, tier: keyPrimary},
// `enter` IS NOT ON A RATIFY LINE. Nothing is waiting on it — the work
// already happened — so there is no pick for enter to take, and a line that
// offered `enter take it` beside `u undo` named a key that answers nothing.
- {key: questionEnterKey, word: "take it", forms: formsLine | formsCard | formsRoom | formsSheet, needs: needPick, tier: keyPrimary, giveUp: 1},
- // THE SHEET'S TWO SIT WHERE A PERSON REACHES FOR THEM — beside `enter`,
- // because answering a batch is open-one, answer, send — and only `g` is ever
- // given up: `s` is the reason the sheet exists (a batch answered row by row
- // and then not sent is a batch nobody answered), so it is ranked zero beside
- // `esc`.
- {key: questionSendKey, word: questionSheetSendWord, forms: formsSheet, tier: keyPrimary},
- {key: questionAlikeKey, word: questionSheetSameWord, forms: formsSheet, giveUp: 2},
+ {key: questionEnterKey, word: "take it", forms: formsLine | formsCard | formsRoom | formsGroup, needs: needPick, tier: keyPrimary, giveUp: 1},
+ // THE REVIEW'S TWO ARE WHAT A REVIEW IS FOR: send what is held, or go back
+ // and change it. Neither is ever given up — a review whose edge lost `send`
+ // is a set nobody can finish.
+ {key: questionEnterKey, word: "send", forms: formsReview, tier: keyPrimary},
+ {key: questionTabBackKey, word: "back", forms: formsReview, tier: keyPrimary},
// AND NEITHER IS `esc later`: a line that says what was already done is not a
// question to come back to.
- {key: questionLaterKey, word: "later", forms: formsLine | formsCard | formsRoom | formsSheet, tier: keyPrimary},
+ {key: questionLaterKey, word: "later", forms: formsLine | formsCard | formsRoom | formsReview | formsGroup, tier: keyPrimary},
// THE TWO KEYS OF THE `something else…` ROW. They are rows of their own
// rather than second words on `enter` and the walk, because a key that reads
// one way on one row and another way on the next is two keys as far as
@@ -518,22 +517,6 @@ func questionVerbFor(key string) (questionVerb, bool) {
return questionVerb{}, false
}
-// questionSheetKeyWord is one key's word ON A SHEET, which is the table's word
-// for every key but `enter`.
-//
-// `enter` IS ONE KEY WITH ONE MEANING AND TWO SENTENCES. It always means "act on
-// what the cursor is on"; on the block that is the answer the asker recommends,
-// and on a sheet the cursor is on a ROW rather than an answer, so acting on it
-// opens that row. The substitution is done here for the same reason `r`'s is
-// done in [app.paintQuestionKeys] — the table holds one row per key, and a word
-// that depends on what is being drawn is filled in by the drawer.
-func questionSheetKeyWord(verb questionVerb) string {
- if verb.key == questionEnterKey {
- return questionSheetOpenWord
- }
- return verb.word
-}
-
// questionKeyWord is one key's word, or "" where the table does not have it.
// The manual's page and the offer row both print through here.
func questionKeyWord(key string) string {
diff --git a/internal/tui3/questionpanel.go b/internal/tui3/questionpanel.go
index 9a4cbd6835..f1c980393c 100644
--- a/internal/tui3/questionpanel.go
+++ b/internal/tui3/questionpanel.go
@@ -76,33 +76,18 @@ const (
// one column.
func (a *app) questionPanelRows(q questionShown, width int) []string {
inner := frameInner(width)
- if len(q.beat) > 0 {
- return a.questionPanelBeat(q, width, inner)
- }
- first := len(a.questionBands)
title, headRows := a.questionPanelHead(q, width, inner)
- rows := append(headRows, a.questionPanelEvidenced(q, width, inner, len(headRows))...)
- keys := a.questionAnswerKeys(q, formsCard)
- keyRow := a.questionKeyRow(q, questionKeysOnTier(keys, keyPrimary), frameEdgeRoom(width))
- if q.writing != "" {
- // THE EDGE SAYS WHAT THE BOX MEANS while the composer is pointed at the
- // question ([app.questionWritingRow]): every letter types, so an edge
- // naming letters would be naming keys that do something else.
- keyRow = a.pal.dim(fit(a.questionWritingRow(q), frameEdgeRoom(width)))
- }
+ // THE FRAME'S TOP EDGE IS A ROW, and so is every row the head took when it
+ // would not fit into that edge: each of them puts the body one row further
+ // down the block. The marks are what a press resolves against, so the inside
+ // is told where it stands rather than left to guess.
+ rows, keyRow, keys := a.questionPanelInside(q, width, inner, 1+len(headRows), nil)
panel := framed{
title: title,
aside: a.questionPanelAside(q, width),
keys: keyRow,
}
- out, _ := panel.draw(a.pal, width, rows)
- // THE FRAME'S TOP EDGE IS A ROW, and so is every row the head took when it
- // would not fit into that edge: each of them puts the body one row further
- // down the block. The bands are what a press resolves against, so they are
- // shifted here rather than guessed at by the body.
- for i := first; i < len(a.questionBands); i++ {
- a.questionBands[i].row += 1 + len(headRows)
- }
+ out, _ := panel.draw(a.pal, width, append(headRows, rows...))
// AND THE SECOND TIER STANDS UNDER THE FRAME, dim, in the same grammar. It
// is not written into the bottom edge because the edge is for the keys that
// ANSWER: a row that mixed `esc later` with `D decide these from now on` was
@@ -113,31 +98,64 @@ func (a *app) questionPanelRows(q questionShown, width int) []string {
return out
}
+// questionPanelInside is everything a question draws INSIDE its frame — the rows
+// under its head, and the keys on the bottom edge that answer them.
+//
+// THE BEAT IS BRANCHED ON HERE AND NOWHERE ELSE. A question part-way through the
+// widening answer ([app.questionPanelBeat]) replaces its answers with the shapes
+// and its edge with the two keys that mean anything while they are up, and it is
+// the same question whether it is alone above the box or one tab of the set its
+// step raised (questionset.go). A second place deciding that was a panel drawing
+// the answers while the digits picked shapes and `esc` backed out of the beat —
+// a person typing into a drawing that is not the one they are looking at.
+//
+// `above` is how many rows already stand inside the frame, counting the frame's
+// own top edge, so the marks a press resolves against are recorded where they
+// will be drawn. `edge` is what the caller puts in front of the answering keys —
+// the set's `←→ question`, and nothing at all for a panel of one.
+func (a *app) questionPanelInside(q questionShown, width, inner, above int, edge []questionVerb) (rows []string, keyRow string, keys []questionVerb) {
+ if len(q.beat) > 0 {
+ return a.questionPanelBeat(q, above, inner)
+ }
+ first := len(a.questionBands)
+ // THE BODY IS THE EVIDENCED ONE, so a tab reads exactly as the panel of one
+ // does: where the answers carry blocks, the pointer's own evidence stands
+ // beside them or unfolds under the row (#972). Everything the caller has
+ // already laid inside the frame is height the evidence may not have, which
+ // is what `above` less the frame's own top edge counts.
+ rows = a.questionPanelEvidenced(q, width, inner, above-1)
+ for i := first; i < len(a.questionBands); i++ {
+ a.questionBands[i].row += above
+ }
+ keys = a.questionAnswerKeys(q, formsCard)
+ keyRow = a.questionKeyRow(q, append(append([]questionVerb{}, edge...), questionKeysOnTier(keys, keyPrimary)...), frameEdgeRoom(width))
+ if q.writing != "" {
+ // THE EDGE SAYS WHAT THE BOX MEANS while the composer is pointed at the
+ // question ([app.questionWritingRow]): every letter types, so an edge
+ // naming letters would be naming keys that do something else.
+ keyRow = a.pal.dim(fit(a.questionWritingRow(q), frameEdgeRoom(width)))
+ }
+ return rows, keyRow, keys
+}
+
// questionPanelBeat is the widening answer asking how far it goes, drawn in the
// same frame rather than in a drawing of its own: the shapes take the rows the
// answers had, and the bottom edge says the two keys that mean anything while
-// they are up.
+// they are up. It hands back no key table, so no second tier stands under it.
//
// IT IS ONE ROW OF SHAPES AND NOT A ROW EACH, because the shapes are one
// question's answers ([app.questionBeatRow] holds the spans a click resolves
// against, and they are a row's worth).
-func (a *app) questionPanelBeat(q questionShown, width, inner int) []string {
- row := a.questionBeatRow(q, 2, inner)
+func (a *app) questionPanelBeat(q questionShown, above, inner int) (rows []string, keyRow string, keys []questionVerb) {
+ row := a.questionBeatRow(q, above+1, inner)
// The frame's side is one cell, so every span the beat recorded stands one
// column further right than the row itself counted it.
for i := range a.questionSpans {
a.questionSpans[i].from++
a.questionSpans[i].to++
}
- title, headRows := a.questionPanelHead(q, width, inner)
- panel := framed{
- title: title,
- aside: a.questionPanelAside(q, width),
- keys: a.pal.data("1–"+itoa(len(q.beat))) + a.pal.dim(" shape") +
- a.pal.dim(questionKeyGap) + a.pal.data(questionLaterKey) + a.pal.dim(" "+questionBeatBack),
- }
- out, _ := panel.draw(a.pal, width, append(headRows, "", row, ""))
- return out
+ return []string{"", row, ""}, a.pal.data("1–"+itoa(len(q.beat))) + a.pal.dim(" shape") +
+ a.pal.dim(questionKeyGap) + a.pal.data(questionLaterKey) + a.pal.dim(" "+questionBeatBack), nil
}
// questionPanelEvidenced is the panel's body where its answers brought
diff --git a/internal/tui3/questionset.go b/internal/tui3/questionset.go
new file mode 100644
index 0000000000..c5b18ed119
--- /dev/null
+++ b/internal/tui3/questionset.go
@@ -0,0 +1,1096 @@
+package tui3
+
+import (
+ "cmp"
+ "slices"
+ "strings"
+
+ tea "charm.land/bubbletea/v2"
+
+ "github.com/charmbracelet/x/ansi"
+
+ "github.com/Agent-Field/aforge-v2/internal/session"
+ "github.com/Agent-Field/aforge-v2/internal/tui2/tokens"
+)
+
+// ── SEVERAL QUESTIONS FROM ONE STEP ARE ONE PANEL ───────────────────────────
+//
+// A model that calls `ask` three times in one batch, or a batch of four reads
+// that each need a permission, puts several questions on somebody's screen in
+// the same instant. They are one moment to the person, and they used to be a
+// queue: the first drawn, `2 more` under it, no way back to one already
+// answered, and every answer sent the moment its key was pressed.
+//
+// THE SET IS THE ENGINE'S STEP, AND NOTHING ELSE. [session.Question.Batch] is
+// minted once per step of a turn and stamped on every question raised from
+// inside it, so "these belong together" is a fact the engine states rather than
+// a guess this surface makes. It replaced two guesses at once: the SHEET, which
+// held quiet questions back until "the model spoke again" (a proxy this surface
+// had to invent because the engine had no step to name) and then drew them as a
+// list with keys of its own, and the queue every blocking question from one
+// batch still stood in. Both are deleted; the set is read off the questions.
+//
+// ── THE TWO DRAWINGS, EXACTLY AS THEY ARE DRAWN ─────────────────────────────
+//
+// TABS (owner pick `multi — A`): one panel, a tab per question across the top
+// edge and a review tab after them. `←→` move between questions, `↑↓` walk the
+// answers of the one on screen, and `enter` takes an answer FOR THIS TAB — it is
+// held, and the panel moves on to the next question still waiting.
+//
+// ╭─ ● storage ○ naming ○ tests ✓ review ───────────────── 1 of 3 ─╮
+// │ │
+// │ ? Which storage for the session index? │
+// │ │
+// │ ▸ 1 SQLite one file beside the conversation ◆ recommended │
+// │ 2 JSONL append-only, no new dependency │
+// │ 3 something else… │
+// │ │
+// ╰─ ←→ question · ↑↓ choose · enter take it · esc later ────────────────╯
+//
+// ╭─ ✓ storage ✓ naming ○ tests ● review ───────────────── review ─╮
+// │ │
+// │ ✓ storage SQLite │
+// │ ✓ naming session-index.db │
+// │ ○ tests not answered — ← to go back │
+// │ │
+// │ ▸ send the 2 answered · 1 stays open │
+// │ │
+// ╰─ enter send · ← back · esc later ────────────────────────────────────╯
+//
+// ONE PERMISSION FRAME (the owner's approved "group permissions"): where every
+// question in the set is a permission with a plain grant and an answer that
+// loses nothing, the set is ONE decision about several calls, and it is asked
+// as one. `one by one` opens the same set as the tabs above.
+//
+// ╭─ ? allow these 4? ──────────────────────────────────────────── read ─╮
+// │ read internal/session/store.go │
+// │ read internal/session/loop.go │
+// │ read internal/session/agent.go │
+// │ read go.mod │
+// │ │
+// │ 1 allow all 4 │
+// │ 2 one by one │
+// │ ▸ 3 deny all safe answer │
+// ╰─ ↑↓ choose · enter take it · esc later ──────────────────────────────╯
+//
+// ── THE LAWS, AND WHAT EACH ONE REFUSES ─────────────────────────────────────
+//
+// - AN ANSWER IN A SET IS HELD UNTIL THE SET IS SENT, AND ONLY A KEY ON THE
+// PANEL HOLDS ONE. Every key a tab takes goes through the block's own
+// routing for the question on screen — the pointer, the digits, the
+// `something else…` row, the second beat, the lifetime row — and what that
+// routing would have sent is held on the question instead
+// ([questionShown.staged]). The hold is lent to the block for the length of
+// ONE keystroke ([app.questionStaging]), so an answer from home, from a
+// second window or from the project's own rule while nobody is here goes
+// straight through the door exactly as it always did.
+// - THE REVIEW SENDS THROUGH THE ONE DOOR, IN TAB ORDER, IN ONE COMMAND
+// ([app.sendAnswers]). A question still unanswered stays open and says so;
+// nothing is delegated to a pick the person did not press.
+// - `esc` IS LATER FOR THE WHOLE SET. It folds every tab to the one rule the
+// block folds a question to, and whatever was held stays held.
+// - AN IRREVERSIBLE QUESTION NEVER JOINS. It keeps its own frame with the
+// pointer on the answer that loses nothing, and neither a review nor an
+// `allow all` may carry it along with its neighbours ([app.questionJoinsSet]).
+
+// The set's own words, spelled once and quoted by the manual.
+const (
+ // questionReviewWord is the last tab, and the top edge's aside while it is
+ // the one on screen.
+ questionReviewWord = "review"
+ // questionTabWord is `←→` on a tab: it moves to another QUESTION, which is
+ // the one thing those two keys do on this panel.
+ questionTabWord = "question"
+ // questionOfWord joins the tab on screen to the count, in the aside: `1 of 3`.
+ questionOfWord = " of "
+ // questionNotAnsweredWord is a review row whose question has no answer held.
+ // It says where the answer is given, because a review a person cannot act
+ // on from is a list of complaints.
+ questionNotAnsweredWord = "not answered — ← to go back"
+ // questionResumesWord is what sending everything does, and it is said only
+ // where it is true: every question in the set is holding the turn.
+ questionResumesWord = "the turn resumes with every answer"
+ // questionAllowAllWord, questionApartWord and questionDenyAllWord are the
+ // permission frame's three answers.
+ questionAllowAllWord = "allow all "
+ questionApartWord = "one by one"
+ questionDenyAllWord = "deny all"
+ // questionTimesWord counts one thing wanted that several questions of the
+ // frame ask for alike, as `ask ×3`.
+ questionTimesWord = " ×"
+)
+
+// questionReviewTab is the review tab's place in [questionSetState.focus]. It
+// can never be a question's token, which is always a lane and an id.
+const questionReviewTab = "review"
+
+// questionSendAt is the review's send row among its pressable rows, which are
+// otherwise the set's questions by index.
+const questionSendAt = questionBandBack - 1
+
+// The permission frame's rows, in the order they are drawn and numbered.
+const (
+ questionGroupAllow = iota
+ questionGroupApart
+ questionGroupDeny
+ questionGroupRows
+)
+
+// questionSetState is what this window remembers about the set on screen that
+// the questions themselves cannot carry: which tab is showing, where the
+// permission frame's pointer stands, and whether the person asked to go one by
+// one.
+//
+// IT IS KEYED BY THE STEP, so a set that has just been sent and a set the next
+// step raises can never share a pointer: a state whose batch is not the one on
+// screen is read as the zero state ([app.questionSetNow]).
+type questionSetState struct {
+ batch string
+ // focus is the token of the tab showing, [questionReviewTab], or empty for
+ // "the first question still waiting".
+ focus string
+ // pick is the permission frame's pointer, and pickSet whether it has been
+ // placed yet (it opens where the members' own pointers would, once).
+ pick int
+ pickSet bool
+ // apart is `one by one`: the permission frame drawn as the tabs.
+ apart bool
+}
+
+// ── who is in a set ─────────────────────────────────────────────────────────
+
+// questionJoinsSet reports whether one question may be answered as a tab of
+// several. Every rung is a property of the question and states what it refuses.
+func (a *app) questionJoinsSet(q questionShown) bool {
+ switch {
+ case strings.TrimSpace(q.question.Batch) == "":
+ // A question raised outside any step has no neighbours — every landing,
+ // every fuel gate, anything this surface raised about itself.
+ return false
+ case !questionWaits(q.question):
+ // Nothing waits on a ratify line or an assumptions card, so there is no
+ // answer to hold and nothing to send.
+ return false
+ case q.question.Ask == session.AskConfirmation || q.question.Stakes == session.StakesIrreversible:
+ // AN IRREVERSIBLE QUESTION NEVER JOINS. It is asked on its own, with the
+ // pointer on the answer that loses nothing, every time.
+ return false
+ case a.questionOffers(q, needMoves):
+ // `←→` move a hole or a dial on such a question, and on a set they are
+ // the keys between questions — one key may not mean both on one screen.
+ return false
+ }
+ return true
+}
+
+// questionSetOf is the set the front of the queue belongs to: every open
+// question that shares its step and may join one, in the order of the rows
+// they are about. Fewer than two is no set at all, and the result is nil.
+//
+// THE TABS READ IN THE TRANSCRIPT'S ORDER, NOT IN THE ORDER THEY ARRIVED. The
+// calls of one step are gated side by side, so their questions land in
+// whichever order the gates happened to finish in — four reads drawn as
+// `e f g h` in the transcript arrived as `g f e h` — and a panel that listed
+// them so would have a person answering the third file while looking at the
+// first. A question with no row of its own keeps its arrival place after the
+// ones that have one.
+//
+// IT IS READ, NEVER KEPT. A question answered in another window, withdrawn, or
+// raised a moment later by the same step changes the set on the next frame
+// because the set is nothing but a reading of [app.questions].
+func (a *app) questionSetOf(open []questionShown) []questionShown {
+ if len(open) < 2 || !a.questionJoinsSet(open[0]) {
+ return nil
+ }
+ batch := open[0].question.Batch
+ var set []questionShown
+ for _, q := range open {
+ if q.question.Batch == batch && a.questionJoinsSet(q) {
+ set = append(set, q)
+ }
+ }
+ if len(set) < 2 {
+ return nil
+ }
+ at := func(q questionShown) int {
+ if row := a.questionSubjectAt(q.question); row >= 0 {
+ return row
+ }
+ return len(a.entries)
+ }
+ slices.SortStableFunc(set, func(x, y questionShown) int { return cmp.Compare(at(x), at(y)) })
+ return set
+}
+
+// questionSet is the set on the block now, or nil.
+func (a *app) questionSet() []questionShown { return a.questionSetOf(a.questionOpen()) }
+
+// questionTabbed reports whether this question is drawn as one tab of a set —
+// the chooser's rung (questionchooser.go).
+func (a *app) questionTabbed(q questionShown) bool {
+ for _, one := range a.questionSet() {
+ if one.token() == q.token() {
+ return true
+ }
+ }
+ return false
+}
+
+// questionSetNow is this window's state for the set on screen, reset to the
+// zero state for a set it has not seen before.
+func (a *app) questionSetNow(set []questionShown) *questionSetState {
+ if len(set) == 0 {
+ return &questionSetState{}
+ }
+ if a.questionSetAt.batch != set[0].question.Batch {
+ a.questionSetAt = questionSetState{batch: set[0].question.Batch}
+ }
+ return &a.questionSetAt
+}
+
+// questionSetFocus is which tab of the set is showing: an index into it, or -1
+// for the review.
+//
+// A TAB THAT WENT AWAY IS NOT A TAB THE PANEL STANDS ON. Its question was
+// answered somewhere else or withdrawn, so the panel moves to the first question
+// still waiting — or to the review, where everything left has an answer held.
+func (a *app) questionSetFocus(set []questionShown) int {
+ state := a.questionSetNow(set)
+ if state.focus == questionReviewTab {
+ return -1
+ }
+ for i, q := range set {
+ if q.token() == state.focus {
+ return i
+ }
+ }
+ for i, q := range set {
+ if q.staged == nil {
+ return i
+ }
+ }
+ if state.focus != "" {
+ return -1
+ }
+ return 0
+}
+
+// questionSetHead is the question the block is drawing when a set is up: the
+// tab on screen, or the set's first question while the review or the permission
+// frame is showing (that one's stamp is the set's settle guard).
+func (a *app) questionSetHead(set []questionShown) questionShown {
+ if a.questionGrouped(set) {
+ return set[0]
+ }
+ if at := a.questionSetFocus(set); at >= 0 {
+ return set[at]
+ }
+ return set[0]
+}
+
+// questionSetReviewing reports whether the review tab is the one on screen.
+func (a *app) questionSetReviewing(set []questionShown) bool {
+ return !a.questionGrouped(set) && a.questionSetFocus(set) < 0
+}
+
+// ── the permission frame ────────────────────────────────────────────────────
+
+// questionGrouped reports whether the set is asked as ONE permission frame.
+//
+// BOTH CLAUSES ARE LOAD-BEARING, and they answer different questions. Asking
+// PERMISSION is what lets the frame use permission's words: `allow all 4` and
+// `deny all` are a sentence about granting, and two confirmations from one step
+// — `stop this task?`, `close this tab?` — would wear it as a lie however their
+// answers happen to be shaped. Offering a plain GRANT AND AN ANSWER THAT LOSES
+// NOTHING is what lets those two rows do anything: each is that question's own
+// answer given in its own keys ([questionGrantAndSafe]), so a question with no
+// such pair has nothing for either row to give it.
+//
+// What is NOT here is any reading of the head or of [session.Question.Kind]: the
+// set itself is keyed on the engine's step ([questionSetOf]) and the frame on
+// the answers, never on how a question is spelled.
+func (a *app) questionGrouped(set []questionShown) bool {
+ if len(set) < 2 || a.questionSetNow(set).apart {
+ return false
+ }
+ for _, q := range set {
+ if q.question.Ask != session.AskPermission {
+ return false
+ }
+ if _, _, ok := questionGrantAndSafe(q.question); !ok {
+ return false
+ }
+ }
+ return true
+}
+
+// questionGrantAndSafe is one question's plain grant — the first answer that
+// neither loses nothing nor grants more than was asked — and its answer that
+// loses nothing, by key.
+func questionGrantAndSafe(q session.Question) (grant, safe string, ok bool) {
+ for i, option := range q.Options {
+ key := questionOptionKeyAt(q, i)
+ switch {
+ case option.Safe:
+ if safe == "" {
+ safe = key
+ }
+ case !option.Widening:
+ if grant == "" {
+ grant = key
+ }
+ }
+ }
+ return grant, safe, grant != "" && safe != ""
+}
+
+// questionGroupStart is where the permission frame's pointer opens: on `allow
+// all` only where every question's OWN pointer would open on its grant, and on
+// `deny all` otherwise. It is [questionPointerStart] read over the set rather
+// than a second rule — a set may not make `enter` mean yes where any one of its
+// questions, asked alone, would not.
+func questionGroupStart(set []questionShown) int {
+ for _, q := range set {
+ grant, _, _ := questionGrantAndSafe(q.question)
+ at := questionPointerStart(q.question)
+ if at >= len(q.question.Options) || questionOptionKeyAt(q.question, at) != grant {
+ return questionGroupDeny
+ }
+ }
+ return questionGroupAllow
+}
+
+// questionGroupPick is the frame's pointer, placed on first reading.
+func (a *app) questionGroupPick(set []questionShown) int {
+ state := a.questionSetNow(set)
+ if !state.pickSet {
+ state.pick, state.pickSet = questionGroupStart(set), true
+ }
+ return state.pick
+}
+
+// A GROUPED PERMISSION CARRIES NO LIFETIME ROW, for the reason a single one
+// does not (questionscope.go's [questionScopes]): [session.Answer.Scope] never
+// reaches the consent gate, so a row saying `from now on` over four calls would
+// promise four times over what it cannot do once. The row comes back here with
+// the grading (#953) — one row for the set, moved together — and until then the
+// frame offers `t` to nobody.
+
+// answerQuestionGroup is one of the frame's three rows taken.
+//
+// `allow all` AND `deny all` ARE EACH QUESTION'S OWN ANSWER, IN ITS OWN KEY,
+// through the one door and in one command — with the lifetime each would carry
+// had it been answered alone ([questionScopeOf]), so a question answered as one
+// of four is the same record as a question answered by itself. `one by one`
+// answers nothing: it opens the same set as tabs.
+func (a *app) answerQuestionGroup(set []questionShown, row int) tea.Cmd {
+ if row == questionGroupApart {
+ state := a.questionSetNow(set)
+ state.apart, state.focus = true, ""
+ a.touch()
+ return nil
+ }
+ all := make([]questionAnswer, 0, len(set))
+ for _, q := range set {
+ grant, safe, _ := questionGrantAndSafe(q.question)
+ key := safe
+ if row == questionGroupAllow {
+ key = grant
+ }
+ answer := session.Answer{Key: key, Picked: []string{key}}
+ if scope := questionScopeOf(q, key); scope != "" {
+ answer.Scope = scope
+ }
+ all = append(all, questionAnswer{q: q, answer: answer})
+ }
+ return a.sendAnswers(all)
+}
+
+// ── holding and sending ─────────────────────────────────────────────────────
+
+// stageAnswers holds every answer in `all` that belongs to the set a keystroke
+// is being routed for, and hands back the rest for the door.
+//
+// IT HOLDS ONLY WHAT WOULD HAVE SETTLED THE QUESTION. `? ask back` and a
+// landing's `tell it` send words while the question stays open
+// ([session.AnswerResolves]), so there is nothing for a review to send later
+// and they go through now, exactly as they would from a panel of one.
+func (a *app) stageAnswers(all []questionAnswer) []questionAnswer {
+ if a.questionStaging == "" {
+ return all
+ }
+ rest := all[:0:0]
+ staged := ""
+ for _, one := range all {
+ open := a.questionHeld(one.q.token())
+ if open == nil || open.question.Batch != a.questionStaging || !session.AnswerResolves(one.answer) {
+ rest = append(rest, one)
+ continue
+ }
+ answer := one.answer
+ open.staged = &answer
+ // THE POINTER STANDS ON THE ANSWER HELD, so going back to the tab shows
+ // what was taken rather than wherever the arrows last were.
+ if at, ok := questionOptionAt(open.question, answer.FirstKey()); ok {
+ open.pick = at
+ }
+ staged = open.token()
+ }
+ if staged != "" {
+ a.advanceQuestionSet(staged)
+ a.touch()
+ }
+ return rest
+}
+
+// advanceQuestionSet moves the panel on after an answer is held for the
+// question `from`: to the next question still waiting after it, round to the
+// first, and to the review once every question has an answer held.
+func (a *app) advanceQuestionSet(from string) {
+ set := a.questionSet()
+ if len(set) == 0 {
+ return
+ }
+ state := a.questionSetNow(set)
+ at := 0
+ for i, q := range set {
+ if q.token() == from {
+ at = i
+ }
+ }
+ for step := 1; step <= len(set); step++ {
+ at := (at + step) % len(set)
+ if set[at].staged == nil {
+ state.focus = set[at].token()
+ return
+ }
+ }
+ state.focus = questionReviewTab
+}
+
+// sendQuestionSet is `enter` on the review: every answer held, in tab order,
+// through the one door in one command. A question with nothing held stays open.
+func (a *app) sendQuestionSet(set []questionShown) tea.Cmd {
+ all := make([]questionAnswer, 0, len(set))
+ for _, q := range set {
+ if q.staged != nil {
+ all = append(all, questionAnswer{q: q, answer: *q.staged})
+ }
+ }
+ if len(all) == 0 {
+ return nil
+ }
+ state := a.questionSetNow(set)
+ state.focus = ""
+ return a.sendAnswers(all)
+}
+
+// moveQuestionTab is `←` or `→`: the next tab that way, the review last, and no
+// wrapping — a row of tabs somebody is reading along is not a carousel.
+func (a *app) moveQuestionTab(set []questionShown, by int) {
+ state := a.questionSetNow(set)
+ at := a.questionSetFocus(set)
+ if at < 0 {
+ at = len(set)
+ }
+ to := at + by
+ switch {
+ case to < 0 || to > len(set):
+ return
+ case to == len(set):
+ state.focus = questionReviewTab
+ default:
+ state.focus = set[to].token()
+ }
+ a.touch()
+}
+
+// foldQuestionSet is `esc` on the set: every tab folds, and whatever was held
+// stays held for when it is opened again.
+func (a *app) foldQuestionSet(set []questionShown) {
+ for _, q := range set {
+ a.foldQuestion(q)
+ }
+}
+
+// ── the keyboard ────────────────────────────────────────────────────────────
+
+// questionSetKey routes one key while a set is on the block, and reports
+// whether it took it. `head` is [app.questionHead]'s reading, which is the tab
+// on screen.
+func (a *app) questionSetKey(set []questionShown, head questionShown, msg tea.KeyPressMsg) (tea.Cmd, bool) {
+ key := msg.String()
+ if key == "ctrl+c" {
+ return nil, false
+ }
+ if a.questionGrouped(set) || a.questionSetReviewing(set) {
+ a.holdQuestionClocks()
+ if !a.questionSettled(head) {
+ // THE SETTLE GUARD, for the set's own rows exactly as for a tab's.
+ return nil, true
+ }
+ var cmd tea.Cmd
+ var took bool
+ if a.questionGrouped(set) {
+ cmd, took = a.questionGroupKey(set, key)
+ } else {
+ cmd, took = a.questionReviewKey(set, key)
+ }
+ if took && questionAimKey(key) {
+ a.aimQuestion(head.token())
+ }
+ return cmd, took
+ }
+ if a.questionSetOwnsKey(head, key) {
+ a.holdQuestionClocks()
+ if !a.questionSettled(head) {
+ return nil, true
+ }
+ switch key {
+ case "left":
+ a.moveQuestionTab(set, -1)
+ case "right":
+ a.moveQuestionTab(set, 1)
+ case questionLaterKey:
+ a.foldQuestionSet(set)
+ }
+ a.aimQuestion(head.token())
+ return nil, true
+ }
+ // EVERY OTHER KEY IS THE BLOCK'S OWN ROUTING FOR THE QUESTION ON SCREEN, with
+ // the set's hold lent to it for this one keystroke.
+ a.questionStaging = head.question.Batch
+ defer func() { a.questionStaging = "" }()
+ return a.questionKeyOn(head, msg)
+}
+
+// questionSetOwnsKey reports whether a key on a tab is the SET's rather than the
+// question's: `←→` between questions and `esc` for the whole set.
+//
+// A TAB THAT IS PART-WAY THROUGH SOMETHING KEEPS ITS OWN KEYS. The second beat
+// walks its shapes with `←→` and backs out with `esc`; the `something else…`
+// row and a box pointed at the question are caret and text, where `←→` move the
+// caret; and with words in the message box every arrow is the box's.
+func (a *app) questionSetOwnsKey(head questionShown, key string) bool {
+ if key != "left" && key != "right" && key != questionLaterKey {
+ return false
+ }
+ if len(head.beat) > 0 || head.writing != "" {
+ return false
+ }
+ if key == questionLaterKey {
+ return true
+ }
+ if strings.TrimSpace(a.input.String()) != "" || a.questionOthering(head) {
+ return false
+ }
+ return true
+}
+
+// questionReviewKey is a key on the review tab.
+func (a *app) questionReviewKey(set []questionShown, key string) (tea.Cmd, bool) {
+ switch key {
+ case questionLaterKey:
+ a.foldQuestionSet(set)
+ return nil, true
+ case "left", "shift+tab", "up":
+ a.moveQuestionTab(set, -1)
+ return nil, true
+ case "right", "tab", "down":
+ return nil, true
+ case questionEnterKey:
+ if strings.TrimSpace(a.input.String()) != "" {
+ return nil, false
+ }
+ return a.sendQuestionSet(set), true
+ }
+ return nil, false
+}
+
+// questionGroupKey is a key on the permission frame.
+func (a *app) questionGroupKey(set []questionShown, key string) (tea.Cmd, bool) {
+ pick := a.questionGroupPick(set)
+ state := a.questionSetNow(set)
+ typing := strings.TrimSpace(a.input.String()) != ""
+ switch key {
+ case questionLaterKey:
+ a.foldQuestionSet(set)
+ return nil, true
+ case "up", "left", "shift+tab":
+ if pick > 0 {
+ state.pick = pick - 1
+ a.touch()
+ }
+ return nil, true
+ case "down", "right", "tab":
+ if pick < questionGroupRows-1 {
+ state.pick = pick + 1
+ a.touch()
+ }
+ return nil, true
+ case questionEnterKey:
+ if typing {
+ return nil, false
+ }
+ return a.answerQuestionGroup(set, pick), true
+ }
+ if typing {
+ return nil, false
+ }
+ if row := questionGroupDigit(key); row >= 0 {
+ return a.answerQuestionGroup(set, row), true
+ }
+ return nil, false
+}
+
+// questionGroupDigit is the row a digit names on the frame, or -1.
+func questionGroupDigit(key string) int {
+ for row := 0; row < questionGroupRows; row++ {
+ if key == itoa(row+1) {
+ return row
+ }
+ }
+ return -1
+}
+
+// questionSetPress resolves a click on the set's own rows — the permission
+// frame's three answers and the review's rows — and reports whether the set
+// had it. A click on a tab's answers is the block's own ([app.questionPress]).
+func (a *app) questionSetPress(set []questionShown, head questionShown, x, y int) (tea.Cmd, bool, bool) {
+ grouped, reviewing := a.questionGrouped(set), a.questionSetReviewing(set)
+ if !grouped && !reviewing {
+ return nil, false, false
+ }
+ mark, found := a.chromeAt(y)
+ if !found || mark.kind != chromeQuestion {
+ return nil, false, true
+ }
+ for _, band := range a.questionBands {
+ if band.row != mark.index || !band.span.holds(x) {
+ continue
+ }
+ a.aimQuestion(head.token())
+ if grouped {
+ return a.answerQuestionGroup(set, band.at), true, true
+ }
+ if band.at == questionSendAt {
+ return a.sendQuestionSet(set), true, true
+ }
+ a.questionSetNow(set).focus = set[band.at].token()
+ a.touch()
+ return nil, true, true
+ }
+ return nil, false, true
+}
+
+// ── drawing ─────────────────────────────────────────────────────────────────
+
+// questionSetRows draws the set: the permission frame, the review, or the tab
+// on screen.
+func (a *app) questionSetRows(set []questionShown, width int) []string {
+ // THE SET ARRIVED AS ONE OBJECT, so every question in it is on the screen
+ // from the frame it was first drawn on — a tab's words are in the top edge
+ // whichever tab is showing.
+ for _, q := range set {
+ a.markQuestionShown(q.token())
+ }
+ switch {
+ case a.questionGrouped(set):
+ return a.questionGroupRows(set, width)
+ case a.questionSetReviewing(set):
+ return a.questionReviewRows(set, width)
+ }
+ return a.questionTabRows(set, a.questionSetFocus(set), width)
+}
+
+// questionTabRows is one tab: the question's own sentence first, then exactly
+// what a panel of one draws under it ([app.questionPanelInside]) — its answers,
+// or its beat's shapes where it is part-way through the widening answer — with
+// the tabs in the top edge and `←→ question` in the bottom one.
+func (a *app) questionTabRows(set []questionShown, at, width int) []string {
+ q := set[at]
+ inner := frameInner(width)
+ room := max(inner-2*len(questionPanelGap), 8)
+ rows := []string{""}
+ headRows := wrap(strings.TrimSpace(q.question.Head), max(room-2, 8))
+ for i, line := range headRows {
+ lead := " "
+ if i == 0 {
+ lead = a.questionMarkFor(q.question) + " "
+ }
+ rows = append(rows, questionPanelGap+lead+a.pal.ink(line))
+ }
+ // The frame's top edge, the blank row and the sentence all stand above what
+ // the question itself draws, and the inside records its marks knowing it.
+ body, keyRow, keys := a.questionPanelInside(q, width, inner, 1+1+len(headRows), questionSetVerbs(formsTabs))
+ rows = append(rows, body...)
+ aside := a.pal.dim(itoa(at+1) + questionOfWord + itoa(len(set)))
+ panel := framed{
+ title: a.questionTabStrip(set, at, frameEdgeRoom(width)-ansi.StringWidth(ansi.Strip(aside))-2),
+ aside: aside,
+ keys: keyRow,
+ }
+ out, _ := panel.draw(a.pal, width, rows)
+ if second := a.questionPanelSecond(q, keys, width); second != "" && q.writing == "" {
+ out = append(out, second)
+ }
+ return out
+}
+
+// questionReviewRows is the review: every question with the answer held for it,
+// and the one row that sends them.
+func (a *app) questionReviewRows(set []questionShown, width int) []string {
+ inner := frameInner(width)
+ room := max(inner-2*len(questionPanelGap), 8)
+ labels := a.questionTabLabels(set)
+ column := 0
+ for _, label := range labels {
+ column = max(column, ansi.StringWidth(label))
+ }
+ column = min(column, room/3)
+ rows := []string{""}
+ held := 0
+ for i, q := range set {
+ mark, words := a.pal.dim(a.icon(tokens.GStepPending)), a.pal.dim(questionNotAnsweredWord)
+ if q.staged != nil {
+ held++
+ mark, words = a.pal.dim(a.icon(tokens.GSettled)), a.pal.ink(questionHeldWords(q))
+ }
+ label := fit(labels[i], column)
+ line := mark + " " + a.pal.ink(label) + strings.Repeat(" ", column-ansi.StringWidth(label)+3) + words
+ a.questionBands = append(a.questionBands, questionBand{
+ row: 1 + len(rows), span: hudSpan{from: 0, to: inner + 2}, at: i,
+ })
+ rows = append(rows, questionPanelGap+fitPainted(line, room))
+ }
+ rows = append(rows, "")
+ keys := questionSetVerbs(formsReview)
+ if held == 0 {
+ // A REVIEW WITH NOTHING HELD SAYS SO BY WHAT IS NOT THERE. Every row
+ // already reads `not answered — ← to go back` and the send row is absent,
+ // so a sentence under them saying the same thing a third time is the
+ // emptiness law's "never a line saying a group is empty" — and on a set
+ // of two it printed one sentence three times.
+ keys = questionDropVerbKey(keys, questionEnterKey)
+ } else {
+ aside := ""
+ if held == len(set) && questionSetHoldsTheTurn(set) {
+ aside = a.pal.dim(questionResumesWord)
+ }
+ a.questionBands = append(a.questionBands, questionBand{
+ row: 1 + len(rows), span: hudSpan{from: 0, to: inner + 2}, at: questionSendAt,
+ })
+ rows = append(rows, a.questionSendRow(questionSendWord(held, len(set)), aside, room), "")
+ }
+ aside := a.pal.dim(questionReviewWord)
+ panel := framed{
+ title: a.questionTabStrip(set, -1, frameEdgeRoom(width)-ansi.StringWidth(questionReviewWord)-2),
+ aside: aside,
+ keys: a.questionKeyRow(questionShown{}, keys, frameEdgeRoom(width)),
+ }
+ out, _ := panel.draw(a.pal, width, rows)
+ return out
+}
+
+// questionSendRow is the review's one answer: the pointer, what it sends, and
+// what sending does, on the ground a pointed row stands on. It has no key
+// cell, because `enter` is the only key that takes it and the edge says so.
+func (a *app) questionSendRow(word, aside string, room int) string {
+ line := questionPanelGap + a.pal.warnBold(a.icon(tokens.GPointer)) + " " + a.pal.ink(word)
+ if aside != "" {
+ if gap := room - ansi.StringWidth(ansi.Strip(line)) - ansi.StringWidth(ansi.Strip(aside)); gap > 1 {
+ line += strings.Repeat(" ", gap) + aside
+ }
+ }
+ width := room + 2*len(questionPanelGap)
+ if a.questionHovering(questionHoverPanel, questionSendAt) {
+ return a.pal.cursor(line, width)
+ }
+ return a.pal.background(line, width, a.pal.ramp.selected)
+}
+
+// questionGroupRows is the permission frame: what each call wants, then the
+// three answers, then the one row of lifetimes they all offer.
+func (a *app) questionGroupRows(set []questionShown, width int) []string {
+ inner := frameInner(width)
+ room := max(inner-2*len(questionPanelGap), 8)
+ rows := make([]string, 0, len(set)+8)
+ tools := make([]string, 0, 2)
+ // ONE ROW PER THING WANTED, NOT PER QUESTION. Three `ask` calls with nothing
+ // to tell them apart are one row that says `×3`, because three identical rows
+ // read as the surface stuttering rather than as three requests.
+ var wants []string
+ count := map[string]int{}
+ for _, q := range set {
+ tool, call := a.questionPanelTool(q), a.questionPanelCall(q)
+ // A CALL TOO LONG FOR ITS ROW LOSES ITS MIDDLE, never its end: four
+ // reads from one folder differ only in the file, and a row cut from the
+ // right would draw four copies of the folder.
+ callRoom := room
+ if tool != "" && call != tool {
+ callRoom -= ansi.StringWidth(tool) + 2
+ }
+ if ansi.StringWidth(call) > callRoom {
+ call = rowMiddle(call, callRoom)
+ }
+ var want string
+ switch {
+ case tool != "" && call != "" && call != tool:
+ want = a.pal.dim(tool) + " " + a.pal.data(call)
+ case call != "":
+ want = a.pal.data(call)
+ default:
+ want = a.pal.ink(strings.TrimSpace(q.question.Head))
+ }
+ if count[want]++; count[want] == 1 {
+ wants = append(wants, want)
+ }
+ if tool != "" && !questionHasWord(tools, tool) {
+ tools = append(tools, tool)
+ }
+ }
+ for _, want := range wants {
+ if n := count[want]; n > 1 {
+ want += a.pal.dim(questionTimesWord + itoa(n))
+ }
+ rows = append(rows, questionPanelGap+fitPainted(want, room))
+ }
+ rows = append(rows, "")
+ pick := a.questionGroupPick(set)
+ words := [questionGroupRows]string{
+ questionAllowAllWord + itoa(len(set)), questionApartWord, questionDenyAllWord,
+ }
+ for row, word := range words {
+ aside := ""
+ if row == questionGroupDeny && pick == questionGroupDeny {
+ // THE SAFE ANSWER SAYS SO, for [app.questionPanelOption]'s reason:
+ // a pointer standing on one answer with nothing saying why reads as
+ // the surface having chosen.
+ aside = a.pal.dim(questionSafeWord)
+ }
+ for _, line := range a.questionPanelRow(set[0], itoa(row+1), "", word, "", aside, 0, room, row == pick, a.questionHovering(questionHoverPanel, row)) {
+ a.questionBands = append(a.questionBands, questionBand{
+ row: 1 + len(rows), span: hudSpan{from: 0, to: inner + 2}, at: row,
+ })
+ rows = append(rows, line)
+ }
+ }
+ rows = append(rows, "")
+ keys := questionSetVerbs(formsGroup)
+ title := a.questionMarkFor(set[0].question) + " " + a.pal.ink("allow these "+itoa(len(set))+"?")
+ panel := framed{
+ title: title,
+ aside: a.pal.dim(strings.Join(tools, questionKeyGap)),
+ keys: a.questionKeyRow(questionShown{}, questionKeysOnTier(keys, keyPrimary), frameEdgeRoom(width)),
+ }
+ out, _ := panel.draw(a.pal, width, rows)
+ if second := questionKeysOnTier(keys, keySecondary); len(second) > 0 {
+ out = append(out, " "+a.questionKeyRow(questionShown{}, second, max(width-2, 1)))
+ }
+ return out
+}
+
+// questionTabStrip is the top edge of a set: a mark and a word per question and
+// the review last. The tab on screen wears the filled dot and ink, a question
+// with an answer held wears the tick, and one still waiting the hollow dot.
+//
+// THE WORDS SHARE THE EDGE EQUALLY and are cut to their share rather than
+// pushing the review off the end: the review is the tab that sends, and an edge
+// that dropped it would be a set with no way to finish it.
+func (a *app) questionTabStrip(set []questionShown, at, room int) string {
+ labels := a.questionTabLabels(set)
+ const gap = " "
+ // Every tab spends its mark, a space and the gap before the next; the
+ // review's word is kept whole.
+ overhead := (len(set)+1)*(2+len(gap)) - len(gap) + ansi.StringWidth(questionReviewWord)
+ share := (room - overhead) / len(set)
+ parts := make([]string, 0, len(set)+1)
+ // THE MARKS ARE THE STEP DOTS' OWN, because a strip of tabs is a sequence
+ // with a place in it and that is what those two marks already say: the
+ // filled dot is where you are and the hollow one is still to come. Minting
+ // a pair of tab-shaped glyphs drew the same two cells in all three tiers
+ // under two more names, which is the one-glyph-one-meaning law's whole
+ // subject. `✓` keeps its own meaning — settled — and a tab wears it only
+ // once its question has an answer held.
+ held := 0
+ for i, q := range set {
+ label := ""
+ if share >= 3 {
+ label = fit(labels[i], share)
+ }
+ switch {
+ case i == at:
+ parts = append(parts, a.pal.warnBold(a.icon(tokens.GStepDone))+" "+a.pal.ink(label))
+ case q.staged != nil:
+ held++
+ parts = append(parts, a.pal.dim(a.icon(tokens.GSettled)+" "+label))
+ default:
+ parts = append(parts, a.pal.dim(a.icon(tokens.GStepPending)+" "+label))
+ }
+ }
+ if at >= 0 && set[at].staged != nil {
+ held++
+ }
+ // AND THE REVIEW IS SETTLED ONLY WHEN THERE IS NOTHING LEFT TO HOLD. It wore
+ // the tick whenever it was not the tab on screen, so `✓ review` stood over a
+ // set nobody had answered — the one mark on this surface that means "done",
+ // promising it about the one tab that had not been opened.
+ switch {
+ case at < 0:
+ parts = append(parts, a.pal.warnBold(a.icon(tokens.GStepDone))+" "+a.pal.ink(questionReviewWord))
+ case held == len(set):
+ parts = append(parts, a.pal.dim(a.icon(tokens.GSettled)+" "+questionReviewWord))
+ default:
+ parts = append(parts, a.pal.dim(a.icon(tokens.GStepPending)+" "+questionReviewWord))
+ }
+ return strings.Join(parts, gap)
+}
+
+// questionTabLabels is what each tab is called: what tells a question apart
+// from its neighbours.
+//
+// A QUESTION ABOUT A CALL IS CALLED BY WHAT THE CALL TOUCHES, because four
+// permissions from one batch all say `needs your ok to run read` and differ only
+// in the file. ANY OTHER QUESTION IS CALLED BY ITS OWN SENTENCE. Either way the
+// lead every label shares is taken off ([questionTrimShared]) — `read
+// package.json?` and `read go.mod?` are `package.json` and `go.mod`, and
+// `/tmp/work/e.txt` and `/tmp/work/f.txt` are `e.txt` and `f.txt` — because the
+// words the set shares are the words that do not help anybody pick a tab.
+func (a *app) questionTabLabels(set []questionShown) []string {
+ labels := make([]string, len(set))
+ for i, q := range set {
+ labels[i] = strings.TrimSpace(strings.TrimRight(strings.TrimSpace(q.question.Head), "?"))
+ if call := a.questionPanelCall(q); call != "" && call != a.questionPanelTool(q) {
+ labels[i] = call
+ }
+ }
+ return questionTrimShared(labels)
+}
+
+// questionTrimShared takes off the lead every label shares, cut back to the
+// last word or path boundary inside it so no label starts halfway through a
+// word. It takes nothing when that would leave a label empty: a tab called by
+// nothing is worse than a tab called by too much.
+func questionTrimShared(labels []string) []string {
+ if len(labels) < 2 {
+ return labels
+ }
+ lead := labels[0]
+ for _, label := range labels[1:] {
+ n := 0
+ for n < len(lead) && n < len(label) && lead[n] == label[n] {
+ n++
+ }
+ lead = lead[:n]
+ }
+ cut := strings.LastIndexAny(lead, " /") + 1
+ out := make([]string, len(labels))
+ for i, label := range labels {
+ out[i] = strings.TrimSpace(label[cut:])
+ if out[i] == "" {
+ return labels
+ }
+ }
+ return out
+}
+
+// questionHeldWords is the answer held for one question, in the words its own
+// answers are drawn in.
+func questionHeldWords(q questionShown) string {
+ if q.staged == nil {
+ return ""
+ }
+ words := strings.Join(questionLabels(q.question, q.staged.Keys()), ", ")
+ if change := strings.TrimSpace(q.staged.Change); change != "" {
+ if words == "" {
+ return change
+ }
+ return words + " · " + change
+ }
+ return words
+}
+
+// questionSendWord is the review's one answer, said as what it will send.
+func questionSendWord(held, of int) string {
+ if held == of {
+ return "send all " + itoa(of)
+ }
+ left := of - held
+ if left == 1 {
+ return "send the " + itoa(held) + " answered · 1 stays open"
+ }
+ return "send the " + itoa(held) + " answered · " + itoa(left) + " stay open"
+}
+
+// questionSetHoldsTheTurn reports whether every question in the set is holding
+// the turn, which is the one case where "the turn resumes" is true of sending.
+func questionSetHoldsTheTurn(set []questionShown) bool {
+ for _, q := range set {
+ if !q.question.Blocking.Turn {
+ return false
+ }
+ }
+ return true
+}
+
+// questionSetVerbs is the key table's rows for one of the set's drawings, in
+// the table's order. The set's drawings are not a question, so there is no
+// question's condition to read; what depends on the drawing is taken off by the
+// drawer ([questionDropVerbKey]).
+func questionSetVerbs(form questionForms) []questionVerb {
+ out := make([]questionVerb, 0, 4)
+ for _, verb := range questionKeys {
+ if verb.forms.holds(form) {
+ out = append(out, verb)
+ }
+ }
+ return out
+}
+
+// questionDropVerbKey is a row of keys without one key.
+func questionDropVerbKey(keys []questionVerb, key string) []questionVerb {
+ out := keys[:0:0]
+ for _, verb := range keys {
+ if verb.key != key {
+ out = append(out, verb)
+ }
+ }
+ return out
+}
+
+func questionHasWord(words []string, word string) bool {
+ for _, one := range words {
+ if one == word {
+ return true
+ }
+ }
+ return false
+}
+
+// ── folded, and opened again ────────────────────────────────────────────────
+
+// questionSetFolded is every folded question sharing a step with `q` that may
+// join a set with it, `q` included — the set a fold rule is standing for.
+func (a *app) questionSetFolded(q questionShown) []questionShown {
+ if !a.questionJoinsSet(q) {
+ return nil
+ }
+ var out []questionShown
+ for _, one := range a.questions {
+ if a.questionFolded[one.token()] && one.question.Batch == q.question.Batch && a.questionJoinsSet(one) {
+ out = append(out, one)
+ }
+ }
+ if len(out) < 2 {
+ return nil
+ }
+ return out
+}
+
+// questionSetFoldedRow is a folded set's one rule: how many questions, and what
+// each is called.
+func (a *app) questionSetFoldedRow(set []questionShown, width int) string {
+ words := itoa(len(set)) + " questions · " + strings.Join(a.questionTabLabels(set), ", ")
+ title := a.questionMarkFor(set[0].question) + a.pal.dim(" "+words)
+ return framed{title: title, aside: a.pal.dim(questionOpenFoldWord)}.rule(a.pal, width)
+}
diff --git a/internal/tui3/questionset_test.go b/internal/tui3/questionset_test.go
new file mode 100644
index 0000000000..05183e051d
--- /dev/null
+++ b/internal/tui3/questionset_test.go
@@ -0,0 +1,568 @@
+package tui3
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/Agent-Field/aforge-v2/internal/session"
+)
+
+// setQuestion is one model question raised by the step `batch`: two answers and
+// the first recommended, which is the ordinary shape of an `ask`.
+func setQuestion(id uint64, batch, head string) session.Question {
+ return session.Question{
+ ID: id, Kind: session.QuestionAsk, Ask: session.AskChoice, Batch: batch,
+ Asker: session.Asker{Kind: session.AskerModel},
+ Head: head,
+ Options: []session.AnswerOption{
+ {Key: "1", Label: "sqlite", Consequence: "one file"},
+ {Key: "2", Label: "jsonl", Consequence: "append only"},
+ },
+ Pick: &session.Pick{Key: "1"},
+ Stakes: session.StakesReversible,
+ Blocking: session.Blocking{Turn: true},
+ }
+}
+
+// setPermission is one approval raised by the step `batch`, in the engine's own
+// answers for that lane, about a call to `tool` on `target` that is already a
+// row in the conversation (the frame reads the call off its row).
+func setPermission(lab *questionLab, id uint64, batch, tool, target string, stakes session.Stakes) session.Question {
+ call := "call-" + itoa(int(id))
+ lab.a.entries = append(lab.a.entries, entry{
+ kind: entryTool, tool: tool, text: tool + " " + target, turn: lab.a.turn,
+ status: toolConsent, callID: call,
+ })
+ return session.Question{
+ ID: id, Kind: session.QuestionConsent, Ask: session.AskPermission, Batch: batch,
+ Asker: session.Asker{Kind: session.AskerEngine},
+ Head: "needs your ok to run " + tool,
+ Subject: session.SubjectRef{Kind: session.SubjectCall, CallID: call, Name: tool},
+ Options: session.AnswerOptions(session.QuestionConsent),
+ Stakes: stakes,
+ Blocking: session.Blocking{Turn: true},
+ Scope: []session.AnswerScope{session.ScopeOnce, session.ScopeAlways},
+ }
+}
+
+// setLab raises three questions from one step and spends the settle guard.
+func setLab(t *testing.T) *questionLab {
+ t.Helper()
+ lab := newQuestionLab(t)
+ lab.raise(setQuestion(1, "step:4", "Which storage for the session index?"))
+ lab.raise(setQuestion(2, "step:4", "What should the index file be called?"))
+ lab.raise(setQuestion(3, "step:4", "Which tests should cover it?"))
+ lab.tick(questionSettle * 2)
+ lab.rows()
+ return lab
+}
+
+func heldOf(lab *questionLab, id uint64) *session.Answer {
+ for _, q := range lab.a.questions {
+ if q.question.ID == id {
+ return q.staged
+ }
+ }
+ return nil
+}
+
+// SEVERAL QUESTIONS FROM ONE STEP ARE ONE PANEL: a tab each and a review in the
+// top edge, the question on screen in the body, and `←→ question` first on the
+// bottom edge.
+func TestSeveralQuestionsFromOneStepAreOnePanelWithATabEach(t *testing.T) {
+ lab := setLab(t)
+ drawn := lab.plain()
+ for _, want := range []string{"1 of 3", questionReviewWord, "←→ " + questionTabWord, "Which storage for the session index?", "sqlite"} {
+ if !strings.Contains(drawn, want) {
+ t.Fatalf("the set's panel does not say %q:\n%s", want, drawn)
+ }
+ }
+ if strings.Contains(drawn, "more") {
+ t.Fatalf("a set drew its own members as a queue behind it:\n%s", drawn)
+ }
+ if got := lab.a.questionViewOf(lab.a.questions[0], lab.a.width); got != viewTabs {
+ t.Fatalf("the chooser gave the set's question view %d, want the tabs", got)
+ }
+}
+
+// A QUESTION FROM ANOTHER STEP IS NOT A TAB, and neither is one raised outside
+// any step: the set is the engine's step and nothing else.
+func TestOnlyTheSameStepMakesASet(t *testing.T) {
+ lab := newQuestionLab(t)
+ lab.raise(setQuestion(1, "step:4", "Which storage?"))
+ lab.raise(setQuestion(2, "step:5", "Which name?"))
+ lone := setQuestion(3, "", "Which tests?")
+ lab.raise(lone)
+ if set := lab.a.questionSet(); set != nil {
+ t.Fatalf("questions from three different steps made a set of %d", len(set))
+ }
+ if drawn := lab.plain(); !strings.Contains(drawn, "2 more") {
+ t.Fatalf("the questions behind the first are not counted:\n%s", drawn)
+ }
+}
+
+// ENTER HOLDS THE ANSWER AND MOVES ON. Nothing reaches the engine until the
+// review sends it.
+func TestEnterOnATabHoldsTheAnswerAndMovesToTheNextQuestion(t *testing.T) {
+ lab := setLab(t)
+ lab.press("enter")
+ if len(lab.answer) != 0 {
+ t.Fatalf("a tab sent its answer before the review: %+v", lab.answer)
+ }
+ if held := heldOf(lab, 1); held == nil || held.Key != "1" {
+ t.Fatalf("the first question holds %+v, want its pick", held)
+ }
+ if head, _ := lab.a.questionHead(); head.question.ID != 2 {
+ t.Fatalf("the panel stayed on question %d, want the next one waiting", head.question.ID)
+ }
+ if drawn := lab.plain(); !strings.Contains(drawn, "2 of 3") || !strings.Contains(drawn, "What should the index file be called?") {
+ t.Fatalf("the second tab is not the one on screen:\n%s", drawn)
+ }
+}
+
+// ←→ GO BETWEEN QUESTIONS, AND AN ANSWER HELD CAN BE CHANGED — "go back to the
+// previous question".
+func TestTheArrowsGoBackAndAHeldAnswerCanBeChanged(t *testing.T) {
+ lab := setLab(t)
+ lab.press("enter")
+ lab.press("left")
+ if head, _ := lab.a.questionHead(); head.question.ID != 1 {
+ t.Fatalf("← left the panel on question %d, want the first", head.question.ID)
+ }
+ lab.press("2")
+ if held := heldOf(lab, 1); held == nil || held.Key != "2" {
+ t.Fatalf("the changed answer is %+v, want 2", held)
+ }
+ if len(lab.answer) != 0 {
+ t.Fatalf("changing a held answer sent it: %+v", lab.answer)
+ }
+ // AND THE ENDS STOP: ← on the first tab goes nowhere.
+ lab.press("left")
+ lab.press("left")
+ if head, _ := lab.a.questionHead(); head.question.ID != 1 {
+ t.Fatalf("← wrapped round to question %d", head.question.ID)
+ }
+}
+
+// THE REVIEW SENDS EVERY HELD ANSWER, IN TAB ORDER, THROUGH THE ONE DOOR IN ONE
+// COMMAND — "answer them all at once".
+func TestTheReviewSendsEveryAnswerInTabOrderInOneCommand(t *testing.T) {
+ lab := setLab(t)
+ lab.press("2")
+ lab.press("enter")
+ lab.press("enter")
+ if !lab.a.questionSetReviewing(lab.a.questionSet()) {
+ t.Fatalf("with every question answered the panel is not on the review:\n%s", lab.plain())
+ }
+ drawn := lab.plain()
+ if !strings.Contains(drawn, questionSendWord(3, 3)) || !strings.Contains(drawn, questionResumesWord) {
+ t.Fatalf("the review does not offer to send all three:\n%s", drawn)
+ }
+ cmd, took := lab.a.questionKey(questionPressOf("enter"))
+ if !took || cmd == nil {
+ t.Fatal("enter on the review sent nothing")
+ }
+ if msgs := runCmd(cmd); len(msgs) != 1 {
+ t.Fatalf("the review became %d door trips, want one", len(msgs))
+ } else {
+ drive(t, lab.a, msgs...)
+ }
+ if len(lab.answer) != 3 {
+ t.Fatalf("the door was told %d answers, want three", len(lab.answer))
+ }
+ for i, want := range []string{"2", "1", "1"} {
+ if lab.answer[i].ID != uint64(i+1) || lab.answer[i].Key != want {
+ t.Fatalf("answer %d went out as %d/%q, want %d/%q", i, lab.answer[i].ID, lab.answer[i].Key, i+1, want)
+ }
+ }
+ if len(lab.a.questions) != 0 {
+ t.Fatalf("%d questions are still open after the review sent them", len(lab.a.questions))
+ }
+}
+
+// A QUESTION NOBODY ANSWERED STAYS OPEN, and the review says so rather than
+// sending a pick nobody pressed.
+func TestTheReviewSendsWhatIsHeldAndLeavesTheRestOpen(t *testing.T) {
+ lab := setLab(t)
+ lab.press("enter")
+ lab.press("right")
+ lab.press("right")
+ lab.press("right")
+ drawn := lab.plain()
+ if !strings.Contains(drawn, questionNotAnsweredWord) || !strings.Contains(drawn, questionSendWord(1, 3)) {
+ t.Fatalf("the review does not say two are unanswered:\n%s", drawn)
+ }
+ lab.press("enter")
+ if len(lab.answer) != 1 || lab.answer[0].ID != 1 {
+ t.Fatalf("the review sent %+v, want only the one answered", lab.answer)
+ }
+ if len(lab.a.questions) != 2 {
+ t.Fatalf("%d questions are left, want the two nobody answered", len(lab.a.questions))
+ }
+ if set := lab.a.questionSet(); len(set) != 2 {
+ t.Fatalf("the two left are not still a set: %d", len(set))
+ }
+}
+
+// A REVIEW WITH NOTHING HELD HAS NOTHING TO SEND, and says where the answers are
+// given on the rows themselves — once each, and never a fourth sentence under
+// them saying the set is empty (the emptiness law).
+func TestAReviewWithNothingHeldOffersNoSend(t *testing.T) {
+ lab := setLab(t)
+ for i := 0; i < 3; i++ {
+ lab.press("right")
+ }
+ drawn := lab.plain()
+ if strings.Contains(drawn, "enter send") {
+ t.Fatalf("an empty review offered to send:\n%s", drawn)
+ }
+ if got, want := strings.Count(drawn, questionNotAnsweredWord), 3; got != want {
+ t.Fatalf("an empty review says where to answer %d times, want %d (one per row):\n%s", got, want, drawn)
+ }
+ lab.press("enter")
+ if len(lab.answer) != 0 {
+ t.Fatalf("an empty review sent %+v", lab.answer)
+ }
+}
+
+// ESC IS LATER FOR THE WHOLE SET. Every tab folds to one rule, the count does not
+// drop, and opening it again brings back every tab with every held answer.
+func TestEscIsLaterForTheWholeSetAndKeepsWhatWasHeld(t *testing.T) {
+ lab := setLab(t)
+ lab.press("enter")
+ lab.press("esc")
+ if len(lab.answer) != 0 {
+ t.Fatalf("esc answered something: %+v", lab.answer)
+ }
+ if lab.a.questioning() {
+ t.Fatal("esc left part of the set on the block")
+ }
+ if got := lab.a.questionCount(); got != 3 {
+ t.Fatalf("the chip counts %d after esc, want all three", got)
+ }
+ if drawn := lab.plain(); !strings.Contains(drawn, "3 questions") {
+ t.Fatalf("the folded set's rule does not say how many:\n%s", drawn)
+ }
+ lab.a.raiseFolded()
+ if set := lab.a.questionSet(); len(set) != 3 {
+ t.Fatalf("opening the fold brought back %d tabs, want three", len(set))
+ }
+ if held := heldOf(lab, 1); held == nil {
+ t.Fatal("the answer held before esc was lost")
+ }
+}
+
+// AN IRREVERSIBLE QUESTION NEVER JOINS. It stands on its own frame with the
+// pointer on the answer that loses nothing.
+func TestAnIrreversibleQuestionNeverJoinsASet(t *testing.T) {
+ lab := newQuestionLab(t)
+ lab.raise(setQuestion(1, "step:4", "Which storage?"))
+ risky := setQuestion(2, "step:4", "Drop the old table?")
+ risky.Stakes, risky.Pick = session.StakesIrreversible, nil
+ lab.raise(risky)
+ if set := lab.a.questionSet(); set != nil {
+ t.Fatalf("an irreversible question joined a set of %d", len(set))
+ }
+}
+
+// ONLY A KEY ON THE PANEL HOLDS AN ANSWER. The same question answered from home
+// or by the project's rule goes straight through the door.
+func TestAnAnswerFromAnywhereButThePanelIsNotHeld(t *testing.T) {
+ lab := setLab(t)
+ member := lab.a.questions[1]
+ lab.spend(lab.a.answerQuestion(member, session.Answer{Key: "2", Picked: []string{"2"}}))
+ if len(lab.answer) != 1 || lab.answer[0].ID != 2 {
+ t.Fatalf("an answer from outside the panel was held: %+v", lab.answer)
+ }
+ if set := lab.a.questionSet(); len(set) != 2 {
+ t.Fatalf("the set did not lose the question answered elsewhere: %d", len(set))
+ }
+}
+
+// A TAB IS CALLED BY WHAT TELLS IT APART: the words every question opens with
+// are taken off, and a call is called by what it touches.
+func TestATabIsCalledByWhatTellsItApart(t *testing.T) {
+ lab := newQuestionLab(t)
+ lab.raise(setQuestion(1, "step:4", "read package.json?"))
+ lab.raise(setQuestion(2, "step:4", "read go.mod?"))
+ labels := lab.a.questionTabLabels(lab.a.questionSet())
+ if strings.Join(labels, "|") != "package.json|go.mod" {
+ t.Fatalf("labels = %q", labels)
+ }
+}
+
+// THE TABS READ IN THE TRANSCRIPT'S ORDER. The gates of one step finish in any
+// order; the panel lists the calls the way the transcript drew them, each called
+// by the file that tells it apart rather than by the folder they share.
+func TestTheTabsFollowTheRowsTheyAreAbout(t *testing.T) {
+ lab := newQuestionLab(t)
+ var qs []session.Question
+ for i, name := range []string{"e", "f", "g", "h"} {
+ qs = append(qs, setPermission(lab, uint64(i+1), "step:9", "read", "/tmp/work/"+name+".txt", session.StakesReversible))
+ }
+ for _, i := range []int{2, 1, 0, 3} {
+ lab.raise(qs[i])
+ }
+ set := lab.a.questionSet()
+ var ids []string
+ for _, q := range set {
+ ids = append(ids, itoa(int(q.question.ID)))
+ }
+ if got := strings.Join(ids, " "); got != "1 2 3 4" {
+ t.Fatalf("the tabs are in arrival order, not the transcript's: %s", got)
+ }
+ if got := strings.Join(lab.a.questionTabLabels(set), "|"); got != "e.txt|f.txt|g.txt|h.txt" {
+ t.Fatalf("labels = %q", got)
+ }
+}
+
+// THE STEP SURVIVES WHICHEVER COPY LANDS LAST. A lane's dressed copy carries no
+// batch, and it may arrive after the engine's.
+func TestTheStepSurvivesADressedCopyArrivingSecond(t *testing.T) {
+ lab := newQuestionLab(t)
+ q := setQuestion(1, "step:4", "Which storage?")
+ lab.raise(q)
+ dressed := q
+ dressed.Batch = ""
+ lab.a.raiseQuestion(questionShown{question: dressed, answered: func(a session.Answer) session.Answer { return a }})
+ if got := lab.a.questions[0].question.Batch; got != "step:4" {
+ t.Fatalf("the dressed copy took the step off the question: %q", got)
+ }
+}
+
+// ── one permission frame ────────────────────────────────────────────────────
+
+func permissionLab(t *testing.T, stakes session.Stakes) *questionLab {
+ t.Helper()
+ lab := newQuestionLab(t)
+ for i, target := range []string{"internal/session/store.go", "internal/session/loop.go", "internal/session/agent.go", "go.mod"} {
+ lab.raise(setPermission(lab, uint64(i+1), "step:9", "read", target, stakes))
+ }
+ lab.tick(questionSettle * 2)
+ lab.rows()
+ return lab
+}
+
+// PERMISSIONS FROM ONE STEP ARE ONE FRAME: what each call wants, then `allow all
+// 4 · one by one · deny all`, and — on a costly call — the pointer on the answer
+// that loses nothing, exactly where each question's own pointer would be.
+func TestPermissionsFromOneStepAreOneFrame(t *testing.T) {
+ lab := permissionLab(t, session.StakesCostly)
+ drawn := lab.plain()
+ for _, want := range []string{"allow these 4?", "internal/session/loop.go", "go.mod",
+ questionAllowAllWord + "4", questionApartWord, questionDenyAllWord, questionSafeWord} {
+ if !strings.Contains(drawn, want) {
+ t.Fatalf("the permission frame does not say %q:\n%s", want, drawn)
+ }
+ }
+ if got := lab.a.questionGroupPick(lab.a.questionSet()); got != questionGroupDeny {
+ t.Fatalf("the pointer opened on row %d of a costly group, want deny all", got)
+ }
+ // "approve all of these" is one key, and it is each question's own grant.
+ lab.press("1")
+ if len(lab.answer) != 4 {
+ t.Fatalf("allow all sent %d answers, want four", len(lab.answer))
+ }
+ for i, answer := range lab.answer {
+ if answer.ID != uint64(i+1) || answer.Key != "1" {
+ t.Fatalf("answer %d went out as %d/%q", i, answer.ID, answer.Key)
+ }
+ }
+}
+
+// THE BEAT'S ROW IS WHERE THE BEAT IS DRAWN, at every length of head.
+//
+// [app.questionBeatRow]'s second argument is written straight into
+// [app.questionSpanRow], which is the screen row a click on the shapes resolves
+// against. It used to be the literal `2`, correct only while the head fitted the
+// frame's top edge: behind a head long enough to wrap, every press on the shapes
+// landed one row off per wrapped line. It is now measured from what the caller
+// has laid above the body, so the panel of one and the tab are both right.
+func TestTheBeatsRowIsWhereTheBeatIsDrawnUnderAHeadThatWraps(t *testing.T) {
+ lab := newQuestionLab(t)
+ long := "needs your ok to run a command that " + strings.Repeat("goes on and on and on ", 12) + "before it ends"
+ q := setPermission(lab, 1, "step:9", "bash", "git status --short", session.StakesCostly)
+ q.Head = long
+ lab.raise(q)
+ lab.a.questions[0].shapes = func() []string { return []string{"git *", "git status*", "git status --short"} }
+ lab.tick(questionSettle * 2)
+ lab.rows()
+ lab.press("2")
+
+ rows := questionPlainRows(lab.rows())
+ if len(wrap(long, 1)) < 2 {
+ t.Fatal("the head under test does not wrap; the case it guards is not being exercised")
+ }
+ drawn := -1
+ for i, row := range rows {
+ if strings.Contains(row, "3 just this line") {
+ drawn = i
+ }
+ }
+ if drawn < 0 {
+ t.Fatalf("the beat was never drawn:\n%s", strings.Join(rows, "\n"))
+ }
+ if lab.a.questionSpanRow != drawn {
+ t.Fatalf("a press on the shapes resolves against row %d, but they are drawn on row %d:\n%s",
+ lab.a.questionSpanRow, drawn, strings.Join(rows, "\n"))
+ }
+}
+
+// A TAB PART-WAY THROUGH THE WIDENING ANSWER DRAWS THE BEAT, not the answers it
+// replaced. `always` on a tab opens the shapes, and from that moment the digits
+// pick a shape and `esc` backs out of the beat ([app.questionSetOwnsKey] hands
+// both back to the question) — so a tab still drawing its answer rows, with
+// `←→ question` on its edge, would be naming keys that do something else. It is
+// the one drawing, [app.questionPanelInside], for the panel of one and the tab
+// alike.
+func TestATabPartWayThroughTheWideningAnswerDrawsTheBeat(t *testing.T) {
+ lab := newQuestionLab(t)
+ for i, target := range []string{"git status --short", "go build ./..."} {
+ lab.raise(setPermission(lab, uint64(i+1), "step:9", "bash", target, session.StakesCostly))
+ }
+ for i := range lab.a.questions {
+ lab.a.questions[i].shapes = func() []string { return []string{"git *", "git status*", "git status --short"} }
+ }
+ lab.tick(questionSettle * 2)
+ lab.rows()
+ // `one by one` opens the group as tabs, and `always` on the tab opens the beat.
+ lab.press("2")
+ lab.press("2")
+
+ drawn := lab.plain()
+ for _, want := range []string{"3 just this line", "1–3 shape", questionBeatBack} {
+ if !strings.Contains(drawn, want) {
+ t.Fatalf("the tab does not draw the beat's %q:\n%s", want, drawn)
+ }
+ }
+ for _, gone := range []string{"allow once", questionTabWord} {
+ if strings.Contains(drawn, gone) {
+ t.Fatalf("the tab still draws %q while the shapes are up:\n%s", gone, drawn)
+ }
+ }
+}
+
+// ONE ROW PER THING WANTED: three calls nothing tells apart are one row that
+// counts them, never the same row three times.
+func TestAFrameCountsTheCallsNothingTellsApart(t *testing.T) {
+ lab := newQuestionLab(t)
+ for i := range 3 {
+ lab.raise(setPermission(lab, uint64(i+1), "step:9", "ask", "", session.StakesCostly))
+ }
+ lab.tick(questionSettle * 2)
+ drawn := lab.plain()
+ if !strings.Contains(drawn, questionTimesWord+"3") {
+ t.Fatalf("three alike calls are not one counted row:\n%s", drawn)
+ }
+ if strings.Count(drawn, "│ ask") != 1 {
+ t.Fatalf("the frame draws the same call more than once:\n%s", drawn)
+ }
+}
+
+// A CALL TOO LONG FOR ITS ROW LOSES ITS MIDDLE: every row still ends in the
+// file that tells it apart from the others.
+func TestAFrameRowKeepsTheEndOfALongCall(t *testing.T) {
+ lab := newQuestionLab(t)
+ lab.a.width = 72
+ folder := "/tmp/" + strings.Repeat("a-long-folder-name/", 5)
+ for i, name := range []string{"e", "f", "g", "h"} {
+ lab.raise(setPermission(lab, uint64(i+1), "step:9", "read", folder+name+".txt", session.StakesReversible))
+ }
+ lab.tick(questionSettle * 2)
+ drawn := lab.plain()
+ for _, name := range []string{"e.txt", "f.txt", "g.txt", "h.txt"} {
+ if !strings.Contains(drawn, name) {
+ t.Fatalf("a row lost the end of its call %s:\n%s", name, drawn)
+ }
+ }
+}
+
+// THE GROUP POINTER IS EACH QUESTION'S OWN, READ OVER THE SET: `allow all` where
+// every question alone would open on its grant, and `deny all` the moment one
+// of them would not — here, because its asker recommended the deny. And enter
+// on `deny all` gives each its own safe answer.
+func TestTheGroupPointerIsEachQuestionsOwnReadOverTheSet(t *testing.T) {
+ lab := permissionLab(t, session.StakesReversible)
+ if got := lab.a.questionGroupPick(lab.a.questionSet()); got != questionGroupAllow {
+ t.Fatalf("the pointer opened on row %d of a reversible group, want allow all", got)
+ }
+ lab.press("down")
+ lab.press("down")
+ lab.press("enter")
+ if len(lab.answer) != 4 || lab.answer[0].Key != "3" {
+ t.Fatalf("deny all sent %+v, want each question's safe answer", lab.answer)
+ }
+
+ wary := newQuestionLab(t)
+ for i, target := range []string{"go.mod", "go.sum"} {
+ q := setPermission(wary, uint64(i+1), "step:9", "read", target, session.StakesReversible)
+ if i == 1 {
+ q.Pick = &session.Pick{Key: "3"}
+ }
+ wary.raise(q)
+ }
+ if got := wary.a.questionGroupPick(wary.a.questionSet()); got != questionGroupDeny {
+ t.Fatalf("one question would open on deny alone, and the frame opened on row %d", got)
+ }
+ if !strings.Contains(wary.plain(), questionSafeWord) {
+ t.Fatalf("the pointer on deny all does not say why:\n%s", wary.plain())
+ }
+}
+
+// ONE BY ONE ANSWERS NOTHING and opens the same questions as tabs.
+func TestOneByOneOpensTheSameSetAsTabs(t *testing.T) {
+ lab := permissionLab(t, session.StakesCostly)
+ lab.press("2")
+ if len(lab.answer) != 0 {
+ t.Fatalf("one by one answered something: %+v", lab.answer)
+ }
+ drawn := lab.plain()
+ if !strings.Contains(drawn, "1 of 4") || !strings.Contains(drawn, "internal/session/store.go") {
+ t.Fatalf("one by one did not open the tabs:\n%s", drawn)
+ }
+}
+
+// A GROUPED PERMISSION OFFERS NO LIFETIME AT ALL, and neither does a single one
+// (questionscope.go): the answer's scope never reaches the consent gate, so a
+// row saying `from now on` over four calls would promise four times over what it
+// cannot do once. THIS TEST FAILS THE DAY THE GRADING LANDS (#953), which is
+// when the row comes back — one row for the set, moved together.
+func TestAGroupedPermissionOffersNoLifetimeRow(t *testing.T) {
+ lab := permissionLab(t, session.StakesReversible)
+ drawn := lab.plain()
+ for _, gone := range []string{
+ questionScopeWord(session.ScopeOnce),
+ questionScopeWord(session.ScopeAlways),
+ questionScopeKey + " how long",
+ } {
+ if strings.Contains(drawn, gone) {
+ t.Fatalf("the frame offers %q, which no answer it sends can carry:\n%s", gone, drawn)
+ }
+ }
+ // And the key is not taken either, so nothing moves where nothing is drawn.
+ lab.press("down")
+ lab.press("up")
+ if lab.press(questionScopeKey) {
+ t.Fatal("the frame took `t` for a lifetime row it does not draw")
+ }
+ for _, q := range lab.a.questions {
+ if q.scope != "" {
+ t.Fatalf("question %d is on %q, and the frame never offered a lifetime", q.question.ID, q.scope)
+ }
+ }
+}
+
+// A GROUP NEEDS A GRANT AND A SAFE ANSWER ON EVERY QUESTION. Two model
+// permissions with no answer marked safe are tabs, not one frame.
+func TestAPermissionWithNoSafeAnswerIsATabNotAGroup(t *testing.T) {
+ lab := newQuestionLab(t)
+ for i := 1; i <= 2; i++ {
+ q := setQuestion(uint64(i), "step:4", "read file "+itoa(i)+"?")
+ q.Ask, q.Pick = session.AskPermission, nil
+ lab.raise(q)
+ }
+ set := lab.a.questionSet()
+ if len(set) != 2 || lab.a.questionGrouped(set) {
+ t.Fatalf("set of %d grouped=%v, want two tabs", len(set), lab.a.questionGrouped(set))
+ }
+}
diff --git a/internal/tui3/questionsheet.go b/internal/tui3/questionsheet.go
deleted file mode 100644
index c8f0739d59..0000000000
--- a/internal/tui3/questionsheet.go
+++ /dev/null
@@ -1,791 +0,0 @@
-package tui3
-
-// ── THE SHEET: MANY DECISIONS, ONE READING ──────────────────────────────────
-//
-// A step that raised four quiet questions used to deliver them one at a time,
-// each landing on top of the last, so a person answered the fourth first and
-// never saw that the first three were the same decision asked about four files.
-// The sheet is what arrives instead: everything one step gathered, grouped by
-// the SHAPE of the decision, answered down the list, sent in one press.
-//
-// ── THE ROWS, EXACTLY AS THEY ARE DRAWN ─────────────────────────────────────
-//
-// ? 4 questions raised together
-// asking permission
-// ✓ read vendor/modernc.org? allow once
-// ? read vendor/golang.org/x? 1 allow once · 2 not now
-// ▸ ? write to .github/workflows? 1 allow once · 2 not now
-// choosing
-// ? which index should this use? 1 sqlite · 2 in memory
-// [enter] open it · [s] send what is answered (1) · [esc] later
-//
-// The cursor is the one `▸`; `✓` is a row this person has answered and the word
-// after it is the answer they gave; `?` is a row still waiting, with the answers
-// it offers. Both marks come from the vocabulary through its one door, and only
-// the waiting mark is amber — a row that has been answered is not waiting on
-// anybody.
-//
-// `1`-`9` answer the row the cursor is on, in THAT question's own keys, and the
-// cursor walks on to the next row still waiting. `↑↓` move without answering,
-// and `g` spreads THE LAST ANSWER GIVEN rather than the focused row's — which is
-// the same row, one keystroke earlier.
-//
-// ── THE FOUR LAWS ───────────────────────────────────────────────────────────
-//
-// - ANSWERS ARE HELD AGAINST THE QUESTION AND NEVER AGAINST THE ROW. A
-// withdrawal takes its row out and the sheet re-flows; if answers were kept
-// by position, that re-flow would silently move somebody's `allow once` onto
-// the question underneath it. They are kept by token ([questionToken]).
-// - `s` SENDS WHAT IS ANSWERED AND DELEGATES THE REST — to each question's OWN
-// pick, never to a key this file chose. A question the asker named no pick
-// on is left open, because delegating it would mean inventing an answer
-// nobody offered.
-// - EVERY ROW WEARS ITS OWN ANSWERS, because a sheet you have to open every
-// row of to use is a list of sentences rather than a way to answer. A row
-// already answered wears the word it was answered with instead.
-// - "SAME ANSWER FOR ALL LIKE THIS" IS OFFERED PER KEY AND NOT PER POSITION.
-// `g` gives the focused row's answer to every other row of the same shape
-// THAT OFFERS THAT KEY under that key. A question whose second option is
-// `deny` and another whose second option is `always` are not the same
-// answer, and a sheet that treated them as one would deny one and approve
-// the other.
-// - THE SHEET STANDS DOWN FOR A QUESTION BEING READ. `enter` takes one row
-// out and puts it on the block in its own form, with everything the block
-// draws; the sheet comes back when that one is answered or folded. The two
-// are never on screen together, because the block is above the message box
-// and a screen with both would be a screen with no conversation left on it.
-
-import (
- "sort"
- "strings"
- "time"
-
- tea "charm.land/bubbletea/v2"
-
- "github.com/charmbracelet/x/ansi"
-
- "github.com/Agent-Field/aforge-v2/internal/session"
- "github.com/Agent-Field/aforge-v2/internal/tui2/tokens"
-)
-
-// questionSheetFloor is how many questions one step has to have gathered before
-// they arrive as a sheet. TWO, because one question is a question: a sheet drawn
-// over a single row would be a heading, a group label and an answers row spent
-// on something the line form says in one.
-const questionSheetFloor = 2
-
-// The sheet's own words, spelled once and quoted in the manual exactly.
-const (
- // questionSheetTogether is the head's tail. It says the one thing about a
- // sheet that is not visible from its rows — these arrived at the same
- // moment, from one piece of work — which is what makes answering them as a
- // group reasonable rather than a shortcut.
- questionSheetTogether = " raised together"
- // questionSheetSendWord is `s`, in the manual's spelling and the row's.
- questionSheetSendWord = "send what is answered"
- // questionSheetSameWord is `g`.
- questionSheetSameWord = "same answer for all like this"
- // questionSheetOpenWord is `enter` on the focused row.
- questionSheetOpenWord = "open it"
- // questionSheetHeldWord is what the sheet says about a row `s` could not
- // delegate. It is the emptiness law's opposite number: something WAS left
- // undone, so the surface says so rather than closing quietly.
- questionSheetHeldWord = " still needs you"
-)
-
-// questionSheet is one batch as this surface holds it.
-type questionSheet struct {
- // questions are the rows, sorted by shape and then by when each was asked.
- questions []session.Question
- // answers are what this person has given so far, keyed by [questionToken]
- // (this file's first law).
- answers map[string]session.Answer
- // cursor is the row `enter` and the digits act on.
- cursor int
- // last is the row an answer was most recently given to, by token.
- //
- // `g` READS THE LAST ANSWER AND NOT THE FOCUSED ROW, because the moment
- // somebody wants "the same for all of these" is the moment just after they
- // gave one — and by then the cursor has walked on to the next row still
- // waiting ([app.answerSheetRow]), which has no answer to copy.
- last string
-}
-
-// newQuestionSheet builds one from what a step boundary released.
-func newQuestionSheet(questions []session.Question) *questionSheet {
- s := &questionSheet{
- questions: append([]session.Question(nil), questions...),
- answers: map[string]session.Answer{},
- }
- s.flow()
- return s
-}
-
-// flow is the grouping, and it is re-run after every removal so the sheet a
-// person is reading is always grouped the way it was drawn.
-//
-// IT IS STABLE ON THE ASK KIND AND THEN ON THE ASKING TIME, which is the order
-// [session.Agent.OpenQuestions] already puts questions in, narrowed by the one
-// thing the sheet adds: like goes with like.
-func (s *questionSheet) flow() {
- sort.SliceStable(s.questions, func(i, j int) bool {
- if s.questions[i].Ask != s.questions[j].Ask {
- return questionShapeOrder(s.questions[i].Ask) < questionShapeOrder(s.questions[j].Ask)
- }
- return s.questions[i].Asked.Before(s.questions[j].Asked)
- })
- if s.cursor >= len(s.questions) {
- s.cursor = len(s.questions) - 1
- }
- if s.cursor < 0 {
- s.cursor = 0
- }
-}
-
-// answered reports whether one row has an answer on it, and what it was.
-func (s *questionSheet) answered(q session.Question) (session.Answer, bool) {
- answer, ok := s.answers[questionToken(q)]
- return answer, ok
-}
-
-// answerAt writes one row's answer, and reports whether the key was one that
-// row offered. A key the question never offered is not an answer to it.
-func (s *questionSheet) answerAt(at int, key string, now time.Time) bool {
- if at < 0 || at >= len(s.questions) {
- return false
- }
- q := s.questions[at]
- if _, ok := q.Option(key); !ok {
- return false
- }
- s.answers[questionToken(q)] = session.Answer{
- At: now, Kind: q.Kind, ID: q.ID, Ref: q.Ref, Ask: q.Ask,
- Key: key, Picked: []string{key}, DecidedBy: session.DecidedByPerson,
- }
- s.last = questionToken(q)
- return true
-}
-
-// lastAnswered is the row an answer was most recently given to, and whether
-// that row is still on the sheet.
-func (s *questionSheet) lastAnswered() (session.Question, session.Answer, bool) {
- for _, q := range s.questions {
- if questionToken(q) != s.last {
- continue
- }
- if answer, ok := s.answered(q); ok {
- return q, answer, true
- }
- }
- return session.Question{}, session.Answer{}, false
-}
-
-// sameForAll is `g`: the focused row's answer given to every other row of the
-// same shape that offers the same key. It answers how many rows it reached,
-// which is what the message line says out loud — a key that quietly answered
-// four decisions would be the hidden rule this design refuses.
-func (s *questionSheet) sameForAll(now time.Time) int {
- focus, answer, ok := s.lastAnswered()
- if !ok {
- return 0
- }
- reached := 0
- for i, q := range s.questions {
- if questionToken(q) == questionToken(focus) || q.Ask != focus.Ask {
- continue
- }
- if _, already := s.answered(q); already {
- continue
- }
- if s.answerAt(i, answer.Key, now) {
- reached++
- }
- }
- // The last answer stays the one the person GAVE, so pressing `g` twice on a
- // sheet that grew a row does the same thing rather than spreading whichever
- // row the loop happened to reach last.
- s.last = questionToken(focus)
- return reached
-}
-
-// sameForAllReaches is whether `g` would reach anything, which is the condition
-// the key is offered under (the emptiness law: a key that would do nothing is
-// not on the row).
-func (s *questionSheet) sameForAllReaches() bool {
- focus, answer, ok := s.lastAnswered()
- if !ok {
- return false
- }
- for _, q := range s.questions {
- if questionToken(q) == questionToken(focus) || q.Ask != focus.Ask {
- continue
- }
- if _, already := s.answered(q); already {
- continue
- }
- if _, offers := q.Option(answer.Key); offers {
- return true
- }
- }
- return false
-}
-
-// takeAt lifts one row out of the sheet — `enter`'s half of THE SHEET STANDS
-// DOWN — and hands it back whole so the block can draw it in its own form.
-func (s *questionSheet) takeAt(at int) (session.Question, bool) {
- if at < 0 || at >= len(s.questions) {
- return session.Question{}, false
- }
- q := s.questions[at]
- delete(s.answers, questionToken(q))
- s.questions = append(s.questions[:at:at], s.questions[at+1:]...)
- s.flow()
- return q, true
-}
-
-// withdraw takes one row out because it stopped being a question, and reports
-// whether this sheet had it. The answer that was on it goes with it; the rest
-// keep theirs, which is this file's first law doing its job.
-func (s *questionSheet) withdraw(q session.Question) bool {
- token := questionToken(q)
- for i := range s.questions {
- if questionToken(s.questions[i]) != token {
- continue
- }
- delete(s.answers, token)
- s.questions = append(s.questions[:i:i], s.questions[i+1:]...)
- s.flow()
- return true
- }
- return false
-}
-
-// send is `s`: what has been answered, plus every remaining question delegated
-// to its OWN pick. Anything with no pick is handed back still open.
-func (s *questionSheet) send(now time.Time) (answers []session.Answer, held []session.Question) {
- for _, q := range s.questions {
- if answer, ok := s.answered(q); ok {
- answers = append(answers, answer)
- continue
- }
- key := ""
- if q.Pick != nil {
- key = strings.TrimSpace(q.Pick.Key)
- }
- if key == "" {
- held = append(held, q)
- continue
- }
- if _, ok := q.Option(key); !ok {
- held = append(held, q)
- continue
- }
- answers = append(answers, session.Answer{
- At: now, Kind: q.Kind, ID: q.ID, Ref: q.Ref, Ask: q.Ask,
- Key: key, Picked: []string{key}, DecidedBy: session.DecidedByAsker,
- })
- }
- return answers, held
-}
-
-// answeredCount is how many rows carry an answer, which the send key wears.
-func (s *questionSheet) answeredCount() int { return len(s.answers) }
-
-// questionShapeOrder is which group goes above which.
-//
-// IT IS THE KIND TABLE'S OWN ORDER (docs/design/questions/DESIGN.md's "Kinds and
-// their defaults") AND NOT THE ALPHABET. The table is ordered by how much of the
-// person a question wants — a permission is a key, a judgement is a reading, a
-// clarification is a sentence only they can write — so a sheet grouped in that
-// order is a sheet whose cheap rows are at the top. Sorting on the value's own
-// spelling would put `assumptions it made` above `asking permission` because of
-// how the two words happen to start.
-func questionShapeOrder(kind session.AskKind) int {
- for i, one := range []session.AskKind{
- session.AskPermission, session.AskChoice, session.AskJudgement,
- session.AskClarification, session.AskConfirmation, session.AskLanding,
- session.AskAssumption, session.AskRatify,
- } {
- if one == kind {
- return i
- }
- }
- return 99
-}
-
-// ── the shapes, in a person's words ─────────────────────────────────────────
-
-// questionShapeWord is one group's heading.
-//
-// NO MACHINERY VOCABULARY. [session.AskKind]'s own spellings are the engine's
-// nouns — `permission`, `judgement`, `ratify` — and three of them are words
-// about a taxonomy rather than about what is being asked. What goes on a
-// heading is what the rows under it have in common, said the way somebody would
-// say it out loud.
-func questionShapeWord(kind session.AskKind) string {
- switch kind {
- case session.AskPermission:
- return "asking permission"
- case session.AskChoice:
- return "choosing"
- case session.AskJudgement:
- return "your judgement"
- case session.AskClarification:
- return "what you meant"
- case session.AskConfirmation:
- return "confirming"
- case session.AskLanding:
- return "your call"
- case session.AskAssumption:
- return "assumptions it made"
- case session.AskRatify:
- return "already done"
- }
- return ""
-}
-
-// ── drawing ─────────────────────────────────────────────────────────────────
-
-// questionSheetRows draws it, at the frame's own width.
-func (a *app) questionSheetRows(s *questionSheet, width int) []string {
- if s == nil || len(s.questions) == 0 || width < 1 {
- return nil
- }
- out := make([]string, 0, len(s.questions)+4)
- out = append(out, a.questionMark()+" "+a.pal.ink(fit(
- itoa(len(s.questions))+plural(" question", len(s.questions))+questionSheetTogether, width-2)))
- group := session.AskKind("")
- for i, q := range s.questions {
- if q.Ask != group {
- group = q.Ask
- if word := questionShapeWord(group); word != "" {
- out = append(out, a.pal.dim(fit(" "+word, width)))
- }
- }
- out = append(out, a.questionSheetRow(s, i, q, width))
- }
- out = append(out, a.questionSheetOffer(s, width))
- return out
-}
-
-// questionSheetRow is one row: the cursor, the state mark, the asker's own
-// sentence, and then either the answers that row offers or the one that was
-// given.
-//
-// EVERY ROW WEARS ITS OWN ANSWERS, and that is what makes the sheet answerable
-// at all. The digits answer the row the cursor is on, and a person cannot press
-// a digit for an answer they cannot see — a sheet that only listed sentences
-// would be a list you had to open every row of to use.
-//
-// AND THE KEYS ARE EACH QUESTION'S OWN. They are read off
-// [session.AnswerOption.Key], never numbered by position, so `2` means what that
-// question says `2` means on the row it is drawn on and nothing else.
-func (a *app) questionSheetRow(s *questionSheet, at int, q session.Question, width int) string {
- // THE POINTER IS THE POINTER EVERYWHERE (owner ruling 2026-09-11): `▸`, in
- // the one amber the marks wear, and never the fold's own chevron — which is
- // a different claim on every other row of this surface.
- cursor, plainCursor := " ", " "
- if at == s.cursor {
- plainCursor = a.icon(tokens.GPointer) + " "
- cursor = a.pal.warnBold(a.icon(tokens.GPointer)) + " "
- }
- answer, given := s.answered(q)
- // EACH ROW WEARS ITS OWN SHAPE'S MARK. A sheet is questions of several
- // shapes gathered at one boundary, so an assumptions row and a permission
- // row sit under each other here — and the one that is waiting on somebody is
- // the only one that may say so ([questionAskSlot]).
- slot, _ := questionAskSlot(q.Ask)
- mark, plainMark := a.questionMarkFor(q), a.icon(slot)
- if given {
- plainMark = a.icon(tokens.GSettled)
- mark = a.pal.dim(plainMark)
- }
- head := strings.TrimSpace(q.Head)
- tail := questionSheetAnswers(q)
- if given {
- tail = ""
- if option, ok := q.Option(answer.Key); ok {
- tail = strings.TrimSpace(option.Label)
- }
- }
- text := plainCursor + plainMark + " " + head
- if tail == "" {
- return fit(cursor+mark+" "+a.pal.ink(head), width)
- }
- // THE ANSWERS ARE IN A COLUMN, AND THE COLUMN IS SET BY THE LONGEST
- // SENTENCE IN THE SHEET RATHER THAN BY THE EDGE OF THE SCREEN.
- //
- // The first draft right-aligned them, which is the switcher's arrangement,
- // and on a 160-column terminal it put `allow once` a hundred cells away from
- // the question it answered — a column that is scannable and unreadable at
- // the same time, because the eye cannot carry a row that far. Setting the
- // column just past the longest head keeps both halves: the keys line up
- // under each other, and each one is beside its own sentence.
- column := s.answerColumn() + 2
- if column+ansi.StringWidth(tail) > width {
- column = width - ansi.StringWidth(tail)
- }
- if column < ansi.StringWidth(text)+2 {
- return fit(cursor+mark+" "+a.pal.ink(head)+" "+a.pal.dim(tail), width)
- }
- gap := column - ansi.StringWidth(text)
- // Painted in pieces rather than nested, for [app.questionPanelOption]'s
- // reason: these hues are raw SGR with an explicit reset, so a colour inside
- // a colour ends the outer one early.
- //
- // THE WORDS ARE INK AND THE ANSWERS DIM, which is the panel's own grammar on
- // a list: the sheet used to paint every cell of every row in the question
- // hue, so a batch of five questions was five amber rows and the mark that
- // says which of them is still waiting had nothing to stand out from.
- line := cursor + mark + " " + a.pal.ink(head) + strings.Repeat(" ", gap)
- return line + a.pal.dim(tail)
-}
-
-// answerColumn is where every row's answers begin: one cell past the longest
-// sentence on the sheet, counting the cursor and mark cells in front of it.
-func (s *questionSheet) answerColumn() int {
- widest := 0
- for _, q := range s.questions {
- if n := ansi.StringWidth(strings.TrimSpace(q.Head)); n > widest {
- widest = n
- }
- }
- // two cells of cursor, one of mark, one of space.
- return widest + 4
-}
-
-// questionSheetAnswers is one row's answers as the row prints them: each key
-// and its own word, joined by the surface's own separator.
-func questionSheetAnswers(q session.Question) string {
- parts := make([]string, 0, len(q.Options))
- for _, option := range q.Options {
- key := strings.TrimSpace(option.Key)
- word := strings.TrimSpace(option.Label)
- if key == "" || word == "" {
- continue
- }
- parts = append(parts, key+" "+word)
- }
- return strings.Join(parts, " · ")
-}
-
-// questionSheetOffer is the sheet's keys row. It is the ONE key row
-// ([app.questionKeyRow]) with the sheet's own words on the verbs: the key in the
-// payload hue, its word dim, given up from the end backwards until what is left
-// fits, and `s` and `esc` never given up.
-//
-// `s` WEARS ITS COUNT, which is the emptiness law read forwards: a send key
-// that said nothing about how much it would send is a key a person presses to
-// find out.
-func (a *app) questionSheetOffer(s *questionSheet, width int) string {
- keys := make([]questionVerb, 0, 4)
- for _, verb := range questionKeys {
- if !verb.forms.holds(formsSheet) {
- continue
- }
- if verb.key == questionAlikeKey && !s.sameForAllReaches() {
- continue
- }
- verb.word = questionSheetKeyWord(verb)
- if verb.key == questionSendKey {
- if n := s.answeredCount(); n > 0 {
- verb.word += " (" + itoa(n) + ")"
- }
- }
- keys = append(keys, verb)
- }
- q := questionShown{}
- if s.cursor >= 0 && s.cursor < len(s.questions) {
- q.question = s.questions[s.cursor]
- }
- return " " + a.questionKeyRow(q, keys, max(width-2, 0))
-}
-
-// ── the sheet on this surface ───────────────────────────────────────────────
-
-// sheetOpen is how many questions the sheet is holding, and zero when there is
-// none. The chip counts these ([app.questionCount]) because a question in a
-// sheet is a question the work is waiting on exactly as much as one on the
-// block.
-func (a *app) sheetOpen() int {
- if a.questionBatch == nil {
- return 0
- }
- return len(a.questionBatch.questions)
-}
-
-// sheetShowing reports whether the sheet has the rows and the keyboard: it is
-// holding something, and nobody has put it off.
-func (a *app) sheetShowing() bool {
- return a.sheetOpen() > 0 && !a.questionBatchFolded
-}
-
-// questionBoundary is a step ending: everything the rule gathered arrives.
-//
-// TWO OR MORE MAKE A SHEET AND ONE MAKES A QUESTION ([questionSheetFloor]). A
-// sheet drawn over a single row would be three rows of furniture around one
-// decision the line form already says in one.
-func (a *app) questionBoundary() tea.Cmd {
- return a.questionBoundaryFor(a.questionStep())
-}
-
-// questionGatherFor is the longest a quiet question waits for its step to end.
-// Three seconds is [questionQuiet]'s number and is chosen the same way: it is a
-// pause somebody would notice as one rather than a gap between two things
-// happening. See [app.deliverQuestion] for why the hold must be bounded at all.
-const questionGatherFor = 3 * time.Second
-
-// questionGatherMsg is that clock going off, carrying the step it was armed for
-// so a batch cannot be released by a timer belonging to the step before it.
-type questionGatherMsg struct{ step string }
-
-// gatherQuestions arms it.
-func (a *app) gatherQuestions() tea.Cmd {
- step := a.questionStep()
- if step == "" {
- return nil
- }
- return surfaceTick(questionGatherFor, func(time.Time) tea.Msg {
- return questionGatherMsg{step: step}
- })
-}
-
-// questionBoundaryFor releases one named step's batch.
-func (a *app) questionBoundaryFor(step string) tea.Cmd {
- if step == "" {
- return nil
- }
- held := a.questionReach.boundary(step)
- if len(held) == 0 {
- return nil
- }
- // THE STEP MOVES ON ONLY WHEN IT HANDED SOMETHING OVER, so a timer that
- // fired over an empty batch does not push the next question into a step of
- // its own.
- if step == a.questionStep() {
- a.questionStepEnded()
- }
- // A QUESTION OUTRANKS A PANEL, on question.go's terms: a batch drawn under
- // a fullscreen overlay is a batch nobody can see to answer.
- a.closeSettings()
- a.closeExpand()
- if len(held) < questionSheetFloor {
- for _, q := range held {
- a.raiseQuestion(questionShown{question: q})
- }
- return nil
- }
- if a.questionBatch == nil {
- a.questionBatch = newQuestionSheet(held)
- } else {
- // A SECOND BOUNDARY JOINS THE SHEET RATHER THAN REPLACING IT. A person
- // half-way down a list of decisions has not finished with it because
- // the work moved on, and a sheet swapped out from under them would lose
- // every answer they had given.
- for _, q := range held {
- a.questionBatch.questions = appendQuestionOnce(a.questionBatch.questions, q)
- }
- a.questionBatch.flow()
- }
- a.touch()
- return nil
-}
-
-// questionSheetKey routes the sheet's own keys. It is asked only when the sheet
-// is what is drawn ([app.questionKey]).
-func (a *app) questionSheetKey(msg tea.KeyPressMsg) (tea.Cmd, bool) {
- if !a.sheetShowing() || !a.questionQuieted() {
- return nil, false
- }
- key := msg.String()
- if key == "ctrl+c" {
- // Leaving is never modal, on [app.questionKey]'s terms exactly.
- return nil, false
- }
- if key == questionLaterKey {
- // esc IS LATER. The sheet folds to the chip; nothing is answered,
- // nothing is cancelled, and every answer already given is still on it.
- a.questionBatchFolded = true
- a.touch()
- return nil, true
- }
- switch key {
- case "up", "shift+tab":
- a.aimSheet()
- a.moveSheetCursor(-1)
- return nil, true
- case "down", "tab":
- a.aimSheet()
- a.moveSheetCursor(1)
- return nil, true
- }
- if strings.TrimSpace(a.input.String()) != "" {
- // EVERY PRINTABLE KEY BELONGS TO THE COMPOSER, which is the block's law
- // and is the sheet's for the same reason: the rows are still there and
- // still answerable the moment the box is clear.
- return nil, false
- }
- switch key {
- case questionEnterKey:
- return a.openSheetRow()
- case questionSendKey, questionAlikeKey:
- // THE SHEET'S TWO VERBS ARE THE BOX'S UNTIL SOMEBODY AIMS AT THE SHEET,
- // which is the block's law and is sharper here: `s` and `g` are about the
- // WHOLE batch, so a letter taken from somebody starting a sentence sends
- // a list they had not even walked (questionkeys.go's THE BOX KEEPS THE
- // FIRST LETTER).
- if !a.sheetHasTheHand() {
- return nil, false
- }
- if key == questionSendKey {
- return a.sendSheet()
- }
- return a.sameSheetAnswer()
- }
- // A DIGIT ANSWERS THE ROW THE CURSOR IS ON, which is ONE KEY GRAMMAR read
- // literally: `1`-`9` pick, on every form. The keys are that row's own
- // ([questionSheet.answerAt] looks them up in its options), so a digit the
- // focused question never offered is not an answer and falls through.
- if a.answerSheetRow(key) {
- return nil, true
- }
- return nil, false
-}
-
-// answerSheetRow takes one digit for the focused row and walks on to the next
-// row still waiting — a person answering down a list does not also want to move
-// down it.
-func (a *app) answerSheetRow(key string) bool {
- if !a.questionBatch.answerAt(a.questionBatch.cursor, key, a.now()) {
- return false
- }
- for at := a.questionBatch.cursor + 1; at < len(a.questionBatch.questions); at++ {
- if _, given := a.questionBatch.answered(a.questionBatch.questions[at]); !given {
- a.questionBatch.cursor = at
- break
- }
- }
- a.touch()
- return true
-}
-
-// moveSheetCursor walks the rows, stopping at both ends rather than wrapping —
-// a list somebody is reading down is not a carousel.
-func (a *app) moveSheetCursor(by int) {
- to := a.questionBatch.cursor + by
- if to < 0 || to >= len(a.questionBatch.questions) {
- return
- }
- a.questionBatch.cursor = to
- a.touch()
-}
-
-// openSheetRow is `enter`: the focused question comes out of the sheet and onto
-// the block in whichever form its own evidence asks for.
-func (a *app) openSheetRow() (tea.Cmd, bool) {
- q, ok := a.questionBatch.takeAt(a.questionBatch.cursor)
- if !ok {
- return nil, false
- }
- a.raiseQuestion(questionShown{question: q})
- return nil, true
-}
-
-// sameSheetAnswer is `g`, and it SAYS WHAT IT DID. A key that quietly answered
-// four decisions would be the hidden rule DESIGN.md refuses; the count on the
-// message line is what makes it visible.
-func (a *app) sameSheetAnswer() (tea.Cmd, bool) {
- reached := a.questionBatch.sameForAll(a.now())
- if reached == 0 {
- return nil, false
- }
- a.note(itoa(reached) + plural(" more question", reached) + " answered the same way")
- a.touch()
- return nil, true
-}
-
-// sendSheet is `s`: everything answered goes through the one door, everything
-// left is delegated to its own pick, and anything that had no pick stays.
-func (a *app) sendSheet() (tea.Cmd, bool) {
- answers, held := a.questionBatch.send(a.now())
- if len(answers) == 0 {
- return nil, false
- }
- // THE ANSWER IS THE RECORD, AND A BATCH IS NOT AN EXCEPTION TO IT. Every
- // other way of answering on this surface leaves the dim line where the
- // question was ([app.closeQuestion]); the sheet was resolving through the
- // door and drawing nothing at all, so a person who answered two decisions
- // and pressed `s` watched both rows disappear with no account of what they
- // had just decided anywhere on the screen. The questions are looked up by
- // token because [questionSheet.send] answers in [session.Answer]s and the
- // record is written from the question and the answer together.
- asked := make(map[sheetRow]session.Question, len(a.questionBatch.questions))
- for _, q := range a.questionBatch.questions {
- asked[sheetRow{kind: q.Kind, id: q.ID, ref: q.Ref}] = q
- }
- // AND A SHEET IS THE LIST DOOR'S OWN CASE. `s` is one gesture over several
- // questions, which is exactly what [app.answerQuestions] is: one command,
- // the doors asked IN ORDER on one goroutine, each row's receipt written on
- // the keystroke and each refusal coming back against its own row. It used to
- // batch one [app.offLoop] per answer — as many goroutines as rows, in
- // whatever order they happened to finish — beside a hand-rolled copy of the
- // record and the reopen.
- sending := make([]questionAnswer, 0, len(answers))
- for _, answer := range answers {
- q, found := asked[sheetRow{kind: answer.Kind, id: answer.ID, ref: answer.Ref}]
- if !found {
- continue
- }
- sending = append(sending, questionAnswer{q: questionShown{question: q}, answer: answer})
- }
- cmd := a.answerQuestions(sending)
- a.questionBatch = nil
- a.questionBatchFolded = false
- if len(held) > 0 {
- // WHAT COULD NOT BE DELEGATED IS SAID OUT LOUD and put back on the
- // block. A sheet that closed over a decision nobody made would be the
- // surface losing a question, which is the one thing DESIGN.md says a
- // question is never allowed to do.
- a.note(itoa(len(held)) + plural(" question", len(held)) + questionSheetHeldWord)
- for _, q := range held {
- a.raiseQuestion(questionShown{question: q})
- }
- }
- a.touch()
- return cmd, true
-}
-
-// sheetRow is one question's identity as both a [session.Question] and a
-// [session.Answer] carry it: the lane, and the lane's OWN id in whichever of its
-// two shapes that lane uses. It is spelled here rather than reusing
-// [questionToken] because that string is built from the question alone and an
-// answer has no method that produces it.
-type sheetRow struct {
- kind session.QuestionKind
- id uint64
- ref string
-}
-
-// aimSheet and sheetHasTheHand are questionkeys.go's THE BOX KEEPS THE FIRST
-// LETTER on the batch form. The hand is a TOKEN there, and the sheet's own two
-// verbs are about the whole batch rather than one row — so what the sheet asks
-// is whether the hand is on ANY row it is holding, and walking it aims at the
-// row the cursor lands on.
-func (a *app) aimSheet() {
- if a.questionBatch == nil || a.questionBatch.cursor >= len(a.questionBatch.questions) || a.questionBatch.cursor < 0 {
- return
- }
- a.aimQuestion(questionTokenOf(a.questionBatch.questions[a.questionBatch.cursor]))
-}
-
-func (a *app) sheetHasTheHand() bool {
- if a.questionHand == "" || a.questionBatch == nil {
- return false
- }
- for _, q := range a.questionBatch.questions {
- if questionTokenOf(q) == a.questionHand {
- return true
- }
- }
- return false
-}
diff --git a/internal/tui3/questionsheet_test.go b/internal/tui3/questionsheet_test.go
deleted file mode 100644
index 03dbb718bf..0000000000
--- a/internal/tui3/questionsheet_test.go
+++ /dev/null
@@ -1,216 +0,0 @@
-package tui3
-
-import (
- "strings"
- "testing"
- "time"
-
- "github.com/charmbracelet/x/ansi"
-
- "github.com/Agent-Field/aforge-v2/internal/session"
- "github.com/Agent-Field/aforge-v2/internal/tui2/tokens"
-)
-
-// sheetQuestion is one row of a batch, with its own answers on its own keys.
-func sheetQuestion(id uint64, ask session.AskKind, head string, options ...session.AnswerOption) session.Question {
- return session.Question{
- ID: id, Kind: session.QuestionTask, Ask: ask, Head: head,
- Stakes: session.StakesReversible, Options: options,
- Asked: time.Date(2026, 9, 9, 12, 0, int(id), 0, time.UTC),
- }
-}
-
-func TestSameAnswerForAllLikeThisIsByKeyAndNeverByPosition(t *testing.T) {
- now := time.Now()
- // Both are choices, and `2` means something different on each of them.
- first := sheetQuestion(1, session.AskChoice, "which index?",
- session.AnswerOption{Key: "1", Label: "sqlite"}, session.AnswerOption{Key: "2", Label: "memory"})
- second := sheetQuestion(2, session.AskChoice, "which cache?",
- session.AnswerOption{Key: "7", Label: "on disk"}, session.AnswerOption{Key: "8", Label: "in memory"})
- third := sheetQuestion(3, session.AskChoice, "which pager?",
- session.AnswerOption{Key: "1", Label: "less"}, session.AnswerOption{Key: "2", Label: "none"})
- sheet := newQuestionSheet([]session.Question{first, second, third})
- if !sheet.answerAt(0, "2", now) {
- t.Fatal("the first row would not take a key it offered")
- }
- // `g` spreads the LAST ANSWER GIVEN, wherever the cursor has walked to.
- sheet.cursor = 1
- if reached := sheet.sameForAll(now); reached != 1 {
- t.Fatalf("same answer reached %d rows, want only the one that offers key 2", reached)
- }
- if _, ok := sheet.answered(second); ok {
- t.Fatal("a question that never offered key 2 was answered with it")
- }
-}
-
-func TestASheetRowTakesOnlyAKeyItOffered(t *testing.T) {
- sheet := newQuestionSheet([]session.Question{
- sheetQuestion(1, session.AskChoice, "which index?", session.AnswerOption{Key: "1", Label: "sqlite"}),
- })
- if sheet.answerAt(0, "9", time.Now()) {
- t.Fatal("a key nobody offered was taken as an answer")
- }
-}
-
-func TestAWithdrawnRowReflowsWithoutMovingAnotherRowsAnswer(t *testing.T) {
- now := time.Now()
- first := sheetQuestion(1, session.AskPermission, "read vendor/?",
- session.AnswerOption{Key: "1", Label: "allow once"})
- second := sheetQuestion(2, session.AskPermission, "write .github/?",
- session.AnswerOption{Key: "1", Label: "allow once"})
- sheet := newQuestionSheet([]session.Question{first, second})
- if !sheet.answerAt(1, "1", now) {
- t.Fatal("the second row would not take its own key")
- }
- if !sheet.withdraw(first) {
- t.Fatal("the sheet did not know the question it was holding")
- }
- if len(sheet.questions) != 1 || sheet.questions[0].ID != 2 {
- t.Fatalf("sheet after withdrawal: %#v", sheet.questions)
- }
- if _, ok := sheet.answered(sheet.questions[0]); !ok {
- t.Fatal("re-flowing the sheet lost the answer that was already on a row")
- }
-}
-
-func TestSendTakesTheAnsweredAndDelegatesOnlyWhatHasAPick(t *testing.T) {
- now := time.Now()
- answered := sheetQuestion(1, session.AskChoice, "which index?",
- session.AnswerOption{Key: "1", Label: "sqlite"}, session.AnswerOption{Key: "2", Label: "memory"})
- delegated := sheetQuestion(2, session.AskChoice, "which cache?",
- session.AnswerOption{Key: "1", Label: "on disk"}, session.AnswerOption{Key: "2", Label: "in memory"})
- delegated.Pick = &session.Pick{Key: "2", Reason: "it is what the last run used"}
- unpicked := sheetQuestion(3, session.AskJudgement, "is this ready to land?",
- session.AnswerOption{Key: "1", Label: "yes"}, session.AnswerOption{Key: "2", Label: "not yet"})
- sheet := newQuestionSheet([]session.Question{answered, delegated, unpicked})
- sheet.answerAt(0, "2", now)
- answers, held := sheet.send(now)
- if len(answers) != 2 {
- t.Fatalf("send produced %d answers, want the answered one and the delegated one", len(answers))
- }
- if answers[0].DecidedBy != session.DecidedByPerson || answers[0].Key != "2" {
- t.Fatalf("the answered row = %#v", answers[0])
- }
- if answers[1].DecidedBy != session.DecidedByAsker || answers[1].Key != "2" {
- t.Fatalf("the delegated row = %#v", answers[1])
- }
- if len(held) != 1 || held[0].ID != 3 {
- t.Fatalf("held = %#v, want the question nobody named a pick on", held)
- }
-}
-
-func TestTheSheetDrawsGroupedRowsWithBothMarksAndItsOwnKeys(t *testing.T) {
- a := newTestApp(&fakeAgent{})
- sheet := newQuestionSheet([]session.Question{
- sheetQuestion(1, session.AskPermission, "read vendor/?", session.AnswerOption{Key: "1", Label: "allow once"}),
- sheetQuestion(2, session.AskPermission, "write .github/?", session.AnswerOption{Key: "1", Label: "allow once"}),
- sheetQuestion(3, session.AskChoice, "which index?", session.AnswerOption{Key: "1", Label: "sqlite"}),
- })
- if !sheet.answerAt(0, "1", time.Now()) {
- t.Fatal("the first permission row would not take its own key")
- }
- text := ansi.Strip(strings.Join(a.questionSheetRows(sheet, 120), "\n"))
- for _, want := range []string{
- "3 questions" + questionSheetTogether,
- questionShapeWord(session.AskPermission),
- questionShapeWord(session.AskChoice),
- a.icon(tokens.GSettled) + " read vendor/?",
- a.icon(tokens.GNeedsHuman) + " write .github/?",
- "1 allow once",
- "enter " + questionSheetOpenWord,
- questionSendKey + " " + questionSheetSendWord + " (1)",
- questionAlikeKey + " " + questionSheetSameWord,
- "esc later",
- } {
- if !strings.Contains(text, want) {
- t.Fatalf("sheet is missing %q:\n%s", want, text)
- }
- }
-}
-
-func TestTheSheetOffersSameAnswerOnlyWhenItWouldReachARow(t *testing.T) {
- a := newTestApp(&fakeAgent{})
- sheet := newQuestionSheet([]session.Question{
- sheetQuestion(1, session.AskChoice, "which index?", session.AnswerOption{Key: "1", Label: "sqlite"}),
- sheetQuestion(2, session.AskJudgement, "is this ready?", session.AnswerOption{Key: "1", Label: "yes"}),
- })
- sheet.answerAt(0, "1", time.Now())
- text := ansi.Strip(a.questionSheetOffer(sheet, 120))
- if strings.Contains(text, questionSheetSameWord) {
- t.Fatalf("same-answer was offered with no row of that shape to reach:\n%s", text)
- }
-}
-
-func TestEnterTakesOneRowOutOfTheSheetAndOntoTheBlock(t *testing.T) {
- a := newTestApp(&fakeAgent{})
- a.questionBatch = newQuestionSheet([]session.Question{
- sheetQuestion(1, session.AskPermission, "read vendor/?", session.AnswerOption{Key: "1", Label: "allow once"}),
- sheetQuestion(2, session.AskPermission, "write .github/?", session.AnswerOption{Key: "1", Label: "allow once"}),
- })
- if _, took := a.openSheetRow(); !took {
- t.Fatal("enter did not open the focused row")
- }
- if a.sheetOpen() != 1 {
- t.Fatalf("the sheet still holds %d rows", a.sheetOpen())
- }
- head, ok := a.questionHead()
- if !ok || head.question.ID != 1 {
- t.Fatalf("the block is holding %#v", head.question)
- }
- // THE TWO ARE NEVER ON SCREEN TOGETHER: with a question on the block, the
- // rows belong to it and the rest of the batch waits behind the chip.
- text := ansi.Strip(strings.Join(a.questionRows(90), "\n"))
- if strings.Contains(text, questionSheetTogether) {
- t.Fatalf("the sheet drew under a question that was opened out of it:\n%s", text)
- }
- if a.questionCount() != 2 {
- t.Fatalf("the chip counts %d, want the opened row and the one still on the sheet", a.questionCount())
- }
-}
-
-func TestADigitAnswersTheFocusedRowInItsOwnKeysAndWalksOn(t *testing.T) {
- a := newTestApp(&fakeAgent{})
- a.questionBatch = newQuestionSheet([]session.Question{
- sheetQuestion(1, session.AskPermission, "read vendor/?",
- session.AnswerOption{Key: "1", Label: "allow once"}, session.AnswerOption{Key: "2", Label: "not now"}),
- sheetQuestion(2, session.AskPermission, "write .github/?",
- session.AnswerOption{Key: "7", Label: "allow once"}, session.AnswerOption{Key: "8", Label: "not now"}),
- })
- if _, took := a.questionSheetKey(key("1")); !took {
- t.Fatal("the focused row would not take its own key")
- }
- if a.questionBatch.cursor != 1 {
- t.Fatalf("the cursor stayed on row %d instead of walking to the next one waiting", a.questionBatch.cursor)
- }
- // `1` is not an answer to the SECOND question, which offers 7 and 8.
- if _, took := a.questionSheetKey(key("1")); took {
- t.Fatal("a key this question never offered was taken as an answer")
- }
- if _, took := a.questionSheetKey(key("7")); !took {
- t.Fatal("the second row would not take its own key")
- }
- if a.questionBatch.answeredCount() != 2 {
- t.Fatalf("%d rows answered, want both", a.questionBatch.answeredCount())
- }
-}
-
-func TestEscFoldsTheSheetAndTheChipKeyBringsItBack(t *testing.T) {
- a := newTestApp(&fakeAgent{})
- a.questionBatch = newQuestionSheet([]session.Question{
- sheetQuestion(1, session.AskChoice, "which index?", session.AnswerOption{Key: "1", Label: "sqlite"}),
- sheetQuestion(2, session.AskChoice, "which cache?", session.AnswerOption{Key: "1", Label: "on disk"}),
- })
- if _, took := a.questionSheetKey(key(questionLaterKey)); !took {
- t.Fatal("esc was not taken by the sheet")
- }
- if strings.Contains(ansi.Strip(strings.Join(a.questionRows(90), "\n")), questionSheetTogether) {
- t.Fatal("the sheet stayed on screen after esc")
- }
- if a.questionCount() != 2 {
- t.Fatalf("esc dropped the count to %d — later is not cancelled", a.questionCount())
- }
- a.raiseFolded()
- if !strings.Contains(ansi.Strip(strings.Join(a.questionRows(90), "\n")), questionSheetTogether) {
- t.Fatal("the chip key did not bring the sheet back")
- }
-}